// file: kb_rpc/src/standard_http.rs // version: 2 //! 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::constants::MAX_MEMCMP_BASE58_LENGTH { return std::result::Result::Err(kb_core::Error::config(format!( "memcmp base58 value must not exceed {} characters", crate::constants::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::constants::MAX_MEMCMP_BASE64_LENGTH { return std::result::Result::Err(kb_core::Error::config(format!( "memcmp base64 value must not exceed {} characters", crate::constants::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::constants::MAX_MEMCMP_DECODED_BYTES { return std::result::Result::Err(kb_core::Error::config(format!( "memcmp value must not exceed {} decoded bytes", crate::constants::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()); } }