0.5.1-pre.002
This commit is contained in:
22
ks-onchain-transport/src/client.rs
Normal file
22
ks-onchain-transport/src/client.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
// file: ks-onchain-transport/src/client.rs
|
||||
// version: 4
|
||||
|
||||
//! 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<std::string::String>,
|
||||
}
|
||||
|
||||
/// Minimal Solana RPC client abstraction.
|
||||
pub trait SolanaRpcClient {
|
||||
/// Fetches a raw transaction payload by signature.
|
||||
fn get_transaction_raw_json(
|
||||
&self,
|
||||
signature: &ks_lib::MdSignature,
|
||||
) -> ks_core::Result<std::option::Option<std::string::String>>;
|
||||
}
|
||||
45
ks-onchain-transport/src/constants.rs
Normal file
45
ks-onchain-transport/src/constants.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
// file: ks-onchain-transport/src/constants.rs
|
||||
// version: 9
|
||||
|
||||
//! Local constants for the `ks-onchain-transport` crate.
|
||||
|
||||
/// Canonical tracing target for this crate.
|
||||
pub(crate) const TRACING_TARGET: &str = "ks-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;
|
||||
/// Maximum decoded account bytes returned by one complete execution RPC account read.
|
||||
pub const MAX_COMPLETE_ACCOUNT_DATA_BYTES: usize = 65_536;
|
||||
/// Maximum retries after one HTTP or JSON-RPC rate-limit response.
|
||||
pub(crate) const MAX_HTTP_RATE_LIMIT_RETRIES: u32 = 4;
|
||||
/// Fallback pause when a matching endpoint role exposes no rate-limit delay.
|
||||
pub(crate) const DEFAULT_HTTP_RATE_LIMIT_PAUSE_MS: u64 = 1_500;
|
||||
/// 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;
|
||||
190
ks-onchain-transport/src/endpoint_role.rs
Normal file
190
ks-onchain-transport/src/endpoint_role.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
// file: ks-onchain-transport/src/endpoint_role.rs
|
||||
// version: 5
|
||||
|
||||
//! 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<std::string::String>,
|
||||
/// 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: &ks_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 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: &ks_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<std::string::String>,
|
||||
) -> ks_config::EndpointRoleConfig {
|
||||
return ks_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);
|
||||
}
|
||||
}
|
||||
2704
ks-onchain-transport/src/execution_rpc.rs
Normal file
2704
ks-onchain-transport/src/execution_rpc.rs
Normal file
File diff suppressed because it is too large
Load Diff
234
ks-onchain-transport/src/get_signatures_for_address.rs
Normal file
234
ks-onchain-transport/src/get_signatures_for_address.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
// file: ks-onchain-transport/src/get_signatures_for_address.rs
|
||||
// version: 3
|
||||
|
||||
//! Standard Solana `getSignaturesForAddress` request and response contracts.
|
||||
|
||||
/// One transaction signature summary returned by `getSignaturesForAddress`.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AddressSignatureInfo {
|
||||
/// Transaction signature.
|
||||
pub signature: std::string::String,
|
||||
/// Slot containing the transaction.
|
||||
pub slot: u64,
|
||||
/// Optional transaction error returned by the RPC node.
|
||||
pub err: std::option::Option<serde_json::Value>,
|
||||
/// Optional memo associated with the transaction.
|
||||
pub memo: std::option::Option<std::string::String>,
|
||||
/// Optional block time as a Unix timestamp.
|
||||
pub block_time: std::option::Option<i64>,
|
||||
/// Optional confirmation status reported by the RPC node.
|
||||
pub confirmation_status: std::option::Option<std::string::String>,
|
||||
}
|
||||
|
||||
/// Configuration for one standard Solana `getSignaturesForAddress` request.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct GetSignaturesForAddressConfig {
|
||||
/// Requested commitment, restricted to `confirmed` or `finalized`.
|
||||
pub commitment: std::string::String,
|
||||
/// Maximum number of signatures requested from the node.
|
||||
pub limit: u16,
|
||||
/// Optional exclusive cursor toward older signatures.
|
||||
pub before: std::option::Option<std::string::String>,
|
||||
/// Optional exclusive lower boundary toward newer signatures.
|
||||
pub until: std::option::Option<std::string::String>,
|
||||
/// Optional minimum context slot.
|
||||
pub min_context_slot: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
impl GetSignaturesForAddressConfig {
|
||||
/// Creates and validates one signature history request configuration.
|
||||
pub fn new(
|
||||
commitment: impl std::convert::Into<std::string::String>,
|
||||
limit: u16,
|
||||
before: std::option::Option<std::string::String>,
|
||||
until: std::option::Option<std::string::String>,
|
||||
min_context_slot: std::option::Option<u64>,
|
||||
) -> ks_core::Result<Self> {
|
||||
let commitment_value = commitment.into();
|
||||
if commitment_value != "confirmed" && commitment_value != "finalized" {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"getSignaturesForAddress commitment must be 'confirmed' or 'finalized'",
|
||||
));
|
||||
}
|
||||
if limit == 0 || limit > 1000 {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"getSignaturesForAddress limit must be between 1 and 1000",
|
||||
));
|
||||
}
|
||||
let before_result = validate_optional_signature(
|
||||
before.as_ref(),
|
||||
"getSignaturesForAddress before signature",
|
||||
);
|
||||
if let std::result::Result::Err(error) = before_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let until_result =
|
||||
validate_optional_signature(until.as_ref(), "getSignaturesForAddress until signature");
|
||||
if let std::result::Result::Err(error) = until_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(Self {
|
||||
commitment: commitment_value,
|
||||
limit,
|
||||
before,
|
||||
until,
|
||||
min_context_slot,
|
||||
});
|
||||
}
|
||||
|
||||
fn request_params(&self, address: &str) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
let address_result =
|
||||
crate::validate_solana_pubkey_text(address, "getSignaturesForAddress address");
|
||||
if let std::result::Result::Err(error) = address_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let mut config = serde_json::Map::<std::string::String, serde_json::Value>::new();
|
||||
config.insert("commitment".to_string(), serde_json::Value::String(self.commitment.clone()));
|
||||
config.insert(
|
||||
"limit".to_string(),
|
||||
serde_json::Value::Number(serde_json::Number::from(self.limit)),
|
||||
);
|
||||
if let std::option::Option::Some(before) = &self.before {
|
||||
config.insert("before".to_string(), serde_json::Value::String(before.clone()));
|
||||
}
|
||||
if let std::option::Option::Some(until) = &self.until {
|
||||
config.insert("until".to_string(), serde_json::Value::String(until.clone()));
|
||||
}
|
||||
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(address.to_string()),
|
||||
serde_json::Value::Object(config),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::HttpClient {
|
||||
/// Fetches one page of signatures associated with an account or program address.
|
||||
pub async fn get_signatures_for_address(
|
||||
&self,
|
||||
address: &str,
|
||||
config: &crate::GetSignaturesForAddressConfig,
|
||||
) -> ks_core::Result<std::vec::Vec<crate::AddressSignatureInfo>> {
|
||||
let params_result = config.request_params(address);
|
||||
let params = match params_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let result = self
|
||||
.execute_json_rpc_result_raw("getSignaturesForAddress".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_signatures_for_address_result(&value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapts a raw standard Solana `getSignaturesForAddress` result value.
|
||||
pub fn adapt_get_signatures_for_address_result(
|
||||
source: &serde_json::Value,
|
||||
) -> ks_core::Result<std::vec::Vec<crate::AddressSignatureInfo>> {
|
||||
let parse_result =
|
||||
serde_json::from_value::<std::vec::Vec<crate::AddressSignatureInfo>>(source.clone());
|
||||
let parsed = match parse_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::json(format!(
|
||||
"cannot parse getSignaturesForAddress result: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
for item in &parsed {
|
||||
let validation_result = crate::validate_transaction_signature_text(
|
||||
item.signature.as_str(),
|
||||
"getSignaturesForAddress result signature",
|
||||
);
|
||||
if let std::result::Result::Err(error) = validation_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(parsed);
|
||||
}
|
||||
|
||||
fn validate_optional_signature(
|
||||
value: std::option::Option<&std::string::String>,
|
||||
field_name: &str,
|
||||
) -> ks_core::Result<()> {
|
||||
if let std::option::Option::Some(signature) = value {
|
||||
return crate::validate_transaction_signature_text(signature.as_str(), field_name);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn signature(byte: u8) -> std::string::String {
|
||||
return bs58::encode([byte; 64]).into_string();
|
||||
}
|
||||
|
||||
fn pubkey(byte: u8) -> std::string::String {
|
||||
return bs58::encode([byte; 32]).into_string();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_supports_before_and_until_for_bounded_pagination() {
|
||||
let config_result = crate::GetSignaturesForAddressConfig::new(
|
||||
"confirmed",
|
||||
1000,
|
||||
std::option::Option::Some(signature(1)),
|
||||
std::option::Option::Some(signature(2)),
|
||||
std::option::Option::Some(44),
|
||||
);
|
||||
let config = match config_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected config error: {error}"),
|
||||
};
|
||||
let params_result = config.request_params(pubkey(3).as_str());
|
||||
let params = match params_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected params error: {error}"),
|
||||
};
|
||||
assert_eq!(params.len(), 2);
|
||||
assert_eq!(params[1]["limit"], serde_json::json!(1000));
|
||||
assert_eq!(params[1]["minContextSlot"], serde_json::json!(44));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_rejects_limit_above_rpc_maximum() {
|
||||
let result = crate::GetSignaturesForAddressConfig::new(
|
||||
"confirmed",
|
||||
1001,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_adapter_preserves_signature_metadata() {
|
||||
let source = serde_json::json!([{
|
||||
"signature": signature(4),
|
||||
"slot": 123,
|
||||
"err": null,
|
||||
"memo": "hello",
|
||||
"blockTime": 456,
|
||||
"confirmationStatus": "finalized"
|
||||
}]);
|
||||
let result = crate::adapt_get_signatures_for_address_result(&source);
|
||||
let values = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected adapter error: {error}"),
|
||||
};
|
||||
assert_eq!(values.len(), 1);
|
||||
assert_eq!(values[0].slot, 123);
|
||||
assert_eq!(values[0].memo.as_deref(), std::option::Option::Some("hello"));
|
||||
}
|
||||
}
|
||||
730
ks-onchain-transport/src/get_transaction.rs
Normal file
730
ks-onchain-transport/src/get_transaction.rs
Normal file
@@ -0,0 +1,730 @@
|
||||
// file: ks-onchain-transport/src/get_transaction.rs
|
||||
// version: 11
|
||||
|
||||
//! Standard Solana `getTransaction` adapter for the canonical transaction contract.
|
||||
|
||||
use base64::Engine; // rust-rules: trait-import
|
||||
|
||||
/// Common adapter contract for transport-specific transaction payloads.
|
||||
pub trait CanonicalTransactionAdapter {
|
||||
/// Source payload type accepted by the adapter.
|
||||
type Source;
|
||||
|
||||
/// Adapts one source payload into an optional canonical transaction.
|
||||
fn adapt(
|
||||
&self,
|
||||
source: &Self::Source,
|
||||
) -> ks_core::Result<std::option::Option<ks_lib::MdCanonicalTransaction>>;
|
||||
}
|
||||
|
||||
/// Configuration for a standard Solana `getTransaction` request.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct GetTransactionConfig {
|
||||
/// Requested commitment, restricted to `confirmed` or `finalized`.
|
||||
pub commitment: std::string::String,
|
||||
/// Maximum supported numbered transaction version.
|
||||
pub max_supported_transaction_version: u8,
|
||||
}
|
||||
|
||||
impl GetTransactionConfig {
|
||||
/// Creates and validates a `getTransaction` request configuration.
|
||||
pub fn new(
|
||||
commitment: impl std::convert::Into<std::string::String>,
|
||||
max_supported_transaction_version: u8,
|
||||
) -> ks_core::Result<Self> {
|
||||
let commitment_value = commitment.into();
|
||||
if commitment_value != "confirmed" && commitment_value != "finalized" {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"getTransaction commitment must be 'confirmed' or 'finalized'",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(Self {
|
||||
commitment: commitment_value,
|
||||
max_supported_transaction_version,
|
||||
});
|
||||
}
|
||||
|
||||
/// Creates the default confirmed configuration supporting transaction version zero.
|
||||
pub fn confirmed_v0() -> Self {
|
||||
return Self {
|
||||
commitment: "confirmed".to_string(),
|
||||
max_supported_transaction_version: 0,
|
||||
};
|
||||
}
|
||||
|
||||
fn request_params(&self, signature: &str) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
let signature_result =
|
||||
crate::validate_transaction_signature_text(signature, "getTransaction signature");
|
||||
if let std::result::Result::Err(error) = signature_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(std::vec![
|
||||
serde_json::Value::String(signature.to_string()),
|
||||
serde_json::json!({
|
||||
"commitment": self.commitment.clone(),
|
||||
"encoding": "json",
|
||||
"maxSupportedTransactionVersion": self.max_supported_transaction_version
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Source payload and canonical result returned by one `getTransaction` request.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GetTransactionAcquisition {
|
||||
/// Source-specific JSON result retained only for immediate metrics and hashing.
|
||||
pub source_json: serde_json::Value,
|
||||
/// Canonical source-independent transaction when the RPC result is not null.
|
||||
pub canonical_transaction: std::option::Option<ks_lib::MdCanonicalTransaction>,
|
||||
}
|
||||
|
||||
/// Adapter from the standard Solana `getTransaction` JSON result to the canonical model.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct GetTransactionAdapter;
|
||||
|
||||
impl crate::CanonicalTransactionAdapter for crate::GetTransactionAdapter {
|
||||
type Source = serde_json::Value;
|
||||
|
||||
fn adapt(
|
||||
&self,
|
||||
source: &Self::Source,
|
||||
) -> ks_core::Result<std::option::Option<ks_lib::MdCanonicalTransaction>> {
|
||||
return adapt_get_transaction_result(source);
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::HttpClient {
|
||||
/// Fetches one source payload and its canonical transaction adaptation.
|
||||
pub async fn get_transaction_acquisition(
|
||||
&self,
|
||||
signature: &str,
|
||||
config: &crate::GetTransactionConfig,
|
||||
) -> ks_core::Result<crate::GetTransactionAcquisition> {
|
||||
let params_result = config.request_params(signature);
|
||||
let params = match params_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let result = self.execute_json_rpc_result_raw("getTransaction".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 adapter = crate::GetTransactionAdapter;
|
||||
let canonical_result = crate::CanonicalTransactionAdapter::adapt(&adapter, &value);
|
||||
let canonical_transaction = match canonical_result {
|
||||
std::result::Result::Ok(canonical) => canonical,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::GetTransactionAcquisition {
|
||||
source_json: value,
|
||||
canonical_transaction,
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetches and adapts one standard Solana transaction into the canonical model.
|
||||
pub async fn get_transaction_canonical(
|
||||
&self,
|
||||
signature: &str,
|
||||
config: &crate::GetTransactionConfig,
|
||||
) -> ks_core::Result<std::option::Option<ks_lib::MdCanonicalTransaction>> {
|
||||
let acquisition_result = self.get_transaction_acquisition(signature, config).await;
|
||||
let acquisition = match acquisition_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(acquisition.canonical_transaction);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapts a raw standard Solana `getTransaction` result value.
|
||||
pub fn adapt_get_transaction_result(
|
||||
source: &serde_json::Value,
|
||||
) -> ks_core::Result<std::option::Option<ks_lib::MdCanonicalTransaction>> {
|
||||
if source.is_null() {
|
||||
return std::result::Result::Ok(std::option::Option::None);
|
||||
}
|
||||
let parse_result = serde_json::from_value::<RpcGetTransactionResult>(source.clone());
|
||||
let parsed = match parse_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::json(format!(
|
||||
"cannot parse getTransaction result: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
let canonical_result = adapt_parsed_transaction(parsed);
|
||||
return match canonical_result {
|
||||
std::result::Result::Ok(transaction) => {
|
||||
std::result::Result::Ok(std::option::Option::Some(transaction))
|
||||
},
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RpcGetTransactionResult {
|
||||
slot: u64,
|
||||
#[serde(default)]
|
||||
block_time: std::option::Option<i64>,
|
||||
#[serde(default)]
|
||||
meta: std::option::Option<RpcTransactionMeta>,
|
||||
transaction: RpcJsonTransaction,
|
||||
#[serde(default)]
|
||||
version: std::option::Option<RpcTransactionVersion>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RpcTransactionVersion {
|
||||
Legacy(std::string::String),
|
||||
Number(u8),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
struct RpcJsonTransaction {
|
||||
signatures: std::vec::Vec<std::string::String>,
|
||||
message: RpcRawMessage,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RpcRawMessage {
|
||||
header: RpcMessageHeader,
|
||||
account_keys: std::vec::Vec<std::string::String>,
|
||||
recent_blockhash: std::string::String,
|
||||
instructions: std::vec::Vec<RpcCompiledInstruction>,
|
||||
#[serde(default)]
|
||||
address_table_lookups: std::vec::Vec<RpcAddressTableLookup>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RpcMessageHeader {
|
||||
num_required_signatures: u8,
|
||||
num_readonly_signed_accounts: u8,
|
||||
num_readonly_unsigned_accounts: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RpcCompiledInstruction {
|
||||
program_id_index: u16,
|
||||
accounts: std::vec::Vec<u16>,
|
||||
data: std::string::String,
|
||||
#[serde(default)]
|
||||
stack_height: std::option::Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RpcAddressTableLookup {
|
||||
account_key: std::string::String,
|
||||
writable_indexes: std::vec::Vec<u8>,
|
||||
readonly_indexes: std::vec::Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RpcTransactionMeta {
|
||||
#[serde(default)]
|
||||
err: std::option::Option<serde_json::Value>,
|
||||
fee: u64,
|
||||
pre_balances: std::vec::Vec<u64>,
|
||||
post_balances: std::vec::Vec<u64>,
|
||||
#[serde(default)]
|
||||
inner_instructions: std::option::Option<std::vec::Vec<RpcInnerInstructionGroup>>,
|
||||
#[serde(default)]
|
||||
log_messages: std::option::Option<std::vec::Vec<std::string::String>>,
|
||||
#[serde(default)]
|
||||
pre_token_balances: std::option::Option<std::vec::Vec<RpcTokenBalance>>,
|
||||
#[serde(default)]
|
||||
post_token_balances: std::option::Option<std::vec::Vec<RpcTokenBalance>>,
|
||||
#[serde(default)]
|
||||
rewards: std::option::Option<std::vec::Vec<RpcReward>>,
|
||||
#[serde(default)]
|
||||
loaded_addresses: std::option::Option<RpcLoadedAddresses>,
|
||||
#[serde(default)]
|
||||
return_data: std::option::Option<RpcReturnData>,
|
||||
#[serde(default)]
|
||||
compute_units_consumed: std::option::Option<u64>,
|
||||
#[serde(default)]
|
||||
cost_units: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
struct RpcInnerInstructionGroup {
|
||||
index: u16,
|
||||
instructions: std::vec::Vec<RpcCompiledInstruction>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RpcTokenBalance {
|
||||
account_index: u16,
|
||||
mint: std::string::String,
|
||||
#[serde(default)]
|
||||
owner: std::option::Option<std::string::String>,
|
||||
#[serde(default)]
|
||||
program_id: std::option::Option<std::string::String>,
|
||||
ui_token_amount: RpcUiTokenAmount,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RpcUiTokenAmount {
|
||||
amount: std::string::String,
|
||||
decimals: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RpcReward {
|
||||
pubkey: std::string::String,
|
||||
lamports: i64,
|
||||
post_balance: u64,
|
||||
#[serde(default)]
|
||||
reward_type: std::option::Option<std::string::String>,
|
||||
#[serde(default)]
|
||||
commission: std::option::Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
struct RpcLoadedAddresses {
|
||||
#[serde(default)]
|
||||
writable: std::vec::Vec<std::string::String>,
|
||||
#[serde(default)]
|
||||
readonly: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RpcReturnData {
|
||||
program_id: std::string::String,
|
||||
data: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
fn adapt_parsed_transaction(
|
||||
parsed: RpcGetTransactionResult,
|
||||
) -> ks_core::Result<ks_lib::MdCanonicalTransaction> {
|
||||
let primary_signature = match parsed.transaction.signatures.first() {
|
||||
std::option::Option::Some(value) => value.clone(),
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::json(
|
||||
"getTransaction result contains no signatures",
|
||||
));
|
||||
},
|
||||
};
|
||||
let version_result = adapt_version(parsed.version);
|
||||
let version = match version_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let loaded_addresses = match &parsed.meta {
|
||||
std::option::Option::Some(metadata) => {
|
||||
adapt_loaded_addresses(metadata.loaded_addresses.clone())
|
||||
},
|
||||
std::option::Option::None => ks_lib::MdCanonicalLoadedAddresses::default(),
|
||||
};
|
||||
let instructions_result = adapt_instructions(parsed.transaction.message.instructions);
|
||||
let instructions = match instructions_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let metadata_result = adapt_metadata(parsed.meta);
|
||||
let metadata = match metadata_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let transaction = ks_lib::MdCanonicalTransaction {
|
||||
format_version: ks_lib::MD_CANONICAL_TRANSACTION_FORMAT_VERSION,
|
||||
primary_signature,
|
||||
slot: parsed.slot,
|
||||
block_time: parsed.block_time,
|
||||
version,
|
||||
signatures: parsed.transaction.signatures,
|
||||
message: ks_lib::MdCanonicalTransactionMessage {
|
||||
header: ks_lib::MdCanonicalMessageHeader {
|
||||
num_required_signatures: parsed.transaction.message.header.num_required_signatures,
|
||||
num_readonly_signed_accounts: parsed
|
||||
.transaction
|
||||
.message
|
||||
.header
|
||||
.num_readonly_signed_accounts,
|
||||
num_readonly_unsigned_accounts: parsed
|
||||
.transaction
|
||||
.message
|
||||
.header
|
||||
.num_readonly_unsigned_accounts,
|
||||
},
|
||||
static_account_keys: parsed.transaction.message.account_keys,
|
||||
recent_blockhash: parsed.transaction.message.recent_blockhash,
|
||||
instructions,
|
||||
address_table_lookups: parsed
|
||||
.transaction
|
||||
.message
|
||||
.address_table_lookups
|
||||
.into_iter()
|
||||
.map(|lookup| {
|
||||
return ks_lib::MdCanonicalAddressTableLookup {
|
||||
account_key: lookup.account_key,
|
||||
writable_indexes: lookup.writable_indexes,
|
||||
readonly_indexes: lookup.readonly_indexes,
|
||||
};
|
||||
})
|
||||
.collect(),
|
||||
loaded_addresses,
|
||||
},
|
||||
metadata,
|
||||
};
|
||||
let validation_result = transaction.validate();
|
||||
if let std::result::Result::Err(error) = validation_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(transaction);
|
||||
}
|
||||
|
||||
fn adapt_version(
|
||||
version: std::option::Option<RpcTransactionVersion>,
|
||||
) -> ks_core::Result<ks_lib::MdCanonicalTransactionVersion> {
|
||||
return match version {
|
||||
std::option::Option::Some(RpcTransactionVersion::Legacy(value)) => {
|
||||
if value != "legacy" {
|
||||
return std::result::Result::Err(ks_core::Error::json(format!(
|
||||
"unsupported textual transaction version: {value}"
|
||||
)));
|
||||
}
|
||||
std::result::Result::Ok(ks_lib::MdCanonicalTransactionVersion::Legacy)
|
||||
},
|
||||
std::option::Option::Some(RpcTransactionVersion::Number(value)) => {
|
||||
std::result::Result::Ok(ks_lib::MdCanonicalTransactionVersion::Number(value))
|
||||
},
|
||||
std::option::Option::None => {
|
||||
std::result::Result::Ok(ks_lib::MdCanonicalTransactionVersion::Legacy)
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn adapt_instructions(
|
||||
instructions: std::vec::Vec<RpcCompiledInstruction>,
|
||||
) -> ks_core::Result<std::vec::Vec<ks_lib::MdCanonicalCompiledInstruction>> {
|
||||
let mut adapted = std::vec::Vec::with_capacity(instructions.len());
|
||||
for instruction in instructions {
|
||||
let instruction_result = adapt_instruction(instruction);
|
||||
let canonical = match instruction_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
adapted.push(canonical);
|
||||
}
|
||||
return std::result::Result::Ok(adapted);
|
||||
}
|
||||
|
||||
fn adapt_instruction(
|
||||
instruction: RpcCompiledInstruction,
|
||||
) -> ks_core::Result<ks_lib::MdCanonicalCompiledInstruction> {
|
||||
let decode_result = bs58::decode(instruction.data.as_str()).into_vec();
|
||||
let bytes = match decode_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::json(format!(
|
||||
"getTransaction instruction data is not valid base58: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
let data_base64 = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
return std::result::Result::Ok(ks_lib::MdCanonicalCompiledInstruction {
|
||||
program_id_index: instruction.program_id_index,
|
||||
account_indexes: instruction.accounts,
|
||||
data_base64,
|
||||
stack_height: instruction.stack_height,
|
||||
});
|
||||
}
|
||||
|
||||
fn adapt_loaded_addresses(
|
||||
loaded_addresses: std::option::Option<RpcLoadedAddresses>,
|
||||
) -> ks_lib::MdCanonicalLoadedAddresses {
|
||||
return match loaded_addresses {
|
||||
std::option::Option::Some(value) => ks_lib::MdCanonicalLoadedAddresses {
|
||||
writable: value.writable,
|
||||
readonly: value.readonly,
|
||||
},
|
||||
std::option::Option::None => ks_lib::MdCanonicalLoadedAddresses::default(),
|
||||
};
|
||||
}
|
||||
|
||||
fn adapt_metadata(
|
||||
metadata: std::option::Option<RpcTransactionMeta>,
|
||||
) -> ks_core::Result<std::option::Option<ks_lib::MdCanonicalTransactionMetadata>> {
|
||||
let source = match metadata {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Ok(std::option::Option::None),
|
||||
};
|
||||
let inner_result = adapt_inner_instructions(source.inner_instructions.unwrap_or_default());
|
||||
let inner_instructions = match inner_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let return_result = adapt_return_data(source.return_data);
|
||||
let return_data = match return_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let pre_token_result = adapt_token_balances(source.pre_token_balances.unwrap_or_default());
|
||||
let pre_token_balances = match pre_token_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let post_token_result = adapt_token_balances(source.post_token_balances.unwrap_or_default());
|
||||
let post_token_balances = match post_token_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let status = if source.err.is_some() {
|
||||
ks_lib::MdCanonicalTransactionStatus::Failed
|
||||
} else {
|
||||
ks_lib::MdCanonicalTransactionStatus::Success
|
||||
};
|
||||
return std::result::Result::Ok(std::option::Option::Some(
|
||||
ks_lib::MdCanonicalTransactionMetadata {
|
||||
status,
|
||||
error: source.err,
|
||||
fee: source.fee,
|
||||
pre_balances: source.pre_balances,
|
||||
post_balances: source.post_balances,
|
||||
inner_instructions,
|
||||
log_messages: source.log_messages.unwrap_or_default(),
|
||||
pre_token_balances,
|
||||
post_token_balances,
|
||||
rewards: source
|
||||
.rewards
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|reward| {
|
||||
return ks_lib::MdCanonicalReward {
|
||||
pubkey: reward.pubkey,
|
||||
lamports: reward.lamports,
|
||||
post_balance: reward.post_balance,
|
||||
reward_type: reward.reward_type,
|
||||
commission: reward.commission,
|
||||
};
|
||||
})
|
||||
.collect(),
|
||||
return_data,
|
||||
compute_units_consumed: source.compute_units_consumed,
|
||||
cost_units: source.cost_units,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
fn adapt_inner_instructions(
|
||||
groups: std::vec::Vec<RpcInnerInstructionGroup>,
|
||||
) -> ks_core::Result<std::vec::Vec<ks_lib::MdCanonicalInnerInstructionGroup>> {
|
||||
let mut adapted = std::vec::Vec::with_capacity(groups.len());
|
||||
for group in groups {
|
||||
let instructions_result = adapt_instructions(group.instructions);
|
||||
let instructions = match instructions_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
adapted.push(ks_lib::MdCanonicalInnerInstructionGroup {
|
||||
parent_instruction_index: group.index,
|
||||
instructions,
|
||||
});
|
||||
}
|
||||
return std::result::Result::Ok(adapted);
|
||||
}
|
||||
|
||||
fn adapt_token_balances(
|
||||
balances: std::vec::Vec<RpcTokenBalance>,
|
||||
) -> ks_core::Result<std::vec::Vec<ks_lib::MdCanonicalTokenBalance>> {
|
||||
let mut adapted = std::vec::Vec::with_capacity(balances.len());
|
||||
for balance in balances {
|
||||
let balance_result = ks_lib::MdCanonicalTokenBalance::new(
|
||||
balance.account_index,
|
||||
balance.mint,
|
||||
balance.owner,
|
||||
balance.program_id,
|
||||
balance.ui_token_amount.amount,
|
||||
balance.ui_token_amount.decimals,
|
||||
);
|
||||
let canonical = match balance_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
adapted.push(canonical);
|
||||
}
|
||||
return std::result::Result::Ok(adapted);
|
||||
}
|
||||
|
||||
fn adapt_return_data(
|
||||
return_data: std::option::Option<RpcReturnData>,
|
||||
) -> ks_core::Result<std::option::Option<ks_lib::MdCanonicalReturnData>> {
|
||||
let source = match return_data {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Ok(std::option::Option::None),
|
||||
};
|
||||
if source.data.len() != 2 {
|
||||
return std::result::Result::Err(ks_core::Error::json(
|
||||
"getTransaction return data must contain payload and encoding",
|
||||
));
|
||||
}
|
||||
if source.data[1] != "base64" {
|
||||
return std::result::Result::Err(ks_core::Error::json(format!(
|
||||
"unsupported getTransaction return data encoding: {}",
|
||||
source.data[1]
|
||||
)));
|
||||
}
|
||||
return std::result::Result::Ok(std::option::Option::Some(ks_lib::MdCanonicalReturnData {
|
||||
program_id: source.program_id,
|
||||
data_base64: source.data[0].clone(),
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn fixture(name: &str) -> serde_json::Value {
|
||||
let text = match name {
|
||||
"legacy_success" => {
|
||||
include_str!("../tests/fixtures/get_transaction_legacy_success.json")
|
||||
},
|
||||
"legacy_failure" => {
|
||||
include_str!("../tests/fixtures/get_transaction_legacy_failure.json")
|
||||
},
|
||||
"v0_alt" => {
|
||||
include_str!("../tests/fixtures/get_transaction_v0_alt_cpi_token_2022.json")
|
||||
},
|
||||
"optional_absent" => {
|
||||
include_str!("../tests/fixtures/get_transaction_optional_absent.json")
|
||||
},
|
||||
"equivalent_a" => include_str!("../tests/fixtures/get_transaction_equivalent_a.json"),
|
||||
"equivalent_b" => include_str!("../tests/fixtures/get_transaction_equivalent_b.json"),
|
||||
_ => panic!("unknown fixture: {name}"),
|
||||
};
|
||||
let parse_result = serde_json::from_str(text);
|
||||
match parse_result {
|
||||
std::result::Result::Ok(value) => return value,
|
||||
std::result::Result::Err(error) => panic!("fixture parse failed: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn adapt_fixture(name: &str) -> ks_lib::MdCanonicalTransaction {
|
||||
let value = fixture(name);
|
||||
let result = crate::adapt_get_transaction_result(&value);
|
||||
match result {
|
||||
std::result::Result::Ok(std::option::Option::Some(transaction)) => return transaction,
|
||||
std::result::Result::Ok(std::option::Option::None) => {
|
||||
panic!("fixture unexpectedly adapted to none");
|
||||
},
|
||||
std::result::Result::Err(error) => panic!("fixture adaptation failed: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_result_adapts_to_none() {
|
||||
let result = crate::adapt_get_transaction_result(&serde_json::Value::Null);
|
||||
assert_eq!(result, std::result::Result::Ok(std::option::Option::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_success_preserves_token_balances_and_return_data() {
|
||||
let transaction = adapt_fixture("legacy_success");
|
||||
assert_eq!(transaction.version, ks_lib::MdCanonicalTransactionVersion::Legacy);
|
||||
assert_eq!(transaction.block_time, std::option::Option::Some(1_700_000_001));
|
||||
let metadata = match transaction.metadata {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => panic!("metadata missing"),
|
||||
};
|
||||
assert_eq!(metadata.status, ks_lib::MdCanonicalTransactionStatus::Success);
|
||||
assert_eq!(transaction.message.instructions[0].data_base64, "AQ==");
|
||||
assert_eq!(metadata.post_token_balances.len(), 1);
|
||||
assert_eq!(metadata.post_token_balances[0].amount, "12345678901234567890");
|
||||
assert_eq!(metadata.post_token_balances[0].decimal_amount, "12345678901.234567890");
|
||||
assert!(metadata.return_data.is_some());
|
||||
assert_eq!(metadata.inner_instructions.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_failure_preserves_error() {
|
||||
let transaction = adapt_fixture("legacy_failure");
|
||||
let metadata = match transaction.metadata {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => panic!("metadata missing"),
|
||||
};
|
||||
assert_eq!(metadata.status, ks_lib::MdCanonicalTransactionStatus::Failed);
|
||||
assert!(metadata.error.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_zero_preserves_alt_cpi_and_token_2022() {
|
||||
let transaction = adapt_fixture("v0_alt");
|
||||
assert_eq!(transaction.version, ks_lib::MdCanonicalTransactionVersion::Number(0));
|
||||
assert_eq!(transaction.message.address_table_lookups.len(), 1);
|
||||
assert_eq!(transaction.message.loaded_addresses.writable.len(), 1);
|
||||
let metadata = match transaction.metadata {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => panic!("metadata missing"),
|
||||
};
|
||||
assert_eq!(metadata.inner_instructions.len(), 1);
|
||||
assert_eq!(
|
||||
metadata.post_token_balances[0].program_id.as_deref(),
|
||||
std::option::Option::Some("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_fields_and_block_time_can_be_absent() {
|
||||
let transaction = adapt_fixture("optional_absent");
|
||||
assert_eq!(transaction.block_time, std::option::Option::None);
|
||||
let metadata = match transaction.metadata {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => panic!("metadata missing"),
|
||||
};
|
||||
assert!(metadata.log_messages.is_empty());
|
||||
assert!(metadata.rewards.is_empty());
|
||||
assert!(metadata.return_data.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equivalent_provider_shapes_produce_identical_hashes() {
|
||||
let first = adapt_fixture("equivalent_a");
|
||||
let second = adapt_fixture("equivalent_b");
|
||||
let first_hash = match first.canonical_json_hash() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("first hash failed: {error}"),
|
||||
};
|
||||
let second_hash = match second.canonical_json_hash() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("second hash failed: {error}"),
|
||||
};
|
||||
assert_eq!(first_hash, second_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_config_rejects_public_key_length_signature() {
|
||||
let config = crate::GetTransactionConfig::confirmed_v0();
|
||||
let result = config.request_params("3Bxs4NN8M2Yn4TLb7gR6Xy7n2D1Q8VjQqWcnpX8C6pQw");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_config_rejects_processed_commitment() {
|
||||
let result = crate::GetTransactionConfig::new("processed", 0);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_config_uses_json_encoding_and_version_zero() {
|
||||
let config = crate::GetTransactionConfig::confirmed_v0();
|
||||
let params_result = config.request_params(
|
||||
"2Ana1pUpv2ZbMVkwF5FXapYeBEjdxDatLn7nvJkhgTSXbs59SyZSx866bXirPgj8QQVB57uxHJBG1YFvkRbFj4T",
|
||||
);
|
||||
let params = match params_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("request params failed: {error}"),
|
||||
};
|
||||
assert_eq!(params[1]["encoding"], serde_json::Value::String("json".to_string()));
|
||||
assert_eq!(params[1]["maxSupportedTransactionVersion"], serde_json::Value::from(0));
|
||||
}
|
||||
}
|
||||
717
ks-onchain-transport/src/http_client.rs
Normal file
717
ks-onchain-transport/src/http_client.rs
Normal file
@@ -0,0 +1,717 @@
|
||||
// file: ks-onchain-transport/src/http_client.rs
|
||||
// version: 14
|
||||
|
||||
//! 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<crate::EndpointRoleSnapshot>,
|
||||
/// Status string.
|
||||
pub status: std::string::String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HttpRequestLimitState {
|
||||
available_tokens: f64,
|
||||
blocked_until: std::time::Instant,
|
||||
last_refill: std::time::Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HttpRequestLimiter {
|
||||
burst_capacity: u32,
|
||||
pause_after_rate_limit_ms: u64,
|
||||
priority: u32,
|
||||
request_kinds: std::vec::Vec<std::string::String>,
|
||||
requests_per_second: u32,
|
||||
role: std::string::String,
|
||||
semaphore: std::sync::Arc<tokio::sync::Semaphore>,
|
||||
state: tokio::sync::Mutex<HttpRequestLimitState>,
|
||||
}
|
||||
|
||||
impl HttpRequestLimiter {
|
||||
fn new(config: &ks_config::EndpointRoleConfig) -> Self {
|
||||
let now = std::time::Instant::now();
|
||||
let burst_capacity = config.burst_capacity.max(1);
|
||||
let max_concurrent_requests = config.max_concurrent_requests.max(1);
|
||||
return Self {
|
||||
burst_capacity,
|
||||
pause_after_rate_limit_ms: config.pause_after_rate_limit_ms,
|
||||
priority: config.priority,
|
||||
request_kinds: config.request_kinds.clone(),
|
||||
requests_per_second: config.requests_per_second.max(1),
|
||||
role: config.role.clone(),
|
||||
semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(
|
||||
max_concurrent_requests as usize,
|
||||
)),
|
||||
state: tokio::sync::Mutex::new(HttpRequestLimitState {
|
||||
available_tokens: f64::from(burst_capacity),
|
||||
blocked_until: now,
|
||||
last_refill: now,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
fn handles_request(&self, request_kind: &str) -> bool {
|
||||
for configured_kind in &self.request_kinds {
|
||||
if configured_kind == request_kind || configured_kind == "*" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async fn acquire(&self) -> ks_core::Result<tokio::sync::OwnedSemaphorePermit> {
|
||||
loop {
|
||||
let wait_duration = {
|
||||
let mut state = self.state.lock().await;
|
||||
let now = std::time::Instant::now();
|
||||
if now < state.blocked_until {
|
||||
state.blocked_until.duration_since(now)
|
||||
} else {
|
||||
let elapsed_seconds = now.duration_since(state.last_refill).as_secs_f64();
|
||||
let refill = elapsed_seconds * f64::from(self.requests_per_second);
|
||||
state.available_tokens =
|
||||
(state.available_tokens + refill).min(f64::from(self.burst_capacity));
|
||||
state.last_refill = now;
|
||||
if state.available_tokens >= 1.0 {
|
||||
state.available_tokens -= 1.0;
|
||||
std::time::Duration::ZERO
|
||||
} else {
|
||||
let missing_tokens = 1.0 - state.available_tokens;
|
||||
std::time::Duration::from_secs_f64(
|
||||
missing_tokens / f64::from(self.requests_per_second),
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
if wait_duration.is_zero() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(wait_duration).await;
|
||||
}
|
||||
let permit_result = self.semaphore.clone().acquire_owned().await;
|
||||
return match permit_result {
|
||||
std::result::Result::Ok(permit) => std::result::Result::Ok(permit),
|
||||
std::result::Result::Err(error) => {
|
||||
std::result::Result::Err(ks_core::Error::invalid_state(format!(
|
||||
"http request limiter '{}' is closed: {error}",
|
||||
self.role
|
||||
)))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async fn block_for(&self, pause_ms: u64) {
|
||||
let mut state = self.state.lock().await;
|
||||
let candidate =
|
||||
std::time::Instant::now().checked_add(std::time::Duration::from_millis(pause_ms));
|
||||
let blocked_until = match candidate {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => std::time::Instant::now(),
|
||||
};
|
||||
if blocked_until > state.blocked_until {
|
||||
state.blocked_until = blocked_until;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP JSON-RPC client bound to one configured endpoint.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HttpClient {
|
||||
endpoint: ks_config::HttpEndpointConfig,
|
||||
client: reqwest::Client,
|
||||
next_request_id: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
request_limiters: std::sync::Arc<std::vec::Vec<std::sync::Arc<HttpRequestLimiter>>>,
|
||||
selected_role: std::option::Option<std::string::String>,
|
||||
}
|
||||
|
||||
impl crate::HttpClient {
|
||||
/// Creates a new HTTP client bound to one endpoint.
|
||||
pub fn new(endpoint: ks_config::HttpEndpointConfig) -> ks_core::Result<Self> {
|
||||
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(ks_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(ks_core::Error::http(format!(
|
||||
"cannot build http client for endpoint '{}': {error}",
|
||||
endpoint.name
|
||||
)));
|
||||
},
|
||||
};
|
||||
let mut request_limiters = std::vec::Vec::new();
|
||||
for role in &endpoint.roles {
|
||||
if role.enabled {
|
||||
request_limiters.push(std::sync::Arc::new(HttpRequestLimiter::new(role)));
|
||||
}
|
||||
}
|
||||
tracing::debug!(target: crate::TRACING_TARGET, action = "create_http_client", endpoint_name = %endpoint.name, provider = %endpoint.provider, role_count = endpoint.roles.len(), limiter_count = request_limiters.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)),
|
||||
request_limiters: std::sync::Arc::new(request_limiters),
|
||||
selected_role: std::option::Option::None,
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns a clone bound to the exact role selected by the endpoint pool.
|
||||
pub(crate) fn with_required_role(&self, required_role: &str) -> Self {
|
||||
let mut selected = self.clone();
|
||||
selected.selected_role = std::option::Option::Some(required_role.to_string());
|
||||
return selected;
|
||||
}
|
||||
|
||||
/// 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) -> &ks_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<Request>(
|
||||
&self,
|
||||
request: &Request,
|
||||
) -> ks_core::Result<<Request as crate::StandardHttpRequest>::Response>
|
||||
where
|
||||
Request: crate::StandardHttpRequest,
|
||||
{
|
||||
let specification =
|
||||
match crate::standard_http_method(<Request as crate::StandardHttpRequest>::METHOD) {
|
||||
std::option::Option::Some(specification) => specification,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::invalid_state(format!(
|
||||
"typed standard request '{}' is absent from the canonical registry",
|
||||
<Request as crate::StandardHttpRequest>::METHOD
|
||||
)));
|
||||
},
|
||||
};
|
||||
if specification.contract != crate::StandardRpcContract::TypedAdapter {
|
||||
return std::result::Result::Err(ks_core::Error::invalid_state(format!(
|
||||
"standard request '{}' is not declared as a typed adapter",
|
||||
<Request as crate::StandardHttpRequest>::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(
|
||||
<Request as crate::StandardHttpRequest>::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::<<Request as crate::StandardHttpRequest>::Response>(
|
||||
raw,
|
||||
) {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => {
|
||||
std::result::Result::Err(ks_core::Error::json(format!(
|
||||
"cannot decode standard JSON-RPC result for '{}': {error}",
|
||||
<Request as crate::StandardHttpRequest>::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<serde_json::Value>,
|
||||
) -> ks_core::Result<serde_json::Value> {
|
||||
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<serde_json::Value>,
|
||||
) -> ks_core::Result<serde_json::Value> {
|
||||
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());
|
||||
let request_kind = crate::request_kind_from_method(method.as_str());
|
||||
let request = crate::JsonRpcRequest::new_with_u64_id(request_id, method.clone(), params);
|
||||
let request_limiter = self.request_limiter(request_kind.as_str());
|
||||
let base_pause_ms = self.configured_rate_limit_pause_ms(request_kind.as_str());
|
||||
let mut retry_index = 0_u32;
|
||||
loop {
|
||||
let request_permit = match &request_limiter {
|
||||
std::option::Option::Some(limiter) => match limiter.acquire().await {
|
||||
std::result::Result::Ok(permit) => std::option::Option::Some(permit),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
},
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
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, retry_index, selected_role = ?self.selected_role.as_deref(), "send HTTP JSON-RPC request");
|
||||
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, retry_index, error = %error, "HTTP JSON-RPC transport failed");
|
||||
return std::result::Result::Err(ks_core::Error::http(format!(
|
||||
"http json-rpc request '{}' failed on endpoint '{}': {error}",
|
||||
method, self.endpoint.name
|
||||
)));
|
||||
},
|
||||
};
|
||||
let status = response.status();
|
||||
let retry_after_ms = crate::HttpClient::retry_after_millis(response.headers());
|
||||
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, retry_index, error = %error, "HTTP JSON-RPC response body read failed");
|
||||
return std::result::Result::Err(ks_core::Error::http(format!(
|
||||
"cannot read http json-rpc response '{}' from endpoint '{}': {error}",
|
||||
method, self.endpoint.name
|
||||
)));
|
||||
},
|
||||
};
|
||||
drop(request_permit);
|
||||
if status == reqwest::StatusCode::TOO_MANY_REQUESTS
|
||||
&& retry_index < crate::MAX_HTTP_RATE_LIMIT_RETRIES
|
||||
{
|
||||
retry_index = retry_index.saturating_add(1);
|
||||
let configured_pause_ms =
|
||||
crate::HttpClient::rate_limit_backoff_ms(base_pause_ms, retry_index);
|
||||
let pause_ms = match retry_after_ms {
|
||||
std::option::Option::Some(value) => std::cmp::max(value, configured_pause_ms),
|
||||
std::option::Option::None => configured_pause_ms,
|
||||
};
|
||||
tracing::warn!(target: crate::TRACING_TARGET, action = "retry_http_json_rpc_after_rate_limit", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, http_status = %status, retry_index, pause_ms, response_byte_length = text.len(), "HTTP JSON-RPC endpoint rate limited the request; retrying with bounded backoff");
|
||||
if let std::option::Option::Some(limiter) = &request_limiter {
|
||||
limiter.block_for(pause_ms).await;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(pause_ms)).await;
|
||||
continue;
|
||||
}
|
||||
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, retry_index, response_byte_length = text.len(), "HTTP JSON-RPC endpoint returned non-success status");
|
||||
return std::result::Result::Err(ks_core::Error::http(format!(
|
||||
"http json-rpc endpoint '{}' returned status {} after {} retries: {}",
|
||||
self.endpoint.name, status, retry_index, 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, retry_index, response_byte_length = text.len(), error = %error, "HTTP JSON-RPC response parsing failed");
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
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, retry_index, response_byte_length = text.len(), outcome = "success", "HTTP JSON-RPC request completed");
|
||||
return std::result::Result::Ok(success.result);
|
||||
},
|
||||
crate::JsonRpcResponse::Error(error_response)
|
||||
if error_response.error.code == 429
|
||||
&& retry_index < crate::MAX_HTTP_RATE_LIMIT_RETRIES =>
|
||||
{
|
||||
retry_index = retry_index.saturating_add(1);
|
||||
let configured_pause_ms =
|
||||
crate::HttpClient::rate_limit_backoff_ms(base_pause_ms, retry_index);
|
||||
let pause_ms = match retry_after_ms {
|
||||
std::option::Option::Some(value) => {
|
||||
std::cmp::max(value, configured_pause_ms)
|
||||
},
|
||||
std::option::Option::None => configured_pause_ms,
|
||||
};
|
||||
tracing::warn!(target: crate::TRACING_TARGET, action = "retry_http_json_rpc_after_rate_limit", 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, retry_index, pause_ms, "HTTP JSON-RPC endpoint returned a rate-limit RPC error; retrying with bounded backoff");
|
||||
if let std::option::Option::Some(limiter) = &request_limiter {
|
||||
limiter.block_for(pause_ms).await;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(pause_ms)).await;
|
||||
continue;
|
||||
},
|
||||
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, retry_index, rpc_error_code = error_response.error.code, rpc_error_message = %error_response.error.message, "HTTP JSON-RPC endpoint returned an RPC error");
|
||||
return std::result::Result::Err(ks_core::Error::http(format!(
|
||||
"json-rpc error {} from '{}' after {} retries: {}",
|
||||
error_response.error.code,
|
||||
self.endpoint.name,
|
||||
retry_index,
|
||||
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, retry_index, "HTTP JSON-RPC response was an unexpected notification");
|
||||
return std::result::Result::Err(ks_core::Error::http(
|
||||
"http json-rpc response cannot be a notification".to_string(),
|
||||
));
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn request_limiter(
|
||||
&self,
|
||||
request_kind: &str,
|
||||
) -> std::option::Option<std::sync::Arc<HttpRequestLimiter>> {
|
||||
if let std::option::Option::Some(selected_role) = &self.selected_role {
|
||||
for limiter in self.request_limiters.iter() {
|
||||
if limiter.role.as_str() == selected_role.as_str()
|
||||
&& limiter.handles_request(request_kind)
|
||||
{
|
||||
return std::option::Option::Some(limiter.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut selected: std::option::Option<std::sync::Arc<HttpRequestLimiter>> =
|
||||
std::option::Option::None;
|
||||
for limiter in self.request_limiters.iter() {
|
||||
if !limiter.handles_request(request_kind) {
|
||||
continue;
|
||||
}
|
||||
let replace = match &selected {
|
||||
std::option::Option::Some(current) => limiter.priority < current.priority,
|
||||
std::option::Option::None => true,
|
||||
};
|
||||
if replace {
|
||||
selected = std::option::Option::Some(limiter.clone());
|
||||
}
|
||||
}
|
||||
if selected.is_some() {
|
||||
return selected;
|
||||
}
|
||||
for limiter in self.request_limiters.iter() {
|
||||
let replace = match &selected {
|
||||
std::option::Option::Some(current) => limiter.priority < current.priority,
|
||||
std::option::Option::None => true,
|
||||
};
|
||||
if replace {
|
||||
selected = std::option::Option::Some(limiter.clone());
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
fn configured_rate_limit_pause_ms(&self, request_kind: &str) -> u64 {
|
||||
let limiter = self.request_limiter(request_kind);
|
||||
return match limiter {
|
||||
std::option::Option::Some(value) => value.pause_after_rate_limit_ms,
|
||||
std::option::Option::None => crate::DEFAULT_HTTP_RATE_LIMIT_PAUSE_MS,
|
||||
};
|
||||
}
|
||||
|
||||
fn rate_limit_backoff_ms(base_pause_ms: u64, retry_index: u32) -> u64 {
|
||||
let shift = retry_index.saturating_sub(1).min(6);
|
||||
let multiplier = match 1_u64.checked_shl(shift) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => 64,
|
||||
};
|
||||
return base_pause_ms.saturating_mul(multiplier).min(60_000);
|
||||
}
|
||||
|
||||
fn retry_after_millis(headers: &reqwest::header::HeaderMap) -> std::option::Option<u64> {
|
||||
let value = match headers.get(reqwest::header::RETRY_AFTER) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let text = match value.to_str() {
|
||||
std::result::Result::Ok(text) => text,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let seconds = match text.parse::<u64>() {
|
||||
std::result::Result::Ok(seconds) => seconds,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
return std::option::Option::Some(seconds.saturating_mul(1_000).min(60_000));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn role_config(
|
||||
role: &str,
|
||||
request_kinds: std::vec::Vec<std::string::String>,
|
||||
) -> ks_config::EndpointRoleConfig {
|
||||
return ks_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) -> ks_config::HttpEndpointConfig {
|
||||
return ks_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
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_rate_limit_pause_uses_matching_role_and_wildcard() {
|
||||
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_eq!(client.configured_rate_limit_pause_ms("get_version"), 1500);
|
||||
assert_eq!(client.configured_rate_limit_pause_ms("send_transaction"), 1500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_backoff_is_exponential_and_bounded() {
|
||||
assert_eq!(crate::HttpClient::rate_limit_backoff_ms(1500, 1), 1500);
|
||||
assert_eq!(crate::HttpClient::rate_limit_backoff_ms(1500, 2), 3000);
|
||||
assert_eq!(crate::HttpClient::rate_limit_backoff_ms(1500, 3), 6000);
|
||||
assert_eq!(crate::HttpClient::rate_limit_backoff_ms(3000, 10), 60_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_role_uses_its_exact_configured_limiter() {
|
||||
let mut endpoint = endpoint(true);
|
||||
endpoint.roles[0].priority = 10;
|
||||
endpoint.roles[0].requests_per_second = 8;
|
||||
endpoint.roles[0].burst_capacity = 16;
|
||||
endpoint.roles[0].max_concurrent_requests = 8;
|
||||
endpoint.roles[0].pause_after_rate_limit_ms = 1500;
|
||||
endpoint.roles[1].priority = 20;
|
||||
endpoint.roles[1].requests_per_second = 2;
|
||||
endpoint.roles[1].burst_capacity = 2;
|
||||
endpoint.roles[1].max_concurrent_requests = 2;
|
||||
endpoint.roles[1].pause_after_rate_limit_ms = 3000;
|
||||
let client = match crate::HttpClient::new(endpoint) {
|
||||
std::result::Result::Ok(client) => client,
|
||||
std::result::Result::Err(error) => panic!("client creation failed: {error}"),
|
||||
};
|
||||
let selected = client.with_required_role("http_heavy");
|
||||
let limiter = match selected.request_limiter("get_block") {
|
||||
std::option::Option::Some(limiter) => limiter,
|
||||
std::option::Option::None => panic!("selected role limiter missing"),
|
||||
};
|
||||
assert_eq!(limiter.role.as_str(), "http_heavy");
|
||||
assert_eq!(limiter.requests_per_second, 2);
|
||||
assert_eq!(limiter.burst_capacity, 2);
|
||||
assert_eq!(limiter.semaphore.available_permits(), 2);
|
||||
assert_eq!(limiter.pause_after_rate_limit_ms, 3000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbound_client_prefers_the_lowest_priority_matching_role() {
|
||||
let mut endpoint = endpoint(true);
|
||||
endpoint.roles[0].priority = 20;
|
||||
endpoint.roles[2].priority = 30;
|
||||
let client = match crate::HttpClient::new(endpoint) {
|
||||
std::result::Result::Ok(client) => client,
|
||||
std::result::Result::Err(error) => panic!("client creation failed: {error}"),
|
||||
};
|
||||
let limiter = match client.request_limiter("get_version") {
|
||||
std::option::Option::Some(limiter) => limiter,
|
||||
std::option::Option::None => panic!("matching limiter missing"),
|
||||
};
|
||||
assert_eq!(limiter.role.as_str(), "http_queries");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_after_seconds_are_converted_to_bounded_milliseconds() {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers
|
||||
.insert(reqwest::header::RETRY_AFTER, reqwest::header::HeaderValue::from_static("3"));
|
||||
assert_eq!(
|
||||
crate::HttpClient::retry_after_millis(&headers),
|
||||
std::option::Option::Some(3000)
|
||||
);
|
||||
headers
|
||||
.insert(reqwest::header::RETRY_AFTER, reqwest::header::HeaderValue::from_static("999"));
|
||||
assert_eq!(
|
||||
crate::HttpClient::retry_after_millis(&headers),
|
||||
std::option::Option::Some(60_000)
|
||||
);
|
||||
}
|
||||
}
|
||||
280
ks-onchain-transport/src/http_pool.rs
Normal file
280
ks-onchain-transport/src/http_pool.rs
Normal file
@@ -0,0 +1,280 @@
|
||||
// file: ks-onchain-transport/src/http_pool.rs
|
||||
// version: 9
|
||||
|
||||
//! HTTP endpoint pool and role-based routing.
|
||||
|
||||
/// Pool of HTTP JSON-RPC endpoints.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HttpEndpointPool {
|
||||
clients: std::vec::Vec<crate::HttpClient>,
|
||||
next_index: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
impl crate::HttpEndpointPool {
|
||||
/// Builds a pool from the active profile HTTP endpoint list.
|
||||
pub fn from_profile(profile: &ks_config::ProfileConfig) -> ks_core::Result<Self> {
|
||||
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<crate::HttpClient>) -> ks_core::Result<Self> {
|
||||
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(ks_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<crate::HttpPoolClientSnapshot> {
|
||||
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,
|
||||
) -> ks_core::Result<crate::HttpClient> {
|
||||
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,
|
||||
) -> ks_core::Result<crate::HttpClient> {
|
||||
if self.clients.is_empty() {
|
||||
return std::result::Result::Err(ks_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) {
|
||||
let selected = client.with_required_role(required_role);
|
||||
tracing::debug!(target: crate::TRACING_TARGET, action = "select_http_endpoint", required_role, request_kind, endpoint_name = %selected.endpoint_name(), provider = %selected.provider(), "selected HTTP endpoint");
|
||||
return std::result::Result::Ok(selected);
|
||||
}
|
||||
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(ks_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<Request>(
|
||||
&self,
|
||||
required_role: &str,
|
||||
request: &Request,
|
||||
) -> ks_core::Result<<Request as crate::StandardHttpRequest>::Response>
|
||||
where
|
||||
Request: crate::StandardHttpRequest,
|
||||
{
|
||||
let client = match self.select_client_for_role_and_method(
|
||||
required_role,
|
||||
<Request as crate::StandardHttpRequest>::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<serde_json::Value>,
|
||||
) -> ks_core::Result<serde_json::Value> {
|
||||
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<serde_json::Value>,
|
||||
) -> ks_core::Result<serde_json::Value> {
|
||||
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<std::string::String>,
|
||||
) -> ks_config::EndpointRoleConfig {
|
||||
return ks_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<std::string::String>,
|
||||
) -> ks_config::HttpEndpointConfig {
|
||||
return ks_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: ks_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());
|
||||
}
|
||||
}
|
||||
315
ks-onchain-transport/src/json_rpc.rs
Normal file
315
ks-onchain-transport/src/json_rpc.rs
Normal file
@@ -0,0 +1,315 @@
|
||||
// file: ks-onchain-transport/src/json_rpc.rs
|
||||
// version: 5
|
||||
|
||||
//! 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<serde_json::Value>,
|
||||
}
|
||||
|
||||
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<serde_json::Value>,
|
||||
) -> 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) -> ks_core::Result<std::string::String> {
|
||||
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(ks_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<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 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) -> ks_core::Result<serde_json::Value> {
|
||||
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(ks_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(ks_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(ks_core::Error::json(error.to_string()))
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an incoming JSON-RPC text payload.
|
||||
pub fn parse_json_rpc_text(text: &str) -> ks_core::Result<crate::JsonRpcResponse> {
|
||||
let value = match serde_json::from_str::<serde_json::Value>(text) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_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(ks_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::<crate::JsonRpcNotification>(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(ks_core::Error::json(
|
||||
format!("cannot parse json-rpc notification: {error}"),
|
||||
)),
|
||||
};
|
||||
}
|
||||
if has_id && has_result && !has_error {
|
||||
let parse_result = serde_json::from_value::<crate::JsonRpcSuccessResponse>(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(ks_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::<crate::JsonRpcErrorResponse>(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(ks_core::Error::json(
|
||||
format!("cannot parse json-rpc error response: {error}"),
|
||||
)),
|
||||
};
|
||||
}
|
||||
return std::result::Result::Err(ks_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"));
|
||||
}
|
||||
}
|
||||
509
ks-onchain-transport/src/lib.rs
Normal file
509
ks-onchain-transport/src/lib.rs
Normal file
@@ -0,0 +1,509 @@
|
||||
// file: ks-onchain-transport/src/lib.rs
|
||||
// version: 9
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
//! Solana on-chain HTTP JSON-RPC transport and standard method contracts.
|
||||
|
||||
mod client;
|
||||
mod constants;
|
||||
mod endpoint_role;
|
||||
mod execution_rpc;
|
||||
mod get_signatures_for_address;
|
||||
mod get_transaction;
|
||||
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 standard_ws;
|
||||
mod validation;
|
||||
mod ws_client;
|
||||
mod ws_pool;
|
||||
mod ws_session;
|
||||
|
||||
/// RPC endpoint configuration.
|
||||
pub use self::client::RpcEndpoint;
|
||||
/// Minimal Solana RPC client abstraction.
|
||||
pub use self::client::SolanaRpcClient;
|
||||
/// Maximum decoded account bytes accepted by one complete account read.
|
||||
pub use self::constants::MAX_COMPLETE_ACCOUNT_DATA_BYTES;
|
||||
/// 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;
|
||||
/// One signature summary returned by `getSignaturesForAddress`.
|
||||
pub use self::get_signatures_for_address::AddressSignatureInfo;
|
||||
/// Configuration for `getSignaturesForAddress`.
|
||||
pub use self::get_signatures_for_address::GetSignaturesForAddressConfig;
|
||||
/// Adapts a raw `getSignaturesForAddress` result.
|
||||
pub use self::get_signatures_for_address::adapt_get_signatures_for_address_result;
|
||||
/// Common adapter contract for transport-specific transaction payloads.
|
||||
pub use self::get_transaction::CanonicalTransactionAdapter;
|
||||
/// Source payload and canonical result returned by `getTransaction`.
|
||||
pub use self::get_transaction::GetTransactionAcquisition;
|
||||
/// Standard Solana `getTransaction` adapter.
|
||||
pub use self::get_transaction::GetTransactionAdapter;
|
||||
/// Configuration for `getTransaction`.
|
||||
pub use self::get_transaction::GetTransactionConfig;
|
||||
/// Adapts a raw standard Solana `getTransaction` result.
|
||||
pub use self::get_transaction::adapt_get_transaction_result;
|
||||
/// 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;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::AccountSubscribeRequest;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::BlockSubscribeRequest;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::LogsSubscribeRequest;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::ProgramSubscribeRequest;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::RootSubscribeRequest;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::SignatureSubscribeRequest;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::SlotSubscribeRequest;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::SlotsUpdatesSubscribeRequest;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::StandardWsCapabilities;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::StandardWsNotification;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::StandardWsRequest;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::VoteSubscribeRequest;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::WsAccountNotification;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::WsAccountSubscribeConfig;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::WsBlockFilter;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::WsBlockNotification;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::WsBlockSubscribeConfig;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::WsBlockUpdate;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::WsLogsFilter;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::WsLogsNotification;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::WsLogsSubscribeConfig;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::WsLogsValue;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::WsProgramNotification;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::WsProgramSubscribeConfig;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::WsRootNotification;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::WsSignatureNotification;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::WsSignatureSubscribeConfig;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::WsSignatureValue;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::WsSlotInfo;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::WsSlotNotification;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::WsSlotTransactionStats;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::WsSlotUpdate;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::WsSlotsUpdatesNotification;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::WsVoteNotification;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::WsVoteValue;
|
||||
/// Typed contracts for all standard Solana WebSocket subscriptions.
|
||||
pub use self::standard_ws::adapt_standard_ws_notification;
|
||||
/// 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;
|
||||
/// Standard Solana WebSocket client bound to one endpoint.
|
||||
pub use self::ws_client::WsClient;
|
||||
/// Snapshot of one WebSocket pool endpoint.
|
||||
pub use self::ws_client::WsPoolClientSnapshot;
|
||||
/// WebSocket endpoint pool with role-based routing.
|
||||
pub use self::ws_pool::WsEndpointPool;
|
||||
/// Persistent WebSocket session contracts.
|
||||
pub use self::ws_session::WsReconnectPolicy;
|
||||
/// Persistent WebSocket session contracts.
|
||||
pub use self::ws_session::WsSession;
|
||||
/// Persistent WebSocket session contracts.
|
||||
pub use self::ws_session::WsSessionEvent;
|
||||
/// Persistent WebSocket session contracts.
|
||||
pub use self::ws_session::WsSessionSnapshot;
|
||||
/// Persistent WebSocket session contracts.
|
||||
pub use self::ws_session::WsSessionState;
|
||||
/// Persistent WebSocket session contracts.
|
||||
pub use self::ws_session::WsSubscriptionAck;
|
||||
/// Persistent WebSocket session contracts.
|
||||
pub use self::ws_session::WsSubscriptionSnapshot;
|
||||
/// Acknowledgement returned after a WebSocket unsubscribe request.
|
||||
pub use self::ws_session::WsUnsubscribeAck;
|
||||
|
||||
/// Internal DEFAULT_HTTP_RATE_LIMIT_PAUSE_MS contract.
|
||||
pub(crate) use self::constants::DEFAULT_HTTP_RATE_LIMIT_PAUSE_MS;
|
||||
/// 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_RPC_BASE64_LENGTH contract.
|
||||
pub(crate) use self::constants::MAX_EXECUTION_RPC_BASE64_LENGTH;
|
||||
/// Internal MAX_HTTP_RATE_LIMIT_RETRIES contract.
|
||||
pub(crate) use self::constants::MAX_HTTP_RATE_LIMIT_RETRIES;
|
||||
/// 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;
|
||||
/// Canonical tracing target for this crate.
|
||||
pub(crate) use self::constants::TRACING_TARGET;
|
||||
/// 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;
|
||||
499
ks-onchain-transport/src/standard_http.rs
Normal file
499
ks-onchain-transport/src/standard_http.rs
Normal file
@@ -0,0 +1,499 @@
|
||||
// file: ks-onchain-transport/src/standard_http.rs
|
||||
// version: 4
|
||||
|
||||
//! 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) -> ks_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) -> ks_core::Result<()> {
|
||||
if self.offset.checked_add(self.length).is_none() {
|
||||
return std::result::Result::Err(ks_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) -> ks_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(ks_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) -> ks_core::Result<()> {
|
||||
let decoded_length = match self {
|
||||
Self::Base58(value) => {
|
||||
if value.len() > crate::MAX_MEMCMP_BASE58_LENGTH {
|
||||
return std::result::Result::Err(ks_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(ks_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(ks_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(ks_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(ks_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) -> ks_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) -> ks_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) -> ks_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,
|
||||
) -> ks_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(ks_core::Error::json(format!(
|
||||
"cannot serialize {method} parameter: {error}"
|
||||
))),
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn validate_pubkey_list(
|
||||
values: &[std::string::String],
|
||||
field: &str,
|
||||
maximum: usize,
|
||||
) -> ks_core::Result<()> {
|
||||
if values.len() > maximum {
|
||||
return std::result::Result::Err(ks_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());
|
||||
}
|
||||
}
|
||||
212
ks-onchain-transport/src/standard_http_accounts.rs
Normal file
212
ks-onchain-transport/src/standard_http_accounts.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
// file: ks-onchain-transport/src/standard_http_accounts.rs
|
||||
// version: 3
|
||||
|
||||
//! 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<crate::RpcCommitmentLevel>,
|
||||
/// Optional circulating-supply filter.
|
||||
#[serde(skip_serializing_if = "std::option::Option::is_none")]
|
||||
pub filter: std::option::Option<crate::RpcLargestAccountsFilter>,
|
||||
/// Optional validator-side deterministic sorting flag.
|
||||
#[serde(skip_serializing_if = "std::option::Option::is_none")]
|
||||
pub sort_results: std::option::Option<bool>,
|
||||
}
|
||||
|
||||
/// Typed `getLargestAccounts` request.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct GetLargestAccountsRequest {
|
||||
/// Optional request configuration. `None` emits no parameter.
|
||||
pub config: std::option::Option<crate::RpcLargestAccountsConfig>,
|
||||
}
|
||||
|
||||
impl crate::StandardHttpRequest for crate::GetLargestAccountsRequest {
|
||||
type Response = crate::RpcResponse<std::vec::Vec<crate::RpcAccountBalance>>;
|
||||
|
||||
const METHOD: &'static str = "getLargestAccounts";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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<std::string::String>,
|
||||
/// Optional account encoding, slicing and context options.
|
||||
pub config: std::option::Option<crate::RpcAccountInfoConfig>,
|
||||
}
|
||||
|
||||
impl crate::StandardHttpRequest for crate::GetMultipleAccountsRequest {
|
||||
type Response = crate::RpcResponse<std::vec::Vec<std::option::Option<crate::RpcUiAccount>>>;
|
||||
|
||||
const METHOD: &'static str = "getMultipleAccounts";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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<crate::RpcProgramAccountsConfig>,
|
||||
}
|
||||
|
||||
impl crate::StandardHttpRequest for crate::GetProgramAccountsRequest {
|
||||
type Response = crate::RpcOptionalContext<std::vec::Vec<crate::RpcKeyedAccount>>;
|
||||
|
||||
const METHOD: &'static str = "getProgramAccounts";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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())
|
||||
);
|
||||
}
|
||||
}
|
||||
548
ks-onchain-transport/src/standard_http_blocks.rs
Normal file
548
ks-onchain-transport/src/standard_http_blocks.rs
Normal file
@@ -0,0 +1,548 @@
|
||||
// file: ks-onchain-transport/src/standard_http_blocks.rs
|
||||
// version: 3
|
||||
|
||||
//! 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<crate::RpcTransactionEncoding>,
|
||||
/// Optional transaction detail level.
|
||||
#[serde(skip_serializing_if = "std::option::Option::is_none")]
|
||||
pub transaction_details: std::option::Option<crate::RpcTransactionDetails>,
|
||||
/// Whether rewards must be included.
|
||||
#[serde(skip_serializing_if = "std::option::Option::is_none")]
|
||||
pub rewards: std::option::Option<bool>,
|
||||
/// Optional confirmed or finalized commitment.
|
||||
#[serde(skip_serializing_if = "std::option::Option::is_none")]
|
||||
pub commitment: std::option::Option<crate::RpcCommitmentLevel>,
|
||||
/// Highest transaction version the caller can decode.
|
||||
#[serde(skip_serializing_if = "std::option::Option::is_none")]
|
||||
pub max_supported_transaction_version: std::option::Option<u8>,
|
||||
}
|
||||
|
||||
impl crate::RpcBlockConfig {
|
||||
/// Rejects the processed commitment unsupported by block-history methods.
|
||||
pub fn validate(&self) -> ks_core::Result<()> {
|
||||
if self.commitment == std::option::Option::Some(crate::RpcCommitmentLevel::Processed) {
|
||||
return std::result::Result::Err(ks_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<u64>,
|
||||
}
|
||||
|
||||
/// 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<std::string::String>,
|
||||
/// Optional inclusive slot range.
|
||||
#[serde(skip_serializing_if = "std::option::Option::is_none")]
|
||||
pub range: std::option::Option<crate::RpcBlockProductionConfigRange>,
|
||||
/// Optional commitment level.
|
||||
#[serde(skip_serializing_if = "std::option::Option::is_none")]
|
||||
pub commitment: std::option::Option<crate::RpcCommitmentLevel>,
|
||||
}
|
||||
|
||||
impl crate::RpcBlockProductionConfig {
|
||||
/// Validates identity and range ordering.
|
||||
pub fn validate(&self) -> ks_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(ks_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<crate::RpcRewardType>,
|
||||
/// Optional validator commission percentage.
|
||||
pub commission: std::option::Option<u8>,
|
||||
/// Optional validator commission in basis points.
|
||||
#[serde(default, skip_serializing_if = "std::option::Option::is_none")]
|
||||
pub commission_bps: std::option::Option<u16>,
|
||||
}
|
||||
|
||||
/// 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<std::vec::Vec<serde_json::Value>>,
|
||||
/// Signature list when signature-only details are requested.
|
||||
#[serde(default)]
|
||||
pub signatures: std::option::Option<std::vec::Vec<std::string::String>>,
|
||||
/// Rewards when requested.
|
||||
#[serde(default)]
|
||||
pub rewards: std::option::Option<std::vec::Vec<crate::RpcReward>>,
|
||||
/// Unix block time when available.
|
||||
#[serde(default)]
|
||||
pub block_time: std::option::Option<i64>,
|
||||
/// Block height when available.
|
||||
#[serde(default)]
|
||||
pub block_height: std::option::Option<u64>,
|
||||
/// Number of reward partitions when available.
|
||||
#[serde(default)]
|
||||
pub num_reward_partitions: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
/// 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<std::vec::Vec<u64>>,
|
||||
/// 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<std::string::String, (usize, usize)>,
|
||||
/// 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<u64>,
|
||||
/// 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<crate::RpcBlockConfig>,
|
||||
}
|
||||
|
||||
impl crate::StandardHttpRequest for crate::GetBlockRequest {
|
||||
type Response = std::option::Option<crate::RpcConfirmedBlock>;
|
||||
|
||||
const METHOD: &'static str = "getBlock";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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<crate::RpcBlockProductionConfig>,
|
||||
}
|
||||
|
||||
impl crate::StandardHttpRequest for crate::GetBlockProductionRequest {
|
||||
type Response = crate::RpcResponse<crate::RpcBlockProduction>;
|
||||
|
||||
const METHOD: &'static str = "getBlockProduction";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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<u64>,
|
||||
/// Optional confirmed or finalized context options.
|
||||
pub config: std::option::Option<crate::RpcContextConfig>,
|
||||
}
|
||||
|
||||
impl crate::StandardHttpRequest for crate::GetBlocksRequest {
|
||||
type Response = std::vec::Vec<u64>;
|
||||
|
||||
const METHOD: &'static str = "getBlocks";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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(ks_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(ks_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<crate::RpcContextConfig>,
|
||||
}
|
||||
|
||||
impl crate::StandardHttpRequest for crate::GetBlocksWithLimitRequest {
|
||||
type Response = std::vec::Vec<u64>;
|
||||
|
||||
const METHOD: &'static str = "getBlocksWithLimit";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
if self.limit > crate::MAX_BLOCK_RANGE {
|
||||
return std::result::Result::Err(ks_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(ks_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<i64>;
|
||||
|
||||
const METHOD: &'static str = "getBlockTime";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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<usize>,
|
||||
}
|
||||
|
||||
impl crate::StandardHttpRequest for crate::GetRecentPerformanceSamplesRequest {
|
||||
type Response = std::vec::Vec<crate::RpcPerformanceSample>;
|
||||
|
||||
const METHOD: &'static str = "getRecentPerformanceSamples";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
if let std::option::Option::Some(limit) = self.limit {
|
||||
if limit > crate::MAX_PERFORMANCE_SAMPLE_COUNT {
|
||||
return std::result::Result::Err(ks_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) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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::<crate::RpcReward>(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)]);
|
||||
}
|
||||
}
|
||||
505
ks-onchain-transport/src/standard_http_cluster.rs
Normal file
505
ks-onchain-transport/src/standard_http_cluster.rs
Normal file
@@ -0,0 +1,505 @@
|
||||
// file: ks-onchain-transport/src/standard_http_cluster.rs
|
||||
// version: 3
|
||||
|
||||
//! 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<std::string::String>,
|
||||
/// TVU UDP socket address.
|
||||
pub tvu: std::option::Option<std::string::String>,
|
||||
/// TPU UDP socket address.
|
||||
pub tpu: std::option::Option<std::string::String>,
|
||||
/// TPU QUIC socket address.
|
||||
pub tpu_quic: std::option::Option<std::string::String>,
|
||||
/// TPU forwarding UDP socket address.
|
||||
pub tpu_forwards: std::option::Option<std::string::String>,
|
||||
/// TPU forwarding QUIC socket address.
|
||||
pub tpu_forwards_quic: std::option::Option<std::string::String>,
|
||||
/// TPU vote socket address.
|
||||
pub tpu_vote: std::option::Option<std::string::String>,
|
||||
/// Repair service socket address.
|
||||
pub serve_repair: std::option::Option<std::string::String>,
|
||||
/// JSON-RPC socket address.
|
||||
pub rpc: std::option::Option<std::string::String>,
|
||||
/// PubSub socket address.
|
||||
pub pubsub: std::option::Option<std::string::String>,
|
||||
/// Validator software version.
|
||||
pub version: std::option::Option<std::string::String>,
|
||||
/// Validator client identifier.
|
||||
pub client_id: std::option::Option<std::string::String>,
|
||||
/// Feature-set identifier prefix.
|
||||
pub feature_set: std::option::Option<u32>,
|
||||
/// Shred version.
|
||||
pub shred_version: std::option::Option<u16>,
|
||||
}
|
||||
|
||||
/// 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<u64>,
|
||||
}
|
||||
|
||||
/// 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<std::string::String>,
|
||||
/// Optional commitment level.
|
||||
#[serde(skip_serializing_if = "std::option::Option::is_none")]
|
||||
pub commitment: std::option::Option<crate::RpcCommitmentLevel>,
|
||||
}
|
||||
|
||||
impl crate::RpcLeaderScheduleConfig {
|
||||
/// Validates the optional identity public key.
|
||||
pub fn validate(&self) -> ks_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<std::string::String, std::vec::Vec<usize>>;
|
||||
|
||||
/// 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<u32>,
|
||||
}
|
||||
|
||||
/// 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<std::string::String>,
|
||||
/// Optional commitment level.
|
||||
#[serde(skip_serializing_if = "std::option::Option::is_none")]
|
||||
pub commitment: std::option::Option<crate::RpcCommitmentLevel>,
|
||||
/// Whether unstaked delinquent validators must be retained.
|
||||
#[serde(skip_serializing_if = "std::option::Option::is_none")]
|
||||
pub keep_unstaked_delinquents: std::option::Option<bool>,
|
||||
/// Optional delinquency threshold in slots.
|
||||
#[serde(skip_serializing_if = "std::option::Option::is_none")]
|
||||
pub delinquent_slot_distance: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
impl crate::RpcGetVoteAccountsConfig {
|
||||
/// Validates the optional vote account public key.
|
||||
pub fn validate(&self) -> ks_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<u16>,
|
||||
/// 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<crate::RpcVoteAccountInfo>,
|
||||
/// Delinquent validator vote accounts.
|
||||
pub delinquent: std::vec::Vec<crate::RpcVoteAccountInfo>,
|
||||
}
|
||||
|
||||
/// Typed `getClusterNodes` request.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct GetClusterNodesRequest;
|
||||
|
||||
impl crate::StandardHttpRequest for crate::GetClusterNodesRequest {
|
||||
type Response = std::vec::Vec<crate::RpcContactInfo>;
|
||||
|
||||
const METHOD: &'static str = "getClusterNodes";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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<u64>,
|
||||
/// Optional identity and commitment options.
|
||||
pub config: std::option::Option<crate::RpcLeaderScheduleConfig>,
|
||||
}
|
||||
|
||||
impl crate::StandardHttpRequest for crate::GetLeaderScheduleRequest {
|
||||
type Response = std::option::Option<crate::RpcLeaderSchedule>;
|
||||
|
||||
const METHOD: &'static str = "getLeaderSchedule";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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<crate::RpcContextConfig>,
|
||||
}
|
||||
|
||||
impl crate::StandardHttpRequest for crate::GetSlotRequest {
|
||||
type Response = u64;
|
||||
|
||||
const METHOD: &'static str = "getSlot";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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<crate::RpcContextConfig>,
|
||||
}
|
||||
|
||||
impl crate::StandardHttpRequest for crate::GetSlotLeaderRequest {
|
||||
type Response = std::string::String;
|
||||
|
||||
const METHOD: &'static str = "getSlotLeader";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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<std::string::String>;
|
||||
|
||||
const METHOD: &'static str = "getSlotLeaders";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
if self.limit == 0 || self.limit > crate::MAX_SLOT_LEADER_COUNT {
|
||||
return std::result::Result::Err(ks_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) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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<crate::RpcGetVoteAccountsConfig>,
|
||||
}
|
||||
|
||||
impl crate::StandardHttpRequest for crate::GetVoteAccountsRequest {
|
||||
type Response = crate::RpcVoteAccountStatus;
|
||||
|
||||
const METHOD: &'static str = "getVoteAccounts";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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::<crate::RpcVoteAccountInfo>(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::<crate::RpcVersionInfo>(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));
|
||||
}
|
||||
}
|
||||
305
ks-onchain-transport/src/standard_http_economics.rs
Normal file
305
ks-onchain-transport/src/standard_http_economics.rs
Normal file
@@ -0,0 +1,305 @@
|
||||
// file: ks-onchain-transport/src/standard_http_economics.rs
|
||||
// version: 3
|
||||
|
||||
//! 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<u8>,
|
||||
/// 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<u16>,
|
||||
}
|
||||
|
||||
/// 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<std::string::String>,
|
||||
}
|
||||
|
||||
/// 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<crate::RpcCommitmentLevel>,
|
||||
}
|
||||
|
||||
/// 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<u64>,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// 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<crate::RpcCommitmentLevel>,
|
||||
/// 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<bool>,
|
||||
}
|
||||
|
||||
/// Typed `getInflationGovernor` request.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct GetInflationGovernorRequest {
|
||||
/// Optional commitment configuration.
|
||||
pub config: std::option::Option<crate::RpcInflationGovernorConfig>,
|
||||
}
|
||||
|
||||
impl crate::StandardHttpRequest for crate::GetInflationGovernorRequest {
|
||||
type Response = crate::RpcInflationGovernor;
|
||||
|
||||
const METHOD: &'static str = "getInflationGovernor";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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<std::string::String>,
|
||||
/// Optional epoch, commitment and minimum-context options.
|
||||
pub config: std::option::Option<crate::RpcEpochConfig>,
|
||||
}
|
||||
|
||||
impl crate::StandardHttpRequest for crate::GetInflationRewardRequest {
|
||||
type Response = std::vec::Vec<std::option::Option<crate::RpcInflationReward>>;
|
||||
|
||||
const METHOD: &'static str = "getInflationReward";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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<crate::RpcContextConfig>,
|
||||
}
|
||||
|
||||
impl crate::StandardHttpRequest for crate::GetStakeMinimumDelegationRequest {
|
||||
type Response = crate::RpcResponse<u64>;
|
||||
|
||||
const METHOD: &'static str = "getStakeMinimumDelegation";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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<crate::RpcSupplyConfig>,
|
||||
}
|
||||
|
||||
impl crate::StandardHttpRequest for crate::GetSupplyRequest {
|
||||
type Response = crate::RpcResponse<crate::RpcSupply>;
|
||||
|
||||
const METHOD: &'static str = "getSupply";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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::<crate::RpcInflationReward>(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!([])]);
|
||||
}
|
||||
}
|
||||
241
ks-onchain-transport/src/standard_http_tokens.rs
Normal file
241
ks-onchain-transport/src/standard_http_tokens.rs
Normal file
@@ -0,0 +1,241 @@
|
||||
// file: ks-onchain-transport/src/standard_http_tokens.rs
|
||||
// version: 4
|
||||
|
||||
//! 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<crate::RpcCommitmentConfig>,
|
||||
) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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<crate::RpcAccountInfoConfig>,
|
||||
) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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<crate::RpcCommitmentConfig>,
|
||||
}
|
||||
|
||||
impl crate::StandardHttpRequest for crate::GetTokenAccountBalanceRequest {
|
||||
type Response = crate::RpcResponse<crate::RpcTokenAmount>;
|
||||
|
||||
const METHOD: &'static str = "getTokenAccountBalance";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
return 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<crate::RpcAccountInfoConfig>,
|
||||
}
|
||||
|
||||
impl crate::StandardHttpRequest for crate::GetTokenAccountsByDelegateRequest {
|
||||
type Response = crate::RpcResponse<std::vec::Vec<crate::RpcKeyedAccount>>;
|
||||
|
||||
const METHOD: &'static str = "getTokenAccountsByDelegate";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
return 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<crate::RpcAccountInfoConfig>,
|
||||
}
|
||||
|
||||
impl crate::StandardHttpRequest for crate::GetTokenAccountsByOwnerRequest {
|
||||
type Response = crate::RpcResponse<std::vec::Vec<crate::RpcKeyedAccount>>;
|
||||
|
||||
const METHOD: &'static str = "getTokenAccountsByOwner";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
return 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<crate::RpcCommitmentConfig>,
|
||||
}
|
||||
|
||||
impl crate::StandardHttpRequest for crate::GetTokenLargestAccountsRequest {
|
||||
type Response = crate::RpcResponse<std::vec::Vec<crate::RpcTokenAccountBalance>>;
|
||||
|
||||
const METHOD: &'static str = "getTokenLargestAccounts";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
return 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<crate::RpcCommitmentConfig>,
|
||||
}
|
||||
|
||||
impl crate::StandardHttpRequest for crate::GetTokenSupplyRequest {
|
||||
type Response = crate::RpcResponse<crate::RpcTokenAmount>;
|
||||
|
||||
const METHOD: &'static str = "getTokenSupply";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
return 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());
|
||||
}
|
||||
}
|
||||
155
ks-onchain-transport/src/standard_http_transactions.rs
Normal file
155
ks-onchain-transport/src/standard_http_transactions.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
// file: ks-onchain-transport/src/standard_http_transactions.rs
|
||||
// version: 4
|
||||
|
||||
//! 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<std::vec::Vec<std::string::String>>,
|
||||
}
|
||||
|
||||
impl crate::StandardHttpRequest for crate::GetRecentPrioritizationFeesRequest {
|
||||
type Response = std::vec::Vec<crate::RpcPrioritizationFee>;
|
||||
|
||||
const METHOD: &'static str = "getRecentPrioritizationFees";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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<crate::RpcContextConfig>,
|
||||
}
|
||||
|
||||
impl crate::StandardHttpRequest for crate::GetTransactionCountRequest {
|
||||
type Response = u64;
|
||||
|
||||
const METHOD: &'static str = "getTransactionCount";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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<crate::RpcContextConfig>,
|
||||
}
|
||||
|
||||
impl crate::StandardHttpRequest for crate::IsBlockhashValidRequest {
|
||||
type Response = crate::RpcResponse<bool>;
|
||||
|
||||
const METHOD: &'static str = "isBlockhashValid";
|
||||
|
||||
fn params(&self) -> ks_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
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::<serde_json::Value>::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));
|
||||
}
|
||||
}
|
||||
671
ks-onchain-transport/src/standard_methods.rs
Normal file
671
ks-onchain-transport/src/standard_methods.rs
Normal file
@@ -0,0 +1,671 @@
|
||||
// file: ks-onchain-transport/src/standard_methods.rs
|
||||
// version: 5
|
||||
|
||||
//! 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 `ks-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 `ks-onchain-transport`.
|
||||
pub request_contract: crate::StandardRpcContract,
|
||||
/// Strongest typed notification contract exposed by `ks-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<Request: crate::StandardHttpRequest>() -> &'static str {
|
||||
return <Request as crate::StandardHttpRequest>::METHOD;
|
||||
}
|
||||
let methods = [
|
||||
method::<crate::GetLargestAccountsRequest>(),
|
||||
method::<crate::GetMultipleAccountsRequest>(),
|
||||
method::<crate::GetProgramAccountsRequest>(),
|
||||
method::<crate::GetTokenAccountBalanceRequest>(),
|
||||
method::<crate::GetTokenAccountsByDelegateRequest>(),
|
||||
method::<crate::GetTokenAccountsByOwnerRequest>(),
|
||||
method::<crate::GetTokenLargestAccountsRequest>(),
|
||||
method::<crate::GetTokenSupplyRequest>(),
|
||||
method::<crate::GetRecentPrioritizationFeesRequest>(),
|
||||
method::<crate::GetTransactionCountRequest>(),
|
||||
method::<crate::IsBlockhashValidRequest>(),
|
||||
method::<crate::GetBlockRequest>(),
|
||||
method::<crate::GetBlockCommitmentRequest>(),
|
||||
method::<crate::GetBlockProductionRequest>(),
|
||||
method::<crate::GetBlocksRequest>(),
|
||||
method::<crate::GetBlocksWithLimitRequest>(),
|
||||
method::<crate::GetBlockTimeRequest>(),
|
||||
method::<crate::GetFirstAvailableBlockRequest>(),
|
||||
method::<crate::GetRecentPerformanceSamplesRequest>(),
|
||||
method::<crate::MinimumLedgerSlotRequest>(),
|
||||
method::<crate::GetClusterNodesRequest>(),
|
||||
method::<crate::GetEpochScheduleRequest>(),
|
||||
method::<crate::GetHealthRequest>(),
|
||||
method::<crate::GetHighestSnapshotSlotRequest>(),
|
||||
method::<crate::GetIdentityRequest>(),
|
||||
method::<crate::GetLeaderScheduleRequest>(),
|
||||
method::<crate::GetMaxRetransmitSlotRequest>(),
|
||||
method::<crate::GetMaxShredInsertSlotRequest>(),
|
||||
method::<crate::GetSlotRequest>(),
|
||||
method::<crate::GetSlotLeaderRequest>(),
|
||||
method::<crate::GetSlotLeadersRequest>(),
|
||||
method::<crate::GetVersionRequest>(),
|
||||
method::<crate::GetVoteAccountsRequest>(),
|
||||
method::<crate::GetInflationGovernorRequest>(),
|
||||
method::<crate::GetInflationRateRequest>(),
|
||||
method::<crate::GetInflationRewardRequest>(),
|
||||
method::<crate::GetStakeMinimumDelegationRequest>(),
|
||||
method::<crate::GetSupplyRequest>(),
|
||||
];
|
||||
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!("../../test-fixtures/contract-matrices/SOLANA_STANDARD_RPC_MATRIX.json");
|
||||
let parsed = match serde_json::from_str::<serde_json::Value>(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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
1251
ks-onchain-transport/src/standard_ws.rs
Normal file
1251
ks-onchain-transport/src/standard_ws.rs
Normal file
File diff suppressed because it is too large
Load Diff
77
ks-onchain-transport/src/validation.rs
Normal file
77
ks-onchain-transport/src/validation.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
// file: ks-onchain-transport/src/validation.rs
|
||||
// version: 5
|
||||
|
||||
//! 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) -> ks_core::Result<()> {
|
||||
return 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) -> ks_core::Result<()> {
|
||||
return 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) -> ks_core::Result<()> {
|
||||
return validate_base58_length(value, field_name, 32);
|
||||
}
|
||||
|
||||
fn validate_base58_length(
|
||||
value: &str,
|
||||
field_name: &str,
|
||||
expected_length: usize,
|
||||
) -> ks_core::Result<()> {
|
||||
if value.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_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(ks_core::Error::config(format!(
|
||||
"{field_name} is not valid base58: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
if decoded.len() != expected_length {
|
||||
return std::result::Result::Err(ks_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());
|
||||
}
|
||||
}
|
||||
386
ks-onchain-transport/src/ws_client.rs
Normal file
386
ks-onchain-transport/src/ws_client.rs
Normal file
@@ -0,0 +1,386 @@
|
||||
// file: ks-onchain-transport/src/ws_client.rs
|
||||
// version: 9
|
||||
|
||||
//! Standard Solana WebSocket client helpers.
|
||||
|
||||
use futures_util::SinkExt; // rust-rules: trait-import
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
|
||||
/// Snapshot of one pooled WebSocket endpoint.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WsPoolClientSnapshot {
|
||||
/// 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<crate::EndpointRoleSnapshot>,
|
||||
/// Status string.
|
||||
pub status: std::string::String,
|
||||
}
|
||||
|
||||
/// Standard Solana WebSocket client bound to one configured endpoint.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WsClient {
|
||||
endpoint: ks_config::WsEndpointConfig,
|
||||
next_request_id: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
}
|
||||
|
||||
impl crate::WsClient {
|
||||
/// Creates a new WebSocket client bound to one endpoint.
|
||||
pub fn new(endpoint: ks_config::WsEndpointConfig) -> ks_core::Result<Self> {
|
||||
if !endpoint.enabled {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "create_ws_client", endpoint_name = %endpoint.name, provider = %endpoint.provider, error_code = "ws_endpoint_disabled", "cannot create WebSocket client for disabled endpoint");
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"ws endpoint '{}' is disabled",
|
||||
endpoint.name
|
||||
)));
|
||||
}
|
||||
tracing::debug!(target: crate::TRACING_TARGET, action = "create_ws_client", endpoint_name = %endpoint.name, provider = %endpoint.provider, role_count = endpoint.roles.len(), "WebSocket client created");
|
||||
return std::result::Result::Ok(Self {
|
||||
endpoint,
|
||||
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) -> &ks_config::WsEndpointConfig {
|
||||
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::WsPoolClientSnapshot {
|
||||
let mut roles = std::vec::Vec::new();
|
||||
for role in &self.endpoint.roles {
|
||||
roles.push(crate::EndpointRoleSnapshot::from_config(role));
|
||||
}
|
||||
return crate::WsPoolClientSnapshot {
|
||||
endpoint_name: self.endpoint.name.clone(),
|
||||
provider: self.endpoint.provider.clone(),
|
||||
endpoint_url: self.endpoint.url.clone(),
|
||||
roles,
|
||||
status: "idle".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Builds a JSON-RPC request with a generated id.
|
||||
pub fn build_json_rpc_request(
|
||||
&self,
|
||||
method: std::string::String,
|
||||
params: std::vec::Vec<serde_json::Value>,
|
||||
) -> crate::JsonRpcRequest {
|
||||
let request_id = self.next_request_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
return crate::JsonRpcRequest::new_with_u64_id(request_id, method, params);
|
||||
}
|
||||
|
||||
/// Builds a subscribe request for one explicitly registered standard subscription.
|
||||
pub fn build_standard_subscribe_request(
|
||||
&self,
|
||||
subscription: &crate::StandardWsSubscriptionSpec,
|
||||
params: std::vec::Vec<serde_json::Value>,
|
||||
) -> crate::JsonRpcRequest {
|
||||
return self.build_json_rpc_request(subscription.subscribe_method.to_string(), params);
|
||||
}
|
||||
|
||||
/// Builds an unsubscribe request for one explicitly registered standard subscription.
|
||||
pub fn build_standard_unsubscribe_request(
|
||||
&self,
|
||||
subscription: &crate::StandardWsSubscriptionSpec,
|
||||
subscription_id: u64,
|
||||
) -> crate::JsonRpcRequest {
|
||||
return self.build_json_rpc_request(
|
||||
subscription.unsubscribe_method.to_string(),
|
||||
std::vec![serde_json::Value::from(subscription_id)],
|
||||
);
|
||||
}
|
||||
|
||||
/// Connects, sends one JSON-RPC request, waits for one response and closes.
|
||||
pub async fn execute_json_rpc_once(
|
||||
&self,
|
||||
method: std::string::String,
|
||||
params: std::vec::Vec<serde_json::Value>,
|
||||
) -> ks_core::Result<crate::JsonRpcResponse> {
|
||||
let parameter_count = params.len();
|
||||
let request = self.build_json_rpc_request(method.clone(), params);
|
||||
tracing::debug!(target: crate::TRACING_TARGET, action = "execute_ws_json_rpc_once", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id = ?request.id, method = %method, parameter_count, "start one-shot WebSocket JSON-RPC request");
|
||||
let request_text = match request.to_json_string() {
|
||||
std::result::Result::Ok(text) => text,
|
||||
std::result::Result::Err(error) => {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "serialize_ws_json_rpc_request", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id = ?request.id, method = %method, error = %error, "WebSocket JSON-RPC request serialization failed");
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
let connect_timeout = std::time::Duration::from_millis(self.endpoint.connect_timeout_ms);
|
||||
let connect_future = tokio_tungstenite::connect_async(self.endpoint.url.as_str());
|
||||
let connect_timeout_result = tokio::time::timeout(connect_timeout, connect_future).await;
|
||||
let connect_result = match connect_timeout_result {
|
||||
std::result::Result::Ok(result) => result,
|
||||
std::result::Result::Err(_) => {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "connect_ws_endpoint", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id = ?request.id, method = %method, timeout_ms = self.endpoint.connect_timeout_ms, error_code = "ws_connect_timeout", "WebSocket endpoint connection timed out");
|
||||
return std::result::Result::Err(ks_core::Error::ws(format!(
|
||||
"websocket connect timed out for endpoint '{}'",
|
||||
self.endpoint.name
|
||||
)));
|
||||
},
|
||||
};
|
||||
let (mut stream, _response) = match connect_result {
|
||||
std::result::Result::Ok(pair) => pair,
|
||||
std::result::Result::Err(error) => {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "connect_ws_endpoint", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id = ?request.id, method = %method, error = %error, "WebSocket endpoint connection failed");
|
||||
return std::result::Result::Err(ks_core::Error::ws(format!(
|
||||
"cannot connect websocket endpoint '{}': {error}",
|
||||
self.endpoint.name
|
||||
)));
|
||||
},
|
||||
};
|
||||
let send_result = stream
|
||||
.send(tokio_tungstenite::tungstenite::Message::Text(request_text.into()))
|
||||
.await;
|
||||
if let std::result::Result::Err(error) = send_result {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "send_ws_json_rpc_request", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id = ?request.id, method = %method, error = %error, "WebSocket JSON-RPC request send failed");
|
||||
return std::result::Result::Err(ks_core::Error::ws(format!(
|
||||
"cannot send websocket request '{}' to endpoint '{}': {error}",
|
||||
method, self.endpoint.name
|
||||
)));
|
||||
}
|
||||
let response_timeout = std::time::Duration::from_millis(self.endpoint.request_timeout_ms);
|
||||
let next_timeout_result = tokio::time::timeout(response_timeout, stream.next()).await;
|
||||
let next_result = match next_timeout_result {
|
||||
std::result::Result::Ok(result) => result,
|
||||
std::result::Result::Err(_) => {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "read_ws_json_rpc_response", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id = ?request.id, method = %method, timeout_ms = self.endpoint.request_timeout_ms, error_code = "ws_response_timeout", "WebSocket JSON-RPC response timed out");
|
||||
return std::result::Result::Err(ks_core::Error::ws(format!(
|
||||
"websocket response timed out for endpoint '{}'",
|
||||
self.endpoint.name
|
||||
)));
|
||||
},
|
||||
};
|
||||
let message = match next_result {
|
||||
std::option::Option::Some(result) => match result {
|
||||
std::result::Result::Ok(message) => message,
|
||||
std::result::Result::Err(error) => {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "read_ws_json_rpc_response", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id = ?request.id, method = %method, error = %error, "WebSocket JSON-RPC response read failed");
|
||||
return std::result::Result::Err(ks_core::Error::ws(format!(
|
||||
"websocket read failed for endpoint '{}': {error}",
|
||||
self.endpoint.name
|
||||
)));
|
||||
},
|
||||
},
|
||||
std::option::Option::None => {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "read_ws_json_rpc_response", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id = ?request.id, method = %method, error_code = "ws_closed_before_response", "WebSocket endpoint closed before response");
|
||||
return std::result::Result::Err(ks_core::Error::ws(format!(
|
||||
"websocket endpoint '{}' closed before response",
|
||||
self.endpoint.name
|
||||
)));
|
||||
},
|
||||
};
|
||||
let close_result = stream
|
||||
.send(tokio_tungstenite::tungstenite::Message::Close(std::option::Option::None))
|
||||
.await;
|
||||
if let std::result::Result::Err(error) = close_result {
|
||||
tracing::debug!(target: crate::TRACING_TARGET, action = "close_ws_endpoint", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id = ?request.id, method = %method, error = %error, "WebSocket close send failed");
|
||||
}
|
||||
return match message {
|
||||
tokio_tungstenite::tungstenite::Message::Text(text) => {
|
||||
let parse_result = crate::parse_json_rpc_text(text.as_str());
|
||||
match parse_result {
|
||||
std::result::Result::Ok(response) => {
|
||||
if let crate::JsonRpcResponse::Error(error_response) = &response {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "execute_ws_json_rpc_once", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id = ?request.id, method = %method, response_kind = response.kind_name(), rpc_error_code = error_response.error.code, rpc_error_message = %error_response.error.message, "WebSocket JSON-RPC endpoint returned an RPC error");
|
||||
} else {
|
||||
tracing::debug!(target: crate::TRACING_TARGET, action = "execute_ws_json_rpc_once", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id = ?request.id, method = %method, response_kind = response.kind_name(), "one-shot WebSocket JSON-RPC request completed");
|
||||
}
|
||||
std::result::Result::Ok(response)
|
||||
},
|
||||
std::result::Result::Err(error) => {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "parse_ws_json_rpc_response", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id = ?request.id, method = %method, response_byte_length = text.len(), error = %error, "WebSocket JSON-RPC response parsing failed");
|
||||
std::result::Result::Err(error)
|
||||
},
|
||||
}
|
||||
},
|
||||
tokio_tungstenite::tungstenite::Message::Binary(binary) => {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "read_ws_json_rpc_response", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id = ?request.id, method = %method, message_kind = "binary", response_byte_length = binary.len(), "WebSocket endpoint returned binary data before JSON response");
|
||||
std::result::Result::Err(ks_core::Error::ws(format!(
|
||||
"websocket endpoint '{}' returned binary message with {} bytes",
|
||||
self.endpoint.name,
|
||||
binary.len()
|
||||
)))
|
||||
},
|
||||
tokio_tungstenite::tungstenite::Message::Ping(_) => {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "read_ws_json_rpc_response", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id = ?request.id, method = %method, message_kind = "ping", "WebSocket endpoint returned ping before JSON response");
|
||||
std::result::Result::Err(ks_core::Error::ws(format!(
|
||||
"websocket endpoint '{}' returned ping before json response",
|
||||
self.endpoint.name
|
||||
)))
|
||||
},
|
||||
tokio_tungstenite::tungstenite::Message::Pong(_) => {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "read_ws_json_rpc_response", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id = ?request.id, method = %method, message_kind = "pong", "WebSocket endpoint returned pong before JSON response");
|
||||
std::result::Result::Err(ks_core::Error::ws(format!(
|
||||
"websocket endpoint '{}' returned pong before json response",
|
||||
self.endpoint.name
|
||||
)))
|
||||
},
|
||||
tokio_tungstenite::tungstenite::Message::Close(_) => {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "read_ws_json_rpc_response", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id = ?request.id, method = %method, message_kind = "close", "WebSocket endpoint closed before JSON response");
|
||||
std::result::Result::Err(ks_core::Error::ws(format!(
|
||||
"websocket endpoint '{}' closed before json response",
|
||||
self.endpoint.name
|
||||
)))
|
||||
},
|
||||
tokio_tungstenite::tungstenite::Message::Frame(_) => {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "read_ws_json_rpc_response", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id = ?request.id, method = %method, message_kind = "frame", "WebSocket endpoint returned raw frame before JSON response");
|
||||
std::result::Result::Err(ks_core::Error::ws(format!(
|
||||
"websocket endpoint '{}' returned raw frame before json response",
|
||||
self.endpoint.name
|
||||
)))
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn role_config(
|
||||
role: &str,
|
||||
request_kinds: std::vec::Vec<std::string::String>,
|
||||
) -> ks_config::EndpointRoleConfig {
|
||||
return ks_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) -> ks_config::WsEndpointConfig {
|
||||
return ks_config::WsEndpointConfig {
|
||||
name: "ws_a".to_string(),
|
||||
enabled,
|
||||
provider: "test".to_string(),
|
||||
cluster: "devnet".to_string(),
|
||||
url: "wss://example.invalid".to_string(),
|
||||
connect_timeout_ms: 100,
|
||||
request_timeout_ms: 100,
|
||||
unsubscribe_timeout_ms: 100,
|
||||
write_channel_capacity: 8,
|
||||
event_channel_capacity: 16,
|
||||
auto_reconnect: false,
|
||||
roles: std::vec![
|
||||
role_config("slot_notifications", std::vec!["slot_subscribe".to_string()]),
|
||||
role_config("program_subscribe", std::vec!["program_subscribe".to_string()]),
|
||||
role_config("ws_any", std::vec!["*".to_string()]),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_disabled_endpoint() {
|
||||
let result = crate::WsClient::new(endpoint(false));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_handle_matches_exact_role_and_kind() {
|
||||
let client = match crate::WsClient::new(endpoint(true)) {
|
||||
std::result::Result::Ok(client) => client,
|
||||
std::result::Result::Err(error) => panic!("client creation failed: {error}"),
|
||||
};
|
||||
assert!(client.can_handle("slot_notifications", "slot_subscribe"));
|
||||
assert!(!client.can_handle("slot_notifications", "root_subscribe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_handle_matches_wildcard_kind() {
|
||||
let client = match crate::WsClient::new(endpoint(true)) {
|
||||
std::result::Result::Ok(client) => client,
|
||||
std::result::Result::Err(error) => panic!("client creation failed: {error}"),
|
||||
};
|
||||
assert!(client.can_handle("ws_any", "logs_subscribe_mentions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_preserves_endpoint_metadata() {
|
||||
let client = match crate::WsClient::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, "ws_a");
|
||||
assert_eq!(snapshot.provider, "test");
|
||||
assert_eq!(snapshot.endpoint_url, "wss://example.invalid");
|
||||
assert_eq!(snapshot.roles.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_subscription_builders_preserve_exact_methods_and_subscription_id() {
|
||||
let client = match crate::WsClient::new(endpoint(true)) {
|
||||
std::result::Result::Ok(client) => client,
|
||||
std::result::Result::Err(error) => panic!("client creation failed: {error}"),
|
||||
};
|
||||
let subscription = match crate::standard_ws_subscription("slotSubscribe") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => panic!("slot subscription missing"),
|
||||
};
|
||||
let subscribe = client.build_standard_subscribe_request(
|
||||
subscription,
|
||||
std::vec![serde_json::json!({"commitment": "confirmed"})],
|
||||
);
|
||||
let unsubscribe = client.build_standard_unsubscribe_request(subscription, 42);
|
||||
assert_eq!(subscribe.method, "slotSubscribe");
|
||||
assert_eq!(subscribe.params.len(), 1);
|
||||
assert_eq!(unsubscribe.method, "slotUnsubscribe");
|
||||
assert_eq!(unsubscribe.params, std::vec![serde_json::Value::from(42)]);
|
||||
assert_eq!(subscribe.id, serde_json::Value::from(1));
|
||||
assert_eq!(unsubscribe.id, serde_json::Value::from(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_json_rpc_request_increments_ids() {
|
||||
let client = match crate::WsClient::new(endpoint(true)) {
|
||||
std::result::Result::Ok(client) => client,
|
||||
std::result::Result::Err(error) => panic!("client creation failed: {error}"),
|
||||
};
|
||||
let first =
|
||||
client.build_json_rpc_request("slotSubscribe".to_string(), std::vec::Vec::new());
|
||||
let second =
|
||||
client.build_json_rpc_request("rootSubscribe".to_string(), std::vec::Vec::new());
|
||||
assert_eq!(first.id, serde_json::Value::from(1));
|
||||
assert_eq!(second.id, serde_json::Value::from(2));
|
||||
}
|
||||
}
|
||||
266
ks-onchain-transport/src/ws_pool.rs
Normal file
266
ks-onchain-transport/src/ws_pool.rs
Normal file
@@ -0,0 +1,266 @@
|
||||
// file: ks-onchain-transport/src/ws_pool.rs
|
||||
// version: 7
|
||||
|
||||
//! WebSocket endpoint pool and role-based routing.
|
||||
|
||||
/// Pool of standard Solana WebSocket endpoints.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WsEndpointPool {
|
||||
clients: std::vec::Vec<crate::WsClient>,
|
||||
next_index: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
impl crate::WsEndpointPool {
|
||||
/// Builds a pool from the active profile WebSocket endpoint list.
|
||||
pub fn from_profile(profile: &ks_config::ProfileConfig) -> ks_core::Result<Self> {
|
||||
let mut clients = std::vec::Vec::new();
|
||||
for endpoint in &profile.solana.ws_endpoints {
|
||||
if !endpoint.enabled {
|
||||
continue;
|
||||
}
|
||||
let client = match crate::WsClient::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::WsEndpointPool::new(clients);
|
||||
}
|
||||
|
||||
/// Creates a pool from already constructed clients.
|
||||
pub fn new(clients: std::vec::Vec<crate::WsClient>) -> ks_core::Result<Self> {
|
||||
if clients.is_empty() {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "create_ws_pool", error_code = "ws_pool_empty", "WebSocket endpoint pool has no enabled endpoint");
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"ws endpoint pool requires at least one enabled endpoint".to_string(),
|
||||
));
|
||||
}
|
||||
tracing::debug!(target: crate::TRACING_TARGET, action = "create_ws_pool", endpoint_count = clients.len(), "WebSocket 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<crate::WsPoolClientSnapshot> {
|
||||
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,
|
||||
) -> ks_core::Result<crate::WsClient> {
|
||||
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,
|
||||
) -> ks_core::Result<crate::WsClient> {
|
||||
if self.clients.is_empty() {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "select_ws_endpoint", required_role, request_kind, error_code = "ws_pool_empty", "WebSocket endpoint pool has no clients");
|
||||
return std::result::Result::Err(ks_core::Error::not_connected(
|
||||
"ws 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_ws_endpoint", required_role, request_kind, endpoint_name = %client.endpoint_name(), provider = %client.provider(), "selected WebSocket endpoint");
|
||||
return std::result::Result::Ok(client);
|
||||
}
|
||||
offset += 1;
|
||||
}
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "select_ws_endpoint", required_role, request_kind, endpoint_count = self.clients.len(), error_code = "ws_endpoint_not_found", "no WebSocket endpoint supports requested role and kind");
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"no ws endpoint supports role '{}' and request kind '{}'",
|
||||
required_role, request_kind
|
||||
)));
|
||||
}
|
||||
|
||||
/// Selects one endpoint for an explicitly registered standard WebSocket subscription.
|
||||
pub fn select_client_for_standard_subscription(
|
||||
&self,
|
||||
required_role: &str,
|
||||
subscription: &crate::StandardWsSubscriptionSpec,
|
||||
) -> ks_core::Result<crate::WsClient> {
|
||||
return self
|
||||
.select_client_for_role_and_method(required_role, subscription.subscribe_method);
|
||||
}
|
||||
|
||||
/// Executes one short WebSocket JSON-RPC request through the selected endpoint.
|
||||
pub async fn execute_json_rpc_once_for_role(
|
||||
&self,
|
||||
required_role: &str,
|
||||
method: std::string::String,
|
||||
params: std::vec::Vec<serde_json::Value>,
|
||||
) -> ks_core::Result<crate::JsonRpcResponse> {
|
||||
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_once(method, params).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn role_config(
|
||||
role: &str,
|
||||
request_kinds: std::vec::Vec<std::string::String>,
|
||||
) -> ks_config::EndpointRoleConfig {
|
||||
return ks_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<std::string::String>,
|
||||
) -> ks_config::WsEndpointConfig {
|
||||
return ks_config::WsEndpointConfig {
|
||||
name: name.to_string(),
|
||||
enabled: true,
|
||||
provider: "test".to_string(),
|
||||
cluster: "devnet".to_string(),
|
||||
url: format!("wss://{name}.invalid"),
|
||||
connect_timeout_ms: 100,
|
||||
request_timeout_ms: 100,
|
||||
unsubscribe_timeout_ms: 100,
|
||||
write_channel_capacity: 8,
|
||||
event_channel_capacity: 16,
|
||||
auto_reconnect: false,
|
||||
roles: std::vec![role_config(role, request_kinds)],
|
||||
};
|
||||
}
|
||||
|
||||
fn client(endpoint: ks_config::WsEndpointConfig) -> crate::WsClient {
|
||||
match crate::WsClient::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::WsEndpointPool::new(std::vec::Vec::new());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_lists_every_client() {
|
||||
let pool = match crate::WsEndpointPool::new(std::vec![
|
||||
client(endpoint("a", "slot_notifications", std::vec!["slot_subscribe".to_string()])),
|
||||
client(endpoint("b", "slot_notifications", std::vec!["slot_subscribe".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::WsEndpointPool::new(std::vec![
|
||||
client(endpoint("a", "slot_notifications", std::vec!["slot_subscribe".to_string()])),
|
||||
client(endpoint("b", "slot_notifications", std::vec!["slot_subscribe".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("slot_notifications", "slotSubscribe") {
|
||||
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("slot_notifications", "slotSubscribe") {
|
||||
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::WsEndpointPool::new(std::vec![
|
||||
client(endpoint("a", "program_subscribe", std::vec!["program_subscribe".to_string()])),
|
||||
client(endpoint("b", "slot_notifications", std::vec!["slot_subscribe".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("slot_notifications", "slotSubscribe") {
|
||||
std::result::Result::Ok(client) => client,
|
||||
std::result::Result::Err(error) => panic!("selection failed: {error}"),
|
||||
};
|
||||
assert_eq!(selected.endpoint_name(), "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_subscription_selection_uses_the_subscribe_method() {
|
||||
let pool = match crate::WsEndpointPool::new(std::vec![client(endpoint(
|
||||
"a",
|
||||
"slot_notifications",
|
||||
std::vec!["slot_subscribe".to_string()],
|
||||
))]) {
|
||||
std::result::Result::Ok(pool) => pool,
|
||||
std::result::Result::Err(error) => panic!("pool creation failed: {error}"),
|
||||
};
|
||||
let subscription = match crate::standard_ws_subscription("slotSubscribe") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => panic!("slot subscription missing"),
|
||||
};
|
||||
let selected = match pool
|
||||
.select_client_for_standard_subscription("slot_notifications", subscription)
|
||||
{
|
||||
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::WsEndpointPool::new(std::vec![client(endpoint(
|
||||
"a",
|
||||
"slot_notifications",
|
||||
std::vec!["slot_subscribe".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("program_subscribe", "program_subscribe");
|
||||
assert!(selected.is_err());
|
||||
}
|
||||
}
|
||||
1672
ks-onchain-transport/src/ws_session.rs
Normal file
1672
ks-onchain-transport/src/ws_session.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user