From f5648ff138d913b01cce41a4db852038798f861b Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Sat, 25 Jul 2026 00:48:29 +0200 Subject: [PATCH] v0.1.0-pre.024 --- CHANGELOG.md | 7 + ROADMAP.md | 8 +- config/example.config.json | 6 +- kb-onchain-transport/Cargo.toml | 11 +- kb-onchain-transport/src/client.rs | 22 + kb-onchain-transport/src/constants.rs | 41 + kb-onchain-transport/src/endpoint_role.rs | 190 ++ kb-onchain-transport/src/execution_rpc.rs | 2703 +++++++++++++++++ kb-onchain-transport/src/http_client.rs | 383 +++ kb-onchain-transport/src/http_pool.rs | 279 ++ kb-onchain-transport/src/json_rpc.rs | 315 ++ kb-onchain-transport/src/lib.rs | 388 ++- kb-onchain-transport/src/standard_http.rs | 499 +++ .../src/standard_http_accounts.rs | 212 ++ .../src/standard_http_blocks.rs | 548 ++++ .../src/standard_http_cluster.rs | 505 +++ .../src/standard_http_economics.rs | 305 ++ .../src/standard_http_tokens.rs | 241 ++ .../src/standard_http_transactions.rs | 155 + kb-onchain-transport/src/standard_methods.rs | 670 ++++ kb-onchain-transport/src/validation.rs | 77 + results.txt | 0 22 files changed, 7554 insertions(+), 11 deletions(-) create mode 100644 kb-onchain-transport/src/client.rs create mode 100644 kb-onchain-transport/src/constants.rs create mode 100644 kb-onchain-transport/src/endpoint_role.rs create mode 100644 kb-onchain-transport/src/execution_rpc.rs create mode 100644 kb-onchain-transport/src/http_client.rs create mode 100644 kb-onchain-transport/src/http_pool.rs create mode 100644 kb-onchain-transport/src/json_rpc.rs create mode 100644 kb-onchain-transport/src/standard_http.rs create mode 100644 kb-onchain-transport/src/standard_http_accounts.rs create mode 100644 kb-onchain-transport/src/standard_http_blocks.rs create mode 100644 kb-onchain-transport/src/standard_http_cluster.rs create mode 100644 kb-onchain-transport/src/standard_http_economics.rs create mode 100644 kb-onchain-transport/src/standard_http_tokens.rs create mode 100644 kb-onchain-transport/src/standard_http_transactions.rs create mode 100644 kb-onchain-transport/src/standard_methods.rs create mode 100644 kb-onchain-transport/src/validation.rs create mode 100644 results.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 6acc53f..5077c8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ # CHANGELOG +## 0.1.0-pre.024 + +- Première tranche fonctionnelle de `kb-onchain-transport`. +- Migration des contrats JSON-RPC, des rôles d’endpoints, des clients et pools HTTP, des validateurs et des méthodes HTTP Solana standard. +- Migration des adaptateurs RPC d’exécution communs vers les types consolidés de `kb-lib`. +- WebSocket, acquisition canonique de transactions et soumission/confirmation réseau restent planifiés pour les tranches suivantes. + ## 0.1.0-pre.023 - Renommage structurel de `kb-rpc` en `kb-onchain-transport`. diff --git a/ROADMAP.md b/ROADMAP.md index 899ffbe..750ef3b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,5 @@ - + # ROADMAP — khadhroony-bot3 @@ -17,6 +17,12 @@ - [x] Porter leurs matérialisateurs déjà implémentés dans bot2. - [ ] Porter leurs exécuteurs. - [x] Fusionner `kb_store_core` et `kb_store_pg` dans `kb-store`. +- [ ] Migrer `kb-onchain-transport` depuis l’ancienne `kb_rpc`. + - [x] Renommer structurellement la crate. + - [x] Porter les contrats JSON-RPC, rôles d’endpoints, validation, clients/pools HTTP et méthodes HTTP standard. + - [ ] Porter WebSocket, sessions et pools d’abonnements. + - [ ] Porter l’acquisition canonique `getTransaction` et `getSignaturesForAddress`. + - [ ] Porter simulation, envoi et confirmation réseau complets. - [ ] Adapter `kb-pipeline` aux nouveaux chemins publics. - [ ] Adapter `kb-app-demo` et rétablir les validations fonctionnelles. - [ ] Créer ultérieurement une crate off-chain dédiée au fetch borné HTTP/IPFS/Arweave des URI de metadata, séparée des décodeurs, matérialisateurs et du replay canonique on-chain. diff --git a/config/example.config.json b/config/example.config.json index e55dbdb..22b8eb0 100644 --- a/config/example.config.json +++ b/config/example.config.json @@ -45,14 +45,14 @@ "kb-lib.materializer.transaction", "kb-store", "kb-onchain-transport", + "kb_wallet", + "kb_pipeline", "kb_app_demo", "kb_executor_metadata_metaplex_token_metadata", "kb_executor_metadata_spl_name_service", "kb_executor_spl_account_compression", "kb_executor_spl_noop", - "kb_executor_spl_single_pool", - "kb_pipeline", - "kb_wallet" + "kb_executor_spl_single_pool" ] }, { diff --git a/kb-onchain-transport/Cargo.toml b/kb-onchain-transport/Cargo.toml index 2f5d380..b8cdee0 100644 --- a/kb-onchain-transport/Cargo.toml +++ b/kb-onchain-transport/Cargo.toml @@ -1,5 +1,5 @@ # file: kb-onchain-transport/Cargo.toml -# version: 2 +# version: 4 [package] name = "kb-onchain-transport" @@ -9,7 +9,16 @@ license.workspace = true publish.workspace = true [dependencies] +base64.workspace = true +bs58.workspace = true +kb-config = { path = "../kb-config" } kb-core = { path = "../kb-core" } +kb-lib = { path = "../kb-lib" } +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tracing.workspace = true [lints] workspace = true diff --git a/kb-onchain-transport/src/client.rs b/kb-onchain-transport/src/client.rs new file mode 100644 index 0000000..120e589 --- /dev/null +++ b/kb-onchain-transport/src/client.rs @@ -0,0 +1,22 @@ +// file: kb-onchain-transport/src/client.rs +// version: 3 + +//! RPC client scaffold for Solana ingestion. + +/// RPC endpoint configuration. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RpcEndpoint { + /// HTTP RPC URL. + pub http_url: std::string::String, + /// Optional WebSocket RPC URL. + pub ws_url: std::option::Option, +} + +/// Minimal Solana RPC client abstraction. +pub trait SolanaRpcClient { + /// Fetches a raw transaction payload by signature. + fn get_transaction_raw_json( + &self, + signature: &kb_lib::MdSignature, + ) -> kb_core::Result>; +} diff --git a/kb-onchain-transport/src/constants.rs b/kb-onchain-transport/src/constants.rs new file mode 100644 index 0000000..a1eda7e --- /dev/null +++ b/kb-onchain-transport/src/constants.rs @@ -0,0 +1,41 @@ +// file: kb-onchain-transport/src/constants.rs +// version: 7 + +//! Local constants for the `kb-onchain-transport` crate. + +/// Canonical tracing target for this crate. +pub(crate) const TRACING_TARGET: &str = "kb-onchain-transport"; +/// Official Devnet genesis hash. +pub(crate) const DEVNET_GENESIS_HASH: &str = "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG"; +/// Official Testnet genesis hash. +pub(crate) const TESTNET_GENESIS_HASH: &str = "4uhcVJyU9pJkvQyS88uRDiswHXSCkY3zQawwpjk2NsNY"; +/// Official Mainnet genesis hash; RPC and CLI endpoints retain the legacy `mainnet-beta` alias. +pub(crate) const MAINNET_GENESIS_HASH: &str = "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d"; +/// Local defensive maximum for one base64 message or transaction request value. +pub(crate) const MAX_EXECUTION_RPC_BASE64_LENGTH: usize = 65_536; +/// Local defensive maximum for decoded account data returned by one execution RPC request. +pub(crate) const MAX_EXECUTION_ACCOUNT_DATA_BYTES: usize = 65_536; +/// Local defensive maximum for account snapshots requested from one simulation. +pub(crate) const MAX_SIMULATION_ACCOUNT_COUNT: usize = 128; +/// Maximum signatures accepted by one `getSignatureStatuses` request. +pub(crate) const MAX_SIGNATURE_STATUS_COUNT: usize = 256; +/// Maximum confirmation polling attempts accepted by the bounded helper. +pub(crate) const MAX_CONFIRMATION_ATTEMPTS: u32 = 10_000; +/// Maximum delay between confirmation polls. +pub(crate) const MAX_CONFIRMATION_POLL_INTERVAL_MS: u64 = 60_000; +/// Maximum number of public keys accepted by `getMultipleAccounts`. +pub(crate) const MAX_MULTIPLE_ACCOUNT_COUNT: usize = 100; +/// Maximum writable-account set accepted by `getRecentPrioritizationFees`. +pub(crate) const MAX_PRIORITIZATION_FEE_ACCOUNT_COUNT: usize = 128; +/// Maximum sample count accepted by `getRecentPerformanceSamples`. +pub(crate) const MAX_PERFORMANCE_SAMPLE_COUNT: usize = 720; +/// Maximum consecutive slot leaders accepted by `getSlotLeaders`. +pub(crate) const MAX_SLOT_LEADER_COUNT: u64 = 5_000; +/// Maximum slot span or result limit accepted by block-range methods. +pub(crate) const MAX_BLOCK_RANGE: u64 = 500_000; +/// Maximum decoded memcmp payload accepted by the standard RPC contract. +pub(crate) const MAX_MEMCMP_DECODED_BYTES: usize = 128; +/// Maximum base58 text length for a 128-byte memcmp payload. +pub(crate) const MAX_MEMCMP_BASE58_LENGTH: usize = 175; +/// Maximum base64 text length for a 128-byte memcmp payload. +pub(crate) const MAX_MEMCMP_BASE64_LENGTH: usize = 172; diff --git a/kb-onchain-transport/src/endpoint_role.rs b/kb-onchain-transport/src/endpoint_role.rs new file mode 100644 index 0000000..d78d7cc --- /dev/null +++ b/kb-onchain-transport/src/endpoint_role.rs @@ -0,0 +1,190 @@ +// file: kb-onchain-transport/src/endpoint_role.rs +// version: 3 + +//! Endpoint role helpers shared by HTTP and WebSocket pools. + +/// Snapshot of one endpoint role and its local limits. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EndpointRoleSnapshot { + /// Role code used by endpoint pools. + pub role: std::string::String, + /// Enables this role on the endpoint. + pub enabled: bool, + /// Request or subscription kinds handled by this role. + pub request_kinds: std::vec::Vec, + /// Role priority where lower values are preferred. + pub priority: u32, + /// Requests per second allowed for this role on this URL. + pub requests_per_second: u32, + /// Burst capacity allowed for this role on this URL. + pub burst_capacity: u32, + /// Maximum concurrent requests allowed for this role on this URL. + pub max_concurrent_requests: u32, + /// Maximum subscriptions allowed for this role on this URL. + pub max_subscriptions: u32, + /// Pause after a rate limit response in milliseconds. + pub pause_after_rate_limit_ms: u64, +} + +impl crate::EndpointRoleSnapshot { + /// Builds a serializable snapshot from configuration. + pub fn from_config(config: &kb_config::EndpointRoleConfig) -> Self { + return Self { + role: config.role.clone(), + enabled: config.enabled, + request_kinds: config.request_kinds.clone(), + priority: config.priority, + requests_per_second: config.requests_per_second, + burst_capacity: config.burst_capacity, + max_concurrent_requests: config.max_concurrent_requests, + max_subscriptions: config.max_subscriptions, + pause_after_rate_limit_ms: config.pause_after_rate_limit_ms, + }; + } +} + +/// Converts a JSON-RPC method name into a stable snake_case request kind. +pub fn request_kind_from_method(method: &str) -> std::string::String { + let trimmed = method.trim(); + if trimmed == "logsSubscribe" { + return "logs_subscribe_mentions".to_string(); + } + return crate::endpoint_role::camel_or_pascal_to_snake(trimmed); +} + +/// Returns true when one endpoint role can handle the requested role and kind. +pub(crate) fn role_matches( + role_config: &kb_config::EndpointRoleConfig, + required_role: &str, + request_kind: &str, +) -> bool { + if !role_config.enabled { + return false; + } + if role_config.role != required_role { + return false; + } + for configured_kind in &role_config.request_kinds { + if configured_kind == request_kind { + return true; + } + if configured_kind == "*" { + return true; + } + } + return false; +} + +fn camel_or_pascal_to_snake(value: &str) -> std::string::String { + let mut output = std::string::String::new(); + let mut previous_was_lower_or_digit = false; + for character in value.chars() { + if character == '-' || character == ' ' || character == '.' { + if !output.ends_with('_') && !output.is_empty() { + output.push('_'); + } + previous_was_lower_or_digit = false; + continue; + } + if character.is_ascii_uppercase() { + if previous_was_lower_or_digit && !output.ends_with('_') && !output.is_empty() { + output.push('_'); + } + output.push(character.to_ascii_lowercase()); + previous_was_lower_or_digit = false; + continue; + } + if character == '_' { + if !output.ends_with('_') && !output.is_empty() { + output.push('_'); + } + previous_was_lower_or_digit = false; + continue; + } + output.push(character); + previous_was_lower_or_digit = character.is_ascii_lowercase() || character.is_ascii_digit(); + } + return output.trim_matches('_').to_string(); +} + +#[cfg(test)] +mod tests { + fn role_config( + role: &str, + enabled: bool, + request_kinds: std::vec::Vec, + ) -> kb_config::EndpointRoleConfig { + return kb_config::EndpointRoleConfig { + role: role.to_string(), + enabled, + request_kinds, + priority: 1, + requests_per_second: 10, + burst_capacity: 10, + max_concurrent_requests: 4, + max_subscriptions: 16, + pause_after_rate_limit_ms: 1500, + }; + } + + #[test] + fn request_kind_converts_camel_case_methods() { + assert_eq!(crate::request_kind_from_method("getLatestBlockhash"), "get_latest_blockhash"); + assert_eq!(crate::request_kind_from_method("sendTransaction"), "send_transaction"); + } + + #[test] + fn request_kind_keeps_existing_snake_case_methods() { + assert_eq!(crate::request_kind_from_method("get_latest_blockhash"), "get_latest_blockhash"); + assert_eq!( + crate::request_kind_from_method("logs_subscribe_mentions"), + "logs_subscribe_mentions" + ); + } + + #[test] + fn request_kind_normalizes_separators() { + assert_eq!(crate::request_kind_from_method("get-Block"), "get_block"); + assert_eq!(crate::request_kind_from_method("program.Subscribe"), "program_subscribe"); + } + + #[test] + fn request_kind_maps_logs_subscribe_to_mentions_role_kind() { + assert_eq!(crate::request_kind_from_method("logsSubscribe"), "logs_subscribe_mentions"); + } + + #[test] + fn role_matches_exact_request_kind() { + let role = role_config("http_queries", true, std::vec!["get_version".to_string()]); + assert!(crate::role_matches(&role, "http_queries", "get_version")); + } + + #[test] + fn role_matches_wildcard_request_kind() { + let role = role_config("http_queries", true, std::vec!["*".to_string()]); + assert!(crate::role_matches(&role, "http_queries", "get_block")); + } + + #[test] + fn role_does_not_match_when_disabled() { + let role = role_config("http_queries", false, std::vec!["*".to_string()]); + assert!(!crate::role_matches(&role, "http_queries", "get_version")); + } + + #[test] + fn role_does_not_match_different_role() { + let role = role_config("http_queries", true, std::vec!["*".to_string()]); + assert!(!crate::role_matches(&role, "http_heavy", "get_block")); + } + + #[test] + fn endpoint_role_snapshot_preserves_limits() { + let role = role_config("http_queries", true, std::vec!["get_version".to_string()]); + let snapshot = crate::EndpointRoleSnapshot::from_config(&role); + assert_eq!(snapshot.role, "http_queries"); + assert_eq!(snapshot.request_kinds, std::vec!["get_version".to_string()]); + assert_eq!(snapshot.requests_per_second, 10); + assert_eq!(snapshot.max_subscriptions, 16); + } +} diff --git a/kb-onchain-transport/src/execution_rpc.rs b/kb-onchain-transport/src/execution_rpc.rs new file mode 100644 index 0000000..d4ffcc1 --- /dev/null +++ b/kb-onchain-transport/src/execution_rpc.rs @@ -0,0 +1,2703 @@ +// file: kb-onchain-transport/src/execution_rpc.rs +// version: 7 + +//! Typed Solana JSON-RPC adapters used by execution orchestration. + +use base64::Engine; // rust-rules: trait-import + +/// Commitment level accepted by execution-oriented RPC methods. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RpcCommitmentLevel { + /// The node's most recent processed state. + Processed, + /// A supermajority-confirmed state. + Confirmed, + /// The strongest finalized state. + Finalized, +} + +impl crate::RpcCommitmentLevel { + /// Returns the wire value expected by Solana JSON-RPC. + pub fn as_str(&self) -> &'static str { + return match self { + Self::Processed => "processed", + Self::Confirmed => "confirmed", + Self::Finalized => "finalized", + }; + } +} + +/// Standard context attached to Solana RPC responses. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcResponseContext { + /// Slot used by the RPC node to answer the request. + pub slot: u64, + /// Optional node API version. + #[serde(default)] + pub api_version: std::option::Option, +} + +/// Genesis hash and optional classification of a public Solana cluster. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct GenesisHashResult { + /// Base58 genesis hash returned by the endpoint. + pub genesis_hash: std::string::String, + /// Known public cluster when the hash matches an official public cluster. + pub classified_cluster: std::option::Option, +} + +/// Configuration for `getEpochInfo` used by stateful execution readiness checks. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GetEpochInfoConfig { + /// Requested commitment. + pub commitment: crate::RpcCommitmentLevel, + /// Optional minimum context slot. + pub min_context_slot: std::option::Option, +} + +impl crate::GetEpochInfoConfig { + /// Creates an epoch information request configuration. + pub fn new( + commitment: crate::RpcCommitmentLevel, + min_context_slot: std::option::Option, + ) -> Self { + return Self { commitment, min_context_slot }; + } + + /// Creates the default confirmed epoch information request. + pub fn confirmed() -> Self { + return Self::new(crate::RpcCommitmentLevel::Confirmed, std::option::Option::None); + } + + fn request_params(&self) -> std::vec::Vec { + let mut config = serde_json::Map::::new(); + config.insert( + "commitment".to_string(), + serde_json::Value::String(self.commitment.as_str().to_string()), + ); + if let std::option::Option::Some(min_context_slot) = self.min_context_slot { + config.insert( + "minContextSlot".to_string(), + serde_json::Value::Number(serde_json::Number::from(min_context_slot)), + ); + } + return vec![serde_json::Value::Object(config)]; + } +} + +/// Current epoch and slot progression returned by `getEpochInfo`. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EpochInfoResult { + /// Absolute cluster slot. + pub absolute_slot: u64, + /// Current block height. + pub block_height: u64, + /// Current epoch number. + pub epoch: u64, + /// Slot index within the current epoch. + pub slot_index: u64, + /// Number of slots in the current epoch. + pub slots_in_epoch: u64, + /// Optional transaction count when the node exposes it. + pub transaction_count: std::option::Option, +} + +/// Configuration for `getLatestBlockhash`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GetLatestBlockhashConfig { + /// Requested commitment. + pub commitment: crate::RpcCommitmentLevel, + /// Optional minimum context slot. + pub min_context_slot: std::option::Option, +} + +impl crate::GetLatestBlockhashConfig { + /// Creates a blockhash request configuration. + pub fn new( + commitment: crate::RpcCommitmentLevel, + min_context_slot: std::option::Option, + ) -> Self { + return Self { commitment, min_context_slot }; + } + + /// Creates the default confirmed blockhash request. + pub fn confirmed() -> Self { + return Self::new(crate::RpcCommitmentLevel::Confirmed, std::option::Option::None); + } + + fn request_params(&self) -> std::vec::Vec { + let mut config = serde_json::Map::::new(); + config.insert( + "commitment".to_string(), + serde_json::Value::String(self.commitment.as_str().to_string()), + ); + if let std::option::Option::Some(min_context_slot) = self.min_context_slot { + config.insert( + "minContextSlot".to_string(), + serde_json::Value::Number(serde_json::Number::from(min_context_slot)), + ); + } + return std::vec![serde_json::Value::Object(config)]; + } +} + +/// Latest recent blockhash returned by the cluster. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct LatestBlockhashResult { + /// RPC response context. + pub context: crate::RpcResponseContext, + /// Base58 recent blockhash. + pub blockhash: std::string::String, + /// Last block height at which the blockhash remains valid. + pub last_valid_block_height: u64, +} + +/// Configuration for `getFeeForMessage`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GetFeeForMessageConfig { + /// Requested commitment. + pub commitment: crate::RpcCommitmentLevel, + /// Optional minimum context slot. + pub min_context_slot: std::option::Option, +} + +impl crate::GetFeeForMessageConfig { + /// Creates a fee request configuration. + pub fn new( + commitment: crate::RpcCommitmentLevel, + min_context_slot: std::option::Option, + ) -> Self { + return Self { commitment, min_context_slot }; + } + + /// Creates the default confirmed fee request. + pub fn confirmed() -> Self { + return Self::new(crate::RpcCommitmentLevel::Confirmed, std::option::Option::None); + } + + fn request_params( + &self, + encoded_message: &str, + ) -> kb_core::Result> { + let validation_result = crate::execution_rpc::validate_base64_payload( + encoded_message, + "getFeeForMessage message", + ); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + let mut config = serde_json::Map::::new(); + config.insert( + "commitment".to_string(), + serde_json::Value::String(self.commitment.as_str().to_string()), + ); + if let std::option::Option::Some(min_context_slot) = self.min_context_slot { + config.insert( + "minContextSlot".to_string(), + serde_json::Value::Number(serde_json::Number::from(min_context_slot)), + ); + } + return std::result::Result::Ok(std::vec![ + serde_json::Value::String(encoded_message.to_string()), + serde_json::Value::Object(config), + ]); + } +} + +/// Fee estimate returned for one serialized transaction message. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct FeeForMessageResult { + /// RPC response context. + pub context: crate::RpcResponseContext, + /// Fee in lamports, or `None` when the referenced blockhash is no longer valid. + pub fee_lamports: std::option::Option, +} + +/// Optional accounts requested from `simulateTransaction`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SimulationAccountsConfig { + /// Account addresses returned after simulation. + pub addresses: std::vec::Vec, +} + +impl crate::SimulationAccountsConfig { + /// Creates a bounded account-return request using base64 account data. + pub fn new(addresses: std::vec::Vec) -> kb_core::Result { + if addresses.is_empty() { + return std::result::Result::Err(kb_core::Error::config( + "simulateTransaction accounts must not be empty", + )); + } + if addresses.len() > crate::MAX_SIMULATION_ACCOUNT_COUNT { + return std::result::Result::Err(kb_core::Error::config(format!( + "simulateTransaction accounts must not exceed {} entries", + crate::MAX_SIMULATION_ACCOUNT_COUNT + ))); + } + for address in &addresses { + let validation_result = crate::validate_solana_pubkey_text( + address.0.as_str(), + "simulateTransaction account address", + ); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + } + return std::result::Result::Ok(Self { addresses }); + } + + fn request_value(&self) -> serde_json::Value { + let addresses = self + .addresses + .iter() + .map(|address| return serde_json::Value::String(address.0.clone())) + .collect::>(); + return serde_json::json!({ + "encoding": "base64", + "addresses": addresses + }); + } +} + +/// Configuration for `simulateTransaction`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SimulateTransactionConfig { + /// Requested commitment. + pub commitment: crate::RpcCommitmentLevel, + /// Whether transaction signatures must be verified by the node. + pub signature_verification: bool, + /// Whether the node should replace the recent blockhash before simulation. + pub replace_recent_blockhash: bool, + /// Optional minimum context slot. + pub min_context_slot: std::option::Option, + /// Whether inner instructions should be returned. + pub inner_instructions: bool, + /// Optional post-simulation account values to return. + pub accounts: std::option::Option, +} + +impl crate::SimulateTransactionConfig { + /// Creates and validates a simulation request configuration. + pub fn new( + commitment: crate::RpcCommitmentLevel, + signature_verification: bool, + replace_recent_blockhash: bool, + min_context_slot: std::option::Option, + inner_instructions: bool, + accounts: std::option::Option, + ) -> kb_core::Result { + if signature_verification && replace_recent_blockhash { + return std::result::Result::Err(kb_core::Error::config( + "simulateTransaction cannot verify signatures while replacing the recent blockhash", + )); + } + return std::result::Result::Ok(Self { + commitment, + signature_verification, + replace_recent_blockhash, + min_context_slot, + inner_instructions, + accounts, + }); + } + + /// Creates an unsigned simulation configuration for the exact supplied blockhash. + pub fn unsigned_exact() -> Self { + return Self { + commitment: crate::RpcCommitmentLevel::Confirmed, + signature_verification: false, + replace_recent_blockhash: false, + min_context_slot: std::option::Option::None, + inner_instructions: true, + accounts: std::option::Option::None, + }; + } + + /// Creates an unsigned diagnostic simulation that replaces the recent blockhash. + /// + /// Its result cannot authorize signing of the original message. + pub fn unsigned_with_replacement() -> Self { + return Self { + commitment: crate::RpcCommitmentLevel::Confirmed, + signature_verification: false, + replace_recent_blockhash: true, + min_context_slot: std::option::Option::None, + inner_instructions: true, + accounts: std::option::Option::None, + }; + } + + fn request_params( + &self, + encoded_transaction: &str, + ) -> kb_core::Result> { + let validation_result = crate::execution_rpc::validate_base64_payload( + encoded_transaction, + "simulateTransaction transaction", + ); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + let mut config = serde_json::Map::::new(); + config.insert( + "commitment".to_string(), + serde_json::Value::String(self.commitment.as_str().to_string()), + ); + config.insert("encoding".to_string(), serde_json::Value::String("base64".to_string())); + config + .insert("sigVerify".to_string(), serde_json::Value::Bool(self.signature_verification)); + config.insert( + "replaceRecentBlockhash".to_string(), + serde_json::Value::Bool(self.replace_recent_blockhash), + ); + config.insert( + "innerInstructions".to_string(), + serde_json::Value::Bool(self.inner_instructions), + ); + if let std::option::Option::Some(min_context_slot) = self.min_context_slot { + config.insert( + "minContextSlot".to_string(), + serde_json::Value::Number(serde_json::Number::from(min_context_slot)), + ); + } + if let std::option::Option::Some(accounts) = &self.accounts { + config.insert("accounts".to_string(), accounts.request_value()); + } + return std::result::Result::Ok(std::vec![ + serde_json::Value::String(encoded_transaction.to_string()), + serde_json::Value::Object(config), + ]); + } +} + +/// Replacement blockhash returned by an unsigned simulation. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SimulationReplacementBlockhash { + /// Base58 replacement blockhash. + pub blockhash: std::string::String, + /// Last valid block height for the replacement blockhash. + pub last_valid_block_height: u64, +} + +/// Typed result returned by `simulateTransaction`. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct SimulateTransactionResult { + /// RPC response context. + pub context: crate::RpcResponseContext, + /// Whether the transaction completed without a runtime error. + pub success: bool, + /// Runtime error value returned by the node. + pub error: std::option::Option, + /// Runtime logs in execution order. + pub logs: std::vec::Vec, + /// Compute units consumed when reported. + pub units_consumed: std::option::Option, + /// Fee reported by simulation when supported by the node. + pub fee_lamports: std::option::Option, + /// Total loaded account data size when reported. + pub loaded_accounts_data_size: std::option::Option, + /// Replacement recent blockhash when requested. + pub replacement_blockhash: std::option::Option, + /// Program return data retained as provider-neutral JSON. + pub return_data: std::option::Option, + /// Inner instructions retained as provider-neutral JSON. + pub inner_instructions: std::option::Option, + /// Requested account snapshots retained as provider-neutral JSON. + pub accounts: std::option::Option>>, +} + +impl crate::SimulateTransactionResult { + /// Converts this RPC result into the common execution simulation contract. + pub fn to_execution_result( + &self, + cluster: kb_lib::ExApiExecutionCluster, + blockhash_kind: kb_lib::ExApiExecutionBlockhashKind, + blockhash_age_slots: std::option::Option, + nonce_account: std::option::Option, + nonce_authority: std::option::Option, + fee_fallback: std::option::Option<&crate::FeeForMessageResult>, + ) -> kb_lib::ExApiExecutionSimulationResult { + let estimated_fee_lamports = match self.fee_lamports { + std::option::Option::Some(fee) => std::option::Option::Some(fee), + std::option::Option::None => fee_fallback.and_then(|result| return result.fee_lamports), + }; + let error = self.error.as_ref().map(|value| return value.to_string()); + let replacement_blockhash = self + .replacement_blockhash + .as_ref() + .map(|replacement| return replacement.blockhash.clone()); + let replacement_last_valid_block_height = self + .replacement_blockhash + .as_ref() + .map(|replacement| return replacement.last_valid_block_height); + return kb_lib::ExApiExecutionSimulationResult { + simulated: true, + success: self.success, + cluster, + blockhash_kind, + blockhash_age_slots, + replacement_blockhash, + replacement_last_valid_block_height, + nonce_account, + nonce_authority, + units_consumed: self.units_consumed, + estimated_fee_lamports, + logs: self.logs.clone(), + error, + }; + } +} + +/// Configuration for `getBalance`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GetBalanceConfig { + /// Requested commitment. + pub commitment: crate::RpcCommitmentLevel, + /// Optional minimum context slot. + pub min_context_slot: std::option::Option, +} + +impl crate::GetBalanceConfig { + /// Creates a balance request configuration. + pub fn new( + commitment: crate::RpcCommitmentLevel, + min_context_slot: std::option::Option, + ) -> Self { + return Self { commitment, min_context_slot }; + } + + /// Creates the default confirmed balance request. + pub fn confirmed() -> Self { + return Self::new(crate::RpcCommitmentLevel::Confirmed, std::option::Option::None); + } + + fn request_params( + &self, + pubkey: &kb_lib::MdPubkey, + ) -> kb_core::Result> { + let validation_result = + crate::validate_solana_pubkey_text(pubkey.0.as_str(), "getBalance account"); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + let mut config = serde_json::Map::::new(); + config.insert( + "commitment".to_string(), + serde_json::Value::String(self.commitment.as_str().to_string()), + ); + if let std::option::Option::Some(min_context_slot) = self.min_context_slot { + config.insert( + "minContextSlot".to_string(), + serde_json::Value::Number(serde_json::Number::from(min_context_slot)), + ); + } + return std::result::Result::Ok(std::vec![ + serde_json::Value::String(pubkey.0.clone()), + serde_json::Value::Object(config), + ]); + } +} + +/// Lamport balance returned for one account. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct BalanceResult { + /// RPC response context. + pub context: crate::RpcResponseContext, + /// Account balance in lamports. + pub lamports: u64, +} + +/// Configuration for `getAccountInfo` used by execution readiness checks. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GetAccountInfoConfig { + /// Requested commitment. + pub commitment: crate::RpcCommitmentLevel, + /// Optional minimum context slot. + pub min_context_slot: std::option::Option, + /// Maximum complete decoded account-data length, or `None` for metadata-only requests. + pub max_data_bytes: std::option::Option, +} + +impl crate::GetAccountInfoConfig { + /// Creates an account information request configuration. + pub fn new( + commitment: crate::RpcCommitmentLevel, + min_context_slot: std::option::Option, + ) -> Self { + return Self { + commitment, + min_context_slot, + max_data_bytes: std::option::Option::None, + }; + } + + /// Creates a complete account-data request bounded by the decoded byte limit. + pub fn new_with_data( + commitment: crate::RpcCommitmentLevel, + min_context_slot: std::option::Option, + max_data_bytes: usize, + ) -> kb_core::Result { + if max_data_bytes == 0 || max_data_bytes > crate::MAX_EXECUTION_ACCOUNT_DATA_BYTES { + return std::result::Result::Err(kb_core::Error::config(format!( + "getAccountInfo complete data limit must be between 1 and {} bytes", + crate::MAX_EXECUTION_ACCOUNT_DATA_BYTES + ))); + } + return std::result::Result::Ok(Self { + commitment, + min_context_slot, + max_data_bytes: std::option::Option::Some(max_data_bytes), + }); + } + + /// Creates the default confirmed metadata-only account information request. + pub fn confirmed() -> Self { + return Self::new(crate::RpcCommitmentLevel::Confirmed, std::option::Option::None); + } + + /// Creates a confirmed complete account-data request bounded by the decoded byte limit. + pub fn confirmed_with_data(max_data_bytes: usize) -> kb_core::Result { + return Self::new_with_data( + crate::RpcCommitmentLevel::Confirmed, + std::option::Option::None, + max_data_bytes, + ); + } + + fn request_params( + &self, + pubkey: &kb_lib::MdPubkey, + ) -> kb_core::Result> { + if let std::option::Option::Some(max_data_bytes) = self.max_data_bytes { + if max_data_bytes == 0 || max_data_bytes > crate::MAX_EXECUTION_ACCOUNT_DATA_BYTES { + return std::result::Result::Err(kb_core::Error::config(format!( + "getAccountInfo complete data limit must be between 1 and {} bytes", + crate::MAX_EXECUTION_ACCOUNT_DATA_BYTES + ))); + } + } + let validation_result = + crate::validate_solana_pubkey_text(pubkey.0.as_str(), "getAccountInfo account"); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + let mut config = serde_json::Map::::new(); + config.insert( + "commitment".to_string(), + serde_json::Value::String(self.commitment.as_str().to_string()), + ); + config.insert("encoding".to_string(), serde_json::Value::String("base64".to_string())); + if self.max_data_bytes.is_none() { + config.insert("dataSlice".to_string(), serde_json::json!({"offset": 0, "length": 0})); + } + if let std::option::Option::Some(min_context_slot) = self.min_context_slot { + config.insert( + "minContextSlot".to_string(), + serde_json::Value::Number(serde_json::Number::from(min_context_slot)), + ); + } + return std::result::Result::Ok(std::vec![ + serde_json::Value::String(pubkey.0.clone()), + serde_json::Value::Object(config), + ]); + } +} + +/// Bounded account metadata and optional complete decoded data returned by `getAccountInfo`. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct AccountInfoValue { + /// Account balance in lamports. + pub lamports: u64, + /// Program that owns the account. + pub owner: kb_lib::MdProgramId, + /// Whether the account contains executable program code. + pub executable: bool, + /// Rent epoch reported by the node. + pub rent_epoch: u64, + /// Complete on-chain account-data length reported independently by the node. + pub space: u64, + /// Decoded account bytes, empty for metadata-only requests. + pub data: std::vec::Vec, +} + +/// Contextual result returned by `getAccountInfo`. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct AccountInfoResult { + /// RPC response context. + pub context: crate::RpcResponseContext, + /// Account value, or `None` when the address does not exist. + pub account: std::option::Option, +} + +/// Configuration for `getMinimumBalanceForRentExemption`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GetMinimumBalanceForRentExemptionConfig { + /// Requested commitment. + pub commitment: crate::RpcCommitmentLevel, +} + +impl crate::GetMinimumBalanceForRentExemptionConfig { + /// Creates a rent-exemption request configuration. + pub fn new(commitment: crate::RpcCommitmentLevel) -> Self { + return Self { commitment }; + } + + /// Creates the default confirmed rent-exemption request. + pub fn confirmed() -> Self { + return Self::new(crate::RpcCommitmentLevel::Confirmed); + } + + fn request_params(&self, data_length: u64) -> std::vec::Vec { + return std::vec![ + serde_json::Value::Number(serde_json::Number::from(data_length)), + serde_json::json!({"commitment": self.commitment.as_str()}), + ]; + } +} + +/// Rent-exempt minimum returned for one account data length. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct MinimumBalanceForRentExemptionResult { + /// Account data length used by the request. + pub data_length: u64, + /// Minimum balance in lamports. + pub minimum_balance_lamports: u64, +} + +/// Configuration for `requestAirdrop`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RequestAirdropConfig { + /// Requested commitment. + pub commitment: crate::RpcCommitmentLevel, + /// Optional recent blockhash supplied to the faucet. + pub recent_blockhash: std::option::Option, +} + +impl crate::RequestAirdropConfig { + /// Creates an airdrop request configuration. + pub fn new( + commitment: crate::RpcCommitmentLevel, + recent_blockhash: std::option::Option, + ) -> kb_core::Result { + if let std::option::Option::Some(blockhash) = &recent_blockhash { + let validation_result = crate::validate_solana_hash_text( + blockhash.as_str(), + "requestAirdrop recent blockhash", + ); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + } + return std::result::Result::Ok(Self { commitment, recent_blockhash }); + } + + /// Creates the default confirmed airdrop request. + pub fn confirmed() -> Self { + return Self { + commitment: crate::RpcCommitmentLevel::Confirmed, + recent_blockhash: std::option::Option::None, + }; + } + + fn request_params( + &self, + pubkey: &kb_lib::MdPubkey, + lamports: u64, + ) -> kb_core::Result> { + let validation_result = + crate::validate_solana_pubkey_text(pubkey.0.as_str(), "requestAirdrop recipient"); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + if lamports == 0 { + return std::result::Result::Err(kb_core::Error::config( + "requestAirdrop lamports must be greater than zero", + )); + } + let mut config = serde_json::Map::::new(); + config.insert( + "commitment".to_string(), + serde_json::Value::String(self.commitment.as_str().to_string()), + ); + if let std::option::Option::Some(blockhash) = &self.recent_blockhash { + config.insert( + "recentBlockhash".to_string(), + serde_json::Value::String(blockhash.clone()), + ); + } + return std::result::Result::Ok(std::vec![ + serde_json::Value::String(pubkey.0.clone()), + serde_json::Value::Number(serde_json::Number::from(lamports)), + serde_json::Value::Object(config), + ]); + } +} + +/// Signature returned by a faucet airdrop request. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct AirdropResult { + /// Faucet transaction signature. + pub signature: kb_lib::MdSignature, +} + +/// Configuration for `sendTransaction`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SendTransactionConfig { + /// Whether the node should skip signature verification and preflight simulation. + pub skip_preflight: bool, + /// Commitment used by the node's preflight simulation. + pub preflight_commitment: crate::RpcCommitmentLevel, + /// Optional maximum number of node retransmission retries. + pub max_retries: std::option::Option, + /// Optional minimum context slot. + pub min_context_slot: std::option::Option, +} + +impl crate::SendTransactionConfig { + /// Creates a validated transaction submission configuration. + pub fn new( + skip_preflight: bool, + preflight_commitment: crate::RpcCommitmentLevel, + max_retries: std::option::Option, + min_context_slot: std::option::Option, + ) -> kb_core::Result { + if skip_preflight { + return std::result::Result::Err(kb_core::Error::config( + "sendTransaction preflight cannot be skipped by execution orchestration", + )); + } + return std::result::Result::Ok(Self { + skip_preflight, + preflight_commitment, + max_retries, + min_context_slot, + }); + } + + /// Creates the default confirmed submission configuration with three retries. + pub fn confirmed() -> Self { + return Self { + skip_preflight: false, + preflight_commitment: crate::RpcCommitmentLevel::Confirmed, + max_retries: std::option::Option::Some(3), + min_context_slot: std::option::Option::None, + }; + } + + /// Creates a confirmed submission configuration from the active execution settings. + pub fn from_execution_config( + config: &kb_config::ExecutionConfig, + min_context_slot: std::option::Option, + ) -> kb_core::Result { + return Self::new( + false, + crate::RpcCommitmentLevel::Confirmed, + std::option::Option::Some(config.send_max_retries), + min_context_slot, + ); + } + + fn request_params( + &self, + encoded_transaction: &str, + ) -> kb_core::Result> { + let validation_result = crate::execution_rpc::validate_base64_payload( + encoded_transaction, + "sendTransaction signed transaction", + ); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + let mut config = serde_json::Map::::new(); + config.insert("encoding".to_string(), serde_json::Value::String("base64".to_string())); + config.insert("skipPreflight".to_string(), serde_json::Value::Bool(self.skip_preflight)); + config.insert( + "preflightCommitment".to_string(), + serde_json::Value::String(self.preflight_commitment.as_str().to_string()), + ); + if let std::option::Option::Some(max_retries) = self.max_retries { + config.insert( + "maxRetries".to_string(), + serde_json::Value::Number(serde_json::Number::from(max_retries)), + ); + } + if let std::option::Option::Some(min_context_slot) = self.min_context_slot { + config.insert( + "minContextSlot".to_string(), + serde_json::Value::Number(serde_json::Number::from(min_context_slot)), + ); + } + return std::result::Result::Ok(std::vec![ + serde_json::Value::String(encoded_transaction.to_string()), + serde_json::Value::Object(config), + ]); + } +} + +/// Result returned after a node accepts a signed transaction for relay. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct SendTransactionResult { + /// First signature embedded in the submitted transaction. + pub signature: kb_lib::MdSignature, +} + +impl crate::SendTransactionResult { + /// Converts the RPC result into the common execution send contract. + pub fn to_execution_result( + &self, + cluster: kb_lib::ExApiExecutionCluster, + ) -> kb_lib::ExApiExecutionSendResult { + return kb_lib::ExApiExecutionSendResult { + cluster, + signature: self.signature.clone(), + submitted: true, + }; + } +} + +/// Configuration for `getSignatureStatuses`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GetSignatureStatusesConfig { + /// Whether the node may search its full transaction history. + pub search_transaction_history: bool, +} + +impl crate::GetSignatureStatusesConfig { + /// Creates a status request configuration. + pub fn new(search_transaction_history: bool) -> Self { + return Self { search_transaction_history }; + } + + fn request_params( + &self, + signatures: &[kb_lib::MdSignature], + ) -> kb_core::Result> { + if signatures.is_empty() { + return std::result::Result::Err(kb_core::Error::config( + "getSignatureStatuses requires at least one signature", + )); + } + if signatures.len() > crate::MAX_SIGNATURE_STATUS_COUNT { + return std::result::Result::Err(kb_core::Error::config(format!( + "getSignatureStatuses supports at most {} signatures", + crate::MAX_SIGNATURE_STATUS_COUNT + ))); + } + let mut values = std::vec::Vec::with_capacity(signatures.len()); + for signature in signatures { + let validation_result = crate::validate_transaction_signature_text( + signature.0.as_str(), + "getSignatureStatuses signature", + ); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + values.push(serde_json::Value::String(signature.0.clone())); + } + return std::result::Result::Ok(std::vec![ + serde_json::Value::Array(values), + serde_json::json!({ + "searchTransactionHistory": self.search_transaction_history + }), + ]); + } +} + +/// Current status for one submitted transaction signature. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct SignatureStatus { + /// Slot that processed the transaction. + pub slot: u64, + /// Number of blocks since processing, or `None` once rooted/finalized. + pub confirmations: std::option::Option, + /// Runtime transaction error when execution failed. + pub error: std::option::Option, + /// Highest commitment reported by the node. + pub confirmation_status: std::option::Option, +} + +/// Status response preserving positional correspondence with requested signatures. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct SignatureStatusesResult { + /// RPC response context. + pub context: crate::RpcResponseContext, + /// Status values in request order; absent entries have not been observed. + pub statuses: std::vec::Vec>, +} + +/// Configuration for `getBlockHeight`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GetBlockHeightConfig { + /// Requested commitment. + pub commitment: crate::RpcCommitmentLevel, + /// Optional minimum context slot. + pub min_context_slot: std::option::Option, +} + +impl crate::GetBlockHeightConfig { + /// Creates a block-height request configuration. + pub fn new( + commitment: crate::RpcCommitmentLevel, + min_context_slot: std::option::Option, + ) -> Self { + return Self { commitment, min_context_slot }; + } + + fn request_params(&self) -> std::vec::Vec { + let mut config = serde_json::Map::::new(); + config.insert( + "commitment".to_string(), + serde_json::Value::String(self.commitment.as_str().to_string()), + ); + if let std::option::Option::Some(min_context_slot) = self.min_context_slot { + config.insert( + "minContextSlot".to_string(), + serde_json::Value::Number(serde_json::Number::from(min_context_slot)), + ); + } + return std::vec![serde_json::Value::Object(config)]; + } +} + +/// Current block height returned by the node. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct BlockHeightResult { + /// Block height observed at the requested commitment. + pub block_height: u64, +} + +/// Bounded polling policy used to confirm one submitted transaction. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConfirmTransactionConfig { + /// Commitment required before confirmation succeeds. + pub commitment: crate::RpcCommitmentLevel, + /// Whether status polling may search transaction history. + pub search_transaction_history: bool, + /// Delay between status polls. + pub poll_interval_ms: u64, + /// Maximum number of status polls. + pub max_attempts: u32, + /// Last valid block height of the recent blockhash, when applicable. + pub last_valid_block_height: std::option::Option, + /// Optional minimum context slot for block-height checks. + pub min_context_slot: std::option::Option, +} + +impl crate::ConfirmTransactionConfig { + /// Creates a bounded confirmation policy. + pub fn new( + commitment: crate::RpcCommitmentLevel, + search_transaction_history: bool, + poll_interval_ms: u64, + max_attempts: u32, + last_valid_block_height: std::option::Option, + min_context_slot: std::option::Option, + ) -> kb_core::Result { + if poll_interval_ms == 0 || poll_interval_ms > crate::MAX_CONFIRMATION_POLL_INTERVAL_MS { + return std::result::Result::Err(kb_core::Error::config(format!( + "confirmation poll interval must be between 1 and {} milliseconds", + crate::MAX_CONFIRMATION_POLL_INTERVAL_MS + ))); + } + if max_attempts == 0 || max_attempts > crate::MAX_CONFIRMATION_ATTEMPTS { + return std::result::Result::Err(kb_core::Error::config(format!( + "confirmation attempts must be between 1 and {}", + crate::MAX_CONFIRMATION_ATTEMPTS + ))); + } + return std::result::Result::Ok(Self { + commitment, + search_transaction_history, + poll_interval_ms, + max_attempts, + last_valid_block_height, + min_context_slot, + }); + } + + /// Creates a confirmed recent-blockhash confirmation policy. + pub fn confirmed( + last_valid_block_height: u64, + poll_interval_ms: u64, + max_attempts: u32, + ) -> kb_core::Result { + return Self::new( + crate::RpcCommitmentLevel::Confirmed, + false, + poll_interval_ms, + max_attempts, + std::option::Option::Some(last_valid_block_height), + std::option::Option::None, + ); + } + + /// Creates a confirmed bounded policy from the active execution settings. + pub fn from_execution_config( + config: &kb_config::ExecutionConfig, + last_valid_block_height: std::option::Option, + min_context_slot: std::option::Option, + ) -> kb_core::Result { + return Self::new( + crate::RpcCommitmentLevel::Confirmed, + false, + config.confirmation_poll_interval_ms, + config.confirmation_max_attempts, + last_valid_block_height, + min_context_slot, + ); + } +} + +/// Classifies a genesis hash as one of the three official public clusters. +pub fn classify_genesis_hash( + genesis_hash: &str, +) -> std::option::Option { + return match genesis_hash { + crate::DEVNET_GENESIS_HASH => { + std::option::Option::Some(kb_lib::ExApiExecutionCluster::Devnet) + }, + crate::TESTNET_GENESIS_HASH => { + std::option::Option::Some(kb_lib::ExApiExecutionCluster::Testnet) + }, + crate::MAINNET_GENESIS_HASH => { + std::option::Option::Some(kb_lib::ExApiExecutionCluster::Mainnet) + }, + _ => std::option::Option::None, + }; +} + +/// Adapts a raw `getGenesisHash` result. +pub fn adapt_get_genesis_hash_result( + source: &serde_json::Value, +) -> kb_core::Result { + let genesis_hash = match source.as_str() { + std::option::Option::Some(value) => value.to_string(), + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::json( + "getGenesisHash result must be a string", + )); + }, + }; + let validation_result = + crate::validate_solana_hash_text(genesis_hash.as_str(), "getGenesisHash result"); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + return std::result::Result::Ok(crate::GenesisHashResult { + classified_cluster: crate::classify_genesis_hash(genesis_hash.as_str()), + genesis_hash, + }); +} + +/// Adapts a raw `getLatestBlockhash` result. +pub fn adapt_get_latest_blockhash_result( + source: &serde_json::Value, +) -> kb_core::Result { + let parse_result = + serde_json::from_value::>(source.clone()); + let parsed = match parse_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::json(format!( + "cannot parse getLatestBlockhash result: {error}" + ))); + }, + }; + let validation_result = crate::validate_solana_hash_text( + parsed.value.blockhash.as_str(), + "getLatestBlockhash blockhash", + ); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + return std::result::Result::Ok(crate::LatestBlockhashResult { + context: parsed.context, + blockhash: parsed.value.blockhash, + last_valid_block_height: parsed.value.last_valid_block_height, + }); +} + +/// Adapts a raw `getFeeForMessage` result. +pub fn adapt_get_fee_for_message_result( + source: &serde_json::Value, +) -> kb_core::Result { + let parse_result = + serde_json::from_value::>>(source.clone()); + let parsed = match parse_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::json(format!( + "cannot parse getFeeForMessage result: {error}" + ))); + }, + }; + return std::result::Result::Ok(crate::FeeForMessageResult { + context: parsed.context, + fee_lamports: parsed.value, + }); +} + +/// Adapts a raw `simulateTransaction` result. +pub fn adapt_simulate_transaction_result( + source: &serde_json::Value, +) -> kb_core::Result { + let parse_result = + serde_json::from_value::>(source.clone()); + let parsed = match parse_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::json(format!( + "cannot parse simulateTransaction result: {error}" + ))); + }, + }; + if let std::option::Option::Some(replacement) = &parsed.value.replacement_blockhash { + let validation_result = crate::validate_solana_hash_text( + replacement.blockhash.as_str(), + "simulateTransaction replacement blockhash", + ); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + } + let logs = match parsed.value.logs { + std::option::Option::Some(logs) => logs, + std::option::Option::None => std::vec::Vec::new(), + }; + return std::result::Result::Ok(crate::SimulateTransactionResult { + context: parsed.context, + success: parsed.value.error.is_none(), + error: parsed.value.error, + logs, + units_consumed: parsed.value.units_consumed, + fee_lamports: parsed.value.fee, + loaded_accounts_data_size: parsed.value.loaded_accounts_data_size, + replacement_blockhash: parsed.value.replacement_blockhash, + return_data: parsed.value.return_data, + inner_instructions: parsed.value.inner_instructions, + accounts: parsed.value.accounts, + }); +} + +/// Adapts a raw `getBalance` result. +pub fn adapt_get_balance_result( + source: &serde_json::Value, +) -> kb_core::Result { + let parse_result = serde_json::from_value::>(source.clone()); + let parsed = match parse_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::json(format!( + "cannot parse getBalance result: {error}" + ))); + }, + }; + return std::result::Result::Ok(crate::BalanceResult { + context: parsed.context, + lamports: parsed.value, + }); +} + +/// Adapts a raw metadata-only `getAccountInfo` result. +pub fn adapt_get_account_info_result( + source: &serde_json::Value, +) -> kb_core::Result { + return crate::adapt_get_account_info_result_with_data_limit(source, std::option::Option::None); +} + +pub(crate) fn adapt_get_account_info_result_with_data_limit( + source: &serde_json::Value, + max_data_bytes: std::option::Option, +) -> kb_core::Result { + let context = match source.get("context") { + std::option::Option::Some(value) => { + let parse_result = serde_json::from_value::(value.clone()); + match parse_result { + std::result::Result::Ok(context) => context, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::json(format!( + "cannot parse getAccountInfo context: {error}" + ))); + }, + } + }, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::json( + "getAccountInfo result is missing context", + )); + }, + }; + let value = match source.get("value") { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::json( + "getAccountInfo result is missing value", + )); + }, + }; + if value.is_null() { + return std::result::Result::Ok(crate::AccountInfoResult { + context, + account: std::option::Option::None, + }); + } + let lamports = match value.get("lamports").and_then(serde_json::Value::as_u64) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::json( + "getAccountInfo account lamports must be an unsigned integer", + )); + }, + }; + let owner_text = match value.get("owner").and_then(serde_json::Value::as_str) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::json( + "getAccountInfo account owner must be a string", + )); + }, + }; + let owner_validation = + crate::validate_solana_pubkey_text(owner_text, "getAccountInfo account owner"); + if let std::result::Result::Err(error) = owner_validation { + return std::result::Result::Err(error); + } + let executable = match value.get("executable").and_then(serde_json::Value::as_bool) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::json( + "getAccountInfo account executable must be a boolean", + )); + }, + }; + let rent_epoch = match value.get("rentEpoch").and_then(serde_json::Value::as_u64) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::json( + "getAccountInfo account rentEpoch must be an unsigned integer", + )); + }, + }; + let space = match value.get("space").and_then(serde_json::Value::as_u64) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::json( + "getAccountInfo account space must be an unsigned integer", + )); + }, + }; + let data = match crate::execution_rpc::decode_account_data(value, space, max_data_bytes) { + std::result::Result::Ok(data) => data, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(crate::AccountInfoResult { + context, + account: std::option::Option::Some(crate::AccountInfoValue { + lamports, + owner: kb_lib::MdProgramId(owner_text.to_string()), + executable, + rent_epoch, + space, + data, + }), + }); +} + +fn decode_account_data( + value: &serde_json::Value, + space: u64, + max_data_bytes: std::option::Option, +) -> kb_core::Result> { + let tuple = match value.get("data").and_then(serde_json::Value::as_array) { + std::option::Option::Some(tuple) if tuple.len() == 2 => tuple, + _ => { + return std::result::Result::Err(kb_core::Error::json( + "getAccountInfo account data must be a two-element encoded tuple", + )); + }, + }; + let encoded = match tuple.first().and_then(serde_json::Value::as_str) { + std::option::Option::Some(encoded) => encoded, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::json( + "getAccountInfo account data payload must be a string", + )); + }, + }; + let encoding = match tuple.get(1).and_then(serde_json::Value::as_str) { + std::option::Option::Some(encoding) => encoding, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::json( + "getAccountInfo account data encoding must be a string", + )); + }, + }; + if encoding != "base64" { + return std::result::Result::Err(kb_core::Error::json(format!( + "getAccountInfo account data encoding {encoding} is unsupported" + ))); + } + match max_data_bytes { + std::option::Option::None => { + if !encoded.is_empty() { + return std::result::Result::Err(kb_core::Error::json( + "metadata-only getAccountInfo returned non-empty account data", + )); + } + }, + std::option::Option::Some(max_data_bytes) => { + if max_data_bytes == 0 || max_data_bytes > crate::MAX_EXECUTION_ACCOUNT_DATA_BYTES { + return std::result::Result::Err(kb_core::Error::json(format!( + "getAccountInfo complete data limit must be between 1 and {} bytes", + crate::MAX_EXECUTION_ACCOUNT_DATA_BYTES + ))); + } + if space > max_data_bytes as u64 { + return std::result::Result::Err(kb_core::Error::json(format!( + "getAccountInfo account space {space} exceeds the requested complete-data limit {max_data_bytes}" + ))); + } + let maximum_encoded_length = max_data_bytes.div_ceil(3) * 4; + if encoded.len() > maximum_encoded_length { + return std::result::Result::Err(kb_core::Error::json(format!( + "getAccountInfo encoded account data length {} exceeds the limit {maximum_encoded_length}", + encoded.len() + ))); + } + }, + } + let decoded = match base64::engine::general_purpose::STANDARD.decode(encoded) { + std::result::Result::Ok(decoded) => decoded, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::json(format!( + "cannot decode getAccountInfo base64 account data: {error}" + ))); + }, + }; + if let std::option::Option::Some(max_data_bytes) = max_data_bytes { + if decoded.len() > max_data_bytes { + return std::result::Result::Err(kb_core::Error::json(format!( + "getAccountInfo decoded account data length {} exceeds the requested limit {max_data_bytes}", + decoded.len() + ))); + } + if decoded.len() as u64 != space { + return std::result::Result::Err(kb_core::Error::json(format!( + "getAccountInfo returned {} decoded bytes for an account reporting {space} bytes", + decoded.len() + ))); + } + } + return std::result::Result::Ok(decoded); +} + +/// Adapts a raw `getMinimumBalanceForRentExemption` result. +pub fn adapt_get_minimum_balance_for_rent_exemption_result( + source: &serde_json::Value, + data_length: u64, +) -> kb_core::Result { + let minimum_balance_lamports = match source.as_u64() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::json( + "getMinimumBalanceForRentExemption result must be an unsigned integer", + )); + }, + }; + return std::result::Result::Ok(crate::MinimumBalanceForRentExemptionResult { + data_length, + minimum_balance_lamports, + }); +} + +/// Adapts a raw `requestAirdrop` result. +pub fn adapt_request_airdrop_result( + source: &serde_json::Value, +) -> kb_core::Result { + let signature = + match crate::execution_rpc::adapt_signature_result(source, "requestAirdrop result") { + std::result::Result::Ok(signature) => signature, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(crate::AirdropResult { signature }); +} + +/// Adapts a raw `sendTransaction` result and verifies the returned primary signature. +pub fn adapt_send_transaction_result( + source: &serde_json::Value, + expected_signature: &kb_lib::MdSignature, +) -> kb_core::Result { + let expected_validation = crate::validate_transaction_signature_text( + expected_signature.0.as_str(), + "sendTransaction expected signature", + ); + if let std::result::Result::Err(error) = expected_validation { + return std::result::Result::Err(error); + } + let signature = + match crate::execution_rpc::adapt_signature_result(source, "sendTransaction result") { + std::result::Result::Ok(signature) => signature, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if signature != *expected_signature { + return std::result::Result::Err(kb_core::Error::new( + "execution_send_signature_mismatch", + format!( + "sendTransaction returned signature '{}' but the signed transaction primary signature is '{}'", + signature.0, expected_signature.0 + ), + )); + } + return std::result::Result::Ok(crate::SendTransactionResult { signature }); +} + +/// Adapts a raw `getSignatureStatuses` result. +pub fn adapt_get_signature_statuses_result( + source: &serde_json::Value, +) -> kb_core::Result { + let parse_result = serde_json::from_value::< + RpcContextValue>>, + >(source.clone()); + let parsed = match parse_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::json(format!( + "cannot parse getSignatureStatuses result: {error}" + ))); + }, + }; + let mut statuses = std::vec::Vec::with_capacity(parsed.value.len()); + for value in parsed.value { + let status = value.map(|value| { + return crate::SignatureStatus { + slot: value.slot, + confirmations: value.confirmations, + error: value.error, + confirmation_status: value.confirmation_status, + }; + }); + statuses.push(status); + } + return std::result::Result::Ok(crate::SignatureStatusesResult { + context: parsed.context, + statuses, + }); +} + +/// Adapts a raw `getEpochInfo` result. +pub fn adapt_get_epoch_info_result( + source: &serde_json::Value, +) -> kb_core::Result { + let parse_result = serde_json::from_value::(source.clone()); + return match parse_result { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::json(format!( + "cannot parse getEpochInfo result: {error}" + ))), + }; +} + +/// Adapts a raw `getBlockHeight` result. +pub fn adapt_get_block_height_result( + source: &serde_json::Value, +) -> kb_core::Result { + let block_height = match source.as_u64() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::json( + "getBlockHeight result must be an unsigned integer", + )); + }, + }; + return std::result::Result::Ok(crate::BlockHeightResult { block_height }); +} + +impl crate::HttpClient { + /// Returns the connected cluster genesis hash and known public-cluster classification. + pub async fn get_genesis_hash(&self) -> kb_core::Result { + let result = self + .execute_json_rpc_result_raw("getGenesisHash".to_string(), std::vec::Vec::new()) + .await; + let value = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::adapt_get_genesis_hash_result(&value); + } + + /// Returns current epoch and slot progression information. + pub async fn get_epoch_info( + &self, + config: &crate::GetEpochInfoConfig, + ) -> kb_core::Result { + let result = self + .execute_json_rpc_result_raw("getEpochInfo".to_string(), config.request_params()) + .await; + let value = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::adapt_get_epoch_info_result(&value); + } + + /// Returns the latest blockhash and its last valid block height. + pub async fn get_latest_blockhash( + &self, + config: &crate::GetLatestBlockhashConfig, + ) -> kb_core::Result { + let result = self + .execute_json_rpc_result_raw("getLatestBlockhash".to_string(), config.request_params()) + .await; + let value = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::adapt_get_latest_blockhash_result(&value); + } + + /// Returns the fee the cluster would charge for a serialized message. + pub async fn get_fee_for_message( + &self, + encoded_message: &str, + config: &crate::GetFeeForMessageConfig, + ) -> kb_core::Result { + let params_result = config.request_params(encoded_message); + let params = match params_result { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let result = self.execute_json_rpc_result_raw("getFeeForMessage".to_string(), params).await; + let value = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::adapt_get_fee_for_message_result(&value); + } + + /// Simulates one base64-encoded transaction without broadcasting it. + pub async fn simulate_transaction( + &self, + encoded_transaction: &str, + config: &crate::SimulateTransactionConfig, + ) -> kb_core::Result { + let params_result = config.request_params(encoded_transaction); + let params = match params_result { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let result = self + .execute_json_rpc_result_raw("simulateTransaction".to_string(), params) + .await; + let value = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::adapt_simulate_transaction_result(&value); + } + + /// Returns the lamport balance for one account. + pub async fn get_balance( + &self, + pubkey: &kb_lib::MdPubkey, + config: &crate::GetBalanceConfig, + ) -> kb_core::Result { + let params = match config.request_params(pubkey) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let result = self.execute_json_rpc_result_raw("getBalance".to_string(), params).await; + let value = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::adapt_get_balance_result(&value); + } + + /// Returns minimal account metadata or `None` when the address does not exist. + pub async fn get_account_info( + &self, + pubkey: &kb_lib::MdPubkey, + config: &crate::GetAccountInfoConfig, + ) -> kb_core::Result { + let params = match config.request_params(pubkey) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let result = self.execute_json_rpc_result_raw("getAccountInfo".to_string(), params).await; + let value = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::adapt_get_account_info_result_with_data_limit(&value, config.max_data_bytes); + } + + /// Returns the rent-exempt minimum for one account data length. + pub async fn get_minimum_balance_for_rent_exemption( + &self, + data_length: u64, + config: &crate::GetMinimumBalanceForRentExemptionConfig, + ) -> kb_core::Result { + let result = self + .execute_json_rpc_result_raw( + "getMinimumBalanceForRentExemption".to_string(), + config.request_params(data_length), + ) + .await; + let value = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::adapt_get_minimum_balance_for_rent_exemption_result(&value, data_length); + } + + /// Requests a faucet airdrop to one account. + pub async fn request_airdrop( + &self, + pubkey: &kb_lib::MdPubkey, + lamports: u64, + config: &crate::RequestAirdropConfig, + ) -> kb_core::Result { + let params = match config.request_params(pubkey, lamports) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let result = self.execute_json_rpc_result_raw("requestAirdrop".to_string(), params).await; + let value = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::adapt_request_airdrop_result(&value); + } + + /// Submits one fully signed base64 transaction and verifies the returned signature. + pub async fn send_transaction( + &self, + encoded_transaction: &str, + expected_signature: &kb_lib::MdSignature, + config: &crate::SendTransactionConfig, + ) -> kb_core::Result { + let params = match config.request_params(encoded_transaction) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let result = self.execute_json_rpc_result_raw("sendTransaction".to_string(), params).await; + let value = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let adapted = match crate::adapt_send_transaction_result(&value, expected_signature) { + std::result::Result::Ok(adapted) => adapted, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + tracing::info!( + target: crate::TRACING_TARGET, + action = "send_transaction", + endpoint_name = %self.endpoint_name(), + provider = %self.provider(), + signature = %adapted.signature.0, + status = "submitted", + "signed transaction accepted for relay" + ); + return std::result::Result::Ok(adapted); + } + + /// Returns current statuses for a bounded signature list. + pub async fn get_signature_statuses( + &self, + signatures: &[kb_lib::MdSignature], + config: &crate::GetSignatureStatusesConfig, + ) -> kb_core::Result { + let params = match config.request_params(signatures) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let result = self + .execute_json_rpc_result_raw("getSignatureStatuses".to_string(), params) + .await; + let value = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::adapt_get_signature_statuses_result(&value); + } + + /// Returns the block height observed by the node. + pub async fn get_block_height( + &self, + config: &crate::GetBlockHeightConfig, + ) -> kb_core::Result { + let result = self + .execute_json_rpc_result_raw("getBlockHeight".to_string(), config.request_params()) + .await; + let value = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::adapt_get_block_height_result(&value); + } +} + +impl crate::HttpEndpointPool { + /// Executes `getGenesisHash` through one endpoint selected for the requested role. + pub async fn get_genesis_hash_for_role( + &self, + required_role: &str, + ) -> kb_core::Result { + let client_result = self.select_client_for_role_and_method(required_role, "getGenesisHash"); + let client = match client_result { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return client.get_genesis_hash().await; + } + + /// Executes `getEpochInfo` through one endpoint selected for the requested role. + pub async fn get_epoch_info_for_role( + &self, + required_role: &str, + config: &crate::GetEpochInfoConfig, + ) -> kb_core::Result { + let client_result = self.select_client_for_role_and_method(required_role, "getEpochInfo"); + let client = match client_result { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return client.get_epoch_info(config).await; + } + + /// Executes `getLatestBlockhash` through one endpoint selected for the requested role. + pub async fn get_latest_blockhash_for_role( + &self, + required_role: &str, + config: &crate::GetLatestBlockhashConfig, + ) -> kb_core::Result { + let client_result = + self.select_client_for_role_and_method(required_role, "getLatestBlockhash"); + let client = match client_result { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return client.get_latest_blockhash(config).await; + } + + /// Executes `getFeeForMessage` through one endpoint selected for the requested role. + pub async fn get_fee_for_message_for_role( + &self, + required_role: &str, + encoded_message: &str, + config: &crate::GetFeeForMessageConfig, + ) -> kb_core::Result { + let client_result = + self.select_client_for_role_and_method(required_role, "getFeeForMessage"); + let client = match client_result { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return client.get_fee_for_message(encoded_message, config).await; + } + + /// Executes `simulateTransaction` through one endpoint selected for the requested role. + pub async fn simulate_transaction_for_role( + &self, + required_role: &str, + encoded_transaction: &str, + config: &crate::SimulateTransactionConfig, + ) -> kb_core::Result { + let client_result = + self.select_client_for_role_and_method(required_role, "simulateTransaction"); + let client = match client_result { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return client.simulate_transaction(encoded_transaction, config).await; + } + + /// Executes `getBalance` through one endpoint selected for the requested role. + pub async fn get_balance_for_role( + &self, + required_role: &str, + pubkey: &kb_lib::MdPubkey, + config: &crate::GetBalanceConfig, + ) -> kb_core::Result { + let client_result = self.select_client_for_role_and_method(required_role, "getBalance"); + let client = match client_result { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return client.get_balance(pubkey, config).await; + } + + /// Executes `getAccountInfo` through one endpoint selected for the requested role. + pub async fn get_account_info_for_role( + &self, + required_role: &str, + pubkey: &kb_lib::MdPubkey, + config: &crate::GetAccountInfoConfig, + ) -> kb_core::Result { + let client_result = self.select_client_for_role_and_method(required_role, "getAccountInfo"); + let client = match client_result { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return client.get_account_info(pubkey, config).await; + } + + /// Executes `getMinimumBalanceForRentExemption` through one selected endpoint. + pub async fn get_minimum_balance_for_rent_exemption_for_role( + &self, + required_role: &str, + data_length: u64, + config: &crate::GetMinimumBalanceForRentExemptionConfig, + ) -> kb_core::Result { + let client_result = self + .select_client_for_role_and_method(required_role, "getMinimumBalanceForRentExemption"); + let client = match client_result { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return client.get_minimum_balance_for_rent_exemption(data_length, config).await; + } + + /// Executes `requestAirdrop` through one endpoint selected for the requested role. + pub async fn request_airdrop_for_role( + &self, + required_role: &str, + pubkey: &kb_lib::MdPubkey, + lamports: u64, + config: &crate::RequestAirdropConfig, + ) -> kb_core::Result { + let client_result = self.select_client_for_role_and_method(required_role, "requestAirdrop"); + let client = match client_result { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return client.request_airdrop(pubkey, lamports, config).await; + } + + /// Executes `sendTransaction` through one endpoint selected for the requested role. + pub async fn send_transaction_for_role( + &self, + required_role: &str, + encoded_transaction: &str, + expected_signature: &kb_lib::MdSignature, + config: &crate::SendTransactionConfig, + ) -> kb_core::Result { + let client_result = + self.select_client_for_role_and_method(required_role, "sendTransaction"); + let client = match client_result { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return client.send_transaction(encoded_transaction, expected_signature, config).await; + } + + /// Executes `getSignatureStatuses` through one endpoint selected for the requested role. + pub async fn get_signature_statuses_for_role( + &self, + required_role: &str, + signatures: &[kb_lib::MdSignature], + config: &crate::GetSignatureStatusesConfig, + ) -> kb_core::Result { + let client_result = + self.select_client_for_role_and_method(required_role, "getSignatureStatuses"); + let client = match client_result { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return client.get_signature_statuses(signatures, config).await; + } + + /// Executes `getBlockHeight` through one endpoint selected for the requested role. + pub async fn get_block_height_for_role( + &self, + required_role: &str, + config: &crate::GetBlockHeightConfig, + ) -> kb_core::Result { + let client_result = self.select_client_for_role_and_method(required_role, "getBlockHeight"); + let client = match client_result { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return client.get_block_height(config).await; + } + + /// Polls one signature until the requested commitment, failure, expiration or timeout. + pub async fn confirm_transaction_for_roles( + &self, + status_role: &str, + block_height_role: &str, + cluster: kb_lib::ExApiExecutionCluster, + signature: &kb_lib::MdSignature, + config: &crate::ConfirmTransactionConfig, + ) -> kb_core::Result { + let status_config = + crate::GetSignatureStatusesConfig::new(config.search_transaction_history); + let block_height_config = + crate::GetBlockHeightConfig::new(config.commitment, config.min_context_slot); + let mut attempts = 0_u32; + let mut last_slot = std::option::Option::None; + let mut last_observed_block_height = std::option::Option::None; + while attempts < config.max_attempts { + attempts = attempts.saturating_add(1); + let statuses_result = self + .get_signature_statuses_for_role( + status_role, + std::slice::from_ref(signature), + &status_config, + ) + .await; + let statuses = match statuses_result { + std::result::Result::Ok(statuses) => statuses, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if statuses.statuses.len() != 1 { + return std::result::Result::Err(kb_core::Error::new( + "execution_confirmation_status_count_mismatch", + format!( + "getSignatureStatuses returned {} entries for one requested signature", + statuses.statuses.len() + ), + )); + } + let status = match statuses.statuses.first() { + std::option::Option::Some(status) => status.as_ref(), + std::option::Option::None => std::option::Option::None, + }; + if let std::option::Option::Some(status) = status { + last_slot = std::option::Option::Some(status.slot); + if let std::option::Option::Some(error) = &status.error { + return std::result::Result::Ok(crate::execution_rpc::confirmation_result( + cluster, + signature, + kb_lib::ExApiExecutionConfirmationStatus::Failed, + last_slot, + attempts, + last_observed_block_height, + std::option::Option::Some(error.to_string()), + )); + } + let observed_commitment = match status.confirmation_status { + std::option::Option::Some(commitment) => commitment, + std::option::Option::None => crate::RpcCommitmentLevel::Processed, + }; + if crate::execution_rpc::commitment_reached(observed_commitment, config.commitment) + { + let execution_status = + crate::execution_rpc::execution_confirmation_status(observed_commitment); + tracing::info!( + target: crate::TRACING_TARGET, + action = "confirm_transaction", + signature = %signature.0, + attempts, + slot = status.slot, + confirmation_status = observed_commitment.as_str(), + "transaction reached requested commitment" + ); + return std::result::Result::Ok(crate::execution_rpc::confirmation_result( + cluster, + signature, + execution_status, + last_slot, + attempts, + last_observed_block_height, + std::option::Option::None, + )); + } + } + if let std::option::Option::Some(last_valid_block_height) = + config.last_valid_block_height + { + let height_result = + self.get_block_height_for_role(block_height_role, &block_height_config).await; + let height = match height_result { + std::result::Result::Ok(height) => height, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + last_observed_block_height = std::option::Option::Some(height.block_height); + if height.block_height > last_valid_block_height { + return std::result::Result::Ok(crate::execution_rpc::confirmation_result( + cluster, + signature, + kb_lib::ExApiExecutionConfirmationStatus::Expired, + last_slot, + attempts, + last_observed_block_height, + std::option::Option::Some(format!( + "recent blockhash expired at block height {last_valid_block_height}; observed {observed}", + observed = height.block_height + )), + )); + } + } + if attempts < config.max_attempts { + tokio::time::sleep(std::time::Duration::from_millis(config.poll_interval_ms)).await; + } + } + return std::result::Result::Ok(crate::execution_rpc::confirmation_result( + cluster, + signature, + kb_lib::ExApiExecutionConfirmationStatus::TimedOut, + last_slot, + attempts, + last_observed_block_height, + std::option::Option::Some(format!( + "transaction did not reach '{}' commitment after {} attempts", + config.commitment.as_str(), + attempts + )), + )); + } +} + +fn validate_base64_payload(value: &str, field_name: &str) -> kb_core::Result<()> { + if value.trim().is_empty() { + return std::result::Result::Err(kb_core::Error::config(format!( + "{field_name} must not be empty" + ))); + } + if value.len() > crate::MAX_EXECUTION_RPC_BASE64_LENGTH { + return std::result::Result::Err(kb_core::Error::config(format!( + "{field_name} exceeds the local encoded length limit of {} bytes", + crate::MAX_EXECUTION_RPC_BASE64_LENGTH + ))); + } + let decode_result = base64::engine::general_purpose::STANDARD.decode(value); + let decoded = match decode_result { + std::result::Result::Ok(decoded) => decoded, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::config(format!( + "{field_name} is not valid base64: {error}" + ))); + }, + }; + if decoded.is_empty() { + return std::result::Result::Err(kb_core::Error::config(format!( + "{field_name} must decode to at least one byte" + ))); + } + return std::result::Result::Ok(()); +} + +#[derive(Clone, Debug, serde::Deserialize)] +struct RpcContextValue { + context: crate::RpcResponseContext, + value: T, +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct RpcLatestBlockhashValue { + blockhash: std::string::String, + last_valid_block_height: u64, +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct RpcSimulationValue { + #[serde(default, rename = "err")] + error: std::option::Option, + #[serde(default)] + logs: std::option::Option>, + #[serde(default)] + units_consumed: std::option::Option, + #[serde(default)] + fee: std::option::Option, + #[serde(default)] + loaded_accounts_data_size: std::option::Option, + #[serde(default)] + replacement_blockhash: std::option::Option, + #[serde(default)] + return_data: std::option::Option, + #[serde(default)] + inner_instructions: std::option::Option, + #[serde(default)] + accounts: std::option::Option>>, +} + +fn adapt_signature_result( + source: &serde_json::Value, + field_name: &str, +) -> kb_core::Result { + let signature = match source.as_str() { + std::option::Option::Some(signature) => signature.to_string(), + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::json(format!( + "{field_name} must be a base58 signature string" + ))); + }, + }; + let validation_result = + crate::validate_transaction_signature_text(signature.as_str(), field_name); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + return std::result::Result::Ok(kb_lib::MdSignature(signature)); +} + +fn commitment_rank(commitment: crate::RpcCommitmentLevel) -> u8 { + return match commitment { + crate::RpcCommitmentLevel::Processed => 0, + crate::RpcCommitmentLevel::Confirmed => 1, + crate::RpcCommitmentLevel::Finalized => 2, + }; +} + +fn commitment_reached( + observed: crate::RpcCommitmentLevel, + required: crate::RpcCommitmentLevel, +) -> bool { + return crate::execution_rpc::commitment_rank(observed) + >= crate::execution_rpc::commitment_rank(required); +} + +fn execution_confirmation_status( + commitment: crate::RpcCommitmentLevel, +) -> kb_lib::ExApiExecutionConfirmationStatus { + return match commitment { + crate::RpcCommitmentLevel::Processed => kb_lib::ExApiExecutionConfirmationStatus::Processed, + crate::RpcCommitmentLevel::Confirmed => kb_lib::ExApiExecutionConfirmationStatus::Confirmed, + crate::RpcCommitmentLevel::Finalized => kb_lib::ExApiExecutionConfirmationStatus::Finalized, + }; +} + +fn confirmation_result( + cluster: kb_lib::ExApiExecutionCluster, + signature: &kb_lib::MdSignature, + status: kb_lib::ExApiExecutionConfirmationStatus, + slot: std::option::Option, + attempts: u32, + last_observed_block_height: std::option::Option, + error: std::option::Option, +) -> kb_lib::ExApiExecutionConfirmationResult { + return kb_lib::ExApiExecutionConfirmationResult { + cluster, + signature: signature.clone(), + status, + slot, + attempts, + last_observed_block_height, + error, + }; +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct RpcSignatureStatusValue { + slot: u64, + #[serde(default)] + confirmations: std::option::Option, + #[serde(default, rename = "err")] + error: std::option::Option, + #[serde(default)] + confirmation_status: std::option::Option, +} + +#[cfg(test)] +mod tests { + use base64::Engine; // rust-rules: trait-import + + fn valid_hash(byte: u8) -> std::string::String { + return bs58::encode([byte; 32]).into_string(); + } + + fn encoded_bytes(bytes: &[u8]) -> std::string::String { + return base64::engine::general_purpose::STANDARD.encode(bytes); + } + + #[test] + fn genesis_hash_classifies_official_public_clusters() { + let devnet = crate::adapt_get_genesis_hash_result(&serde_json::Value::String( + crate::DEVNET_GENESIS_HASH.to_string(), + )) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!( + devnet.classified_cluster, + std::option::Option::Some(kb_lib::ExApiExecutionCluster::Devnet) + ); + let mainnet = crate::adapt_get_genesis_hash_result(&serde_json::Value::String( + crate::MAINNET_GENESIS_HASH.to_string(), + )) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!( + mainnet.classified_cluster, + std::option::Option::Some(kb_lib::ExApiExecutionCluster::Mainnet) + ); + let local = crate::adapt_get_genesis_hash_result(&serde_json::Value::String(valid_hash(7))) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(local.classified_cluster, std::option::Option::None); + } + + #[test] + fn latest_blockhash_request_and_result_are_typed() { + let config = crate::GetLatestBlockhashConfig::new( + crate::RpcCommitmentLevel::Processed, + std::option::Option::Some(41), + ); + assert_eq!( + config.request_params(), + std::vec![serde_json::json!({ + "commitment": "processed", + "minContextSlot": 41 + })] + ); + let blockhash = valid_hash(9); + let result = crate::adapt_get_latest_blockhash_result(&serde_json::json!({ + "context": {"slot": 42, "apiVersion": "4.1.1"}, + "value": {"blockhash": blockhash, "lastValidBlockHeight": 99} + })) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(result.context.slot, 42); + assert_eq!(result.last_valid_block_height, 99); + } + + #[test] + fn fee_request_rejects_invalid_base64_and_accepts_null_fee() { + let config = crate::GetFeeForMessageConfig::confirmed(); + assert!(config.request_params("not base64").is_err()); + let result = crate::adapt_get_fee_for_message_result(&serde_json::json!({ + "context": {"slot": 51}, + "value": null + })) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(result.fee_lamports, std::option::Option::None); + } + + #[test] + fn simulation_configuration_builds_unsigned_request() { + let exact = crate::SimulateTransactionConfig::unsigned_exact(); + assert!(!exact.signature_verification); + assert!(!exact.replace_recent_blockhash); + let account = kb_lib::MdPubkey(valid_hash(11)); + let accounts = crate::SimulationAccountsConfig::new(std::vec![account]) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + let config = crate::SimulateTransactionConfig::new( + crate::RpcCommitmentLevel::Confirmed, + false, + true, + std::option::Option::Some(60), + true, + std::option::Option::Some(accounts), + ) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + let encoded = encoded_bytes(&[1, 2, 3]); + let params = config + .request_params(encoded.as_str()) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(params[0], serde_json::Value::String(encoded)); + assert_eq!(params[1]["encoding"], "base64"); + assert_eq!(params[1]["sigVerify"], false); + assert_eq!(params[1]["replaceRecentBlockhash"], true); + assert_eq!(params[1]["innerInstructions"], true); + assert_eq!(params[1]["minContextSlot"], 60); + assert_eq!(params[1]["accounts"]["encoding"], "base64"); + } + + #[test] + fn simulation_configuration_rejects_signature_verification_with_replacement() { + let result = crate::SimulateTransactionConfig::new( + crate::RpcCommitmentLevel::Confirmed, + true, + true, + std::option::Option::None, + false, + std::option::Option::None, + ); + assert!(result.is_err()); + } + + #[test] + fn simulation_result_preserves_runtime_diagnostics() { + let replacement = valid_hash(13); + let result = crate::adapt_simulate_transaction_result(&serde_json::json!({ + "context": {"slot": 70, "apiVersion": "4.1.1"}, + "value": { + "err": null, + "logs": ["Program 111 invoke [1]", "Program 111 success"], + "unitsConsumed": 1714, + "fee": 5000, + "loadedAccountsDataSize": 413, + "replacementBlockhash": { + "blockhash": replacement, + "lastValidBlockHeight": 100 + }, + "returnData": null, + "innerInstructions": null, + "accounts": null + } + })) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert!(result.success); + assert_eq!(result.units_consumed, std::option::Option::Some(1714)); + assert_eq!(result.fee_lamports, std::option::Option::Some(5000)); + assert_eq!(result.logs.len(), 2); + } + + #[test] + fn simulation_result_converts_to_common_execution_contract() { + let rpc = crate::SimulateTransactionResult { + context: crate::RpcResponseContext { + slot: 90, + api_version: std::option::Option::None, + }, + success: false, + error: std::option::Option::Some( + serde_json::json!({"InstructionError": [0, "Custom"]}), + ), + logs: std::vec!["failed".to_string()], + units_consumed: std::option::Option::Some(12), + fee_lamports: std::option::Option::None, + loaded_accounts_data_size: std::option::Option::None, + replacement_blockhash: std::option::Option::Some( + crate::SimulationReplacementBlockhash { + blockhash: valid_hash(14), + last_valid_block_height: 101, + }, + ), + return_data: std::option::Option::None, + inner_instructions: std::option::Option::None, + accounts: std::option::Option::None, + }; + let fee = crate::FeeForMessageResult { + context: crate::RpcResponseContext { + slot: 90, + api_version: std::option::Option::None, + }, + fee_lamports: std::option::Option::Some(5000), + }; + let execution = rpc.to_execution_result( + kb_lib::ExApiExecutionCluster::Devnet, + kb_lib::ExApiExecutionBlockhashKind::Latest, + std::option::Option::Some(3), + std::option::Option::None, + std::option::Option::None, + std::option::Option::Some(&fee), + ); + assert!(execution.simulated); + assert!(!execution.success); + assert_eq!(execution.estimated_fee_lamports, std::option::Option::Some(5000)); + assert_eq!(execution.replacement_last_valid_block_height, std::option::Option::Some(101)); + assert!(execution.replacement_blockhash.is_some()); + assert!(execution.error.is_some()); + } + + #[test] + fn adapters_reject_invalid_hash_lengths() { + let invalid = bs58::encode([1_u8; 31]).into_string(); + let result = crate::adapt_get_latest_blockhash_result(&serde_json::json!({ + "context": {"slot": 1}, + "value": {"blockhash": invalid, "lastValidBlockHeight": 2} + })); + assert!(result.is_err()); + } + fn valid_signature(byte: u8) -> std::string::String { + return bs58::encode([byte; 64]).into_string(); + } + + fn execution_config() -> kb_config::ExecutionConfig { + return kb_config::ExecutionConfig { + dry_run_default: true, + require_simulation: true, + require_operator_confirmation: true, + localnet_max_spend_lamports: 1_000_000, + devnet_max_spend_lamports: 1_000_000, + testnet_max_spend_lamports: 0, + mainnet_max_spend_lamports: 0, + max_fee_lamports: 10_000, + max_compute_unit_price_micro_lamports: 5, + recent_blockhash_max_age_slots: 150, + send_max_retries: 7, + confirmation_poll_interval_ms: 250, + confirmation_max_attempts: 80, + devnet_airdrop_max_lamports: 2_000_000_000, + }; + } + + #[test] + fn epoch_info_request_and_result_are_typed() { + let config = crate::GetEpochInfoConfig::new( + crate::RpcCommitmentLevel::Confirmed, + std::option::Option::Some(44), + ); + let params = config.request_params(); + assert_eq!(params[0]["commitment"], "confirmed"); + assert_eq!(params[0]["minContextSlot"], 44); + let epoch = crate::adapt_get_epoch_info_result(&serde_json::json!({ + "absoluteSlot": 500, + "blockHeight": 480, + "epoch": 9, + "slotIndex": 20, + "slotsInEpoch": 100, + "transactionCount": null + })) + .unwrap_or_else(|error| panic!("unexpected epoch error: {error}")); + assert_eq!(epoch.absolute_slot, 500); + assert_eq!(epoch.epoch, 9); + assert_eq!(epoch.transaction_count, std::option::Option::None); + assert!( + crate::adapt_get_epoch_info_result(&serde_json::json!({ + "absoluteSlot": "bad" + })) + .is_err() + ); + } + + #[test] + fn balance_and_airdrop_requests_are_typed() { + let pubkey = kb_lib::MdPubkey(valid_hash(20)); + let balance_config = crate::GetBalanceConfig::confirmed(); + let balance_params = balance_config + .request_params(&pubkey) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(balance_params[0], pubkey.0); + assert_eq!(balance_params[1]["commitment"], "confirmed"); + let balance = crate::adapt_get_balance_result(&serde_json::json!({ + "context": {"slot": 101}, + "value": 9000 + })) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(balance.lamports, 9000); + let airdrop_config = crate::RequestAirdropConfig::confirmed(); + let airdrop_params = airdrop_config + .request_params(&pubkey, 1_000_000) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(airdrop_params[1], 1_000_000); + let signature = valid_signature(21); + let airdrop = crate::adapt_request_airdrop_result(&serde_json::json!(signature)) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(airdrop.signature.0, signature); + assert!(airdrop_config.request_params(&pubkey, 0).is_err()); + } + + #[test] + fn account_existence_and_rent_requests_are_typed() { + let pubkey = kb_lib::MdPubkey(valid_hash(22)); + let account_config = crate::GetAccountInfoConfig::confirmed(); + let account_params = account_config + .request_params(&pubkey) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(account_params[0], pubkey.0); + assert_eq!(account_params[1]["encoding"], "base64"); + assert_eq!(account_params[1]["dataSlice"]["length"], 0); + let missing = crate::adapt_get_account_info_result(&serde_json::json!({ + "context": {"slot": 102}, + "value": null + })) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert!(missing.account.is_none()); + let owner = valid_hash(23); + let existing = crate::adapt_get_account_info_result(&serde_json::json!({ + "context": {"slot": 103}, + "value": { + "lamports": 890880, + "owner": owner, + "executable": false, + "rentEpoch": 0, + "space": 0, + "data": ["", "base64"] + } + })) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!( + existing.account.as_ref().map(|account| return account.lamports), + std::option::Option::Some(890880) + ); + assert_eq!( + existing.account.as_ref().map(|account| return account.space), + std::option::Option::Some(0) + ); + assert!( + existing + .account + .as_ref() + .map(|account| return account.data.is_empty()) + .unwrap_or(false) + ); + let complete_config = crate::GetAccountInfoConfig::confirmed_with_data(80) + .unwrap_or_else(|error| panic!("unexpected complete-data config error: {error}")); + let complete_params = complete_config + .request_params(&pubkey) + .unwrap_or_else(|error| panic!("unexpected complete-data request error: {error}")); + assert!(complete_params[1].get("dataSlice").is_none()); + let complete_bytes = vec![7_u8; 80]; + let complete = crate::adapt_get_account_info_result_with_data_limit( + &serde_json::json!({ + "context": {"slot": 104}, + "value": { + "lamports": 1_500_000, + "owner": owner, + "executable": false, + "rentEpoch": 0, + "space": 80, + "data": [encoded_bytes(complete_bytes.as_slice()), "base64"] + } + }), + std::option::Option::Some(80), + ) + .unwrap_or_else(|error| panic!("unexpected complete-data adapter error: {error}")); + assert_eq!( + complete.account.as_ref().map(|account| return account.data.clone()), + std::option::Option::Some(complete_bytes) + ); + assert!(crate::GetAccountInfoConfig::confirmed_with_data(0).is_err()); + assert!( + crate::GetAccountInfoConfig::confirmed_with_data( + crate::MAX_EXECUTION_ACCOUNT_DATA_BYTES + 1, + ) + .is_err() + ); + assert!( + crate::adapt_get_account_info_result_with_data_limit( + &serde_json::json!({ + "context": {"slot": 105}, + "value": { + "lamports": 1, + "owner": valid_hash(24), + "executable": false, + "rentEpoch": 0, + "space": 81, + "data": [encoded_bytes(&[0_u8; 81]), "base64"] + } + }), + std::option::Option::Some(80), + ) + .is_err() + ); + let rent_config = crate::GetMinimumBalanceForRentExemptionConfig::confirmed(); + assert_eq!(rent_config.request_params(0)[0], 0); + let rent = crate::adapt_get_minimum_balance_for_rent_exemption_result( + &serde_json::json!(890880), + 0, + ) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(rent.minimum_balance_lamports, 890880); + } + + #[test] + fn execution_settings_build_submission_and_confirmation_policies() { + let execution = execution_config(); + let send = crate::SendTransactionConfig::from_execution_config( + &execution, + std::option::Option::Some(44), + ) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert!(!send.skip_preflight); + assert_eq!(send.max_retries, std::option::Option::Some(7)); + assert_eq!(send.min_context_slot, std::option::Option::Some(44)); + let confirmation = crate::ConfirmTransactionConfig::from_execution_config( + &execution, + std::option::Option::Some(900), + std::option::Option::Some(44), + ) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(confirmation.poll_interval_ms, 250); + assert_eq!(confirmation.max_attempts, 80); + assert_eq!(confirmation.last_valid_block_height, std::option::Option::Some(900)); + assert_eq!(confirmation.min_context_slot, std::option::Option::Some(44)); + } + + #[test] + fn send_transaction_requires_preflight_and_matching_signature() { + let invalid = crate::SendTransactionConfig::new( + true, + crate::RpcCommitmentLevel::Confirmed, + std::option::Option::Some(3), + std::option::Option::None, + ); + assert!(invalid.is_err()); + let config = crate::SendTransactionConfig::confirmed(); + let encoded = encoded_bytes(&[4, 5, 6]); + let params = config + .request_params(encoded.as_str()) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(params[1]["encoding"], "base64"); + assert_eq!(params[1]["skipPreflight"], false); + assert_eq!(params[1]["preflightCommitment"], "confirmed"); + assert_eq!(params[1]["maxRetries"], 3); + let expected = kb_lib::MdSignature(valid_signature(22)); + let accepted = + crate::adapt_send_transaction_result(&serde_json::json!(expected.0.clone()), &expected) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(accepted.signature, expected); + let different = serde_json::json!(valid_signature(23)); + assert!(crate::adapt_send_transaction_result(&different, &expected).is_err()); + } + + #[test] + fn signature_statuses_preserve_order_and_runtime_errors() { + let signature = kb_lib::MdSignature(valid_signature(24)); + let config = crate::GetSignatureStatusesConfig::new(true); + let params = config + .request_params(std::slice::from_ref(&signature)) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(params[0][0], signature.0); + assert_eq!(params[1]["searchTransactionHistory"], true); + let statuses = crate::adapt_get_signature_statuses_result(&serde_json::json!({ + "context": {"slot": 120}, + "value": [ + { + "slot": 118, + "confirmations": 2, + "err": null, + "status": {"Ok": null}, + "confirmationStatus": "confirmed" + }, + null, + { + "slot": 119, + "confirmations": null, + "err": {"InstructionError": [0, "Custom"]}, + "confirmationStatus": "finalized" + } + ] + })) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(statuses.statuses.len(), 3); + let confirmed = statuses.statuses[0] + .as_ref() + .unwrap_or_else(|| panic!("missing confirmed status")); + assert_eq!( + confirmed.confirmation_status, + std::option::Option::Some(crate::RpcCommitmentLevel::Confirmed) + ); + assert!(statuses.statuses[1].is_none()); + let failed = + statuses.statuses[2].as_ref().unwrap_or_else(|| panic!("missing failed status")); + assert!(failed.error.is_some()); + } + + #[test] + fn signature_status_request_is_bounded_to_official_limit() { + let config = crate::GetSignatureStatusesConfig::new(false); + assert!(config.request_params(&[]).is_err()); + let signatures = (0..=crate::MAX_SIGNATURE_STATUS_COUNT) + .map(|index| { + return kb_lib::MdSignature(valid_signature((index % 251) as u8)); + }) + .collect::>(); + assert!(config.request_params(signatures.as_slice()).is_err()); + } + + #[test] + fn confirmation_policy_and_commitment_order_are_bounded() { + assert!(crate::ConfirmTransactionConfig::confirmed(100, 0, 10).is_err()); + assert!(crate::ConfirmTransactionConfig::confirmed(100, 500, 0).is_err()); + let config = crate::ConfirmTransactionConfig::confirmed(100, 500, 20) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(config.last_valid_block_height, std::option::Option::Some(100)); + assert!(crate::execution_rpc::commitment_reached( + crate::RpcCommitmentLevel::Finalized, + crate::RpcCommitmentLevel::Confirmed, + )); + assert!(!crate::execution_rpc::commitment_reached( + crate::RpcCommitmentLevel::Processed, + crate::RpcCommitmentLevel::Confirmed, + )); + let height = crate::adapt_get_block_height_result(&serde_json::json!(121)) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(height.block_height, 121); + } +} diff --git a/kb-onchain-transport/src/http_client.rs b/kb-onchain-transport/src/http_client.rs new file mode 100644 index 0000000..ac07643 --- /dev/null +++ b/kb-onchain-transport/src/http_client.rs @@ -0,0 +1,383 @@ +// file: kb-onchain-transport/src/http_client.rs +// version: 11 + +//! HTTP JSON-RPC client for standard Solana RPC endpoints. + +/// Local HTTP method class used for routing diagnostics. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)] +pub enum HttpMethodClass { + /// Standard RPC reads and generic methods. + GeneralRpc, + /// Transaction submission methods. + SendTransaction, + /// Resource-intensive read methods. + HeavyRead, +} + +/// Snapshot of one pooled HTTP endpoint. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HttpPoolClientSnapshot { + /// Logical endpoint name. + pub endpoint_name: std::string::String, + /// Provider name. + pub provider: std::string::String, + /// Endpoint URL. + pub endpoint_url: std::string::String, + /// Supported roles. + pub roles: std::vec::Vec, + /// Status string. + pub status: std::string::String, +} + +/// HTTP JSON-RPC client bound to one configured endpoint. +#[derive(Clone, Debug)] +pub struct HttpClient { + endpoint: kb_config::HttpEndpointConfig, + client: reqwest::Client, + next_request_id: std::sync::Arc, +} + +impl crate::HttpClient { + /// Creates a new HTTP client bound to one endpoint. + pub fn new(endpoint: kb_config::HttpEndpointConfig) -> kb_core::Result { + if !endpoint.enabled { + tracing::error!(target: crate::TRACING_TARGET, action = "create_http_client", endpoint_name = %endpoint.name, provider = %endpoint.provider, error_code = "http_endpoint_disabled", "cannot create HTTP client for disabled endpoint"); + return std::result::Result::Err(kb_core::Error::config(format!( + "http endpoint '{}' is disabled", + endpoint.name + ))); + } + let timeout = std::time::Duration::from_millis(endpoint.request_timeout_ms); + let connect_timeout = std::time::Duration::from_millis(endpoint.connect_timeout_ms); + let client_result = reqwest::Client::builder() + .timeout(timeout) + .connect_timeout(connect_timeout) + .pool_max_idle_per_host(endpoint.max_idle_connections_per_host as usize) + .build(); + let client = match client_result { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => { + tracing::error!(target: crate::TRACING_TARGET, action = "create_http_client", endpoint_name = %endpoint.name, provider = %endpoint.provider, error = %error, "HTTP client construction failed"); + return std::result::Result::Err(kb_core::Error::http(format!( + "cannot build http client for endpoint '{}': {error}", + endpoint.name + ))); + }, + }; + tracing::debug!(target: crate::TRACING_TARGET, action = "create_http_client", endpoint_name = %endpoint.name, provider = %endpoint.provider, role_count = endpoint.roles.len(), "HTTP client created"); + return std::result::Result::Ok(Self { + endpoint, + client, + next_request_id: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1)), + }); + } + + /// Returns the endpoint name. + pub fn endpoint_name(&self) -> &str { + return self.endpoint.name.as_str(); + } + + /// Returns the provider name. + pub fn provider(&self) -> &str { + return self.endpoint.provider.as_str(); + } + + /// Returns the endpoint URL. + pub fn endpoint_url(&self) -> &str { + return self.endpoint.url.as_str(); + } + + /// Returns the endpoint configuration. + pub fn endpoint_config(&self) -> &kb_config::HttpEndpointConfig { + return &self.endpoint; + } + + /// Returns true when this endpoint supports the required role and request kind. + pub fn can_handle(&self, required_role: &str, request_kind: &str) -> bool { + if !self.endpoint.enabled { + return false; + } + for role in &self.endpoint.roles { + if crate::role_matches(role, required_role, request_kind) { + return true; + } + } + return false; + } + + /// Returns a serializable endpoint snapshot. + pub fn snapshot(&self) -> crate::HttpPoolClientSnapshot { + let mut roles = std::vec::Vec::new(); + for role in &self.endpoint.roles { + roles.push(crate::EndpointRoleSnapshot::from_config(role)); + } + return crate::HttpPoolClientSnapshot { + endpoint_name: self.endpoint.name.clone(), + provider: self.endpoint.provider.clone(), + endpoint_url: self.endpoint.url.clone(), + roles, + status: "active".to_string(), + }; + } + + /// Classifies a Solana HTTP method into a broad local class. + pub fn classify_method(method: &str) -> crate::HttpMethodClass { + let standard = crate::standard_http_method(method); + return match standard { + std::option::Option::Some(specification) => specification.method_class(), + std::option::Option::None => crate::HttpMethodClass::GeneralRpc, + }; + } + + /// Executes one typed standard HTTP request and decodes its method-specific result. + pub async fn execute_standard_request( + &self, + request: &Request, + ) -> kb_core::Result<::Response> + where + Request: crate::StandardHttpRequest, + { + let specification = + match crate::standard_http_method(::METHOD) { + std::option::Option::Some(specification) => specification, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::invalid_state(format!( + "typed standard request '{}' is absent from the canonical registry", + ::METHOD + ))); + }, + }; + if specification.contract != crate::StandardRpcContract::TypedAdapter { + return std::result::Result::Err(kb_core::Error::invalid_state(format!( + "standard request '{}' is not declared as a typed adapter", + ::METHOD + ))); + } + let params = match request.params() { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let raw = match self + .execute_json_rpc_result_raw( + ::METHOD.to_string(), + params, + ) + .await + { + std::result::Result::Ok(raw) => raw, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return match serde_json::from_value::<::Response>( + raw, + ) { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => { + std::result::Result::Err(kb_core::Error::json(format!( + "cannot decode standard JSON-RPC result for '{}': {error}", + ::METHOD + ))) + }, + }; + } + + /// Executes one explicitly registered standard HTTP method and returns its raw result. + pub async fn execute_standard_method_raw( + &self, + method: &crate::StandardHttpMethodSpec, + params: std::vec::Vec, + ) -> kb_core::Result { + return self.execute_json_rpc_result_raw(method.method.to_string(), params).await; + } + + /// Executes one JSON-RPC request and returns the raw result value. + pub async fn execute_json_rpc_result_raw( + &self, + method: std::string::String, + params: std::vec::Vec, + ) -> kb_core::Result { + let request_id = self.next_request_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let parameter_count = params.len(); + let method_class = crate::HttpClient::classify_method(method.as_str()); + tracing::debug!(target: crate::TRACING_TARGET, action = "execute_http_json_rpc", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, method_class = ?method_class, parameter_count, "send HTTP JSON-RPC request"); + let request = crate::JsonRpcRequest::new_with_u64_id(request_id, method.clone(), params); + let response_result = + self.client.post(self.endpoint.url.as_str()).json(&request).send().await; + let response = match response_result { + std::result::Result::Ok(response) => response, + std::result::Result::Err(error) => { + tracing::error!(target: crate::TRACING_TARGET, action = "execute_http_json_rpc", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, error = %error, "HTTP JSON-RPC transport failed"); + return std::result::Result::Err(kb_core::Error::http(format!( + "http json-rpc request '{}' failed on endpoint '{}': {error}", + method, self.endpoint.name + ))); + }, + }; + let status = response.status(); + let text_result = response.text().await; + let text = match text_result { + std::result::Result::Ok(text) => text, + std::result::Result::Err(error) => { + tracing::error!(target: crate::TRACING_TARGET, action = "execute_http_json_rpc", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, http_status = %status, error = %error, "HTTP JSON-RPC response body read failed"); + return std::result::Result::Err(kb_core::Error::http(format!( + "cannot read http json-rpc response '{}' from endpoint '{}': {error}", + method, self.endpoint.name + ))); + }, + }; + if !status.is_success() { + tracing::error!(target: crate::TRACING_TARGET, action = "execute_http_json_rpc", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, http_status = %status, response_byte_length = text.len(), "HTTP JSON-RPC endpoint returned non-success status"); + return std::result::Result::Err(kb_core::Error::http(format!( + "http json-rpc endpoint '{}' returned status {}: {}", + self.endpoint.name, status, text + ))); + } + let parsed = match crate::parse_json_rpc_text(&text) { + std::result::Result::Ok(parsed) => parsed, + std::result::Result::Err(error) => { + tracing::error!(target: crate::TRACING_TARGET, action = "execute_http_json_rpc", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, http_status = %status, response_byte_length = text.len(), error = %error, "HTTP JSON-RPC response parsing failed"); + return std::result::Result::Err(error); + }, + }; + return match parsed { + crate::JsonRpcResponse::Success(success) => { + tracing::debug!(target: crate::TRACING_TARGET, action = "execute_http_json_rpc", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, http_status = %status, response_byte_length = text.len(), outcome = "success", "HTTP JSON-RPC request completed"); + std::result::Result::Ok(success.result) + }, + crate::JsonRpcResponse::Error(error_response) => { + tracing::error!(target: crate::TRACING_TARGET, action = "execute_http_json_rpc", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, rpc_error_code = error_response.error.code, rpc_error_message = %error_response.error.message, "HTTP JSON-RPC endpoint returned an RPC error"); + std::result::Result::Err(kb_core::Error::http(format!( + "json-rpc error {} from '{}': {}", + error_response.error.code, self.endpoint.name, error_response.error.message + ))) + }, + crate::JsonRpcResponse::Notification(_) => { + tracing::error!(target: crate::TRACING_TARGET, action = "execute_http_json_rpc", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, "HTTP JSON-RPC response was an unexpected notification"); + std::result::Result::Err(kb_core::Error::http( + "http json-rpc response cannot be a notification".to_string(), + )) + }, + }; + } +} + +#[cfg(test)] +mod tests { + fn role_config( + role: &str, + request_kinds: std::vec::Vec, + ) -> kb_config::EndpointRoleConfig { + return kb_config::EndpointRoleConfig { + role: role.to_string(), + enabled: true, + request_kinds, + priority: 1, + requests_per_second: 10, + burst_capacity: 10, + max_concurrent_requests: 4, + max_subscriptions: 16, + pause_after_rate_limit_ms: 1500, + }; + } + + fn endpoint(enabled: bool) -> kb_config::HttpEndpointConfig { + return kb_config::HttpEndpointConfig { + name: "http_a".to_string(), + enabled, + provider: "test".to_string(), + cluster: "devnet".to_string(), + url: "https://example.invalid".to_string(), + connect_timeout_ms: 100, + request_timeout_ms: 100, + max_idle_connections_per_host: 2, + roles: std::vec![ + role_config("http_queries", std::vec!["get_version".to_string()]), + role_config("http_heavy", std::vec!["get_block".to_string()]), + role_config("http_any", std::vec!["*".to_string()]), + ], + }; + } + + #[test] + fn new_rejects_disabled_endpoint() { + let result = crate::HttpClient::new(endpoint(false)); + assert!(result.is_err()); + } + + #[test] + fn can_handle_matches_exact_role_and_kind() { + let client = match crate::HttpClient::new(endpoint(true)) { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => panic!("client creation failed: {error}"), + }; + assert!(client.can_handle("http_queries", "get_version")); + assert!(!client.can_handle("http_queries", "get_block")); + } + + #[test] + fn can_handle_matches_wildcard_kind() { + let client = match crate::HttpClient::new(endpoint(true)) { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => panic!("client creation failed: {error}"), + }; + assert!(client.can_handle("http_any", "send_transaction")); + } + + #[test] + fn snapshot_preserves_endpoint_metadata() { + let client = match crate::HttpClient::new(endpoint(true)) { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => panic!("client creation failed: {error}"), + }; + let snapshot = client.snapshot(); + assert_eq!(snapshot.endpoint_name, "http_a"); + assert_eq!(snapshot.provider, "test"); + assert_eq!(snapshot.endpoint_url, "https://example.invalid"); + assert_eq!(snapshot.roles.len(), 3); + } + + #[test] + fn classify_method_detects_transaction_submission() { + for method in ["requestAirdrop", "sendTransaction"] { + assert_eq!( + crate::HttpClient::classify_method(method), + crate::HttpMethodClass::SendTransaction + ); + } + } + + #[test] + fn classify_method_detects_heavy_reads() { + assert_eq!( + crate::HttpClient::classify_method("getBlock"), + crate::HttpMethodClass::HeavyRead + ); + assert_eq!( + crate::HttpClient::classify_method("getProgramAccounts"), + crate::HttpMethodClass::HeavyRead + ); + assert_eq!( + crate::HttpClient::classify_method("getSignaturesForAddress"), + crate::HttpMethodClass::HeavyRead + ); + assert_eq!( + crate::HttpClient::classify_method("simulateTransaction"), + crate::HttpMethodClass::HeavyRead + ); + } + + #[test] + fn every_standard_method_uses_its_declared_routing_class() { + for method in &crate::STANDARD_HTTP_METHODS { + assert_eq!(crate::HttpClient::classify_method(method.method), method.method_class()); + } + } + + #[test] + fn classify_method_defaults_to_general_rpc() { + assert_eq!( + crate::HttpClient::classify_method("getVersion"), + crate::HttpMethodClass::GeneralRpc + ); + } +} diff --git a/kb-onchain-transport/src/http_pool.rs b/kb-onchain-transport/src/http_pool.rs new file mode 100644 index 0000000..8d0147c --- /dev/null +++ b/kb-onchain-transport/src/http_pool.rs @@ -0,0 +1,279 @@ +// file: kb-onchain-transport/src/http_pool.rs +// version: 7 + +//! HTTP endpoint pool and role-based routing. + +/// Pool of HTTP JSON-RPC endpoints. +#[derive(Clone, Debug)] +pub struct HttpEndpointPool { + clients: std::vec::Vec, + next_index: std::sync::Arc, +} + +impl crate::HttpEndpointPool { + /// Builds a pool from the active profile HTTP endpoint list. + pub fn from_profile(profile: &kb_config::ProfileConfig) -> kb_core::Result { + let mut clients = std::vec::Vec::new(); + for endpoint in &profile.solana.http_endpoints { + if !endpoint.enabled { + continue; + } + let client = match crate::HttpClient::new(endpoint.clone()) { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + clients.push(client); + } + return crate::HttpEndpointPool::new(clients); + } + + /// Creates a pool from already constructed clients. + pub fn new(clients: std::vec::Vec) -> kb_core::Result { + if clients.is_empty() { + tracing::error!(target: crate::TRACING_TARGET, action = "create_http_pool", error_code = "http_pool_empty", "HTTP endpoint pool has no enabled endpoint"); + return std::result::Result::Err(kb_core::Error::config( + "http endpoint pool requires at least one enabled endpoint".to_string(), + )); + } + tracing::debug!(target: crate::TRACING_TARGET, action = "create_http_pool", endpoint_count = clients.len(), "HTTP endpoint pool created"); + return std::result::Result::Ok(Self { + clients, + next_index: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)), + }); + } + + /// Returns a serializable snapshot of every endpoint in the pool. + pub fn snapshot(&self) -> std::vec::Vec { + let mut snapshots = std::vec::Vec::new(); + for client in &self.clients { + snapshots.push(client.snapshot()); + } + return snapshots; + } + + /// Selects one endpoint for the requested role and method. + pub fn select_client_for_role_and_method( + &self, + required_role: &str, + method: &str, + ) -> kb_core::Result { + let request_kind = crate::request_kind_from_method(method); + return self.select_client_for_role_and_kind(required_role, &request_kind); + } + + /// Selects one endpoint for the requested role and request kind. + pub fn select_client_for_role_and_kind( + &self, + required_role: &str, + request_kind: &str, + ) -> kb_core::Result { + if self.clients.is_empty() { + return std::result::Result::Err(kb_core::Error::not_connected( + "http endpoint pool has no clients".to_string(), + )); + } + let start_index = self.next_index.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let client_count = self.clients.len(); + let mut offset = 0_usize; + while offset < client_count { + let index = (start_index + offset) % client_count; + let client = self.clients[index].clone(); + if client.can_handle(required_role, request_kind) { + tracing::debug!(target: crate::TRACING_TARGET, action = "select_http_endpoint", required_role, request_kind, endpoint_name = %client.endpoint_name(), provider = %client.provider(), "selected HTTP endpoint"); + return std::result::Result::Ok(client); + } + offset += 1; + } + tracing::error!(target: crate::TRACING_TARGET, action = "select_http_endpoint", required_role, request_kind, endpoint_count = self.clients.len(), error_code = "http_endpoint_not_found", "no HTTP endpoint supports requested role and kind"); + return std::result::Result::Err(kb_core::Error::config(format!( + "no http endpoint supports role '{}' and request kind '{}'", + required_role, request_kind + ))); + } + + /// Executes one typed standard HTTP request through an endpoint selected by role. + pub async fn execute_standard_request_for_role( + &self, + required_role: &str, + request: &Request, + ) -> kb_core::Result<::Response> + where + Request: crate::StandardHttpRequest, + { + let client = match self.select_client_for_role_and_method( + required_role, + ::METHOD, + ) { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return client.execute_standard_request(request).await; + } + + /// Executes one explicitly registered standard HTTP method through the selected endpoint. + pub async fn execute_standard_method_raw_for_role( + &self, + required_role: &str, + method: &crate::StandardHttpMethodSpec, + params: std::vec::Vec, + ) -> kb_core::Result { + let client = match self.select_client_for_role_and_method(required_role, method.method) { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return client.execute_standard_method_raw(method, params).await; + } + + /// Executes one JSON-RPC request through the selected endpoint. + pub async fn execute_json_rpc_result_raw_for_role( + &self, + required_role: &str, + method: std::string::String, + params: std::vec::Vec, + ) -> kb_core::Result { + let client = match self.select_client_for_role_and_method(required_role, &method) { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return client.execute_json_rpc_result_raw(method, params).await; + } +} + +#[cfg(test)] +mod tests { + fn role_config( + role: &str, + request_kinds: std::vec::Vec, + ) -> kb_config::EndpointRoleConfig { + return kb_config::EndpointRoleConfig { + role: role.to_string(), + enabled: true, + request_kinds, + priority: 1, + requests_per_second: 10, + burst_capacity: 10, + max_concurrent_requests: 4, + max_subscriptions: 16, + pause_after_rate_limit_ms: 1500, + }; + } + + fn endpoint( + name: &str, + role: &str, + request_kinds: std::vec::Vec, + ) -> kb_config::HttpEndpointConfig { + return kb_config::HttpEndpointConfig { + name: name.to_string(), + enabled: true, + provider: "test".to_string(), + cluster: "devnet".to_string(), + url: format!("https://{name}.invalid"), + connect_timeout_ms: 100, + request_timeout_ms: 100, + max_idle_connections_per_host: 2, + roles: std::vec![role_config(role, request_kinds)], + }; + } + + fn client(endpoint: kb_config::HttpEndpointConfig) -> crate::HttpClient { + match crate::HttpClient::new(endpoint) { + std::result::Result::Ok(client) => return client, + std::result::Result::Err(error) => panic!("client creation failed: {error}"), + } + } + + #[test] + fn new_rejects_empty_pool() { + let result = crate::HttpEndpointPool::new(std::vec::Vec::new()); + assert!(result.is_err()); + } + + #[test] + fn snapshot_lists_every_client() { + let pool = match crate::HttpEndpointPool::new(std::vec![ + client(endpoint("a", "http_queries", std::vec!["get_version".to_string()])), + client(endpoint("b", "http_queries", std::vec!["get_version".to_string()])), + ]) { + std::result::Result::Ok(pool) => pool, + std::result::Result::Err(error) => panic!("pool creation failed: {error}"), + }; + let snapshot = pool.snapshot(); + assert_eq!(snapshot.len(), 2); + assert_eq!(snapshot[0].endpoint_name, "a"); + assert_eq!(snapshot[1].endpoint_name, "b"); + } + + #[test] + fn select_client_round_robins_matching_clients() { + let pool = match crate::HttpEndpointPool::new(std::vec![ + client(endpoint("a", "http_queries", std::vec!["get_version".to_string()])), + client(endpoint("b", "http_queries", std::vec!["get_version".to_string()])), + ]) { + std::result::Result::Ok(pool) => pool, + std::result::Result::Err(error) => panic!("pool creation failed: {error}"), + }; + let first = match pool.select_client_for_role_and_method("http_queries", "getVersion") { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => panic!("selection failed: {error}"), + }; + let second = match pool.select_client_for_role_and_method("http_queries", "getVersion") { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => panic!("selection failed: {error}"), + }; + assert_eq!(first.endpoint_name(), "a"); + assert_eq!(second.endpoint_name(), "b"); + } + + #[test] + fn select_client_skips_unsupported_clients() { + let pool = match crate::HttpEndpointPool::new(std::vec![ + client(endpoint("a", "http_heavy", std::vec!["get_block".to_string()])), + client(endpoint("b", "http_queries", std::vec!["get_version".to_string()])), + ]) { + std::result::Result::Ok(pool) => pool, + std::result::Result::Err(error) => panic!("pool creation failed: {error}"), + }; + let selected = match pool.select_client_for_role_and_method("http_queries", "getVersion") { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => panic!("selection failed: {error}"), + }; + assert_eq!(selected.endpoint_name(), "b"); + } + + #[test] + fn standard_method_selection_uses_the_canonical_request_kind() { + let pool = match crate::HttpEndpointPool::new(std::vec![client(endpoint( + "a", + "http_queries", + std::vec!["get_version".to_string()], + ))]) { + std::result::Result::Ok(pool) => pool, + std::result::Result::Err(error) => panic!("pool creation failed: {error}"), + }; + let method = match crate::standard_http_method("getVersion") { + std::option::Option::Some(value) => value, + std::option::Option::None => panic!("getVersion specification missing"), + }; + let selected = match pool.select_client_for_role_and_method("http_queries", method.method) { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => panic!("selection failed: {error}"), + }; + assert_eq!(selected.endpoint_name(), "a"); + } + + #[test] + fn select_client_returns_error_for_missing_role() { + let pool = match crate::HttpEndpointPool::new(std::vec![client(endpoint( + "a", + "http_queries", + std::vec!["get_version".to_string()] + )),]) + { + std::result::Result::Ok(pool) => pool, + std::result::Result::Err(error) => panic!("pool creation failed: {error}"), + }; + let selected = pool.select_client_for_role_and_kind("http_heavy", "get_block"); + assert!(selected.is_err()); + } +} diff --git a/kb-onchain-transport/src/json_rpc.rs b/kb-onchain-transport/src/json_rpc.rs new file mode 100644 index 0000000..d90c6fd --- /dev/null +++ b/kb-onchain-transport/src/json_rpc.rs @@ -0,0 +1,315 @@ +// file: kb-onchain-transport/src/json_rpc.rs +// version: 4 + +//! JSON-RPC 2.0 envelopes used by Solana HTTP and WebSocket transports. + +/// Generic JSON-RPC 2.0 request. +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct JsonRpcRequest { + /// JSON-RPC version, expected to be `"2.0"`. + pub jsonrpc: std::string::String, + /// Client request identifier. + pub id: serde_json::Value, + /// RPC method name. + pub method: std::string::String, + /// Ordered method parameters. + pub params: std::vec::Vec, +} + +impl crate::JsonRpcRequest { + /// Creates a request with a numeric identifier. + pub fn new_with_u64_id( + id: u64, + method: std::string::String, + params: std::vec::Vec, + ) -> Self { + return Self { + jsonrpc: "2.0".to_string(), + id: serde_json::Value::from(id), + method, + params, + }; + } + + /// Serializes the request into a compact JSON string. + pub fn to_json_string(&self) -> kb_core::Result { + let serialization_result = serde_json::to_string(self); + return match serialization_result { + std::result::Result::Ok(text) => std::result::Result::Ok(text), + std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::json( + format!("cannot serialize json-rpc request '{}': {error}", self.method), + )), + }; + } +} + +/// JSON-RPC 2.0 success response. +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct JsonRpcSuccessResponse { + /// JSON-RPC version, expected to be `"2.0"`. + pub jsonrpc: std::string::String, + /// Result payload. + pub result: serde_json::Value, + /// Request identifier echoed by the server. + pub id: serde_json::Value, +} + +/// JSON-RPC 2.0 error object. +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct JsonRpcErrorObject { + /// Numeric JSON-RPC error code. + pub code: i64, + /// Human-readable error message. + pub message: std::string::String, + /// Optional server-provided payload. + pub data: std::option::Option, +} + +/// JSON-RPC 2.0 error response. +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct JsonRpcErrorResponse { + /// JSON-RPC version, expected to be `"2.0"`. + pub jsonrpc: std::string::String, + /// Error payload. + pub error: crate::JsonRpcErrorObject, + /// Request identifier echoed by the server. + pub id: serde_json::Value, +} + +/// JSON-RPC 2.0 notification parameters. +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct JsonRpcNotificationParams { + /// Method-specific result payload. + pub result: serde_json::Value, + /// Remote subscription identifier. + pub subscription: u64, +} + +/// JSON-RPC 2.0 notification message. +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct JsonRpcNotification { + /// JSON-RPC version, expected to be `"2.0"`. + pub jsonrpc: std::string::String, + /// Notification method name. + pub method: std::string::String, + /// Notification payload. + pub params: crate::JsonRpcNotificationParams, +} + +/// Parsed JSON-RPC response or notification. +#[derive(Clone, Debug, PartialEq)] +pub enum JsonRpcResponse { + /// Success response. + Success(crate::JsonRpcSuccessResponse), + /// Error response. + Error(crate::JsonRpcErrorResponse), + /// Notification message. + Notification(crate::JsonRpcNotification), +} + +impl crate::JsonRpcResponse { + /// Returns a stable diagnostic kind name. + pub fn kind_name(&self) -> &'static str { + return match self { + Self::Success(_) => "success", + Self::Error(_) => "error", + Self::Notification(_) => "notification", + }; + } + + /// Converts the parsed response into a JSON value for UI display. + pub fn to_value(&self) -> kb_core::Result { + return match self { + Self::Success(response) => { + let value_result = serde_json::to_value(response); + match value_result { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => { + std::result::Result::Err(kb_core::Error::json(error.to_string())) + }, + } + }, + Self::Error(response) => { + let value_result = serde_json::to_value(response); + match value_result { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => { + std::result::Result::Err(kb_core::Error::json(error.to_string())) + }, + } + }, + Self::Notification(notification) => { + let value_result = serde_json::to_value(notification); + match value_result { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => { + std::result::Result::Err(kb_core::Error::json(error.to_string())) + }, + } + }, + }; + } +} + +/// Parses an incoming JSON-RPC text payload. +pub fn parse_json_rpc_text(text: &str) -> kb_core::Result { + let value = match serde_json::from_str::(text) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::json(format!( + "cannot parse json-rpc text: {error}" + ))); + }, + }; + let object = match value.as_object() { + std::option::Option::Some(object) => object, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::json( + "json-rpc payload must be an object".to_string(), + )); + }, + }; + let has_method = object.contains_key("method"); + let has_params = object.contains_key("params"); + let has_result = object.contains_key("result"); + let has_error = object.contains_key("error"); + let has_id = object.contains_key("id"); + if has_method && has_params && !has_id { + let parse_result = serde_json::from_value::(value); + return match parse_result { + std::result::Result::Ok(notification) => { + std::result::Result::Ok(crate::JsonRpcResponse::Notification(notification)) + }, + std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::json( + format!("cannot parse json-rpc notification: {error}"), + )), + }; + } + if has_id && has_result && !has_error { + let parse_result = serde_json::from_value::(value); + return match parse_result { + std::result::Result::Ok(response) => { + std::result::Result::Ok(crate::JsonRpcResponse::Success(response)) + }, + std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::json( + format!("cannot parse json-rpc success response: {error}"), + )), + }; + } + if has_id && has_error && !has_result { + let parse_result = serde_json::from_value::(value); + return match parse_result { + std::result::Result::Ok(response) => { + std::result::Result::Ok(crate::JsonRpcResponse::Error(response)) + }, + std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::json( + format!("cannot parse json-rpc error response: {error}"), + )), + }; + } + return std::result::Result::Err(kb_core::Error::json( + "unsupported json-rpc response shape".to_string(), + )); +} + +#[cfg(test)] +mod tests { + #[test] + fn request_serialization_contains_jsonrpc_version() { + let request = crate::JsonRpcRequest::new_with_u64_id( + 1, + "getVersion".to_string(), + std::vec::Vec::new(), + ); + let text = match request.to_json_string() { + std::result::Result::Ok(text) => text, + std::result::Result::Err(error) => panic!("request serialization failed: {error}"), + }; + assert!(text.contains("\"jsonrpc\":\"2.0\"")); + assert!(text.contains("\"method\":\"getVersion\"")); + } + + #[test] + fn request_serialization_preserves_params() { + let request = crate::JsonRpcRequest::new_with_u64_id( + 9, + "getBalance".to_string(), + std::vec![serde_json::Value::String("account".to_string())], + ); + let text = match request.to_json_string() { + std::result::Result::Ok(text) => text, + std::result::Result::Err(error) => panic!("request serialization failed: {error}"), + }; + assert!(text.contains("\"params\":[\"account\"]")); + } + + #[test] + fn parses_success_response() { + let parsed = match crate::parse_json_rpc_text("{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":1}") + { + std::result::Result::Ok(parsed) => parsed, + std::result::Result::Err(error) => panic!("response parsing failed: {error}"), + }; + assert_eq!(parsed.kind_name(), "success"); + } + + #[test] + fn parses_error_response() { + let parsed = match crate::parse_json_rpc_text( + "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32602,\"message\":\"invalid params\"},\"id\":2}", + ) { + std::result::Result::Ok(parsed) => parsed, + std::result::Result::Err(error) => panic!("error response parsing failed: {error}"), + }; + match parsed { + crate::JsonRpcResponse::Error(response) => { + assert_eq!(response.error.code, -32602); + assert_eq!(response.error.message, "invalid params"); + }, + _ => panic!("expected error response"), + } + } + + #[test] + fn parses_notification_response() { + let text = "{\"jsonrpc\":\"2.0\",\"method\":\"slotNotification\",\"params\":{\"result\":123,\"subscription\":77}}"; + let parsed = match crate::parse_json_rpc_text(text) { + std::result::Result::Ok(parsed) => parsed, + std::result::Result::Err(error) => panic!("notification parsing failed: {error}"), + }; + match parsed { + crate::JsonRpcResponse::Notification(notification) => { + assert_eq!(notification.method, "slotNotification"); + assert_eq!(notification.params.subscription, 77); + }, + _ => panic!("expected notification"), + } + } + + #[test] + fn parses_rejects_non_object_payload() { + let parsed = crate::parse_json_rpc_text("[1,2,3]"); + assert!(parsed.is_err()); + } + + #[test] + fn parses_rejects_unsupported_shape() { + let parsed = crate::parse_json_rpc_text("{\"jsonrpc\":\"2.0\",\"method\":\"x\",\"id\":1}"); + assert!(parsed.is_err()); + } + + #[test] + fn response_to_value_preserves_success_result() { + let parsed = match crate::parse_json_rpc_text( + "{\"jsonrpc\":\"2.0\",\"result\":{\"solana-core\":\"x\"},\"id\":1}", + ) { + std::result::Result::Ok(parsed) => parsed, + std::result::Result::Err(error) => panic!("response parsing failed: {error}"), + }; + let value = match parsed.to_value() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("response value conversion failed: {error}"), + }; + assert_eq!(value["result"]["solana-core"].as_str(), std::option::Option::Some("x")); + } +} diff --git a/kb-onchain-transport/src/lib.rs b/kb-onchain-transport/src/lib.rs index 999cadb..e1e836d 100644 --- a/kb-onchain-transport/src/lib.rs +++ b/kb-onchain-transport/src/lib.rs @@ -1,13 +1,389 @@ // file: kb-onchain-transport/src/lib.rs -// version: 2 +// version: 4 #![forbid(unsafe_code)] #![deny(unreachable_pub)] #![warn(missing_docs)] -//! Solana on-chain transport contracts. +//! Solana on-chain HTTP JSON-RPC transport and standard method contracts. -/// Returns the crate migration status. -pub fn migration_status() -> &'static str { - return "scaffolded"; -} +mod client; +mod constants; +mod endpoint_role; +mod execution_rpc; +mod http_client; +mod http_pool; +mod json_rpc; +mod standard_http; +mod standard_http_accounts; +mod standard_http_blocks; +mod standard_http_cluster; +mod standard_http_economics; +mod standard_http_tokens; +mod standard_http_transactions; +mod standard_methods; +mod validation; + +/// RPC endpoint configuration. +pub use self::client::RpcEndpoint; +/// Minimal Solana RPC client abstraction. +pub use self::client::SolanaRpcClient; +/// Canonical tracing target for this crate. +pub(crate) use self::constants::TRACING_TARGET; +/// Endpoint role snapshot shared by HTTP and WebSocket pools. +pub use self::endpoint_role::EndpointRoleSnapshot; +/// Converts a JSON-RPC method name into a stable request kind. +pub use self::endpoint_role::request_kind_from_method; +/// Contextual account information result. +pub use self::execution_rpc::AccountInfoResult; +/// Bounded account metadata and optional complete decoded data returned by `getAccountInfo`. +pub use self::execution_rpc::AccountInfoValue; +/// Signature returned by a faucet airdrop request. +pub use self::execution_rpc::AirdropResult; +/// Lamport balance returned for one account. +pub use self::execution_rpc::BalanceResult; +/// Current block height returned by the node. +pub use self::execution_rpc::BlockHeightResult; +/// Bounded transaction confirmation policy. +pub use self::execution_rpc::ConfirmTransactionConfig; +/// Current epoch and slot progression returned by `getEpochInfo`. +pub use self::execution_rpc::EpochInfoResult; +/// Fee estimate returned for one serialized message. +pub use self::execution_rpc::FeeForMessageResult; +/// Genesis hash and known public-cluster classification. +pub use self::execution_rpc::GenesisHashResult; +/// Configuration for `getAccountInfo`. +pub use self::execution_rpc::GetAccountInfoConfig; +/// Configuration for `getBalance`. +pub use self::execution_rpc::GetBalanceConfig; +/// Configuration for `getBlockHeight`. +pub use self::execution_rpc::GetBlockHeightConfig; +/// Configuration for `getEpochInfo`. +pub use self::execution_rpc::GetEpochInfoConfig; +/// Configuration for `getFeeForMessage`. +pub use self::execution_rpc::GetFeeForMessageConfig; +/// Configuration for `getLatestBlockhash`. +pub use self::execution_rpc::GetLatestBlockhashConfig; +/// Configuration for `getMinimumBalanceForRentExemption`. +pub use self::execution_rpc::GetMinimumBalanceForRentExemptionConfig; +/// Configuration for `getSignatureStatuses`. +pub use self::execution_rpc::GetSignatureStatusesConfig; +/// Latest recent blockhash returned by the cluster. +pub use self::execution_rpc::LatestBlockhashResult; +/// Rent-exempt minimum for one account data length. +pub use self::execution_rpc::MinimumBalanceForRentExemptionResult; +/// Configuration for `requestAirdrop`. +pub use self::execution_rpc::RequestAirdropConfig; +/// Commitment level accepted by execution-oriented RPC methods. +pub use self::execution_rpc::RpcCommitmentLevel; +/// Standard context attached to Solana RPC responses. +pub use self::execution_rpc::RpcResponseContext; +/// Configuration for `sendTransaction`. +pub use self::execution_rpc::SendTransactionConfig; +/// Result returned after a node accepts a signed transaction. +pub use self::execution_rpc::SendTransactionResult; +/// Current status for one submitted signature. +pub use self::execution_rpc::SignatureStatus; +/// Positional status response for submitted signatures. +pub use self::execution_rpc::SignatureStatusesResult; +/// Configuration for `simulateTransaction`. +pub use self::execution_rpc::SimulateTransactionConfig; +/// Typed result returned by `simulateTransaction`. +pub use self::execution_rpc::SimulateTransactionResult; +/// Optional accounts requested from `simulateTransaction`. +pub use self::execution_rpc::SimulationAccountsConfig; +/// Replacement blockhash returned by simulation. +pub use self::execution_rpc::SimulationReplacementBlockhash; +/// Adapts a raw `getAccountInfo` result. +pub use self::execution_rpc::adapt_get_account_info_result; +/// Adapts a raw `getAccountInfo` result with an optional decoded-data limit. +pub(crate) use self::execution_rpc::adapt_get_account_info_result_with_data_limit; +/// Adapts a raw `getBalance` result. +pub use self::execution_rpc::adapt_get_balance_result; +/// Adapts a raw `getBlockHeight` result. +pub use self::execution_rpc::adapt_get_block_height_result; +/// Adapts a raw `getEpochInfo` result. +pub use self::execution_rpc::adapt_get_epoch_info_result; +/// Adapts a raw `getFeeForMessage` result. +pub use self::execution_rpc::adapt_get_fee_for_message_result; +/// Adapts a raw `getGenesisHash` result. +pub use self::execution_rpc::adapt_get_genesis_hash_result; +/// Adapts a raw `getLatestBlockhash` result. +pub use self::execution_rpc::adapt_get_latest_blockhash_result; +/// Adapts a raw `getMinimumBalanceForRentExemption` result. +pub use self::execution_rpc::adapt_get_minimum_balance_for_rent_exemption_result; +/// Adapts a raw `getSignatureStatuses` result. +pub use self::execution_rpc::adapt_get_signature_statuses_result; +/// Adapts a raw `requestAirdrop` result. +pub use self::execution_rpc::adapt_request_airdrop_result; +/// Adapts a raw `sendTransaction` result. +pub use self::execution_rpc::adapt_send_transaction_result; +/// Adapts a raw `simulateTransaction` result. +pub use self::execution_rpc::adapt_simulate_transaction_result; +/// Classifies a genesis hash as an official public cluster. +pub use self::execution_rpc::classify_genesis_hash; +/// HTTP JSON-RPC client bound to one endpoint. +pub use self::http_client::HttpClient; +/// HTTP method class used for routing diagnostics. +pub use self::http_client::HttpMethodClass; +/// Snapshot of one HTTP pool endpoint. +pub use self::http_client::HttpPoolClientSnapshot; +/// HTTP endpoint pool with role-based routing. +pub use self::http_pool::HttpEndpointPool; +/// JSON-RPC 2.0 error object. +pub use self::json_rpc::JsonRpcErrorObject; +/// JSON-RPC 2.0 error response. +pub use self::json_rpc::JsonRpcErrorResponse; +/// JSON-RPC 2.0 notification. +pub use self::json_rpc::JsonRpcNotification; +/// JSON-RPC 2.0 notification parameters. +pub use self::json_rpc::JsonRpcNotificationParams; +/// JSON-RPC 2.0 request. +pub use self::json_rpc::JsonRpcRequest; +/// JSON-RPC 2.0 response parsed from HTTP or WebSocket text. +pub use self::json_rpc::JsonRpcResponse; +/// JSON-RPC 2.0 success response. +pub use self::json_rpc::JsonRpcSuccessResponse; +/// Parses an incoming JSON-RPC text payload. +pub use self::json_rpc::parse_json_rpc_text; +/// Configurable standard HTTP request trait and shared wire contracts. +pub use self::standard_http::RpcAccountBalance; +/// Configurable standard HTTP request trait and shared wire contracts. +pub use self::standard_http::RpcAccountEncoding; +/// Configurable standard HTTP request trait and shared wire contracts. +pub use self::standard_http::RpcAccountInfoConfig; +/// Configurable standard HTTP request trait and shared wire contracts. +pub use self::standard_http::RpcCommitmentConfig; +/// Configurable standard HTTP request trait and shared wire contracts. +pub use self::standard_http::RpcContextConfig; +/// Configurable standard HTTP request trait and shared wire contracts. +pub use self::standard_http::RpcDataSlice; +/// Configurable standard HTTP request trait and shared wire contracts. +pub use self::standard_http::RpcKeyedAccount; +/// Configurable standard HTTP request trait and shared wire contracts. +pub use self::standard_http::RpcMemcmp; +/// Configurable standard HTTP request trait and shared wire contracts. +pub use self::standard_http::RpcMemcmpEncodedBytes; +/// Configurable standard HTTP request trait and shared wire contracts. +pub use self::standard_http::RpcOptionalContext; +/// Configurable standard HTTP request trait and shared wire contracts. +pub use self::standard_http::RpcProgramAccountFilter; +/// Configurable standard HTTP request trait and shared wire contracts. +pub use self::standard_http::RpcProgramAccountsConfig; +/// Configurable standard HTTP request trait and shared wire contracts. +pub use self::standard_http::RpcResponse; +/// Configurable standard HTTP request trait and shared wire contracts. +pub use self::standard_http::RpcTokenAccountBalance; +/// Configurable standard HTTP request trait and shared wire contracts. +pub use self::standard_http::RpcTokenAccountsFilter; +/// Configurable standard HTTP request trait and shared wire contracts. +pub use self::standard_http::RpcTokenAmount; +/// Configurable standard HTTP request trait and shared wire contracts. +pub use self::standard_http::RpcTransactionDetails; +/// Configurable standard HTTP request trait and shared wire contracts. +pub use self::standard_http::RpcTransactionEncoding; +/// Configurable standard HTTP request trait and shared wire contracts. +pub use self::standard_http::RpcUiAccount; +/// Configurable standard HTTP request trait and shared wire contracts. +pub use self::standard_http::StandardHttpRequest; +/// Configurable account-oriented standard HTTP requests. +pub use self::standard_http_accounts::GetLargestAccountsRequest; +/// Configurable account-oriented standard HTTP requests. +pub use self::standard_http_accounts::GetMultipleAccountsRequest; +/// Configurable account-oriented standard HTTP requests. +pub use self::standard_http_accounts::GetProgramAccountsRequest; +/// Configurable account-oriented standard HTTP requests. +pub use self::standard_http_accounts::RpcLargestAccountsConfig; +/// Configurable account-oriented standard HTTP requests. +pub use self::standard_http_accounts::RpcLargestAccountsFilter; +/// Configurable block and ledger standard HTTP requests. +pub use self::standard_http_blocks::GetBlockCommitmentRequest; +/// Configurable block and ledger standard HTTP requests. +pub use self::standard_http_blocks::GetBlockProductionRequest; +/// Configurable block and ledger standard HTTP requests. +pub use self::standard_http_blocks::GetBlockRequest; +/// Configurable block and ledger standard HTTP requests. +pub use self::standard_http_blocks::GetBlockTimeRequest; +/// Configurable block and ledger standard HTTP requests. +pub use self::standard_http_blocks::GetBlocksRequest; +/// Configurable block and ledger standard HTTP requests. +pub use self::standard_http_blocks::GetBlocksWithLimitRequest; +/// Configurable block and ledger standard HTTP requests. +pub use self::standard_http_blocks::GetFirstAvailableBlockRequest; +/// Configurable block and ledger standard HTTP requests. +pub use self::standard_http_blocks::GetRecentPerformanceSamplesRequest; +/// Configurable block and ledger standard HTTP requests. +pub use self::standard_http_blocks::MinimumLedgerSlotRequest; +/// Configurable block and ledger standard HTTP requests. +pub use self::standard_http_blocks::RpcBlockCommitment; +/// Configurable block and ledger standard HTTP requests. +pub use self::standard_http_blocks::RpcBlockConfig; +/// Configurable block and ledger standard HTTP requests. +pub use self::standard_http_blocks::RpcBlockProduction; +/// Configurable block and ledger standard HTTP requests. +pub use self::standard_http_blocks::RpcBlockProductionConfig; +/// Configurable block and ledger standard HTTP requests. +pub use self::standard_http_blocks::RpcBlockProductionConfigRange; +/// Configurable block and ledger standard HTTP requests. +pub use self::standard_http_blocks::RpcBlockProductionRange; +/// Configurable block and ledger standard HTTP requests. +pub use self::standard_http_blocks::RpcConfirmedBlock; +/// Configurable block and ledger standard HTTP requests. +pub use self::standard_http_blocks::RpcPerformanceSample; +/// Configurable block and ledger standard HTTP requests. +pub use self::standard_http_blocks::RpcReward; +/// Configurable block and ledger standard HTTP requests. +pub use self::standard_http_blocks::RpcRewardType; +/// Configurable cluster-oriented standard HTTP requests. +pub use self::standard_http_cluster::GetClusterNodesRequest; +/// Configurable cluster-oriented standard HTTP requests. +pub use self::standard_http_cluster::GetEpochScheduleRequest; +/// Configurable cluster-oriented standard HTTP requests. +pub use self::standard_http_cluster::GetHealthRequest; +/// Configurable cluster-oriented standard HTTP requests. +pub use self::standard_http_cluster::GetHighestSnapshotSlotRequest; +/// Configurable cluster-oriented standard HTTP requests. +pub use self::standard_http_cluster::GetIdentityRequest; +/// Configurable cluster-oriented standard HTTP requests. +pub use self::standard_http_cluster::GetLeaderScheduleRequest; +/// Configurable cluster-oriented standard HTTP requests. +pub use self::standard_http_cluster::GetMaxRetransmitSlotRequest; +/// Configurable cluster-oriented standard HTTP requests. +pub use self::standard_http_cluster::GetMaxShredInsertSlotRequest; +/// Configurable cluster-oriented standard HTTP requests. +pub use self::standard_http_cluster::GetSlotLeaderRequest; +/// Configurable cluster-oriented standard HTTP requests. +pub use self::standard_http_cluster::GetSlotLeadersRequest; +/// Configurable cluster-oriented standard HTTP requests. +pub use self::standard_http_cluster::GetSlotRequest; +/// Configurable cluster-oriented standard HTTP requests. +pub use self::standard_http_cluster::GetVersionRequest; +/// Configurable cluster-oriented standard HTTP requests. +pub use self::standard_http_cluster::GetVoteAccountsRequest; +/// Configurable cluster-oriented standard HTTP requests. +pub use self::standard_http_cluster::RpcContactInfo; +/// Configurable cluster-oriented standard HTTP requests. +pub use self::standard_http_cluster::RpcEpochSchedule; +/// Configurable cluster-oriented standard HTTP requests. +pub use self::standard_http_cluster::RpcGetVoteAccountsConfig; +/// Configurable cluster-oriented standard HTTP requests. +pub use self::standard_http_cluster::RpcIdentity; +/// Configurable cluster-oriented standard HTTP requests. +pub use self::standard_http_cluster::RpcLeaderSchedule; +/// Configurable cluster-oriented standard HTTP requests. +pub use self::standard_http_cluster::RpcLeaderScheduleConfig; +/// Configurable cluster-oriented standard HTTP requests. +pub use self::standard_http_cluster::RpcSnapshotSlotInfo; +/// Configurable cluster-oriented standard HTTP requests. +pub use self::standard_http_cluster::RpcVersionInfo; +/// Configurable cluster-oriented standard HTTP requests. +pub use self::standard_http_cluster::RpcVoteAccountInfo; +/// Configurable cluster-oriented standard HTTP requests. +pub use self::standard_http_cluster::RpcVoteAccountStatus; +/// Configurable economics-oriented standard HTTP requests. +pub use self::standard_http_economics::GetInflationGovernorRequest; +/// Configurable economics-oriented standard HTTP requests. +pub use self::standard_http_economics::GetInflationRateRequest; +/// Configurable economics-oriented standard HTTP requests. +pub use self::standard_http_economics::GetInflationRewardRequest; +/// Configurable economics-oriented standard HTTP requests. +pub use self::standard_http_economics::GetStakeMinimumDelegationRequest; +/// Configurable economics-oriented standard HTTP requests. +pub use self::standard_http_economics::GetSupplyRequest; +/// Configurable economics-oriented standard HTTP requests. +pub use self::standard_http_economics::RpcEpochConfig; +/// Configurable economics-oriented standard HTTP requests. +pub use self::standard_http_economics::RpcInflationGovernor; +/// Configurable economics-oriented standard HTTP requests. +pub use self::standard_http_economics::RpcInflationGovernorConfig; +/// Configurable economics-oriented standard HTTP requests. +pub use self::standard_http_economics::RpcInflationRate; +/// Configurable economics-oriented standard HTTP requests. +pub use self::standard_http_economics::RpcInflationReward; +/// Configurable economics-oriented standard HTTP requests. +pub use self::standard_http_economics::RpcSupply; +/// Configurable economics-oriented standard HTTP requests. +pub use self::standard_http_economics::RpcSupplyConfig; +/// Configurable token-oriented standard HTTP requests. +pub use self::standard_http_tokens::GetTokenAccountBalanceRequest; +/// Configurable token-oriented standard HTTP requests. +pub use self::standard_http_tokens::GetTokenAccountsByDelegateRequest; +/// Configurable token-oriented standard HTTP requests. +pub use self::standard_http_tokens::GetTokenAccountsByOwnerRequest; +/// Configurable token-oriented standard HTTP requests. +pub use self::standard_http_tokens::GetTokenLargestAccountsRequest; +/// Configurable token-oriented standard HTTP requests. +pub use self::standard_http_tokens::GetTokenSupplyRequest; +/// Configurable transaction-oriented standard HTTP requests. +pub use self::standard_http_transactions::GetRecentPrioritizationFeesRequest; +/// Configurable transaction-oriented standard HTTP requests. +pub use self::standard_http_transactions::GetTransactionCountRequest; +/// Configurable transaction-oriented standard HTTP requests. +pub use self::standard_http_transactions::IsBlockhashValidRequest; +/// Configurable transaction-oriented standard HTTP requests. +pub use self::standard_http_transactions::RpcPrioritizationFee; +/// Every standard Solana HTTP JSON-RPC method. +pub use self::standard_methods::STANDARD_HTTP_METHODS; +/// Every standard Solana WebSocket subscription pair. +pub use self::standard_methods::STANDARD_WS_SUBSCRIPTIONS; +/// Official category used to group standard Solana HTTP methods. +pub use self::standard_methods::StandardHttpCategory; +/// One canonical standard Solana HTTP method specification. +pub use self::standard_methods::StandardHttpMethodSpec; +/// Documentation-level contract exposed for a standard RPC method. +pub use self::standard_methods::StandardRpcContract; +/// Stability of one standard Solana WebSocket subscription surface. +pub use self::standard_methods::StandardWsStability; +/// One canonical standard Solana WebSocket subscribe/unsubscribe pair. +pub use self::standard_methods::StandardWsSubscriptionSpec; +/// Finds one exact standard HTTP method specification. +pub use self::standard_methods::standard_http_method; +/// Finds one standard WebSocket subscription from its subscribe or unsubscribe method. +pub use self::standard_methods::standard_ws_subscription; +/// Validates one base58 Solana blockhash or genesis hash. +pub use self::validation::validate_solana_hash_text; +/// Validates one base58 Solana public key. +pub use self::validation::validate_solana_pubkey_text; +/// Validates one base58 Solana transaction signature. +pub use self::validation::validate_transaction_signature_text; + +/// Internal DEVNET_GENESIS_HASH contract. +pub(crate) use self::constants::DEVNET_GENESIS_HASH; +/// Internal MAINNET_GENESIS_HASH contract. +pub(crate) use self::constants::MAINNET_GENESIS_HASH; +/// Internal MAX_BLOCK_RANGE contract. +pub(crate) use self::constants::MAX_BLOCK_RANGE; +/// Internal MAX_CONFIRMATION_ATTEMPTS contract. +pub(crate) use self::constants::MAX_CONFIRMATION_ATTEMPTS; +/// Internal MAX_CONFIRMATION_POLL_INTERVAL_MS contract. +pub(crate) use self::constants::MAX_CONFIRMATION_POLL_INTERVAL_MS; +/// Internal MAX_EXECUTION_ACCOUNT_DATA_BYTES contract. +pub(crate) use self::constants::MAX_EXECUTION_ACCOUNT_DATA_BYTES; +/// Internal MAX_EXECUTION_RPC_BASE64_LENGTH contract. +pub(crate) use self::constants::MAX_EXECUTION_RPC_BASE64_LENGTH; +/// Internal MAX_MEMCMP_BASE58_LENGTH contract. +pub(crate) use self::constants::MAX_MEMCMP_BASE58_LENGTH; +/// Internal MAX_MEMCMP_BASE64_LENGTH contract. +pub(crate) use self::constants::MAX_MEMCMP_BASE64_LENGTH; +/// Internal MAX_MEMCMP_DECODED_BYTES contract. +pub(crate) use self::constants::MAX_MEMCMP_DECODED_BYTES; +/// Internal MAX_MULTIPLE_ACCOUNT_COUNT contract. +pub(crate) use self::constants::MAX_MULTIPLE_ACCOUNT_COUNT; +/// Internal MAX_PERFORMANCE_SAMPLE_COUNT contract. +pub(crate) use self::constants::MAX_PERFORMANCE_SAMPLE_COUNT; +/// Internal MAX_PRIORITIZATION_FEE_ACCOUNT_COUNT contract. +pub(crate) use self::constants::MAX_PRIORITIZATION_FEE_ACCOUNT_COUNT; +/// Internal MAX_SIGNATURE_STATUS_COUNT contract. +pub(crate) use self::constants::MAX_SIGNATURE_STATUS_COUNT; +/// Internal MAX_SIMULATION_ACCOUNT_COUNT contract. +pub(crate) use self::constants::MAX_SIMULATION_ACCOUNT_COUNT; +/// Internal MAX_SLOT_LEADER_COUNT contract. +pub(crate) use self::constants::MAX_SLOT_LEADER_COUNT; +/// Internal TESTNET_GENESIS_HASH contract. +pub(crate) use self::constants::TESTNET_GENESIS_HASH; +/// Internal role_matches contract. +pub(crate) use self::endpoint_role::role_matches; +/// Internal serialize_parameter contract. +pub(crate) use self::standard_http::serialize_parameter; +/// Internal validate_pubkey_list contract. +pub(crate) use self::standard_http::validate_pubkey_list; diff --git a/kb-onchain-transport/src/standard_http.rs b/kb-onchain-transport/src/standard_http.rs new file mode 100644 index 0000000..34d059f --- /dev/null +++ b/kb-onchain-transport/src/standard_http.rs @@ -0,0 +1,499 @@ +// file: kb-onchain-transport/src/standard_http.rs +// version: 3 + +//! Shared contracts for configurable standard Solana HTTP JSON-RPC requests. + +use base64::Engine; // rust-rules: trait-import + +/// Typed request contract for one standard Solana HTTP JSON-RPC method. +pub trait StandardHttpRequest { + /// Method-specific response decoded from the JSON-RPC `result` value. + type Response: serde::de::DeserializeOwned; + + /// Exact standard Solana JSON-RPC method name. + const METHOD: &'static str; + + /// Builds the exact positional JSON-RPC parameter array. + fn params(&self) -> kb_core::Result>; +} + +/// Standard contextual Solana RPC response. +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct RpcResponse { + /// Slot and optional API version used by the node. + pub context: crate::RpcResponseContext, + /// Method-specific response value. + pub value: T, +} + +/// Response that may be returned directly or wrapped with an RPC context. +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(untagged)] +pub enum RpcOptionalContext { + /// Contextual response form. + Context(crate::RpcResponse), + /// Backward-compatible response form without context. + Value(T), +} + +/// Commitment and minimum-context options shared by standard read methods. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcContextConfig { + /// Optional commitment level. Absence delegates the default to the endpoint. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub commitment: std::option::Option, + /// Optional minimum slot at which the request may be evaluated. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub min_context_slot: std::option::Option, +} + +/// Commitment-only options used by methods that do not accept `minContextSlot`. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcCommitmentConfig { + /// Optional commitment level. Absence delegates the default to the endpoint. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub commitment: std::option::Option, +} + +/// Account-data encoding accepted by standard account RPC methods. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub enum RpcAccountEncoding { + /// Legacy binary account data encoding alias. + #[serde(rename = "binary")] + Binary, + /// Legacy base58 account data encoding. + #[serde(rename = "base58")] + Base58, + /// Base64 account data encoding. + #[serde(rename = "base64")] + Base64, + /// Zstandard-compressed base64 account data encoding. + #[serde(rename = "base64+zstd")] + Base64Zstd, + /// Program-aware parsed JSON account data. + #[serde(rename = "jsonParsed")] + JsonParsed, +} + +/// Transaction encoding accepted by block and transaction RPC methods. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub enum RpcTransactionEncoding { + /// Legacy binary transaction encoding. + #[serde(rename = "binary")] + Binary, + /// Base58 transaction encoding. + #[serde(rename = "base58")] + Base58, + /// Base64 transaction encoding. + #[serde(rename = "base64")] + Base64, + /// Structured JSON transaction encoding. + #[serde(rename = "json")] + Json, + /// Program-aware parsed JSON transaction encoding. + #[serde(rename = "jsonParsed")] + JsonParsed, +} + +/// Transaction detail level accepted by block-oriented RPC methods. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub enum RpcTransactionDetails { + /// Full transactions and metadata. + Full, + /// Signatures only. + Signatures, + /// No transaction entries. + None, + /// Account lists without full transaction data. + Accounts, +} + +/// Optional byte range requested from account data. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcDataSlice { + /// Byte offset from the start of account data. + pub offset: usize, + /// Number of bytes requested. + pub length: usize, +} + +impl crate::RpcDataSlice { + fn validate(&self) -> kb_core::Result<()> { + if self.offset.checked_add(self.length).is_none() { + return std::result::Result::Err(kb_core::Error::config( + "account data slice offset and length overflow usize", + )); + } + return std::result::Result::Ok(()); + } +} + +/// Configurable account representation shared by account and token methods. +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcAccountInfoConfig { + /// Optional account-data encoding. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub encoding: std::option::Option, + /// Optional account-data byte slice. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub data_slice: std::option::Option, + /// Optional commitment level. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub commitment: std::option::Option, + /// Optional minimum context slot. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub min_context_slot: std::option::Option, +} + +impl crate::RpcAccountInfoConfig { + /// Validates combinations that the standard account RPC contract cannot represent. + pub fn validate(&self) -> kb_core::Result<()> { + if let std::option::Option::Some(data_slice) = self.data_slice { + let slice_result = data_slice.validate(); + if let std::result::Result::Err(error) = slice_result { + return std::result::Result::Err(error); + } + if self.encoding == std::option::Option::Some(crate::RpcAccountEncoding::JsonParsed) { + return std::result::Result::Err(kb_core::Error::config( + "jsonParsed account encoding cannot be combined with dataSlice", + )); + } + } + return std::result::Result::Ok(()); + } +} + +/// Encoded bytes used by a program-account memcmp filter. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase", tag = "encoding", content = "bytes")] +pub enum RpcMemcmpEncodedBytes { + /// Base58-encoded bytes. + Base58(std::string::String), + /// Base64-encoded bytes. + Base64(std::string::String), + /// Explicit raw byte array. + Bytes(std::vec::Vec), +} + +impl crate::RpcMemcmpEncodedBytes { + fn validate(&self) -> kb_core::Result<()> { + let decoded_length = match self { + Self::Base58(value) => { + if value.len() > crate::MAX_MEMCMP_BASE58_LENGTH { + return std::result::Result::Err(kb_core::Error::config(format!( + "memcmp base58 value must not exceed {} characters", + crate::MAX_MEMCMP_BASE58_LENGTH + ))); + } + let decoded = match bs58::decode(value).into_vec() { + std::result::Result::Ok(decoded) => decoded, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::config(format!( + "memcmp base58 value is invalid: {error}" + ))); + }, + }; + decoded.len() + }, + Self::Base64(value) => { + if value.len() > crate::MAX_MEMCMP_BASE64_LENGTH { + return std::result::Result::Err(kb_core::Error::config(format!( + "memcmp base64 value must not exceed {} characters", + crate::MAX_MEMCMP_BASE64_LENGTH + ))); + } + let decoded = match base64::prelude::BASE64_STANDARD.decode(value) { + std::result::Result::Ok(decoded) => decoded, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::config(format!( + "memcmp base64 value is invalid: {error}" + ))); + }, + }; + decoded.len() + }, + Self::Bytes(value) => value.len(), + }; + if decoded_length > crate::MAX_MEMCMP_DECODED_BYTES { + return std::result::Result::Err(kb_core::Error::config(format!( + "memcmp value must not exceed {} decoded bytes", + crate::MAX_MEMCMP_DECODED_BYTES + ))); + } + return std::result::Result::Ok(()); + } +} + +/// Offset and encoded bytes used by a memcmp account filter. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct RpcMemcmp { + /// Data offset at which the comparison starts. + pub offset: usize, + /// Bytes compared at the requested offset. + #[serde(flatten)] + pub bytes: crate::RpcMemcmpEncodedBytes, +} + +/// One filter accepted by `getProgramAccounts` and `programSubscribe`. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub enum RpcProgramAccountFilter { + /// Exact account data size. + DataSize(u64), + /// Byte comparison at an account-data offset. + Memcmp(crate::RpcMemcmp), + /// Standard SPL Token account state filter. + TokenAccountState, +} + +impl crate::RpcProgramAccountFilter { + fn validate(&self) -> kb_core::Result<()> { + if let Self::Memcmp(memcmp) = self { + return memcmp.bytes.validate(); + } + return std::result::Result::Ok(()); + } +} + +/// Configurable `getProgramAccounts` request options. +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcProgramAccountsConfig { + /// Optional account filters, evaluated by the node in the provided order. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub filters: std::option::Option>, + /// Optional account-data encoding. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub encoding: std::option::Option, + /// Optional account-data byte slice. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub data_slice: std::option::Option, + /// Optional commitment level. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub commitment: std::option::Option, + /// Optional minimum context slot. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub min_context_slot: std::option::Option, + /// Whether the response must include a context wrapper. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub with_context: std::option::Option, + /// Optional validator-side deterministic result sorting. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub sort_results: std::option::Option, +} + +impl crate::RpcProgramAccountsConfig { + /// Validates account encoding, data slicing and every filter. + pub fn validate(&self) -> kb_core::Result<()> { + let account_config = crate::RpcAccountInfoConfig { + encoding: self.encoding, + data_slice: self.data_slice, + commitment: self.commitment, + min_context_slot: self.min_context_slot, + }; + let account_result = account_config.validate(); + if let std::result::Result::Err(error) = account_result { + return std::result::Result::Err(error); + } + if let std::option::Option::Some(filters) = &self.filters { + for filter in filters { + let filter_result = filter.validate(); + if let std::result::Result::Err(error) = filter_result { + return std::result::Result::Err(error); + } + } + } + return std::result::Result::Ok(()); + } +} + +/// Mint or Token Program selector used by token-account queries. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub enum RpcTokenAccountsFilter { + /// Select token accounts for one mint. + Mint(std::string::String), + /// Select accounts owned by one Token Program generation. + ProgramId(std::string::String), +} + +impl crate::RpcTokenAccountsFilter { + /// Validates the public key embedded in the selected filter. + pub fn validate(&self) -> kb_core::Result<()> { + let value = match self { + Self::Mint(value) | Self::ProgramId(value) => value, + }; + return crate::validate_solana_pubkey_text(value, "token account filter public key"); + } +} + +/// Account representation returned by configurable standard account methods. +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcUiAccount { + /// Account lamports. + pub lamports: u64, + /// Owner Program ID. + pub owner: std::string::String, + /// Whether the account is executable. + pub executable: bool, + /// Rent epoch reported by the node. + pub rent_epoch: u64, + /// Account data length when exposed by the node. + #[serde(default)] + pub space: std::option::Option, + /// Encoding-dependent account data payload. + pub data: serde_json::Value, +} + +/// Public key and account pair returned by program and token-account scans. +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcKeyedAccount { + /// Account public key. + pub pubkey: std::string::String, + /// Account state and data. + pub account: crate::RpcUiAccount, +} + +/// Token amount with exact integer and decimal string representations. +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcTokenAmount { + /// Raw token amount as an unsigned decimal string. + pub amount: std::string::String, + /// Mint decimal precision. + pub decimals: u8, + /// Optional floating representation retained for wire compatibility. + pub ui_amount: std::option::Option, + /// Exact decimal display string. + pub ui_amount_string: std::string::String, +} + +/// Token account address and balance returned by `getTokenLargestAccounts`. +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcTokenAccountBalance { + /// Token account public key. + pub address: std::string::String, + /// Raw token amount as an unsigned decimal string. + pub amount: std::string::String, + /// Mint decimal precision. + pub decimals: u8, + /// Optional floating representation retained for wire compatibility. + pub ui_amount: std::option::Option, + /// Exact decimal display string. + pub ui_amount_string: std::string::String, +} + +/// One lamport-ranked account returned by `getLargestAccounts`. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcAccountBalance { + /// Account public key. + pub address: std::string::String, + /// Lamport balance. + pub lamports: u64, +} + +pub(crate) fn serialize_parameter( + method: &str, + value: &T, +) -> kb_core::Result { + return match serde_json::to_value(value) { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::json(format!( + "cannot serialize {method} parameter: {error}" + ))), + }; +} + +pub(crate) fn validate_pubkey_list( + values: &[std::string::String], + field: &str, + maximum: usize, +) -> kb_core::Result<()> { + if values.len() > maximum { + return std::result::Result::Err(kb_core::Error::config(format!( + "{field} must not exceed {maximum} entries" + ))); + } + for value in values { + let validation_result = crate::validate_solana_pubkey_text(value, field); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + } + return std::result::Result::Ok(()); +} + +#[cfg(test)] +mod tests { + #[test] + fn optional_account_options_serialize_only_selected_fields() { + let config = crate::RpcAccountInfoConfig { + encoding: std::option::Option::Some(crate::RpcAccountEncoding::Base64Zstd), + data_slice: std::option::Option::Some(crate::RpcDataSlice { offset: 32, length: 64 }), + commitment: std::option::Option::None, + min_context_slot: std::option::Option::Some(91), + }; + let value = match serde_json::to_value(config) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("config serialization failed: {error}"), + }; + assert_eq!( + value, + serde_json::json!({ + "encoding": "base64+zstd", + "dataSlice": { "offset": 32, "length": 64 }, + "minContextSlot": 91 + }) + ); + let binary = match serde_json::to_value(crate::RpcAccountEncoding::Binary) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + panic!("binary encoding serialization failed: {error}") + }, + }; + assert_eq!(binary, serde_json::Value::String("binary".to_string())); + } + + #[test] + fn memcmp_filter_preserves_encoding_and_enforces_decoded_bound() { + let filter = crate::RpcProgramAccountFilter::Memcmp(crate::RpcMemcmp { + offset: 8, + bytes: crate::RpcMemcmpEncodedBytes::Bytes(std::vec![1_u8, 2_u8, 3_u8]), + }); + let value = match serde_json::to_value(&filter) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("filter serialization failed: {error}"), + }; + assert_eq!( + value, + serde_json::json!({ + "memcmp": { "offset": 8, "encoding": "bytes", "bytes": [1, 2, 3] } + }) + ); + let oversized = crate::RpcProgramAccountFilter::Memcmp(crate::RpcMemcmp { + offset: 0, + bytes: crate::RpcMemcmpEncodedBytes::Bytes(std::vec![0_u8; 129]), + }); + assert!(oversized.validate().is_err()); + } + + #[test] + fn json_parsed_account_data_rejects_data_slice() { + let config = crate::RpcAccountInfoConfig { + encoding: std::option::Option::Some(crate::RpcAccountEncoding::JsonParsed), + data_slice: std::option::Option::Some(crate::RpcDataSlice { offset: 0, length: 1 }), + commitment: std::option::Option::None, + min_context_slot: std::option::Option::None, + }; + assert!(config.validate().is_err()); + } +} diff --git a/kb-onchain-transport/src/standard_http_accounts.rs b/kb-onchain-transport/src/standard_http_accounts.rs new file mode 100644 index 0000000..67e6a56 --- /dev/null +++ b/kb-onchain-transport/src/standard_http_accounts.rs @@ -0,0 +1,212 @@ +// file: kb-onchain-transport/src/standard_http_accounts.rs +// version: 2 + +//! Configurable standard account-oriented Solana HTTP JSON-RPC requests. + +/// Optional circulating-supply filter for `getLargestAccounts`. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub enum RpcLargestAccountsFilter { + /// Accounts included in circulating supply. + Circulating, + /// Accounts excluded from circulating supply. + NonCirculating, +} + +/// Options accepted by `getLargestAccounts`. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcLargestAccountsConfig { + /// Optional commitment level. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub commitment: std::option::Option, + /// Optional circulating-supply filter. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub filter: std::option::Option, + /// Optional validator-side deterministic sorting flag. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub sort_results: std::option::Option, +} + +/// Typed `getLargestAccounts` request. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct GetLargestAccountsRequest { + /// Optional request configuration. `None` emits no parameter. + pub config: std::option::Option, +} + +impl crate::StandardHttpRequest for crate::GetLargestAccountsRequest { + type Response = crate::RpcResponse>; + + const METHOD: &'static str = "getLargestAccounts"; + + fn params(&self) -> kb_core::Result> { + if let std::option::Option::Some(config) = &self.config { + let value = match crate::serialize_parameter(Self::METHOD, config) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(std::vec![value]); + } + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +/// Typed `getMultipleAccounts` request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GetMultipleAccountsRequest { + /// Account public keys in positional response order. + pub addresses: std::vec::Vec, + /// Optional account encoding, slicing and context options. + pub config: std::option::Option, +} + +impl crate::StandardHttpRequest for crate::GetMultipleAccountsRequest { + type Response = crate::RpcResponse>>; + + const METHOD: &'static str = "getMultipleAccounts"; + + fn params(&self) -> kb_core::Result> { + let validation_result = crate::validate_pubkey_list( + &self.addresses, + "getMultipleAccounts address", + crate::MAX_MULTIPLE_ACCOUNT_COUNT, + ); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + if let std::option::Option::Some(config) = &self.config { + let config_result = config.validate(); + if let std::result::Result::Err(error) = config_result { + return std::result::Result::Err(error); + } + } + let addresses = match crate::serialize_parameter(Self::METHOD, &self.addresses) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut params = std::vec![addresses]; + if let std::option::Option::Some(config) = &self.config { + let config_value = match crate::serialize_parameter(Self::METHOD, config) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + params.push(config_value); + } + return std::result::Result::Ok(params); + } +} + +/// Typed `getProgramAccounts` request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GetProgramAccountsRequest { + /// Program ID whose owned accounts are requested. + pub program_id: std::string::String, + /// Optional filters, account representation and contextual response options. + pub config: std::option::Option, +} + +impl crate::StandardHttpRequest for crate::GetProgramAccountsRequest { + type Response = crate::RpcOptionalContext>; + + const METHOD: &'static str = "getProgramAccounts"; + + fn params(&self) -> kb_core::Result> { + let validation_result = + crate::validate_solana_pubkey_text(&self.program_id, "getProgramAccounts program id"); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + if let std::option::Option::Some(config) = &self.config { + let config_result = config.validate(); + if let std::result::Result::Err(error) = config_result { + return std::result::Result::Err(error); + } + } + let mut params = std::vec![serde_json::Value::String(self.program_id.clone())]; + if let std::option::Option::Some(config) = &self.config { + let config_value = match crate::serialize_parameter(Self::METHOD, config) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + params.push(config_value); + } + return std::result::Result::Ok(params); + } +} + +#[cfg(test)] +mod tests { + fn pubkey(seed: u8) -> std::string::String { + return bs58::encode([seed; 32]).into_string(); + } + + #[test] + fn multiple_accounts_preserves_optional_encoding_slice_and_context() { + let request = crate::GetMultipleAccountsRequest { + addresses: std::vec![pubkey(1), pubkey(2)], + config: std::option::Option::Some(crate::RpcAccountInfoConfig { + encoding: std::option::Option::Some(crate::RpcAccountEncoding::Base64), + data_slice: std::option::Option::Some(crate::RpcDataSlice { offset: 4, length: 8 }), + commitment: std::option::Option::Some(crate::RpcCommitmentLevel::Confirmed), + min_context_slot: std::option::Option::Some(99), + }), + }; + let params = match crate::StandardHttpRequest::params(&request) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => panic!("params failed: {error}"), + }; + assert_eq!(params.len(), 2); + assert_eq!(params[0].as_array().map(std::vec::Vec::len), std::option::Option::Some(2)); + assert_eq!(params[1]["encoding"], serde_json::Value::String("base64".to_string())); + assert_eq!(params[1]["minContextSlot"], serde_json::Value::from(99_u64)); + } + + #[test] + fn multiple_accounts_enforces_official_request_bound() { + let request = crate::GetMultipleAccountsRequest { + addresses: (0_u8..101_u8).map(pubkey).collect(), + config: std::option::Option::None, + }; + assert!(crate::StandardHttpRequest::params(&request).is_err()); + + let empty = crate::GetMultipleAccountsRequest { + addresses: std::vec::Vec::new(), + config: std::option::Option::None, + }; + let empty_params = match crate::StandardHttpRequest::params(&empty) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => panic!("empty params failed: {error}"), + }; + assert_eq!(empty_params, std::vec![serde_json::json!([])]); + } + + #[test] + fn program_accounts_preserves_filters_and_context_switch() { + let request = crate::GetProgramAccountsRequest { + program_id: pubkey(7), + config: std::option::Option::Some(crate::RpcProgramAccountsConfig { + filters: std::option::Option::Some(std::vec![ + crate::RpcProgramAccountFilter::DataSize(165), + crate::RpcProgramAccountFilter::TokenAccountState, + ]), + encoding: std::option::Option::Some(crate::RpcAccountEncoding::JsonParsed), + data_slice: std::option::Option::None, + commitment: std::option::Option::Some(crate::RpcCommitmentLevel::Finalized), + min_context_slot: std::option::Option::None, + with_context: std::option::Option::Some(true), + sort_results: std::option::Option::Some(true), + }), + }; + let params = match crate::StandardHttpRequest::params(&request) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => panic!("params failed: {error}"), + }; + assert_eq!(params[1]["withContext"], serde_json::Value::Bool(true)); + assert_eq!(params[1]["sortResults"], serde_json::Value::Bool(true)); + assert_eq!( + params[1]["filters"][1], + serde_json::Value::String("tokenAccountState".to_string()) + ); + } +} diff --git a/kb-onchain-transport/src/standard_http_blocks.rs b/kb-onchain-transport/src/standard_http_blocks.rs new file mode 100644 index 0000000..da67b16 --- /dev/null +++ b/kb-onchain-transport/src/standard_http_blocks.rs @@ -0,0 +1,548 @@ +// file: kb-onchain-transport/src/standard_http_blocks.rs +// version: 2 + +//! Configurable standard block and ledger Solana HTTP JSON-RPC requests. + +/// Options accepted by `getBlock`. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcBlockConfig { + /// Optional transaction encoding. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub encoding: std::option::Option, + /// Optional transaction detail level. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub transaction_details: std::option::Option, + /// Whether rewards must be included. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub rewards: std::option::Option, + /// Optional confirmed or finalized commitment. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub commitment: std::option::Option, + /// Highest transaction version the caller can decode. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub max_supported_transaction_version: std::option::Option, +} + +impl crate::RpcBlockConfig { + /// Rejects the processed commitment unsupported by block-history methods. + pub fn validate(&self) -> kb_core::Result<()> { + if self.commitment == std::option::Option::Some(crate::RpcCommitmentLevel::Processed) { + return std::result::Result::Err(kb_core::Error::config( + "getBlock does not support processed commitment", + )); + } + return std::result::Result::Ok(()); + } +} + +/// Inclusive slot range accepted by `getBlockProduction`. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcBlockProductionConfigRange { + /// First slot included in the range. + pub first_slot: u64, + /// Optional final slot included in the range. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub last_slot: std::option::Option, +} + +/// Options accepted by `getBlockProduction`. +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcBlockProductionConfig { + /// Optional validator identity filter. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub identity: std::option::Option, + /// Optional inclusive slot range. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub range: std::option::Option, + /// Optional commitment level. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub commitment: std::option::Option, +} + +impl crate::RpcBlockProductionConfig { + /// Validates identity and range ordering. + pub fn validate(&self) -> kb_core::Result<()> { + if let std::option::Option::Some(identity) = &self.identity { + let identity_result = + crate::validate_solana_pubkey_text(identity, "getBlockProduction identity"); + if let std::result::Result::Err(error) = identity_result { + return std::result::Result::Err(error); + } + } + if let std::option::Option::Some(range) = self.range { + if let std::option::Option::Some(last_slot) = range.last_slot { + if last_slot < range.first_slot { + return std::result::Result::Err(kb_core::Error::config( + "getBlockProduction last slot must not precede first slot", + )); + } + } + } + return std::result::Result::Ok(()); + } +} + +/// Reward category attached to a block reward entry. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub enum RpcRewardType { + /// Transaction fee reward. + Fee, + /// Rent reward. + Rent, + /// Staking reward. + Staking, + /// Vote reward. + Voting, +} + +/// One reward entry returned with a block. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcReward { + /// Recipient public key. + pub pubkey: std::string::String, + /// Signed lamport balance change. + pub lamports: i64, + /// Recipient balance after the reward. + pub post_balance: u64, + /// Optional reward category. + pub reward_type: std::option::Option, + /// Optional validator commission percentage. + pub commission: std::option::Option, + /// Optional validator commission in basis points. + #[serde(default, skip_serializing_if = "std::option::Option::is_none")] + pub commission_bps: std::option::Option, +} + +/// Encoding-dependent confirmed block returned by `getBlock`. +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcConfirmedBlock { + /// Block hash. + pub blockhash: std::string::String, + /// Previous block hash. + pub previous_blockhash: std::string::String, + /// Parent slot. + pub parent_slot: u64, + /// Encoding-dependent transaction entries when requested. + #[serde(default)] + pub transactions: std::option::Option>, + /// Signature list when signature-only details are requested. + #[serde(default)] + pub signatures: std::option::Option>, + /// Rewards when requested. + #[serde(default)] + pub rewards: std::option::Option>, + /// Unix block time when available. + #[serde(default)] + pub block_time: std::option::Option, + /// Block height when available. + #[serde(default)] + pub block_height: std::option::Option, + /// Number of reward partitions when available. + #[serde(default)] + pub num_reward_partitions: std::option::Option, +} + +/// Stake commitment information returned for one block. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcBlockCommitment { + /// Commitment stake by lockout depth, or `None` when unavailable. + pub commitment: std::option::Option>, + /// Total active stake used for the commitment calculation. + pub total_stake: u64, +} + +/// Actual slot range represented by a block-production response. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcBlockProductionRange { + /// First represented slot. + pub first_slot: u64, + /// Last represented slot. + pub last_slot: u64, +} + +/// Block-production counts grouped by validator identity. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcBlockProduction { + /// Validator identity to `(leader slots, blocks produced)` map. + pub by_identity: std::collections::BTreeMap, + /// Actual represented slot range. + pub range: crate::RpcBlockProductionRange, +} + +/// Recent cluster performance sample. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcPerformanceSample { + /// Slot at the end of the sample window. + pub slot: u64, + /// Total transactions processed during the sample. + pub num_transactions: u64, + /// Optional count excluding vote transactions. + #[serde(default)] + pub num_non_vote_transactions: std::option::Option, + /// Slots processed during the sample. + pub num_slots: u64, + /// Sample period in seconds. + pub sample_period_secs: u16, +} + +/// Typed `getBlock` request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GetBlockRequest { + /// Block slot. + pub slot: u64, + /// Optional encoding, details, rewards, commitment and version options. + pub config: std::option::Option, +} + +impl crate::StandardHttpRequest for crate::GetBlockRequest { + type Response = std::option::Option; + + const METHOD: &'static str = "getBlock"; + + fn params(&self) -> kb_core::Result> { + let mut params = std::vec![serde_json::Value::from(self.slot)]; + if let std::option::Option::Some(config) = &self.config { + let config_result = config.validate(); + if let std::result::Result::Err(error) = config_result { + return std::result::Result::Err(error); + } + let value = match crate::serialize_parameter(Self::METHOD, config) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + params.push(value); + } + return std::result::Result::Ok(params); + } +} + +/// Typed `getBlockCommitment` request. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GetBlockCommitmentRequest { + /// Block slot. + pub slot: u64, +} + +impl crate::StandardHttpRequest for crate::GetBlockCommitmentRequest { + type Response = crate::RpcBlockCommitment; + + const METHOD: &'static str = "getBlockCommitment"; + + fn params(&self) -> kb_core::Result> { + return std::result::Result::Ok(std::vec![serde_json::Value::from(self.slot)]); + } +} + +/// Typed `getBlockProduction` request. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct GetBlockProductionRequest { + /// Optional identity, range and commitment options. + pub config: std::option::Option, +} + +impl crate::StandardHttpRequest for crate::GetBlockProductionRequest { + type Response = crate::RpcResponse; + + const METHOD: &'static str = "getBlockProduction"; + + fn params(&self) -> kb_core::Result> { + if let std::option::Option::Some(config) = &self.config { + let validation_result = config.validate(); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + let value = match crate::serialize_parameter(Self::METHOD, config) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(std::vec![value]); + } + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +/// Typed `getBlocks` request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GetBlocksRequest { + /// First slot included in the scan. + pub start_slot: u64, + /// Optional final slot included in the scan. + pub end_slot: std::option::Option, + /// Optional confirmed or finalized context options. + pub config: std::option::Option, +} + +impl crate::StandardHttpRequest for crate::GetBlocksRequest { + type Response = std::vec::Vec; + + const METHOD: &'static str = "getBlocks"; + + fn params(&self) -> kb_core::Result> { + if let std::option::Option::Some(end_slot) = self.end_slot { + if end_slot >= self.start_slot + && end_slot.saturating_sub(self.start_slot) > crate::MAX_BLOCK_RANGE + { + return std::result::Result::Err(kb_core::Error::config(format!( + "getBlocks range must not exceed {} slots", + crate::MAX_BLOCK_RANGE + ))); + } + } + if let std::option::Option::Some(config) = &self.config { + if config.commitment == std::option::Option::Some(crate::RpcCommitmentLevel::Processed) + { + return std::result::Result::Err(kb_core::Error::config( + "getBlocks does not support processed commitment", + )); + } + } + let mut params = std::vec![serde_json::Value::from(self.start_slot)]; + if let std::option::Option::Some(end_slot) = self.end_slot { + params.push(serde_json::Value::from(end_slot)); + } + if let std::option::Option::Some(config) = &self.config { + let value = match crate::serialize_parameter(Self::METHOD, config) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + params.push(value); + } + return std::result::Result::Ok(params); + } +} + +/// Typed `getBlocksWithLimit` request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GetBlocksWithLimitRequest { + /// First slot considered by the scan. + pub start_slot: u64, + /// Maximum number of block slots returned. + pub limit: u64, + /// Optional confirmed or finalized context options. + pub config: std::option::Option, +} + +impl crate::StandardHttpRequest for crate::GetBlocksWithLimitRequest { + type Response = std::vec::Vec; + + const METHOD: &'static str = "getBlocksWithLimit"; + + fn params(&self) -> kb_core::Result> { + if self.limit > crate::MAX_BLOCK_RANGE { + return std::result::Result::Err(kb_core::Error::config(format!( + "getBlocksWithLimit limit must not exceed {}", + crate::MAX_BLOCK_RANGE + ))); + } + if let std::option::Option::Some(config) = &self.config { + if config.commitment == std::option::Option::Some(crate::RpcCommitmentLevel::Processed) + { + return std::result::Result::Err(kb_core::Error::config( + "getBlocksWithLimit does not support processed commitment", + )); + } + } + let mut params = std::vec![ + serde_json::Value::from(self.start_slot), + serde_json::Value::from(self.limit), + ]; + if let std::option::Option::Some(config) = &self.config { + let value = match crate::serialize_parameter(Self::METHOD, config) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + params.push(value); + } + return std::result::Result::Ok(params); + } +} + +/// Typed `getBlockTime` request. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GetBlockTimeRequest { + /// Block slot. + pub slot: u64, +} + +impl crate::StandardHttpRequest for crate::GetBlockTimeRequest { + type Response = std::option::Option; + + const METHOD: &'static str = "getBlockTime"; + + fn params(&self) -> kb_core::Result> { + return std::result::Result::Ok(std::vec![serde_json::Value::from(self.slot)]); + } +} + +/// Typed `getFirstAvailableBlock` request. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct GetFirstAvailableBlockRequest; + +impl crate::StandardHttpRequest for crate::GetFirstAvailableBlockRequest { + type Response = u64; + + const METHOD: &'static str = "getFirstAvailableBlock"; + + fn params(&self) -> kb_core::Result> { + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +/// Typed `getRecentPerformanceSamples` request. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct GetRecentPerformanceSamplesRequest { + /// Optional sample count. Absence delegates the default to the endpoint. + pub limit: std::option::Option, +} + +impl crate::StandardHttpRequest for crate::GetRecentPerformanceSamplesRequest { + type Response = std::vec::Vec; + + const METHOD: &'static str = "getRecentPerformanceSamples"; + + fn params(&self) -> kb_core::Result> { + if let std::option::Option::Some(limit) = self.limit { + if limit > crate::MAX_PERFORMANCE_SAMPLE_COUNT { + return std::result::Result::Err(kb_core::Error::config(format!( + "getRecentPerformanceSamples limit must not exceed {}", + crate::MAX_PERFORMANCE_SAMPLE_COUNT + ))); + } + return std::result::Result::Ok(std::vec![serde_json::Value::from(limit)]); + } + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +/// Typed `minimumLedgerSlot` request. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct MinimumLedgerSlotRequest; + +impl crate::StandardHttpRequest for crate::MinimumLedgerSlotRequest { + type Response = u64; + + const METHOD: &'static str = "minimumLedgerSlot"; + + fn params(&self) -> kb_core::Result> { + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +#[cfg(test)] +mod tests { + #[test] + fn block_options_remain_independently_selectable() { + let request = crate::GetBlockRequest { + slot: 55, + config: std::option::Option::Some(crate::RpcBlockConfig { + encoding: std::option::Option::Some(crate::RpcTransactionEncoding::JsonParsed), + transaction_details: std::option::Option::Some( + crate::RpcTransactionDetails::Accounts, + ), + rewards: std::option::Option::Some(false), + commitment: std::option::Option::Some(crate::RpcCommitmentLevel::Confirmed), + max_supported_transaction_version: std::option::Option::Some(0), + }), + }; + let params = match crate::StandardHttpRequest::params(&request) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => panic!("params failed: {error}"), + }; + assert_eq!(params[1]["encoding"], serde_json::Value::String("jsonParsed".to_string())); + assert_eq!( + params[1]["transactionDetails"], + serde_json::Value::String("accounts".to_string()) + ); + assert_eq!(params[1]["rewards"], serde_json::Value::Bool(false)); + assert_eq!(params[1]["maxSupportedTransactionVersion"], serde_json::Value::from(0_u64)); + + let reward = match serde_json::from_value::(serde_json::json!({ + "pubkey": "validator", + "lamports": 42, + "postBalance": 84, + "rewardType": "voting", + "commission": 5, + "commissionBps": 550 + })) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("reward parsing failed: {error}"), + }; + assert_eq!(reward.commission, std::option::Option::Some(5)); + assert_eq!(reward.commission_bps, std::option::Option::Some(550)); + } + + #[test] + fn blocks_without_end_slot_places_config_in_second_position() { + let request = crate::GetBlocksRequest { + start_slot: 10, + end_slot: std::option::Option::None, + config: std::option::Option::Some(crate::RpcContextConfig { + commitment: std::option::Option::Some(crate::RpcCommitmentLevel::Finalized), + min_context_slot: std::option::Option::Some(9), + }), + }; + let params = match crate::StandardHttpRequest::params(&request) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => panic!("params failed: {error}"), + }; + assert_eq!(params.len(), 2); + assert_eq!(params[1]["commitment"], serde_json::Value::String("finalized".to_string())); + } + + #[test] + fn block_ranges_and_performance_samples_are_bounded() { + let range = crate::GetBlocksRequest { + start_slot: 0, + end_slot: std::option::Option::Some(500_001), + config: std::option::Option::None, + }; + let samples = + crate::GetRecentPerformanceSamplesRequest { limit: std::option::Option::Some(721) }; + assert!(crate::StandardHttpRequest::params(&range).is_err()); + assert!(crate::StandardHttpRequest::params(&samples).is_err()); + + let reversed_range = crate::GetBlocksRequest { + start_slot: 10, + end_slot: std::option::Option::Some(9), + config: std::option::Option::None, + }; + let zero_blocks = crate::GetBlocksWithLimitRequest { + start_slot: 10, + limit: 0, + config: std::option::Option::None, + }; + let zero_samples = + crate::GetRecentPerformanceSamplesRequest { limit: std::option::Option::Some(0) }; + let reversed_params = match crate::StandardHttpRequest::params(&reversed_range) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => panic!("reversed range failed: {error}"), + }; + let zero_block_params = match crate::StandardHttpRequest::params(&zero_blocks) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => panic!("zero block limit failed: {error}"), + }; + let zero_sample_params = match crate::StandardHttpRequest::params(&zero_samples) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => panic!("zero sample limit failed: {error}"), + }; + assert_eq!( + reversed_params, + std::vec![serde_json::Value::from(10_u64), serde_json::Value::from(9_u64),] + ); + assert_eq!( + zero_block_params, + std::vec![serde_json::Value::from(10_u64), serde_json::Value::from(0_u64),] + ); + assert_eq!(zero_sample_params, std::vec![serde_json::Value::from(0_u64)]); + } +} diff --git a/kb-onchain-transport/src/standard_http_cluster.rs b/kb-onchain-transport/src/standard_http_cluster.rs new file mode 100644 index 0000000..55e24eb --- /dev/null +++ b/kb-onchain-transport/src/standard_http_cluster.rs @@ -0,0 +1,505 @@ +// file: kb-onchain-transport/src/standard_http_cluster.rs +// version: 2 + +//! Configurable standard cluster-oriented Solana HTTP JSON-RPC requests. + +/// Contact information returned for one cluster node. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcContactInfo { + /// Node identity public key. + pub pubkey: std::string::String, + /// Gossip socket address. + pub gossip: std::option::Option, + /// TVU UDP socket address. + pub tvu: std::option::Option, + /// TPU UDP socket address. + pub tpu: std::option::Option, + /// TPU QUIC socket address. + pub tpu_quic: std::option::Option, + /// TPU forwarding UDP socket address. + pub tpu_forwards: std::option::Option, + /// TPU forwarding QUIC socket address. + pub tpu_forwards_quic: std::option::Option, + /// TPU vote socket address. + pub tpu_vote: std::option::Option, + /// Repair service socket address. + pub serve_repair: std::option::Option, + /// JSON-RPC socket address. + pub rpc: std::option::Option, + /// PubSub socket address. + pub pubsub: std::option::Option, + /// Validator software version. + pub version: std::option::Option, + /// Validator client identifier. + pub client_id: std::option::Option, + /// Feature-set identifier prefix. + pub feature_set: std::option::Option, + /// Shred version. + pub shred_version: std::option::Option, +} + +/// Epoch schedule derived from the cluster genesis configuration. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcEpochSchedule { + /// Slots in a normal epoch. + pub slots_per_epoch: u64, + /// Leader-schedule offset in slots. + pub leader_schedule_slot_offset: u64, + /// Whether warmup epochs are enabled. + pub warmup: bool, + /// First epoch using the normal slot count. + pub first_normal_epoch: u64, + /// First slot of the first normal epoch. + pub first_normal_slot: u64, +} + +/// Highest complete and incremental snapshot slots available from a node. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct RpcSnapshotSlotInfo { + /// Highest full snapshot slot. + pub full: u64, + /// Highest incremental snapshot slot when available. + pub incremental: std::option::Option, +} + +/// Node identity response. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct RpcIdentity { + /// Node identity public key. + pub identity: std::string::String, +} + +/// Options accepted by `getLeaderSchedule`. +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcLeaderScheduleConfig { + /// Optional validator identity whose schedule is requested. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub identity: std::option::Option, + /// Optional commitment level. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub commitment: std::option::Option, +} + +impl crate::RpcLeaderScheduleConfig { + /// Validates the optional identity public key. + pub fn validate(&self) -> kb_core::Result<()> { + if let std::option::Option::Some(identity) = &self.identity { + return crate::validate_solana_pubkey_text(identity, "getLeaderSchedule identity"); + } + return std::result::Result::Ok(()); + } +} + +/// Leader schedule keyed by validator identity. +pub type RpcLeaderSchedule = std::collections::BTreeMap>; + +/// Validator software version information. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct RpcVersionInfo { + /// Validator software version. + pub solana_core: std::string::String, + /// Feature-set identifier prefix. + pub feature_set: std::option::Option, +} + +/// Options accepted by `getVoteAccounts`. +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcGetVoteAccountsConfig { + /// Optional vote account public key filter. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub vote_pubkey: std::option::Option, + /// Optional commitment level. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub commitment: std::option::Option, + /// Whether unstaked delinquent validators must be retained. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub keep_unstaked_delinquents: std::option::Option, + /// Optional delinquency threshold in slots. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub delinquent_slot_distance: std::option::Option, +} + +impl crate::RpcGetVoteAccountsConfig { + /// Validates the optional vote account public key. + pub fn validate(&self) -> kb_core::Result<()> { + if let std::option::Option::Some(vote_pubkey) = &self.vote_pubkey { + return crate::validate_solana_pubkey_text(vote_pubkey, "getVoteAccounts vote account"); + } + return std::result::Result::Ok(()); + } +} + +/// Vote account information returned by `getVoteAccounts`. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcVoteAccountInfo { + /// Vote account public key. + pub vote_pubkey: std::string::String, + /// Validator identity public key. + pub node_pubkey: std::string::String, + /// Activated stake in lamports. + pub activated_stake: u64, + /// Vote commission percentage. + pub commission: u8, + /// Vote inflation-reward commission in basis points when exposed by the node. + #[serde(default, skip_serializing_if = "std::option::Option::is_none")] + pub inflation_rewards_commission_bps: std::option::Option, + /// Whether the vote account is staked in the current epoch. + pub epoch_vote_account: bool, + /// `(epoch, credits, previous credits)` history. + pub epoch_credits: std::vec::Vec<(u64, u64, u64)>, + /// Most recent voted slot. + pub last_vote: u64, + /// Current root slot. + pub root_slot: u64, +} + +/// Current and delinquent validator vote accounts. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct RpcVoteAccountStatus { + /// Current validator vote accounts. + pub current: std::vec::Vec, + /// Delinquent validator vote accounts. + pub delinquent: std::vec::Vec, +} + +/// Typed `getClusterNodes` request. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct GetClusterNodesRequest; + +impl crate::StandardHttpRequest for crate::GetClusterNodesRequest { + type Response = std::vec::Vec; + + const METHOD: &'static str = "getClusterNodes"; + + fn params(&self) -> kb_core::Result> { + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +/// Typed `getEpochSchedule` request. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct GetEpochScheduleRequest; + +impl crate::StandardHttpRequest for crate::GetEpochScheduleRequest { + type Response = crate::RpcEpochSchedule; + + const METHOD: &'static str = "getEpochSchedule"; + + fn params(&self) -> kb_core::Result> { + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +/// Typed `getHealth` request. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct GetHealthRequest; + +impl crate::StandardHttpRequest for crate::GetHealthRequest { + type Response = std::string::String; + + const METHOD: &'static str = "getHealth"; + + fn params(&self) -> kb_core::Result> { + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +/// Typed `getHighestSnapshotSlot` request. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct GetHighestSnapshotSlotRequest; + +impl crate::StandardHttpRequest for crate::GetHighestSnapshotSlotRequest { + type Response = crate::RpcSnapshotSlotInfo; + + const METHOD: &'static str = "getHighestSnapshotSlot"; + + fn params(&self) -> kb_core::Result> { + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +/// Typed `getIdentity` request. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct GetIdentityRequest; + +impl crate::StandardHttpRequest for crate::GetIdentityRequest { + type Response = crate::RpcIdentity; + + const METHOD: &'static str = "getIdentity"; + + fn params(&self) -> kb_core::Result> { + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +/// Typed `getLeaderSchedule` request. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct GetLeaderScheduleRequest { + /// Optional slot selecting the epoch whose schedule is requested. + pub slot: std::option::Option, + /// Optional identity and commitment options. + pub config: std::option::Option, +} + +impl crate::StandardHttpRequest for crate::GetLeaderScheduleRequest { + type Response = std::option::Option; + + const METHOD: &'static str = "getLeaderSchedule"; + + fn params(&self) -> kb_core::Result> { + if let std::option::Option::Some(config) = &self.config { + let validation_result = config.validate(); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + } + let mut params = std::vec::Vec::new(); + if let std::option::Option::Some(slot) = self.slot { + params.push(serde_json::Value::from(slot)); + } else if self.config.is_some() { + params.push(serde_json::Value::Null); + } + if let std::option::Option::Some(config) = &self.config { + let value = match crate::serialize_parameter(Self::METHOD, config) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + params.push(value); + } + return std::result::Result::Ok(params); + } +} + +/// Typed `getMaxRetransmitSlot` request. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct GetMaxRetransmitSlotRequest; + +impl crate::StandardHttpRequest for crate::GetMaxRetransmitSlotRequest { + type Response = u64; + + const METHOD: &'static str = "getMaxRetransmitSlot"; + + fn params(&self) -> kb_core::Result> { + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +/// Typed `getMaxShredInsertSlot` request. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct GetMaxShredInsertSlotRequest; + +impl crate::StandardHttpRequest for crate::GetMaxShredInsertSlotRequest { + type Response = u64; + + const METHOD: &'static str = "getMaxShredInsertSlot"; + + fn params(&self) -> kb_core::Result> { + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +/// Typed `getSlot` request. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct GetSlotRequest { + /// Optional commitment and minimum-context options. + pub config: std::option::Option, +} + +impl crate::StandardHttpRequest for crate::GetSlotRequest { + type Response = u64; + + const METHOD: &'static str = "getSlot"; + + fn params(&self) -> kb_core::Result> { + if let std::option::Option::Some(config) = &self.config { + let value = match crate::serialize_parameter(Self::METHOD, config) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(std::vec![value]); + } + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +/// Typed `getSlotLeader` request. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct GetSlotLeaderRequest { + /// Optional commitment and minimum-context options. + pub config: std::option::Option, +} + +impl crate::StandardHttpRequest for crate::GetSlotLeaderRequest { + type Response = std::string::String; + + const METHOD: &'static str = "getSlotLeader"; + + fn params(&self) -> kb_core::Result> { + if let std::option::Option::Some(config) = &self.config { + let value = match crate::serialize_parameter(Self::METHOD, config) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(std::vec![value]); + } + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +/// Typed `getSlotLeaders` request. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GetSlotLeadersRequest { + /// First slot whose leader is requested. + pub start_slot: u64, + /// Number of consecutive leaders requested. + pub limit: u64, +} + +impl crate::StandardHttpRequest for crate::GetSlotLeadersRequest { + type Response = std::vec::Vec; + + const METHOD: &'static str = "getSlotLeaders"; + + fn params(&self) -> kb_core::Result> { + if self.limit == 0 || self.limit > crate::MAX_SLOT_LEADER_COUNT { + return std::result::Result::Err(kb_core::Error::config(format!( + "getSlotLeaders limit must be between 1 and {}", + crate::MAX_SLOT_LEADER_COUNT + ))); + } + return std::result::Result::Ok(std::vec![ + serde_json::Value::from(self.start_slot), + serde_json::Value::from(self.limit), + ]); + } +} + +/// Typed `getVersion` request. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct GetVersionRequest; + +impl crate::StandardHttpRequest for crate::GetVersionRequest { + type Response = crate::RpcVersionInfo; + + const METHOD: &'static str = "getVersion"; + + fn params(&self) -> kb_core::Result> { + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +/// Typed `getVoteAccounts` request. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct GetVoteAccountsRequest { + /// Optional vote-account, commitment and delinquency options. + pub config: std::option::Option, +} + +impl crate::StandardHttpRequest for crate::GetVoteAccountsRequest { + type Response = crate::RpcVoteAccountStatus; + + const METHOD: &'static str = "getVoteAccounts"; + + fn params(&self) -> kb_core::Result> { + if let std::option::Option::Some(config) = &self.config { + let validation_result = config.validate(); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + let value = match crate::serialize_parameter(Self::METHOD, config) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(std::vec![value]); + } + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +#[cfg(test)] +mod tests { + fn pubkey(seed: u8) -> std::string::String { + return bs58::encode([seed; 32]).into_string(); + } + + #[test] + fn leader_schedule_uses_null_slot_placeholder_when_only_config_is_selected() { + let request = crate::GetLeaderScheduleRequest { + slot: std::option::Option::None, + config: std::option::Option::Some(crate::RpcLeaderScheduleConfig { + identity: std::option::Option::Some(pubkey(1)), + commitment: std::option::Option::Some(crate::RpcCommitmentLevel::Processed), + }), + }; + let params = match crate::StandardHttpRequest::params(&request) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => panic!("params failed: {error}"), + }; + assert_eq!(params[0], serde_json::Value::Null); + assert_eq!(params[1]["identity"], serde_json::Value::String(pubkey(1))); + + let maximum = crate::GetSlotLeadersRequest { + start_slot: 1, + limit: crate::MAX_SLOT_LEADER_COUNT, + }; + let too_many = crate::GetSlotLeadersRequest { + start_slot: 1, + limit: crate::MAX_SLOT_LEADER_COUNT + 1, + }; + assert!(crate::StandardHttpRequest::params(&maximum).is_ok()); + assert!(crate::StandardHttpRequest::params(&too_many).is_err()); + } + + #[test] + fn vote_account_options_are_independently_selectable() { + let request = crate::GetVoteAccountsRequest { + config: std::option::Option::Some(crate::RpcGetVoteAccountsConfig { + vote_pubkey: std::option::Option::Some(pubkey(2)), + commitment: std::option::Option::None, + keep_unstaked_delinquents: std::option::Option::Some(true), + delinquent_slot_distance: std::option::Option::Some(512), + }), + }; + let params = match crate::StandardHttpRequest::params(&request) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => panic!("params failed: {error}"), + }; + assert!(params[0].get("commitment").is_none()); + assert_eq!(params[0]["keepUnstakedDelinquents"], serde_json::Value::Bool(true)); + assert_eq!(params[0]["delinquentSlotDistance"], serde_json::Value::from(512_u64)); + + let vote_account = + match serde_json::from_value::(serde_json::json!({ + "votePubkey": pubkey(3), + "nodePubkey": pubkey(4), + "activatedStake": 1, + "commission": 5, + "inflationRewardsCommissionBps": 525, + "epochVoteAccount": true, + "epochCredits": [[1, 2, 1]], + "lastVote": 8, + "rootSlot": 7 + })) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("vote account parsing failed: {error}"), + }; + assert_eq!(vote_account.inflation_rewards_commission_bps, std::option::Option::Some(525)); + } + + #[test] + fn version_response_accepts_kebab_case_wire_fields() { + let value = serde_json::json!({ "solana-core": "4.0.0", "feature-set": 123 }); + let parsed = match serde_json::from_value::(value) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("version parsing failed: {error}"), + }; + assert_eq!(parsed.solana_core, "4.0.0"); + assert_eq!(parsed.feature_set, std::option::Option::Some(123)); + } +} diff --git a/kb-onchain-transport/src/standard_http_economics.rs b/kb-onchain-transport/src/standard_http_economics.rs new file mode 100644 index 0000000..1fce624 --- /dev/null +++ b/kb-onchain-transport/src/standard_http_economics.rs @@ -0,0 +1,305 @@ +// file: kb-onchain-transport/src/standard_http_economics.rs +// version: 2 + +//! Configurable standard inflation, supply and stake-economics HTTP requests. + +/// Inflation governor parameters. +#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcInflationGovernor { + /// Initial inflation rate. + pub initial: f64, + /// Terminal inflation rate. + pub terminal: f64, + /// Annual taper rate. + pub taper: f64, + /// Foundation allocation rate. + pub foundation: f64, + /// Foundation allocation term in years. + pub foundation_term: f64, +} + +/// Inflation rates for the current epoch. +#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcInflationRate { + /// Total inflation rate. + pub total: f64, + /// Validator inflation rate. + pub validator: f64, + /// Foundation inflation rate. + pub foundation: f64, + /// Epoch represented by the rates. + pub epoch: u64, +} + +/// Inflation reward credited to one requested address. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcInflationReward { + /// Reward epoch. + pub epoch: u64, + /// First effective slot of the rewarded epoch. + pub effective_slot: u64, + /// Reward amount in lamports. + pub amount: u64, + /// Account balance after the reward. + pub post_balance: u64, + /// Legacy vote commission percentage when applicable. + pub commission: std::option::Option, + /// Vote commission in basis points when exposed by the node. + #[serde(default, skip_serializing_if = "std::option::Option::is_none")] + pub commission_bps: std::option::Option, +} + +/// Current lamport supply breakdown. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcSupply { + /// Total lamport supply. + pub total: u64, + /// Circulating lamport supply. + pub circulating: u64, + /// Non-circulating lamport supply. + pub non_circulating: u64, + /// Non-circulating account list when requested. + #[serde(default)] + pub non_circulating_accounts: std::vec::Vec, +} + +/// Optional commitment accepted by `getInflationGovernor`. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcInflationGovernorConfig { + /// Optional commitment level. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub commitment: std::option::Option, +} + +/// Epoch and contextual options accepted by `getInflationReward`. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcEpochConfig { + /// Optional reward epoch. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub epoch: std::option::Option, + /// Optional commitment level. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub commitment: std::option::Option, + /// Optional minimum context slot. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub min_context_slot: std::option::Option, +} + +/// Options accepted by `getSupply`. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcSupplyConfig { + /// Optional commitment level. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub commitment: std::option::Option, + /// Optional omission of the potentially large non-circulating account list. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub exclude_non_circulating_accounts_list: std::option::Option, +} + +/// Typed `getInflationGovernor` request. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct GetInflationGovernorRequest { + /// Optional commitment configuration. + pub config: std::option::Option, +} + +impl crate::StandardHttpRequest for crate::GetInflationGovernorRequest { + type Response = crate::RpcInflationGovernor; + + const METHOD: &'static str = "getInflationGovernor"; + + fn params(&self) -> kb_core::Result> { + if let std::option::Option::Some(config) = &self.config { + let value = match crate::serialize_parameter(Self::METHOD, config) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(std::vec![value]); + } + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +/// Typed `getInflationRate` request. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct GetInflationRateRequest; + +impl crate::StandardHttpRequest for crate::GetInflationRateRequest { + type Response = crate::RpcInflationRate; + + const METHOD: &'static str = "getInflationRate"; + + fn params(&self) -> kb_core::Result> { + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +/// Typed `getInflationReward` request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GetInflationRewardRequest { + /// Account public keys whose rewards are requested, in response order. + pub addresses: std::vec::Vec, + /// Optional epoch, commitment and minimum-context options. + pub config: std::option::Option, +} + +impl crate::StandardHttpRequest for crate::GetInflationRewardRequest { + type Response = std::vec::Vec>; + + const METHOD: &'static str = "getInflationReward"; + + fn params(&self) -> kb_core::Result> { + let validation_result = + crate::validate_pubkey_list(&self.addresses, "getInflationReward address", usize::MAX); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + let addresses = match crate::serialize_parameter(Self::METHOD, &self.addresses) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut params = std::vec![addresses]; + if let std::option::Option::Some(config) = &self.config { + let value = match crate::serialize_parameter(Self::METHOD, config) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + params.push(value); + } + return std::result::Result::Ok(params); + } +} + +/// Typed `getStakeMinimumDelegation` request. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct GetStakeMinimumDelegationRequest { + /// Optional commitment and minimum-context options. + pub config: std::option::Option, +} + +impl crate::StandardHttpRequest for crate::GetStakeMinimumDelegationRequest { + type Response = crate::RpcResponse; + + const METHOD: &'static str = "getStakeMinimumDelegation"; + + fn params(&self) -> kb_core::Result> { + if let std::option::Option::Some(config) = &self.config { + let value = match crate::serialize_parameter(Self::METHOD, config) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(std::vec![value]); + } + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +/// Typed `getSupply` request. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct GetSupplyRequest { + /// Optional commitment and non-circulating-list options. + pub config: std::option::Option, +} + +impl crate::StandardHttpRequest for crate::GetSupplyRequest { + type Response = crate::RpcResponse; + + const METHOD: &'static str = "getSupply"; + + fn params(&self) -> kb_core::Result> { + if let std::option::Option::Some(config) = &self.config { + let value = match crate::serialize_parameter(Self::METHOD, config) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(std::vec![value]); + } + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +#[cfg(test)] +mod tests { + fn pubkey(seed: u8) -> std::string::String { + return bs58::encode([seed; 32]).into_string(); + } + + #[test] + fn supply_distinguishes_omitted_and_explicit_false_option() { + let omitted = crate::GetSupplyRequest { + config: std::option::Option::Some(crate::RpcSupplyConfig { + commitment: std::option::Option::Some(crate::RpcCommitmentLevel::Finalized), + exclude_non_circulating_accounts_list: std::option::Option::None, + }), + }; + let explicit = crate::GetSupplyRequest { + config: std::option::Option::Some(crate::RpcSupplyConfig { + commitment: std::option::Option::Some(crate::RpcCommitmentLevel::Finalized), + exclude_non_circulating_accounts_list: std::option::Option::Some(false), + }), + }; + let omitted_params = match crate::StandardHttpRequest::params(&omitted) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => panic!("params failed: {error}"), + }; + let explicit_params = match crate::StandardHttpRequest::params(&explicit) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => panic!("params failed: {error}"), + }; + assert!(omitted_params[0].get("excludeNonCirculatingAccountsList").is_none()); + assert_eq!( + explicit_params[0]["excludeNonCirculatingAccountsList"], + serde_json::Value::Bool(false) + ); + } + + #[test] + fn inflation_reward_preserves_epoch_commitment_and_minimum_context() { + let request = crate::GetInflationRewardRequest { + addresses: std::vec![pubkey(1), pubkey(2)], + config: std::option::Option::Some(crate::RpcEpochConfig { + epoch: std::option::Option::Some(44), + commitment: std::option::Option::Some(crate::RpcCommitmentLevel::Confirmed), + min_context_slot: std::option::Option::Some(99), + }), + }; + let params = match crate::StandardHttpRequest::params(&request) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => panic!("params failed: {error}"), + }; + assert_eq!(params[1]["epoch"], serde_json::Value::from(44_u64)); + assert_eq!(params[1]["commitment"], serde_json::Value::String("confirmed".to_string())); + assert_eq!(params[1]["minContextSlot"], serde_json::Value::from(99_u64)); + + let reward = match serde_json::from_value::(serde_json::json!({ + "epoch": 44, + "effectiveSlot": 100, + "amount": 200, + "postBalance": 300, + "commission": 5, + "commissionBps": 575 + })) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("inflation reward parsing failed: {error}"), + }; + assert_eq!(reward.commission, std::option::Option::Some(5)); + assert_eq!(reward.commission_bps, std::option::Option::Some(575)); + + let empty = crate::GetInflationRewardRequest { + addresses: std::vec::Vec::new(), + config: std::option::Option::None, + }; + let empty_params = match crate::StandardHttpRequest::params(&empty) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => panic!("empty params failed: {error}"), + }; + assert_eq!(empty_params, std::vec![serde_json::json!([])]); + } +} diff --git a/kb-onchain-transport/src/standard_http_tokens.rs b/kb-onchain-transport/src/standard_http_tokens.rs new file mode 100644 index 0000000..ddebaae --- /dev/null +++ b/kb-onchain-transport/src/standard_http_tokens.rs @@ -0,0 +1,241 @@ +// file: kb-onchain-transport/src/standard_http_tokens.rs +// version: 2 + +//! Configurable standard SPL Token-oriented Solana HTTP JSON-RPC requests. + +fn pubkey_with_optional_commitment_params( + method: &str, + address: &str, + field: &str, + config: &std::option::Option, +) -> kb_core::Result> { + let validation_result = crate::validate_solana_pubkey_text(address, field); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + let mut params = std::vec![serde_json::Value::String(address.to_string())]; + if let std::option::Option::Some(config) = config { + let config_value = match crate::serialize_parameter(method, config) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + params.push(config_value); + } + return std::result::Result::Ok(params); +} + +fn token_accounts_query_params( + method: &str, + authority: &str, + field: &str, + filter: &crate::RpcTokenAccountsFilter, + config: &std::option::Option, +) -> kb_core::Result> { + let authority_result = crate::validate_solana_pubkey_text(authority, field); + if let std::result::Result::Err(error) = authority_result { + return std::result::Result::Err(error); + } + let filter_result = filter.validate(); + if let std::result::Result::Err(error) = filter_result { + return std::result::Result::Err(error); + } + if let std::option::Option::Some(config) = config { + let config_result = config.validate(); + if let std::result::Result::Err(error) = config_result { + return std::result::Result::Err(error); + } + } + let filter_value = match crate::serialize_parameter(method, filter) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut params = std::vec![serde_json::Value::String(authority.to_string()), filter_value,]; + if let std::option::Option::Some(config) = config { + let config_value = match crate::serialize_parameter(method, config) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + params.push(config_value); + } + return std::result::Result::Ok(params); +} + +/// Typed `getTokenAccountBalance` request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GetTokenAccountBalanceRequest { + /// Token account public key. + pub address: std::string::String, + /// Optional commitment option. + pub config: std::option::Option, +} + +impl crate::StandardHttpRequest for crate::GetTokenAccountBalanceRequest { + type Response = crate::RpcResponse; + + const METHOD: &'static str = "getTokenAccountBalance"; + + fn params(&self) -> kb_core::Result> { + return crate::standard_http_tokens::pubkey_with_optional_commitment_params( + Self::METHOD, + &self.address, + "getTokenAccountBalance address", + &self.config, + ); + } +} + +/// Typed `getTokenAccountsByDelegate` request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GetTokenAccountsByDelegateRequest { + /// Delegate public key. + pub delegate: std::string::String, + /// Mint or Token Program selector. + pub filter: crate::RpcTokenAccountsFilter, + /// Optional account representation and context options. + pub config: std::option::Option, +} + +impl crate::StandardHttpRequest for crate::GetTokenAccountsByDelegateRequest { + type Response = crate::RpcResponse>; + + const METHOD: &'static str = "getTokenAccountsByDelegate"; + + fn params(&self) -> kb_core::Result> { + return crate::standard_http_tokens::token_accounts_query_params( + Self::METHOD, + &self.delegate, + "getTokenAccountsByDelegate delegate", + &self.filter, + &self.config, + ); + } +} + +/// Typed `getTokenAccountsByOwner` request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GetTokenAccountsByOwnerRequest { + /// Owner public key. + pub owner: std::string::String, + /// Mint or Token Program selector. + pub filter: crate::RpcTokenAccountsFilter, + /// Optional account representation and context options. + pub config: std::option::Option, +} + +impl crate::StandardHttpRequest for crate::GetTokenAccountsByOwnerRequest { + type Response = crate::RpcResponse>; + + const METHOD: &'static str = "getTokenAccountsByOwner"; + + fn params(&self) -> kb_core::Result> { + return crate::standard_http_tokens::token_accounts_query_params( + Self::METHOD, + &self.owner, + "getTokenAccountsByOwner owner", + &self.filter, + &self.config, + ); + } +} + +/// Typed `getTokenLargestAccounts` request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GetTokenLargestAccountsRequest { + /// Mint public key. + pub mint: std::string::String, + /// Optional commitment option. + pub config: std::option::Option, +} + +impl crate::StandardHttpRequest for crate::GetTokenLargestAccountsRequest { + type Response = crate::RpcResponse>; + + const METHOD: &'static str = "getTokenLargestAccounts"; + + fn params(&self) -> kb_core::Result> { + return crate::standard_http_tokens::pubkey_with_optional_commitment_params( + Self::METHOD, + &self.mint, + "getTokenLargestAccounts mint", + &self.config, + ); + } +} + +/// Typed `getTokenSupply` request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GetTokenSupplyRequest { + /// Mint public key. + pub mint: std::string::String, + /// Optional commitment option. + pub config: std::option::Option, +} + +impl crate::StandardHttpRequest for crate::GetTokenSupplyRequest { + type Response = crate::RpcResponse; + + const METHOD: &'static str = "getTokenSupply"; + + fn params(&self) -> kb_core::Result> { + return crate::standard_http_tokens::pubkey_with_optional_commitment_params( + Self::METHOD, + &self.mint, + "getTokenSupply mint", + &self.config, + ); + } +} + +#[cfg(test)] +mod tests { + fn pubkey(seed: u8) -> std::string::String { + return bs58::encode([seed; 32]).into_string(); + } + + #[test] + fn token_owner_query_preserves_filter_and_independent_account_options() { + let request = crate::GetTokenAccountsByOwnerRequest { + owner: pubkey(1), + filter: crate::RpcTokenAccountsFilter::ProgramId(pubkey(2)), + config: std::option::Option::Some(crate::RpcAccountInfoConfig { + encoding: std::option::Option::Some(crate::RpcAccountEncoding::Base64Zstd), + data_slice: std::option::Option::None, + commitment: std::option::Option::Some(crate::RpcCommitmentLevel::Processed), + min_context_slot: std::option::Option::Some(42), + }), + }; + let params = match crate::StandardHttpRequest::params(&request) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => panic!("params failed: {error}"), + }; + assert_eq!(params[1], serde_json::json!({ "programId": pubkey(2) })); + assert_eq!(params[2]["encoding"], serde_json::Value::String("base64+zstd".to_string())); + assert_eq!(params[2]["commitment"], serde_json::Value::String("processed".to_string())); + } + + #[test] + fn token_balance_omits_unselected_options() { + let request = crate::GetTokenAccountBalanceRequest { + address: pubkey(3), + config: std::option::Option::None, + }; + let params = match crate::StandardHttpRequest::params(&request) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => panic!("params failed: {error}"), + }; + assert_eq!(params, std::vec![serde_json::Value::String(pubkey(3))]); + + let configured = crate::GetTokenSupplyRequest { + mint: pubkey(4), + config: std::option::Option::Some(crate::RpcCommitmentConfig { + commitment: std::option::Option::Some(crate::RpcCommitmentLevel::Finalized), + }), + }; + let configured_params = match crate::StandardHttpRequest::params(&configured) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => panic!("params failed: {error}"), + }; + assert_eq!(configured_params[1], serde_json::json!({ "commitment": "finalized" })); + assert!(configured_params[1].get("minContextSlot").is_none()); + } +} diff --git a/kb-onchain-transport/src/standard_http_transactions.rs b/kb-onchain-transport/src/standard_http_transactions.rs new file mode 100644 index 0000000..69719a5 --- /dev/null +++ b/kb-onchain-transport/src/standard_http_transactions.rs @@ -0,0 +1,155 @@ +// file: kb-onchain-transport/src/standard_http_transactions.rs +// version: 3 + +//! Configurable standard transaction-oriented Solana HTTP JSON-RPC requests. + +/// Prioritization fee observed for one recent slot. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcPrioritizationFee { + /// Slot from which the fee sample was retained. + pub slot: u64, + /// Minimum compute-unit price in micro-lamports for the requested writable set. + pub prioritization_fee: u64, +} + +/// Typed `getRecentPrioritizationFees` request. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct GetRecentPrioritizationFeesRequest { + /// Optional writable account set. `None` omits the parameter; `Some([])` sends an explicit empty set. + pub locked_writable_accounts: std::option::Option>, +} + +impl crate::StandardHttpRequest for crate::GetRecentPrioritizationFeesRequest { + type Response = std::vec::Vec; + + const METHOD: &'static str = "getRecentPrioritizationFees"; + + fn params(&self) -> kb_core::Result> { + if let std::option::Option::Some(accounts) = &self.locked_writable_accounts { + let validation_result = crate::validate_pubkey_list( + accounts, + "getRecentPrioritizationFees writable account", + crate::MAX_PRIORITIZATION_FEE_ACCOUNT_COUNT, + ); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + let value = match crate::serialize_parameter(Self::METHOD, accounts) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(std::vec![value]); + } + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +/// Typed `getTransactionCount` request. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct GetTransactionCountRequest { + /// Optional commitment and minimum-context options. + pub config: std::option::Option, +} + +impl crate::StandardHttpRequest for crate::GetTransactionCountRequest { + type Response = u64; + + const METHOD: &'static str = "getTransactionCount"; + + fn params(&self) -> kb_core::Result> { + if let std::option::Option::Some(config) = &self.config { + let value = match crate::serialize_parameter(Self::METHOD, config) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(std::vec![value]); + } + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +/// Typed `isBlockhashValid` request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct IsBlockhashValidRequest { + /// Base58 blockhash being checked. + pub blockhash: std::string::String, + /// Optional commitment and minimum-context options. + pub config: std::option::Option, +} + +impl crate::StandardHttpRequest for crate::IsBlockhashValidRequest { + type Response = crate::RpcResponse; + + const METHOD: &'static str = "isBlockhashValid"; + + fn params(&self) -> kb_core::Result> { + let validation_result = + crate::validate_solana_hash_text(&self.blockhash, "isBlockhashValid blockhash"); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + let mut params = std::vec![serde_json::Value::String(self.blockhash.clone())]; + if let std::option::Option::Some(config) = &self.config { + let value = match crate::serialize_parameter(Self::METHOD, config) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + params.push(value); + } + return std::result::Result::Ok(params); + } +} + +#[cfg(test)] +mod tests { + fn pubkey(seed: u8) -> std::string::String { + return bs58::encode([seed; 32]).into_string(); + } + + #[test] + fn prioritization_fee_request_distinguishes_omitted_and_explicit_empty_sets() { + let omitted = crate::GetRecentPrioritizationFeesRequest { + locked_writable_accounts: std::option::Option::None, + }; + let explicit = crate::GetRecentPrioritizationFeesRequest { + locked_writable_accounts: std::option::Option::Some(std::vec::Vec::new()), + }; + let omitted_params = match crate::StandardHttpRequest::params(&omitted) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => panic!("unexpected omitted params error: {error}"), + }; + let explicit_params = match crate::StandardHttpRequest::params(&explicit) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => panic!("unexpected explicit params error: {error}"), + }; + assert_eq!(omitted_params, std::vec::Vec::::new()); + assert_eq!(explicit_params, std::vec![serde_json::json!([])]); + } + + #[test] + fn prioritization_fee_request_enforces_official_account_bound() { + let request = crate::GetRecentPrioritizationFeesRequest { + locked_writable_accounts: std::option::Option::Some( + (0_u16..129_u16).map(|value| return pubkey(value as u8)).collect(), + ), + }; + assert!(crate::StandardHttpRequest::params(&request).is_err()); + } + + #[test] + fn blockhash_validity_preserves_minimum_context_slot() { + let request = crate::IsBlockhashValidRequest { + blockhash: pubkey(9), + config: std::option::Option::Some(crate::RpcContextConfig { + commitment: std::option::Option::Some(crate::RpcCommitmentLevel::Finalized), + min_context_slot: std::option::Option::Some(123), + }), + }; + let params = match crate::StandardHttpRequest::params(&request) { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => panic!("params failed: {error}"), + }; + assert_eq!(params[1]["minContextSlot"], serde_json::Value::from(123_u64)); + } +} diff --git a/kb-onchain-transport/src/standard_methods.rs b/kb-onchain-transport/src/standard_methods.rs new file mode 100644 index 0000000..ac78916 --- /dev/null +++ b/kb-onchain-transport/src/standard_methods.rs @@ -0,0 +1,670 @@ +// file: kb-onchain-transport/src/standard_methods.rs +// version: 4 + +//! Canonical inventory of standard Solana HTTP and WebSocket JSON-RPC methods. + +/// Strongest implementation contract exposed for one standard RPC method. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum StandardRpcContract { + /// The crate exposes a dedicated request/result adapter in addition to raw JSON transport. + TypedAdapter, + /// The method is explicitly registered and callable through validated raw JSON transport. + RawJson, +} + +impl crate::StandardRpcContract { + /// Returns the stable matrix code for this contract. + pub const fn code(self) -> &'static str { + return match self { + Self::TypedAdapter => "typed_adapter", + Self::RawJson => "raw_json", + }; + } +} + +/// Local category used to group standard Solana HTTP methods. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum StandardHttpCategory { + /// Account state and account ownership reads. + Accounts, + /// SPL Token account and mint reads. + Tokens, + /// Transaction, signature, fee, simulation and submission methods. + Transactions, + /// Block, ledger and performance methods. + Blocks, + /// Cluster, node, epoch, slot and validator methods. + Cluster, + /// Inflation, supply and stake-economics methods. + Economics, +} + +impl crate::StandardHttpCategory { + /// Returns the stable matrix code for this category. + pub const fn code(self) -> &'static str { + return match self { + Self::Accounts => "accounts", + Self::Tokens => "tokens", + Self::Transactions => "transactions", + Self::Blocks => "blocks", + Self::Cluster => "cluster", + Self::Economics => "economics", + }; + } +} + +/// One standard Solana HTTP JSON-RPC method specification. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StandardHttpMethodSpec { + /// Exact JSON-RPC method name. + pub method: &'static str, + /// Local inventory category. + pub category: crate::StandardHttpCategory, + /// Strongest contract currently exposed by `kb-onchain-transport`. + pub contract: crate::StandardRpcContract, +} + +impl crate::StandardHttpMethodSpec { + /// Returns the local HTTP routing class for this method. + pub fn method_class(&self) -> crate::HttpMethodClass { + return match self.method { + "requestAirdrop" | "sendTransaction" => crate::HttpMethodClass::SendTransaction, + "getBlock" + | "getBlocks" + | "getBlocksWithLimit" + | "getProgramAccounts" + | "getSignaturesForAddress" + | "getTransaction" + | "simulateTransaction" => crate::HttpMethodClass::HeavyRead, + _ => crate::HttpMethodClass::GeneralRpc, + }; + } +} + +/// Every standard Solana HTTP JSON-RPC method documented by the canonical RPC reference. +pub const STANDARD_HTTP_METHODS: [crate::StandardHttpMethodSpec; 52] = [ + crate::StandardHttpMethodSpec { + method: "getAccountInfo", + category: crate::StandardHttpCategory::Accounts, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getBalance", + category: crate::StandardHttpCategory::Accounts, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getLargestAccounts", + category: crate::StandardHttpCategory::Accounts, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getMinimumBalanceForRentExemption", + category: crate::StandardHttpCategory::Accounts, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getMultipleAccounts", + category: crate::StandardHttpCategory::Accounts, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getProgramAccounts", + category: crate::StandardHttpCategory::Accounts, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getTokenAccountBalance", + category: crate::StandardHttpCategory::Tokens, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getTokenAccountsByDelegate", + category: crate::StandardHttpCategory::Tokens, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getTokenAccountsByOwner", + category: crate::StandardHttpCategory::Tokens, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getTokenLargestAccounts", + category: crate::StandardHttpCategory::Tokens, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getTokenSupply", + category: crate::StandardHttpCategory::Tokens, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getFeeForMessage", + category: crate::StandardHttpCategory::Transactions, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getLatestBlockhash", + category: crate::StandardHttpCategory::Transactions, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getRecentPrioritizationFees", + category: crate::StandardHttpCategory::Transactions, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getSignaturesForAddress", + category: crate::StandardHttpCategory::Transactions, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getSignatureStatuses", + category: crate::StandardHttpCategory::Transactions, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getTransaction", + category: crate::StandardHttpCategory::Transactions, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getTransactionCount", + category: crate::StandardHttpCategory::Transactions, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "isBlockhashValid", + category: crate::StandardHttpCategory::Transactions, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "requestAirdrop", + category: crate::StandardHttpCategory::Transactions, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "sendTransaction", + category: crate::StandardHttpCategory::Transactions, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "simulateTransaction", + category: crate::StandardHttpCategory::Transactions, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getBlock", + category: crate::StandardHttpCategory::Blocks, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getBlockCommitment", + category: crate::StandardHttpCategory::Blocks, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getBlockHeight", + category: crate::StandardHttpCategory::Blocks, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getBlockProduction", + category: crate::StandardHttpCategory::Blocks, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getBlocks", + category: crate::StandardHttpCategory::Blocks, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getBlocksWithLimit", + category: crate::StandardHttpCategory::Blocks, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getBlockTime", + category: crate::StandardHttpCategory::Blocks, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getFirstAvailableBlock", + category: crate::StandardHttpCategory::Blocks, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getRecentPerformanceSamples", + category: crate::StandardHttpCategory::Blocks, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "minimumLedgerSlot", + category: crate::StandardHttpCategory::Blocks, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getClusterNodes", + category: crate::StandardHttpCategory::Cluster, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getEpochInfo", + category: crate::StandardHttpCategory::Cluster, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getEpochSchedule", + category: crate::StandardHttpCategory::Cluster, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getGenesisHash", + category: crate::StandardHttpCategory::Cluster, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getHealth", + category: crate::StandardHttpCategory::Cluster, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getHighestSnapshotSlot", + category: crate::StandardHttpCategory::Cluster, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getIdentity", + category: crate::StandardHttpCategory::Cluster, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getLeaderSchedule", + category: crate::StandardHttpCategory::Cluster, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getMaxRetransmitSlot", + category: crate::StandardHttpCategory::Cluster, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getMaxShredInsertSlot", + category: crate::StandardHttpCategory::Cluster, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getSlot", + category: crate::StandardHttpCategory::Cluster, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getSlotLeader", + category: crate::StandardHttpCategory::Cluster, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getSlotLeaders", + category: crate::StandardHttpCategory::Cluster, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getVersion", + category: crate::StandardHttpCategory::Cluster, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getVoteAccounts", + category: crate::StandardHttpCategory::Cluster, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getInflationGovernor", + category: crate::StandardHttpCategory::Economics, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getInflationRate", + category: crate::StandardHttpCategory::Economics, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getInflationReward", + category: crate::StandardHttpCategory::Economics, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getStakeMinimumDelegation", + category: crate::StandardHttpCategory::Economics, + contract: crate::StandardRpcContract::TypedAdapter, + }, + crate::StandardHttpMethodSpec { + method: "getSupply", + category: crate::StandardHttpCategory::Economics, + contract: crate::StandardRpcContract::TypedAdapter, + }, +]; + +/// Returns the canonical specification for one exact standard HTTP method name. +pub fn standard_http_method( + method: &str, +) -> std::option::Option<&'static crate::StandardHttpMethodSpec> { + return crate::STANDARD_HTTP_METHODS.iter().find(|entry| return entry.method == method); +} + +/// Stability of one standard Solana WebSocket subscription surface. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum StandardWsStability { + /// Stable standard PubSub method. + Stable, + /// Method documented as unstable and potentially gated by validator flags. + Unstable, +} + +impl crate::StandardWsStability { + /// Returns the stable matrix code for this stability class. + pub const fn code(self) -> &'static str { + return match self { + Self::Stable => "stable", + Self::Unstable => "unstable", + }; + } +} + +/// One standard Solana WebSocket subscribe/unsubscribe pair. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StandardWsSubscriptionSpec { + /// Exact subscribe method name. + pub subscribe_method: &'static str, + /// Exact unsubscribe method name. + pub unsubscribe_method: &'static str, + /// Exact notification method name emitted by the server. + pub notification_method: &'static str, + /// Stability declared by the canonical Solana RPC documentation. + pub stability: crate::StandardWsStability, + /// Strongest typed request contract exposed by `kb-onchain-transport`. + pub request_contract: crate::StandardRpcContract, + /// Strongest typed notification contract exposed by `kb-onchain-transport`. + pub notification_contract: crate::StandardRpcContract, + /// Whether the pair is supported by the reusable persistent session runtime. + pub persistent_runtime: bool, +} + +/// Every standard Solana WebSocket subscription pair documented by the canonical RPC reference. +pub const STANDARD_WS_SUBSCRIPTIONS: [crate::StandardWsSubscriptionSpec; 9] = [ + crate::StandardWsSubscriptionSpec { + subscribe_method: "accountSubscribe", + unsubscribe_method: "accountUnsubscribe", + notification_method: "accountNotification", + stability: crate::StandardWsStability::Stable, + request_contract: crate::StandardRpcContract::TypedAdapter, + notification_contract: crate::StandardRpcContract::TypedAdapter, + persistent_runtime: true, + }, + crate::StandardWsSubscriptionSpec { + subscribe_method: "blockSubscribe", + unsubscribe_method: "blockUnsubscribe", + notification_method: "blockNotification", + stability: crate::StandardWsStability::Unstable, + request_contract: crate::StandardRpcContract::TypedAdapter, + notification_contract: crate::StandardRpcContract::TypedAdapter, + persistent_runtime: true, + }, + crate::StandardWsSubscriptionSpec { + subscribe_method: "logsSubscribe", + unsubscribe_method: "logsUnsubscribe", + notification_method: "logsNotification", + stability: crate::StandardWsStability::Stable, + request_contract: crate::StandardRpcContract::TypedAdapter, + notification_contract: crate::StandardRpcContract::TypedAdapter, + persistent_runtime: true, + }, + crate::StandardWsSubscriptionSpec { + subscribe_method: "programSubscribe", + unsubscribe_method: "programUnsubscribe", + notification_method: "programNotification", + stability: crate::StandardWsStability::Stable, + request_contract: crate::StandardRpcContract::TypedAdapter, + notification_contract: crate::StandardRpcContract::TypedAdapter, + persistent_runtime: true, + }, + crate::StandardWsSubscriptionSpec { + subscribe_method: "rootSubscribe", + unsubscribe_method: "rootUnsubscribe", + notification_method: "rootNotification", + stability: crate::StandardWsStability::Stable, + request_contract: crate::StandardRpcContract::TypedAdapter, + notification_contract: crate::StandardRpcContract::TypedAdapter, + persistent_runtime: true, + }, + crate::StandardWsSubscriptionSpec { + subscribe_method: "signatureSubscribe", + unsubscribe_method: "signatureUnsubscribe", + notification_method: "signatureNotification", + stability: crate::StandardWsStability::Stable, + request_contract: crate::StandardRpcContract::TypedAdapter, + notification_contract: crate::StandardRpcContract::TypedAdapter, + persistent_runtime: true, + }, + crate::StandardWsSubscriptionSpec { + subscribe_method: "slotSubscribe", + unsubscribe_method: "slotUnsubscribe", + notification_method: "slotNotification", + stability: crate::StandardWsStability::Stable, + request_contract: crate::StandardRpcContract::TypedAdapter, + notification_contract: crate::StandardRpcContract::TypedAdapter, + persistent_runtime: true, + }, + crate::StandardWsSubscriptionSpec { + subscribe_method: "slotsUpdatesSubscribe", + unsubscribe_method: "slotsUpdatesUnsubscribe", + notification_method: "slotsUpdatesNotification", + stability: crate::StandardWsStability::Unstable, + request_contract: crate::StandardRpcContract::TypedAdapter, + notification_contract: crate::StandardRpcContract::TypedAdapter, + persistent_runtime: true, + }, + crate::StandardWsSubscriptionSpec { + subscribe_method: "voteSubscribe", + unsubscribe_method: "voteUnsubscribe", + notification_method: "voteNotification", + stability: crate::StandardWsStability::Unstable, + request_contract: crate::StandardRpcContract::TypedAdapter, + notification_contract: crate::StandardRpcContract::TypedAdapter, + persistent_runtime: true, + }, +]; + +/// Returns the canonical subscription specification matching a subscribe or unsubscribe method. +pub fn standard_ws_subscription( + method: &str, +) -> std::option::Option<&'static crate::StandardWsSubscriptionSpec> { + return crate::STANDARD_WS_SUBSCRIPTIONS.iter().find(|entry| { + return entry.subscribe_method == method || entry.unsubscribe_method == method; + }); +} + +#[cfg(test)] +mod tests { + #[test] + fn standard_http_inventory_is_exact_unique_and_classified() { + assert_eq!(crate::STANDARD_HTTP_METHODS.len(), 52); + let mut names = std::collections::BTreeSet::new(); + let mut typed_count = 0_usize; + for method in &crate::STANDARD_HTTP_METHODS { + assert!(names.insert(method.method)); + if method.contract == crate::StandardRpcContract::TypedAdapter { + typed_count = typed_count.saturating_add(1); + } + assert_eq!( + crate::standard_http_method(method.method), + std::option::Option::Some(method) + ); + } + assert_eq!(typed_count, 52); + } + + #[test] + fn configurable_request_registry_covers_the_former_thirty_eight_raw_methods() { + fn method() -> &'static str { + return ::METHOD; + } + let methods = [ + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + method::(), + ]; + assert_eq!(methods.len(), 38); + let mut unique = std::collections::BTreeSet::new(); + for method_name in methods { + assert!(unique.insert(method_name)); + let specification = match crate::standard_http_method(method_name) { + std::option::Option::Some(specification) => specification, + std::option::Option::None => panic!("typed method absent from registry"), + }; + assert_eq!(specification.contract, crate::StandardRpcContract::TypedAdapter); + } + } + + #[test] + fn standard_ws_inventory_contains_nine_pairs_and_eighteen_unique_methods() { + assert_eq!(crate::STANDARD_WS_SUBSCRIPTIONS.len(), 9); + let mut names = std::collections::BTreeSet::new(); + let mut unstable_count = 0_usize; + for subscription in &crate::STANDARD_WS_SUBSCRIPTIONS { + assert!(names.insert(subscription.subscribe_method)); + assert!(names.insert(subscription.unsubscribe_method)); + if subscription.stability == crate::StandardWsStability::Unstable { + unstable_count = unstable_count.saturating_add(1); + } + assert_eq!(subscription.request_contract, crate::StandardRpcContract::TypedAdapter); + assert_eq!( + subscription.notification_contract, + crate::StandardRpcContract::TypedAdapter + ); + assert!(subscription.persistent_runtime); + assert_eq!( + crate::standard_ws_subscription(subscription.subscribe_method), + std::option::Option::Some(subscription) + ); + assert_eq!( + crate::standard_ws_subscription(subscription.unsubscribe_method), + std::option::Option::Some(subscription) + ); + } + assert_eq!(names.len(), 18); + assert_eq!(unstable_count, 3); + } + + #[test] + fn standard_rpc_matrix_matches_compiled_inventory() { + let raw = include_str!("../../docs/SOLANA_STANDARD_RPC_MATRIX.json"); + let parsed = match serde_json::from_str::(raw) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("rpc matrix parsing failed: {error}"), + }; + let http_methods = match parsed.get("http_methods").and_then(serde_json::Value::as_array) { + std::option::Option::Some(value) => value, + std::option::Option::None => panic!("rpc matrix http_methods missing"), + }; + let ws_subscriptions = + match parsed.get("ws_subscriptions").and_then(serde_json::Value::as_array) { + std::option::Option::Some(value) => value, + std::option::Option::None => panic!("rpc matrix ws_subscriptions missing"), + }; + assert_eq!(http_methods.len(), crate::STANDARD_HTTP_METHODS.len()); + assert_eq!(ws_subscriptions.len(), crate::STANDARD_WS_SUBSCRIPTIONS.len()); + for method in &crate::STANDARD_HTTP_METHODS { + let matrix_entry = http_methods.iter().find(|entry| { + return entry.get("method").and_then(serde_json::Value::as_str) + == std::option::Option::Some(method.method); + }); + let matrix_entry = match matrix_entry { + std::option::Option::Some(value) => value, + std::option::Option::None => panic!("rpc matrix HTTP method missing"), + }; + assert_eq!( + matrix_entry.get("category").and_then(serde_json::Value::as_str), + std::option::Option::Some(method.category.code()) + ); + assert_eq!( + matrix_entry.get("contract").and_then(serde_json::Value::as_str), + std::option::Option::Some(method.contract.code()) + ); + } + for subscription in &crate::STANDARD_WS_SUBSCRIPTIONS { + let matrix_entry = ws_subscriptions.iter().find(|entry| { + return entry.get("subscribe_method").and_then(serde_json::Value::as_str) + == std::option::Option::Some(subscription.subscribe_method); + }); + let matrix_entry = match matrix_entry { + std::option::Option::Some(value) => value, + std::option::Option::None => panic!("rpc matrix WS subscription missing"), + }; + assert_eq!( + matrix_entry.get("unsubscribe_method").and_then(serde_json::Value::as_str), + std::option::Option::Some(subscription.unsubscribe_method) + ); + assert_eq!( + matrix_entry.get("notification_method").and_then(serde_json::Value::as_str), + std::option::Option::Some(subscription.notification_method) + ); + assert_eq!( + matrix_entry.get("stability").and_then(serde_json::Value::as_str), + std::option::Option::Some(subscription.stability.code()) + ); + assert_eq!( + matrix_entry.get("request_contract").and_then(serde_json::Value::as_str), + std::option::Option::Some(subscription.request_contract.code()) + ); + assert_eq!( + matrix_entry.get("notification_contract").and_then(serde_json::Value::as_str), + std::option::Option::Some(subscription.notification_contract.code()) + ); + assert_eq!( + matrix_entry.get("persistent_runtime").and_then(serde_json::Value::as_bool), + std::option::Option::Some(subscription.persistent_runtime) + ); + } + } +} diff --git a/kb-onchain-transport/src/validation.rs b/kb-onchain-transport/src/validation.rs new file mode 100644 index 0000000..e76d697 --- /dev/null +++ b/kb-onchain-transport/src/validation.rs @@ -0,0 +1,77 @@ +// file: kb-onchain-transport/src/validation.rs +// version: 3 + +//! Shared validation helpers for Solana RPC addresses, hashes and signatures. + +/// Validates one base58 Solana transaction signature. +pub fn validate_transaction_signature_text(value: &str, field_name: &str) -> kb_core::Result<()> { + return crate::validation::validate_base58_length(value, field_name, 64); +} + +/// Validates one base58 Solana public key or account address. +pub fn validate_solana_pubkey_text(value: &str, field_name: &str) -> kb_core::Result<()> { + return crate::validation::validate_base58_length(value, field_name, 32); +} + +/// Validates one base58 Solana blockhash or genesis hash. +pub fn validate_solana_hash_text(value: &str, field_name: &str) -> kb_core::Result<()> { + return crate::validation::validate_base58_length(value, field_name, 32); +} + +fn validate_base58_length( + value: &str, + field_name: &str, + expected_length: usize, +) -> kb_core::Result<()> { + if value.trim().is_empty() { + return std::result::Result::Err(kb_core::Error::config(format!( + "{field_name} must not be empty" + ))); + } + let decode_result = bs58::decode(value).into_vec(); + let decoded = match decode_result { + std::result::Result::Ok(bytes) => bytes, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::config(format!( + "{field_name} is not valid base58: {error}" + ))); + }, + }; + if decoded.len() != expected_length { + return std::result::Result::Err(kb_core::Error::config(format!( + "{field_name} must decode to {expected_length} bytes" + ))); + } + return std::result::Result::Ok(()); +} + +#[cfg(test)] +mod tests { + #[test] + fn signature_validation_accepts_sixty_four_bytes() { + let signature = bs58::encode([7_u8; 64]).into_string(); + let result = crate::validate_transaction_signature_text(&signature, "signature"); + assert!(result.is_ok()); + } + + #[test] + fn pubkey_validation_accepts_thirty_two_bytes() { + let pubkey = bs58::encode([9_u8; 32]).into_string(); + let result = crate::validate_solana_pubkey_text(&pubkey, "pubkey"); + assert!(result.is_ok()); + } + + #[test] + fn hash_validation_accepts_thirty_two_bytes() { + let hash = bs58::encode([10_u8; 32]).into_string(); + let result = crate::validate_solana_hash_text(&hash, "hash"); + assert!(result.is_ok()); + } + + #[test] + fn signature_validation_rejects_public_key_length() { + let pubkey = bs58::encode([11_u8; 32]).into_string(); + let result = crate::validate_transaction_signature_text(&pubkey, "signature"); + assert!(result.is_err()); + } +} diff --git a/results.txt b/results.txt new file mode 100644 index 0000000..e69de29