v0.1.0-pre.025

This commit is contained in:
2026-07-25 08:13:05 +02:00
parent f5648ff138
commit f4d024680c
13 changed files with 3729 additions and 57 deletions

View File

@@ -1,8 +1,16 @@
<!-- file: CHANGELOG.md -->
<!-- version: 6 -->
<!-- version: 7 -->
# CHANGELOG
## 0.1.0-pre.025
- Migration de la couche WebSocket standard de `kb-onchain-transport`.
- Portage des requêtes et notifications typées des neuf familles dabonnements Solana.
- Portage du client WebSocket one-shot, du pool orienté rôles et des sessions persistantes avec reconnexion et restauration des abonnements.
- Conservation des 27 tests WebSocket de bot2 adaptés au nouveau nom de crate.
- `Cargo.lock` nest pas livré dans les deltas ; seules les modifications de `Cargo.toml` sont incluses.
## 0.1.0-pre.024
- Première tranche fonctionnelle de `kb-onchain-transport`.

View File

@@ -1,5 +1,5 @@
# file: Cargo.toml
# version: 2
# version: 3
[workspace]
resolver = "3"
@@ -27,7 +27,7 @@ publish = false
[workspace.dependencies]
argon2 = { version = "^0.5", features = ["std", "zeroize"] }
async-trait = { version = "^0.1", features = [] }
base64 = { version = "^0.22", features = [] }
base64 = { version = "^0.23", features = [] }
bytemuck = { version = "^1.25", features = ["derive"] }
borsh_0_10 = { package = "borsh", version = "^0.10" }
borsh = { version = "^1.7", features = ["ascii", "bson", "bytes", "default", "derive", "de_strict_order", "borsh-derive", "indexmap", "std", "rc"] }

View File

@@ -1,5 +1,5 @@
<!-- file: ROADMAP.md -->
<!-- version: 17 -->
<!-- version: 18 -->
# ROADMAP — khadhroony-bot3
@@ -15,12 +15,12 @@
- [x] Porter les modèles de `kb_model` vers `kb-lib`.
- [x] Porter les décodeurs Solana core, SPL et Metaplex déjà implémentés dans bot2.
- [x] Porter leurs matérialisateurs déjà implémentés dans bot2.
- [ ] Porter leurs exécuteurs.
- [x] Porter leurs exécuteurs.
- [x] Fusionner `kb_store_core` et `kb_store_pg` dans `kb-store`.
- [ ] Migrer `kb-onchain-transport` depuis lancienne `kb_rpc`.
- [x] Renommer structurellement la crate.
- [x] Porter les contrats JSON-RPC, rôles dendpoints, validation, clients/pools HTTP et méthodes HTTP standard.
- [ ] Porter WebSocket, sessions et pools dabonnements.
- [x] Porter WebSocket, sessions et pools dabonnements.
- [ ] Porter lacquisition canonique `getTransaction` et `getSignaturesForAddress`.
- [ ] Porter simulation, envoi et confirmation réseau complets.
- [ ] Adapter `kb-pipeline` aux nouveaux chemins publics.

View File

@@ -9,6 +9,7 @@ license.workspace = true
publish.workspace = true
[dependencies]
futures-util.workspace = true
base64.workspace = true
bs58.workspace = true
kb-config = { path = "../kb-config" }
@@ -18,6 +19,7 @@ reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tokio-tungstenite.workspace = true
tracing.workspace = true
[lints]

View File

@@ -1,5 +1,5 @@
// file: kb-onchain-transport/src/endpoint_role.rs
// version: 3
// version: 4
//! Endpoint role helpers shared by HTTP and WebSocket pools.
@@ -50,7 +50,7 @@ pub fn request_kind_from_method(method: &str) -> std::string::String {
if trimmed == "logsSubscribe" {
return "logs_subscribe_mentions".to_string();
}
return crate::endpoint_role::camel_or_pascal_to_snake(trimmed);
return camel_or_pascal_to_snake(trimmed);
}
/// Returns true when one endpoint role can handle the requested role and kind.

View File

@@ -1,5 +1,5 @@
// file: kb-onchain-transport/src/execution_rpc.rs
// version: 7
// version: 8
//! Typed Solana JSON-RPC adapters used by execution orchestration.
@@ -182,10 +182,8 @@ impl crate::GetFeeForMessageConfig {
&self,
encoded_message: &str,
) -> kb_core::Result<std::vec::Vec<serde_json::Value>> {
let validation_result = crate::execution_rpc::validate_base64_payload(
encoded_message,
"getFeeForMessage message",
);
let validation_result =
validate_base64_payload(encoded_message, "getFeeForMessage message");
if let std::result::Result::Err(error) = validation_result {
return std::result::Result::Err(error);
}
@@ -334,10 +332,8 @@ impl crate::SimulateTransactionConfig {
&self,
encoded_transaction: &str,
) -> kb_core::Result<std::vec::Vec<serde_json::Value>> {
let validation_result = crate::execution_rpc::validate_base64_payload(
encoded_transaction,
"simulateTransaction transaction",
);
let validation_result =
validate_base64_payload(encoded_transaction, "simulateTransaction transaction");
if let std::result::Result::Err(error) = validation_result {
return std::result::Result::Err(error);
}
@@ -805,10 +801,8 @@ impl crate::SendTransactionConfig {
&self,
encoded_transaction: &str,
) -> kb_core::Result<std::vec::Vec<serde_json::Value>> {
let validation_result = crate::execution_rpc::validate_base64_payload(
encoded_transaction,
"sendTransaction signed transaction",
);
let validation_result =
validate_base64_payload(encoded_transaction, "sendTransaction signed transaction");
if let std::result::Result::Err(error) = validation_result {
return std::result::Result::Err(error);
}
@@ -1290,7 +1284,7 @@ pub(crate) fn adapt_get_account_info_result_with_data_limit(
));
},
};
let data = match crate::execution_rpc::decode_account_data(value, space, max_data_bytes) {
let data = match decode_account_data(value, space, max_data_bytes) {
std::result::Result::Ok(data) => data,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
@@ -1418,11 +1412,10 @@ pub fn adapt_get_minimum_balance_for_rent_exemption_result(
pub fn adapt_request_airdrop_result(
source: &serde_json::Value,
) -> kb_core::Result<crate::AirdropResult> {
let signature =
match crate::execution_rpc::adapt_signature_result(source, "requestAirdrop result") {
std::result::Result::Ok(signature) => signature,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let signature = match adapt_signature_result(source, "requestAirdrop result") {
std::result::Result::Ok(signature) => signature,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(crate::AirdropResult { signature });
}
@@ -1438,11 +1431,10 @@ pub fn adapt_send_transaction_result(
if let std::result::Result::Err(error) = expected_validation {
return std::result::Result::Err(error);
}
let signature =
match crate::execution_rpc::adapt_signature_result(source, "sendTransaction result") {
std::result::Result::Ok(signature) => signature,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let signature = match adapt_signature_result(source, "sendTransaction result") {
std::result::Result::Ok(signature) => signature,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if signature != *expected_signature {
return std::result::Result::Err(kb_core::Error::new(
"execution_send_signature_mismatch",
@@ -1970,7 +1962,7 @@ impl crate::HttpEndpointPool {
if let std::option::Option::Some(status) = status {
last_slot = std::option::Option::Some(status.slot);
if let std::option::Option::Some(error) = &status.error {
return std::result::Result::Ok(crate::execution_rpc::confirmation_result(
return std::result::Result::Ok(confirmation_result(
cluster,
signature,
kb_lib::ExApiExecutionConfirmationStatus::Failed,
@@ -1984,10 +1976,8 @@ impl crate::HttpEndpointPool {
std::option::Option::Some(commitment) => commitment,
std::option::Option::None => crate::RpcCommitmentLevel::Processed,
};
if crate::execution_rpc::commitment_reached(observed_commitment, config.commitment)
{
let execution_status =
crate::execution_rpc::execution_confirmation_status(observed_commitment);
if commitment_reached(observed_commitment, config.commitment) {
let execution_status = execution_confirmation_status(observed_commitment);
tracing::info!(
target: crate::TRACING_TARGET,
action = "confirm_transaction",
@@ -1997,7 +1987,7 @@ impl crate::HttpEndpointPool {
confirmation_status = observed_commitment.as_str(),
"transaction reached requested commitment"
);
return std::result::Result::Ok(crate::execution_rpc::confirmation_result(
return std::result::Result::Ok(confirmation_result(
cluster,
signature,
execution_status,
@@ -2019,7 +2009,7 @@ impl crate::HttpEndpointPool {
};
last_observed_block_height = std::option::Option::Some(height.block_height);
if height.block_height > last_valid_block_height {
return std::result::Result::Ok(crate::execution_rpc::confirmation_result(
return std::result::Result::Ok(confirmation_result(
cluster,
signature,
kb_lib::ExApiExecutionConfirmationStatus::Expired,
@@ -2037,7 +2027,7 @@ impl crate::HttpEndpointPool {
tokio::time::sleep(std::time::Duration::from_millis(config.poll_interval_ms)).await;
}
}
return std::result::Result::Ok(crate::execution_rpc::confirmation_result(
return std::result::Result::Ok(confirmation_result(
cluster,
signature,
kb_lib::ExApiExecutionConfirmationStatus::TimedOut,
@@ -2150,8 +2140,7 @@ fn commitment_reached(
observed: crate::RpcCommitmentLevel,
required: crate::RpcCommitmentLevel,
) -> bool {
return crate::execution_rpc::commitment_rank(observed)
>= crate::execution_rpc::commitment_rank(required);
return commitment_rank(observed) >= commitment_rank(required);
}
fn execution_confirmation_status(
@@ -2688,11 +2677,11 @@ mod tests {
let config = crate::ConfirmTransactionConfig::confirmed(100, 500, 20)
.unwrap_or_else(|error| panic!("unexpected error: {error}"));
assert_eq!(config.last_valid_block_height, std::option::Option::Some(100));
assert!(crate::execution_rpc::commitment_reached(
assert!(super::commitment_reached(
crate::RpcCommitmentLevel::Finalized,
crate::RpcCommitmentLevel::Confirmed,
));
assert!(!crate::execution_rpc::commitment_reached(
assert!(!super::commitment_reached(
crate::RpcCommitmentLevel::Processed,
crate::RpcCommitmentLevel::Confirmed,
));

View File

@@ -1,5 +1,5 @@
// file: kb-onchain-transport/src/lib.rs
// version: 4
// version: 6
#![forbid(unsafe_code)]
#![deny(unreachable_pub)]
@@ -22,14 +22,16 @@ 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;
/// Canonical tracing target for this crate.
pub(crate) use self::constants::TRACING_TARGET;
/// Endpoint role snapshot shared by HTTP and WebSocket pools.
pub use self::endpoint_role::EndpointRoleSnapshot;
/// Converts a JSON-RPC method name into a stable request kind.
@@ -340,12 +342,106 @@ pub use self::standard_methods::StandardWsSubscriptionSpec;
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 DEVNET_GENESIS_HASH contract.
pub(crate) use self::constants::DEVNET_GENESIS_HASH;
@@ -381,6 +477,8 @@ pub(crate) use self::constants::MAX_SIMULATION_ACCOUNT_COUNT;
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.

View File

@@ -1,5 +1,5 @@
// file: kb-onchain-transport/src/standard_http_tokens.rs
// version: 2
// version: 3
//! Configurable standard SPL Token-oriented Solana HTTP JSON-RPC requests.
@@ -75,7 +75,7 @@ impl crate::StandardHttpRequest for crate::GetTokenAccountBalanceRequest {
const METHOD: &'static str = "getTokenAccountBalance";
fn params(&self) -> kb_core::Result<std::vec::Vec<serde_json::Value>> {
return crate::standard_http_tokens::pubkey_with_optional_commitment_params(
return pubkey_with_optional_commitment_params(
Self::METHOD,
&self.address,
"getTokenAccountBalance address",
@@ -101,7 +101,7 @@ impl crate::StandardHttpRequest for crate::GetTokenAccountsByDelegateRequest {
const METHOD: &'static str = "getTokenAccountsByDelegate";
fn params(&self) -> kb_core::Result<std::vec::Vec<serde_json::Value>> {
return crate::standard_http_tokens::token_accounts_query_params(
return token_accounts_query_params(
Self::METHOD,
&self.delegate,
"getTokenAccountsByDelegate delegate",
@@ -128,7 +128,7 @@ impl crate::StandardHttpRequest for crate::GetTokenAccountsByOwnerRequest {
const METHOD: &'static str = "getTokenAccountsByOwner";
fn params(&self) -> kb_core::Result<std::vec::Vec<serde_json::Value>> {
return crate::standard_http_tokens::token_accounts_query_params(
return token_accounts_query_params(
Self::METHOD,
&self.owner,
"getTokenAccountsByOwner owner",
@@ -153,7 +153,7 @@ impl crate::StandardHttpRequest for crate::GetTokenLargestAccountsRequest {
const METHOD: &'static str = "getTokenLargestAccounts";
fn params(&self) -> kb_core::Result<std::vec::Vec<serde_json::Value>> {
return crate::standard_http_tokens::pubkey_with_optional_commitment_params(
return pubkey_with_optional_commitment_params(
Self::METHOD,
&self.mint,
"getTokenLargestAccounts mint",
@@ -177,7 +177,7 @@ impl crate::StandardHttpRequest for crate::GetTokenSupplyRequest {
const METHOD: &'static str = "getTokenSupply";
fn params(&self) -> kb_core::Result<std::vec::Vec<serde_json::Value>> {
return crate::standard_http_tokens::pubkey_with_optional_commitment_params(
return pubkey_with_optional_commitment_params(
Self::METHOD,
&self.mint,
"getTokenSupply mint",

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,21 @@
// file: kb-onchain-transport/src/validation.rs
// version: 3
// version: 4
//! Shared validation helpers for Solana RPC addresses, hashes and signatures.
/// Validates one base58 Solana transaction signature.
pub fn validate_transaction_signature_text(value: &str, field_name: &str) -> kb_core::Result<()> {
return crate::validation::validate_base58_length(value, field_name, 64);
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) -> kb_core::Result<()> {
return crate::validation::validate_base58_length(value, field_name, 32);
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) -> kb_core::Result<()> {
return crate::validation::validate_base58_length(value, field_name, 32);
return validate_base58_length(value, field_name, 32);
}
fn validate_base58_length(

View File

@@ -0,0 +1,386 @@
// file: kb-onchain-transport/src/ws_client.rs
// version: 8
//! 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: kb_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: kb_config::WsEndpointConfig) -> kb_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(kb_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) -> &kb_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>,
) -> kb_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(kb_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(kb_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(kb_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(kb_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(kb_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(kb_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(kb_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(kb_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(kb_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(kb_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(kb_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>,
) -> kb_config::EndpointRoleConfig {
return kb_config::EndpointRoleConfig {
role: role.to_string(),
enabled: true,
request_kinds,
priority: 1,
requests_per_second: 10,
burst_capacity: 10,
max_concurrent_requests: 4,
max_subscriptions: 16,
pause_after_rate_limit_ms: 1500,
};
}
fn endpoint(enabled: bool) -> kb_config::WsEndpointConfig {
return kb_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: kb-onchain-transport/src/ws_pool.rs
// version: 6
//! 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: &kb_config::ProfileConfig) -> kb_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>) -> kb_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(kb_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,
) -> kb_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,
) -> kb_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(kb_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(kb_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,
) -> kb_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>,
) -> kb_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>,
) -> kb_config::EndpointRoleConfig {
return kb_config::EndpointRoleConfig {
role: role.to_string(),
enabled: true,
request_kinds,
priority: 1,
requests_per_second: 10,
burst_capacity: 10,
max_concurrent_requests: 4,
max_subscriptions: 16,
pause_after_rate_limit_ms: 1500,
};
}
fn endpoint(
name: &str,
role: &str,
request_kinds: std::vec::Vec<std::string::String>,
) -> kb_config::WsEndpointConfig {
return kb_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: kb_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