0.5.1-pre.002

This commit is contained in:
2026-08-09 19:34:08 +02:00
parent 816eee59a9
commit 6a680767ae
767 changed files with 12257 additions and 12195 deletions

View File

@@ -0,0 +1,60 @@
<!-- file: ks-onchain-transport/CHANGELOG.md -->
<!-- version: 13 -->
# CHANGELOG — ks-onchain-transport
## `0.5.1-pre.002`
- renomme `kb-onchain-transport` en `ks-onchain-transport` et `kb_onchain_transport` en `ks_onchain_transport` ;
- aligne le target racine de tracing et les consommateurs sans modifier les contrats HTTP/WS/RPC.
## `0.4.8-pre.011`
- propage le champ JSON-RPC `returnData` de `simulateTransaction` vers le résultat commun dexécution sans interprétation fournisseur.
## 0.4.8-pre.010
- documente que les limites de rôles doivent être additionnées pour respecter le quota global dun endpoint public et que le quota par méthode peut être plus strict que le quota global ;
- aligne lexemple `local_devnet` sur un pacing conservateur pour `api.devnet.solana.com` : `3 r/s` pour les lectures et `1 r/s` pour les transactions, avec bursts bornés et cooldown de `10 s` ;
- applique réellement `requests_per_second`, `burst_capacity` et `max_concurrent_requests` au rôle HTTP sélectionné avant chaque tentative réseau ;
- partage le token bucket, le sémaphore de concurrence et le cooldown distant entre tous les clones dun même endpoint ;
- conserve le retry réactif `429` comme seconde ligne de défense lorsque le quota distant est plus strict ou partagé avec dautres clients ;
- remplace les trois opérateurs `?` accidentels du parseur `Retry-After` par des branches explicites conformes aux règles du workspace ;
- expose la borne publique `MAX_COMPLETE_ACCOUNT_DATA_BYTES = 65536` pour réconcilier les pipelines stateful avec le contrat réel de `getAccountInfo` ;
- ajoute un retry borné avec backoff exponentiel sur les statuts HTTP `429` et les erreurs JSON-RPC `429` ;
- respecte `Retry-After` lorsquil contient un délai en secondes et utilise sinon `pause_after_rate_limit_ms` des rôles configurés ;
- conserve le même payload et la même transaction signée lors des retries, ce qui maintient lidempotence de `sendTransaction` ;
## 0.4.6
- alignement de la crate sur la version fonctionnelle bot3 `0.4.6` ;
- clôture des tâches de migration applicables et report explicite des évolutions ultérieures dans le TODO.
## 0.1.0-pre.074
- clôture de la tâche documentaire préalable à `0.4.6` après publication du guide transversal RPC/backfill/WebSocket ;
## 0.1.0-pre.073
- ajout du guide transversal [`docs/guides/RPC_BACKFILL_AND_WEBSOCKET.md`](../docs/guides/RPC_BACKFILL_AND_WEBSOCKET.md) ;
## 0.1.0-pre.072
- reclassement du TODO selon les blocants avant `0.4.6`, les travaux `0.4.7`, les versions ultérieures et les dépendances conditionnelles.
## 0.1.0-pre.070
- enrichissement du `USAGE.md` avec plusieurs exemples couvrant les principales familles dAPI publiques ;
- ajout du contrat documentaire de la crate ;
- description des APIs publiques HTTP, WebSocket, JSON-RPC et exécution ;
- clarification de la séparation entre transport, pipeline, décodage et stockage ;
- classement des travaux futurs de streaming et résilience dans le TODO.
## 0.1.0-pre.062
- migration et consolidation des anciennes crates RPC de bot2 ;
- renommage en `ks-onchain-transport` ;
- conservation des transports HTTP, WebSocket, acquisition canonique, simulation, soumission et confirmation ;
- adaptation aux normes Rust 2024 et Khadhroony bot3.

View File

@@ -0,0 +1,26 @@
# file: ks-onchain-transport/Cargo.toml
# version: 5
[package]
name = "ks-onchain-transport"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
futures-util.workspace = true
base64.workspace = true
bs58.workspace = true
ks-config = { path = "../ks-config" }
ks-core = { path = "../ks-core" }
ks-lib = { path = "../ks-lib" }
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tokio-tungstenite.workspace = true
tracing.workspace = true
[lints]
workspace = true

View File

@@ -0,0 +1,37 @@
<!-- file: ks-onchain-transport/README.md -->
<!-- version: 2 -->
# ks-onchain-transport
`ks-onchain-transport` fournit les transports Solana on-chain de `khadhroony-bot3`.
## Responsabilités
- requêtes JSON-RPC HTTP standard ;
- sessions et pools WebSocket ;
- routage par rôle dendpoint ;
- adaptation des réponses RPC vers les contrats canoniques ;
- simulation, soumission et confirmation de transactions ;
- acquisition de signatures et transactions pour le backfill ;
- validation bornée des paramètres et réponses réseau.
La crate ne décode pas les programmes Solana et ne persiste pas directement les données. Elle alimente `ks-pipeline`, qui orchestre lacquisition, lextraction et le replay, et utilise les contrats de `ks-core` et `ks-lib`.
## Surfaces publiques principales
- `HttpClient`, `HttpEndpointPool` ;
- `SolanaRpcClient`, `RpcEndpoint` ;
- contrats JSON-RPC ;
- méthodes HTTP standard et leurs types de configuration ;
- `WsClient`, `WsEndpointPool`, sessions et abonnements WebSocket ;
- adaptateurs `getTransaction`, `getSignaturesForAddress` et méthodes dexécution RPC.
Voir [USAGE.md](USAGE.md) pour les APIs publiques et les exemples.
## Documentation
- [USAGE.md](USAGE.md)
- [TODO.md](TODO.md)
- [CHANGELOG.md](CHANGELOG.md)
- [Architecture du pipeline](../docs/architecture/PIPELINE_ARCHITECTURE.md)
- [Carte des crates](../docs/architecture/CRATE_MAP.md)

View File

@@ -0,0 +1,12 @@
<!-- file: ks-onchain-transport/TODO.md -->
<!-- version: 5 -->
# TODO — ks-onchain-transport
## Série `0.13.x`
- [ ] WebSocket - étendre le support Helius selon les contrats retenus.
- [ ] LaserStream - auditer létat réel et définir les compléments nécessaires.
- [ ] Yellowstone - concevoir lintégration gRPC sans coupler le pipeline à un fournisseur.
- [ ] Résilience - formaliser reprise, continuité, reconnexion, backpressure et métriques.
- [ ] Tests - compléter les tests dintégration opt-in des rôles dendpoints et scénarios de reconnexion.

View File

@@ -0,0 +1,181 @@
<!-- file: ks-onchain-transport/USAGE.md -->
<!-- version: 6 -->
# Utilisation de ks-onchain-transport
## Objectif
La crate expose les contrats publics nécessaires aux communications RPC HTTP et WebSocket avec Solana.
## Prérequis
- une configuration `ks-config` contenant au moins un endpoint compatible ;
- un runtime Tokio pour les opérations asynchrones ;
- des limites et engagements adaptés à lopération demandée.
## Construire un client HTTP
```rust
fn build_http_client(
endpoint: ks_config::HttpEndpointConfig,
) -> ks_core::Result<ks_onchain_transport::HttpClient> {
let result = ks_onchain_transport::HttpClient::new(endpoint);
match result {
Ok(client) => Ok(client),
Err(error) => Err(error),
}
}
```
## Construire et interroger un pool HTTP
```rust
fn select_query_client(
profile: &ks_config::ProfileConfig,
) -> ks_core::Result<ks_onchain_transport::HttpClient> {
let pool_result = ks_onchain_transport::HttpEndpointPool::from_profile(profile);
let pool = match pool_result {
Ok(pool) => pool,
Err(error) => return Err(error),
};
pool.select_client_for_role_and_method("http_queries", "getBalance")
}
```
`HttpEndpointPool::snapshot` fournit un état sérialisable des endpoints actifs et de leurs rôles.
## Classer une méthode RPC
```rust
fn classify_method(method: &str) -> String {
ks_onchain_transport::request_kind_from_method(method)
}
assert_eq!(classify_method("getTransaction"), "transaction_read");
```
La valeur retournée sert au routage par rôle. Elle ne remplace pas le nom RPC exact utilisé sur le wire.
## Parser une réponse JSON-RPC
```rust
fn parse_response(
text: &str,
) -> ks_core::Result<ks_onchain_transport::JsonRpcResponse> {
let result = ks_onchain_transport::parse_json_rpc_text(text);
match result {
Ok(response) => Ok(response),
Err(error) => Err(error),
}
}
```
Le parseur distingue les réponses de succès, les erreurs JSON-RPC et les notifications.
## Charger un solde
```rust
async fn load_balance(
client: &ks_onchain_transport::HttpClient,
address: &str,
) -> ks_core::Result<u64> {
let result = client
.get_balance(
address,
ks_onchain_transport::GetBalanceConfig::default(),
)
.await;
match result {
Ok(balance) => Ok(balance.value),
Err(error) => Err(error),
}
}
```
## Adapter une transaction canonique
`GetTransactionAdapter` et `CanonicalTransactionAdapter` convertissent une réponse RPC standard en acquisition canonique sans imposer au pipeline le format brut du fournisseur.
```rust
fn adapt_transaction(
raw: serde_json::Value,
config: &ks_onchain_transport::GetTransactionConfig,
) -> ks_core::Result<ks_onchain_transport::GetTransactionAcquisition> {
let result = ks_onchain_transport::adapt_get_transaction_result(raw, config);
match result {
Ok(acquisition) => Ok(acquisition),
Err(error) => Err(error),
}
}
```
## Exécution RPC
Les types suivants couvrent les opérations dexécution :
- `SimulateTransactionConfig` et `SimulateTransactionResult` ;
- `SendTransactionConfig` et `SendTransactionResult` ;
- `ConfirmTransactionConfig` ;
- `GetLatestBlockhashConfig` ;
- `GetSignatureStatusesConfig`.
La soumission ne remplace pas les contrôles de sécurité, préflights et confirmations opérateur réalisés par les couches supérieures.
## Lectures complètes et limites de débit
`MAX_COMPLETE_ACCOUNT_DATA_BYTES` expose la borne commune des données décodées retournées par une lecture complète `getAccountInfo`. Les couches supérieures doivent utiliser cette constante au lieu daligner une lecture RPC sur la capacité maximale dun décodeur offline.
Le client applique dabord les limites préventives du rôle sélectionné :
- `requests_per_second` alimente un token bucket partagé ;
- `burst_capacity` borne le burst initial et les crédits accumulés ;
- `max_concurrent_requests` borne les requêtes simultanément en vol.
Ces limiteurs sont partagés entre les clones dun même endpoint. Les statuts HTTP `429` et erreurs JSON-RPC `429` restent réessayés avec un backoff borné, car un fournisseur peut appliquer un quota global, dynamique ou partagé que la configuration locale ne peut pas connaître. Le délai configuré par `pause_after_rate_limit_ms` est alors appliqué comme cooldown partagé, sauf lorsquun en-tête `Retry-After` impose une attente supérieure. Le nombre de retries reste limité et une erreur terminale conserve le nombre de tentatives.
## Erreurs et invariants
- les réponses sont validées et adaptées avant exposition aux couches supérieures ;
- les tailles, encodages et listes sont bornés par les contrats de la crate ;
- une erreur RPC distante reste distincte dune erreur de transport ou dadaptation ;
- les endpoints ne doivent pas être supposés interchangeables lorsquils ont des rôles différents.
## Tests de référence
- tests déquivalence des fixtures `getTransaction` ;
- tests legacy, v0, ALT, CPI et Token-2022 dans `tests/fixtures/` ;
- tests de `standard_methods.rs` vérifiant la matrice contractuelle RPC ;
- tests des pools HTTP/WebSocket et du routage par rôle.
## Limites durables
- la crate traite les transports on-chain ; elle ne gère pas les métadonnées HTTP, IPFS ou Arweave ;
- elle ne décode pas les instructions de programmes ;
- elle ne décide pas seule de lautorisation denvoyer une transaction.
## Limitation préventive et réponses `429`
Le client applique les limites du rôle sélectionné avant chaque tentative :
- `requests_per_second` et `burst_capacity` alimentent un token bucket partagé ;
- `max_concurrent_requests` borne les requêtes simultanées ;
- `pause_after_rate_limit_ms` fournit le cooldown de repli ;
- `Retry-After` est prioritaire lorsquil est fourni par lendpoint.
Ces limites sont propres aux rôles du client. Elles ne créent pas de quotas indépendants côté fournisseur. Lorsque plusieurs rôles utilisent le même endpoint, leur débit et leurs bursts doivent être additionnés pour rester sous la limite globale de cet endpoint.
Pour `api.devnet.solana.com`, le profil dexemple utilise volontairement :
```text
http_queries 3 r/s, burst 3, concurrence 2
http_transactions 1 r/s, burst 1, concurrence 1
cooldown 10000 ms
```
Un warning `retry_http_json_rpc_after_rate_limit` signifie que le serveur a tout de même renvoyé `429` et que la seconde ligne de défense a été activée. La requête nest perdue que si le retry borné se termine en erreur.

View 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>>;
}

View 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;

View 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);
}
}

File diff suppressed because it is too large Load Diff

View 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"));
}
}

View 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));
}
}

View 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)
);
}
}

View 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());
}
}

View 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"));
}
}

View 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;

View 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());
}
}

View 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())
);
}
}

View 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)]);
}
}

View 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));
}
}

View 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!([])]);
}
}

View 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());
}
}

View 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));
}
}

View 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)
);
}
}
}

File diff suppressed because it is too large Load Diff

View 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());
}
}

View 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));
}
}

View 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());
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
{
"slot": 104,
"blockTime": null,
"version": "legacy",
"transaction": {
"signatures": ["6psR5zDjAeh7HyLceXFQkWTJeRxjor8asYrG1AeUrrKVNXGE7DkvDqvVo98R9rPPQKHGm6TW3PBMSLrw2fHfxaX"],
"message": {
"header": {
"numRequiredSignatures": 1,
"numReadonlySignedAccounts": 0,
"numReadonlyUnsignedAccounts": 1
},
"accountKeys": [
"11111111111111111111111111111111",
"ComputeBudget111111111111111111111111111111"
],
"recentBlockhash": "11111111111111111111111111111111",
"instructions": [
{"programIdIndex": 1, "accounts": [0], "data": "8", "stackHeight": null}
],
"addressTableLookups": []
}
},
"meta": {
"err": null,
"fee": 5000,
"preBalances": [10000, 1],
"postBalances": [5000, 1],
"innerInstructions": null,
"logMessages": null,
"preTokenBalances": null,
"postTokenBalances": null,
"rewards": null,
"loadedAddresses": null,
"returnData": null,
"computeUnitsConsumed": null,
"costUnits": null
}
}

View File

@@ -0,0 +1,30 @@
{
"meta": {
"postBalances": [5000, 1],
"fee": 5000,
"status": {"Ok": null},
"err": null,
"preBalances": [10000, 1]
},
"transaction": {
"message": {
"instructions": [
{"data": "8", "accounts": [0], "programIdIndex": 1}
],
"recentBlockhash": "11111111111111111111111111111111",
"accountKeys": [
"11111111111111111111111111111111",
"ComputeBudget111111111111111111111111111111"
],
"header": {
"numReadonlyUnsignedAccounts": 1,
"numReadonlySignedAccounts": 0,
"numRequiredSignatures": 1
}
},
"signatures": ["6psR5zDjAeh7HyLceXFQkWTJeRxjor8asYrG1AeUrrKVNXGE7DkvDqvVo98R9rPPQKHGm6TW3PBMSLrw2fHfxaX"]
},
"blockTime": null,
"slot": 104,
"providerSpecificField": "ignored"
}

View File

@@ -0,0 +1,37 @@
{
"blockTime": 1700000002,
"meta": {
"err": {"InstructionError": [0, {"Custom": 6001}]},
"fee": 5000,
"innerInstructions": null,
"loadedAddresses": {"readonly": [], "writable": []},
"logMessages": ["Program 11111111111111111111111111111111 failed: custom program error: 0x1771"],
"postBalances": [5000, 1],
"postTokenBalances": [],
"preBalances": [10000, 1],
"preTokenBalances": [],
"rewards": null,
"status": {"Err": {"InstructionError": [0, {"Custom": 6001}]}}
},
"slot": 101,
"transaction": {
"message": {
"accountKeys": [
"11111111111111111111111111111111",
"ComputeBudget111111111111111111111111111111"
],
"addressTableLookups": [],
"header": {
"numReadonlySignedAccounts": 0,
"numReadonlyUnsignedAccounts": 1,
"numRequiredSignatures": 1
},
"instructions": [
{"accounts": [0], "data": "4", "programIdIndex": 1}
],
"recentBlockhash": "11111111111111111111111111111111"
},
"signatures": ["3LJnHMv3ygbULcerbSFVsk2Jo33Qv8DoyUJQh2E9UZAGYXcv7Y7KGZ3hPw5F5j9C9tBhVcYqipRnNEugKV1rnSU"]
},
"version": "legacy"
}

View File

@@ -0,0 +1,80 @@
{
"slot": 100,
"blockTime": 1700000001,
"version": "legacy",
"transaction": {
"signatures": ["2Ana1pUpv2ZbMVkwF5FXapYeBEjdxDatLn7nvJkhgTSXbs59SyZSx866bXirPgj8QQVB57uxHJBG1YFvkRbFj4T"],
"message": {
"header": {
"numRequiredSignatures": 1,
"numReadonlySignedAccounts": 0,
"numReadonlyUnsignedAccounts": 2
},
"accountKeys": [
"11111111111111111111111111111111",
"ComputeBudget111111111111111111111111111111",
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
],
"recentBlockhash": "11111111111111111111111111111111",
"instructions": [
{
"programIdIndex": 1,
"accounts": [0],
"data": "2",
"stackHeight": 1
}
],
"addressTableLookups": []
}
},
"meta": {
"err": null,
"status": {"Ok": null},
"fee": 5000,
"preBalances": [10000, 1, 1],
"postBalances": [5000, 1, 1],
"innerInstructions": [
{
"index": 0,
"instructions": [
{
"programIdIndex": 2,
"accounts": [0],
"data": "3",
"stackHeight": 2
}
]
}
],
"logMessages": [
"Program ComputeBudget111111111111111111111111111111 invoke [1]",
"Program ComputeBudget111111111111111111111111111111 success"
],
"preTokenBalances": [],
"postTokenBalances": [
{
"accountIndex": 0,
"mint": "So11111111111111111111111111111111111111112",
"owner": "11111111111111111111111111111111",
"programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
"uiTokenAmount": {
"amount": "12345678901234567890",
"decimals": 9,
"uiAmount": null,
"uiAmountString": "12345678901.23456789"
}
}
],
"rewards": [],
"loadedAddresses": {
"writable": [],
"readonly": []
},
"returnData": {
"programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
"data": ["AQI=", "base64"]
},
"computeUnitsConsumed": 1200,
"costUnits": 1300
}
}

View File

@@ -0,0 +1,28 @@
{
"slot": 103,
"blockTime": null,
"transaction": {
"signatures": ["5fMCpSnW6zfEJrShJAFSTaye2dexqwVfErfeETB34kbkRriTSfD3uQxtzjn2ToyKeqakLbpcbrvq5eDBTbs4uCW"],
"message": {
"header": {
"numRequiredSignatures": 1,
"numReadonlySignedAccounts": 0,
"numReadonlyUnsignedAccounts": 1
},
"accountKeys": [
"11111111111111111111111111111111",
"ComputeBudget111111111111111111111111111111"
],
"recentBlockhash": "11111111111111111111111111111111",
"instructions": [
{"programIdIndex": 1, "accounts": [0], "data": "7"}
]
}
},
"meta": {
"err": null,
"fee": 5000,
"preBalances": [10000, 1],
"postBalances": [5000, 1]
}
}

View File

@@ -0,0 +1,88 @@
{
"slot": 102,
"blockTime": 1700000003,
"version": 0,
"transaction": {
"signatures": ["4VpzYuMH3LdMKjYmwoFUAfVyQqMBt2rjcAV2TjhbGet1VCAgn6fBaz1JCLRdmmZFuMtDv7BjALgJiwZRtYSTqpV"],
"message": {
"header": {
"numRequiredSignatures": 1,
"numReadonlySignedAccounts": 0,
"numReadonlyUnsignedAccounts": 2
},
"accountKeys": [
"11111111111111111111111111111111",
"TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb",
"ComputeBudget111111111111111111111111111111"
],
"recentBlockhash": "11111111111111111111111111111111",
"instructions": [
{
"programIdIndex": 3,
"accounts": [0, 4],
"data": "5",
"stackHeight": 1
}
],
"addressTableLookups": [
{
"accountKey": "AddressLookupTab1e1111111111111111111111111",
"writableIndexes": [0],
"readonlyIndexes": [1]
}
]
}
},
"meta": {
"err": null,
"fee": 7000,
"preBalances": [100000, 1, 1, 1, 1],
"postBalances": [93000, 1, 1, 1, 1],
"innerInstructions": [
{
"index": 0,
"instructions": [
{
"programIdIndex": 4,
"accounts": [0, 1],
"data": "6",
"stackHeight": 2
}
]
}
],
"logMessages": ["Program log: Token-2022 CPI"],
"preTokenBalances": [
{
"accountIndex": 1,
"mint": "So11111111111111111111111111111111111111112",
"owner": "11111111111111111111111111111111",
"programId": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb",
"uiTokenAmount": {
"amount": "10",
"decimals": 0,
"uiAmountString": "10"
}
}
],
"postTokenBalances": [
{
"accountIndex": 1,
"mint": "So11111111111111111111111111111111111111112",
"owner": "11111111111111111111111111111111",
"programId": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb",
"uiTokenAmount": {
"amount": "9",
"decimals": 0,
"uiAmountString": "9"
}
}
],
"rewards": [],
"loadedAddresses": {
"writable": ["SysvarRent111111111111111111111111111111111"],
"readonly": ["SysvarC1ock11111111111111111111111111111111"]
},
"computeUnitsConsumed": 9999
}
}