500 lines
19 KiB
Rust
500 lines
19 KiB
Rust
// 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<std::vec::Vec<serde_json::Value>>;
|
|
}
|
|
|
|
/// Standard contextual Solana RPC response.
|
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
|
pub struct RpcResponse<T> {
|
|
/// 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<T> {
|
|
/// Contextual response form.
|
|
Context(crate::RpcResponse<T>),
|
|
/// 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<crate::RpcCommitmentLevel>,
|
|
/// 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<u64>,
|
|
}
|
|
|
|
/// 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<crate::RpcCommitmentLevel>,
|
|
}
|
|
|
|
/// 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<crate::RpcAccountEncoding>,
|
|
/// Optional account-data byte slice.
|
|
#[serde(skip_serializing_if = "std::option::Option::is_none")]
|
|
pub data_slice: std::option::Option<crate::RpcDataSlice>,
|
|
/// Optional commitment level.
|
|
#[serde(skip_serializing_if = "std::option::Option::is_none")]
|
|
pub commitment: std::option::Option<crate::RpcCommitmentLevel>,
|
|
/// Optional minimum context slot.
|
|
#[serde(skip_serializing_if = "std::option::Option::is_none")]
|
|
pub min_context_slot: std::option::Option<u64>,
|
|
}
|
|
|
|
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<u8>),
|
|
}
|
|
|
|
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<std::vec::Vec<crate::RpcProgramAccountFilter>>,
|
|
/// Optional account-data encoding.
|
|
#[serde(skip_serializing_if = "std::option::Option::is_none")]
|
|
pub encoding: std::option::Option<crate::RpcAccountEncoding>,
|
|
/// Optional account-data byte slice.
|
|
#[serde(skip_serializing_if = "std::option::Option::is_none")]
|
|
pub data_slice: std::option::Option<crate::RpcDataSlice>,
|
|
/// Optional commitment level.
|
|
#[serde(skip_serializing_if = "std::option::Option::is_none")]
|
|
pub commitment: std::option::Option<crate::RpcCommitmentLevel>,
|
|
/// Optional minimum context slot.
|
|
#[serde(skip_serializing_if = "std::option::Option::is_none")]
|
|
pub min_context_slot: std::option::Option<u64>,
|
|
/// Whether the response must include a context wrapper.
|
|
#[serde(skip_serializing_if = "std::option::Option::is_none")]
|
|
pub with_context: std::option::Option<bool>,
|
|
/// Optional validator-side deterministic result sorting.
|
|
#[serde(skip_serializing_if = "std::option::Option::is_none")]
|
|
pub sort_results: std::option::Option<bool>,
|
|
}
|
|
|
|
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<u64>,
|
|
/// 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<f64>,
|
|
/// 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<f64>,
|
|
/// 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<T: serde::Serialize>(
|
|
method: &str,
|
|
value: &T,
|
|
) -> kb_core::Result<serde_json::Value> {
|
|
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());
|
|
}
|
|
}
|