v0.2.7-pre.002

This commit is contained in:
2026-08-22 16:39:13 +02:00
parent cf4b28df2b
commit b64a799c85
11 changed files with 1491 additions and 29 deletions

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml # file: Cargo.toml
# version: 192 # version: 193
[workspace] [workspace]
resolver = "3" resolver = "3"
members = ["crates/ksp-app-config-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"] members = ["crates/ksp-app-config-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"]
[workspace.package] [workspace.package]
version = "0.2.7-pre.1" version = "0.2.7-pre.2"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/constants.rs // file: crates/ksp-onchain-transport-lib/src/constants.rs
// version: 1 // version: 2
//! Transport-owned tracing constants. //! Transport-owned tracing constants.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/lib.rs // file: crates/ksp-onchain-transport-lib/src/lib.rs
// version: 21 // version: 22
#![warn(missing_docs)] #![warn(missing_docs)]
#![deny(unreachable_pub)] #![deny(unreachable_pub)]
@@ -16,6 +16,8 @@
//! modern/legacy `getTransaction` coverage. `0.2.4` completes the HTTP surface with all ten Blocks and five Economics wrappers, including complete //! modern/legacy `getTransaction` coverage. `0.2.4` completes the HTTP surface with all ten Blocks and five Economics wrappers, including complete
//! modern/legacy `getBlock`, positional inflation rewards, runtime-provided economics values and the final `KSP-TRANSPORT-007` compliance target. //! modern/legacy `getBlock`, positional inflation rewards, runtime-provided economics values and the final `KSP-TRANSPORT-007` compliance target.
//! The candidate surface therefore exposes typed wrappers for all 52 current audited Solana HTTP methods while retaining 14 removed historical descriptors. //! The candidate surface therefore exposes typed wrappers for all 52 current audited Solana HTTP methods while retaining 14 removed historical descriptors.
//! `0.2.7-pre.002` adds the provider-neutral WebSocket settings foundation, redacted endpoint URLs, explicit protocol-family discrimination, local session/subscription
//! identities, observable lifecycle states and safe snapshots. Physical sockets and subscription execution are intentionally deferred to later prereleases.
mod client; mod client;
mod constants; mod constants;
@@ -34,6 +36,8 @@ mod rpc_method;
mod rpc_tokens; mod rpc_tokens;
mod rpc_transactions; mod rpc_transactions;
mod settings; mod settings;
mod ws_lifecycle;
mod ws_settings;
/// Passive runtime availability reported for one logical HTTP endpoint. /// Passive runtime availability reported for one logical HTTP endpoint.
pub use self::client::HttpEndpointAvailability; pub use self::client::HttpEndpointAvailability;
@@ -291,6 +295,38 @@ pub use self::settings::HttpRoleLimits;
pub use self::settings::HttpRoleName; pub use self::settings::HttpRoleName;
/// Complete runtime settings consumed by the Solana HTTP transport foundation. /// Complete runtime settings consumed by the Solana HTTP transport foundation.
pub use self::settings::HttpTransportSettings; pub use self::settings::HttpTransportSettings;
/// Stable local identity assigned to one physical WebSocket session.
pub use self::ws_lifecycle::WsSessionId;
/// Safe runtime snapshot for one physical WebSocket session.
pub use self::ws_lifecycle::WsSessionSnapshot;
/// Observable lifecycle state of one physical WebSocket session.
pub use self::ws_lifecycle::WsSessionState;
/// Stable local identity assigned to one logical WebSocket subscription.
pub use self::ws_lifecycle::WsSubscriptionId;
/// Standard Solana subscription family represented by one logical WebSocket subscription.
pub use self::ws_lifecycle::WsSubscriptionKind;
/// Safe lifecycle projection for one logical WebSocket subscription.
pub use self::ws_lifecycle::WsSubscriptionSnapshot;
/// Observable lifecycle state of one logical WebSocket subscription.
pub use self::ws_lifecycle::WsSubscriptionState;
/// Open cluster or network descriptor used by WebSocket endpoint settings.
pub use self::ws_settings::WsClusterName;
/// Runtime settings for one named WebSocket endpoint.
pub use self::ws_settings::WsEndpointSettings;
/// Runtime WebSocket endpoint URL with redacted diagnostics.
pub use self::ws_settings::WsEndpointUrl;
/// WebSocket protocol family understood by KSP Transport.
pub use self::ws_settings::WsProtocolKind;
/// Open provider descriptor used by WebSocket endpoint settings.
pub use self::ws_settings::WsProviderName;
/// Bounded reconnect settings owned by the WebSocket transport runtime.
pub use self::ws_settings::WsReconnectSettings;
/// Policy controlling logical resubscription after reconnect.
pub use self::ws_settings::WsResubscribePolicy;
/// Runtime limits and lifecycle settings for one physical WebSocket session.
pub use self::ws_settings::WsSessionSettings;
/// Complete runtime settings consumed by the KSP WebSocket transport foundation.
pub use self::ws_settings::WsTransportSettings;
/// Owning tracing target for events emitted by the on-chain transport crate. /// Owning tracing target for events emitted by the on-chain transport crate.
pub(crate) use self::constants::TRACING_TARGET; pub(crate) use self::constants::TRACING_TARGET;

View File

@@ -0,0 +1,281 @@
// file: crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
// version: 1
/// Stable local identity assigned to one physical WebSocket session.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct WsSessionId(std::num::NonZeroU64);
impl WsSessionId {
/// Creates a session identity from a non-zero local value.
#[must_use]
pub const fn new(value: std::num::NonZeroU64) -> Self {
return Self(value);
}
/// Returns the stable local numeric value.
#[must_use]
pub const fn get(self) -> u64 {
return self.0.get();
}
}
/// Stable local identity assigned to one logical WebSocket subscription.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct WsSubscriptionId(std::num::NonZeroU64);
impl WsSubscriptionId {
/// Creates a subscription identity from a non-zero local value.
#[must_use]
pub const fn new(value: std::num::NonZeroU64) -> Self {
return Self(value);
}
/// Returns the stable local numeric value.
#[must_use]
pub const fn get(self) -> u64 {
return self.0.get();
}
}
/// Observable lifecycle state of one physical WebSocket session.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WsSessionState {
/// No physical connection is currently active and no connection attempt is running.
Disconnected,
/// The actor is establishing the physical connection.
Connecting,
/// The physical connection is active.
Active,
/// The actor is reconnecting after an unexpected physical disconnect.
Reconnecting {
/// One-based reconnect attempt currently in progress or waiting for backoff.
attempt: u32,
},
/// Explicit shutdown has started and new subscriptions are refused.
Closing,
/// Explicit shutdown completed.
Closed,
/// The session reached a terminal failure state.
Failed,
}
/// Observable lifecycle state of one logical WebSocket subscription.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WsSubscriptionState {
/// The local subscription exists but its initial subscribe request has not completed.
Requested,
/// The logical subscription is bound to an active remote subscription.
Active,
/// The logical subscription is being restored after reconnect.
Resubscribing,
/// Local cancellation has won and remote cleanup is in progress when possible.
Cancelling,
/// The logical subscription reached a non-error terminal state.
Closed,
/// The logical subscription reached a terminal failure state.
Failed,
}
/// Standard Solana subscription family represented by one logical WebSocket subscription.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum WsSubscriptionKind {
/// `accountSubscribe` family.
Account,
/// `blockSubscribe` family.
Block,
/// `logsSubscribe` family.
Logs,
/// `programSubscribe` family.
Program,
/// `rootSubscribe` family.
Root,
/// `signatureSubscribe` family.
Signature,
/// `slotSubscribe` family.
Slot,
/// `slotsUpdatesSubscribe` family.
SlotsUpdates,
/// `voteSubscribe` family.
Vote,
}
impl WsSubscriptionKind {
/// Returns the stable KSP descriptor for this standard subscription family.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Account => "account",
Self::Block => "block",
Self::Logs => "logs",
Self::Program => "program",
Self::Root => "root",
Self::Signature => "signature",
Self::Slot => "slot",
Self::SlotsUpdates => "slots_updates",
Self::Vote => "vote",
}
}
}
/// Safe lifecycle projection for one logical WebSocket subscription.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WsSubscriptionSnapshot {
id: crate::WsSubscriptionId,
kind: crate::WsSubscriptionKind,
state: crate::WsSubscriptionState,
remote_bound: bool,
}
impl WsSubscriptionSnapshot {
/// Creates one safe subscription lifecycle projection for Transport runtime internals.
#[must_use]
pub(crate) const fn new(id: crate::WsSubscriptionId, kind: crate::WsSubscriptionKind, state: crate::WsSubscriptionState, remote_bound: bool) -> Self {
return Self { id, kind, state, remote_bound };
}
/// Returns the stable local subscription identity.
#[must_use]
pub const fn id(&self) -> crate::WsSubscriptionId {
return self.id;
}
/// Returns the standard subscription family.
#[must_use]
pub const fn kind(&self) -> crate::WsSubscriptionKind {
return self.kind;
}
/// Returns the current logical lifecycle state.
#[must_use]
pub const fn state(&self) -> crate::WsSubscriptionState {
return self.state;
}
/// Returns whether a current remote subscription ID is bound internally.
///
/// The remote ID itself is deliberately absent because it is ephemeral across reconnects.
#[must_use]
pub const fn remote_bound(&self) -> bool {
return self.remote_bound;
}
}
/// Safe runtime snapshot for one physical WebSocket session.
///
/// The snapshot deliberately contains logical endpoint metadata and local identities only. It never stores the endpoint URL, credentials, request payloads or
/// raw notifications.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WsSessionSnapshot {
id: crate::WsSessionId,
endpoint_name: std::string::String,
provider: crate::WsProviderName,
cluster: crate::WsClusterName,
protocol: crate::WsProtocolKind,
state: crate::WsSessionState,
pending_request_count: usize,
continuity_gap_count: u64,
overflow_count: u64,
subscriptions: std::vec::Vec<crate::WsSubscriptionSnapshot>,
}
impl WsSessionSnapshot {
/// Creates one safe session projection for Transport runtime internals.
#[must_use]
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
id: crate::WsSessionId,
endpoint_name: impl std::convert::Into<std::string::String>,
provider: crate::WsProviderName,
cluster: crate::WsClusterName,
protocol: crate::WsProtocolKind,
state: crate::WsSessionState,
pending_request_count: usize,
continuity_gap_count: u64,
overflow_count: u64,
subscriptions: std::vec::Vec<crate::WsSubscriptionSnapshot>,
) -> Self {
return Self {
id,
endpoint_name: endpoint_name.into(),
provider,
cluster,
protocol,
state,
pending_request_count,
continuity_gap_count,
overflow_count,
subscriptions,
};
}
/// Returns the stable local session identity.
#[must_use]
pub const fn id(&self) -> crate::WsSessionId {
return self.id;
}
/// Returns the safe logical endpoint name.
#[must_use]
pub fn endpoint_name(&self) -> &str {
return self.endpoint_name.as_str();
}
/// Returns the provider descriptor without endpoint credentials.
#[must_use]
pub const fn provider(&self) -> &crate::WsProviderName {
return &self.provider;
}
/// Returns the cluster descriptor.
#[must_use]
pub const fn cluster(&self) -> &crate::WsClusterName {
return &self.cluster;
}
/// Returns the WebSocket protocol family.
#[must_use]
pub const fn protocol(&self) -> crate::WsProtocolKind {
return self.protocol;
}
/// Returns the current physical session lifecycle state.
#[must_use]
pub const fn state(&self) -> crate::WsSessionState {
return self.state;
}
/// Returns the number of JSON-RPC requests currently awaiting responses.
#[must_use]
pub const fn pending_request_count(&self) -> usize {
return self.pending_request_count;
}
/// Returns the number of observed physical continuity gaps for this session.
#[must_use]
pub const fn continuity_gap_count(&self) -> u64 {
return self.continuity_gap_count;
}
/// Returns the cumulative number of notification queue overflows observed by this session.
#[must_use]
pub const fn overflow_count(&self) -> u64 {
return self.overflow_count;
}
/// Returns safe lifecycle projections for logical subscriptions owned by this session.
#[must_use]
pub fn subscriptions(&self) -> &[crate::WsSubscriptionSnapshot] {
return self.subscriptions.as_slice();
}
/// Returns the number of logical subscriptions currently projected by the session.
#[must_use]
pub fn subscription_count(&self) -> usize {
return self.subscriptions.len();
}
}
#[cfg(test)]
#[path = "../unit_tests/ws_lifecycle.rs"]
mod tests;

View File

@@ -0,0 +1,544 @@
// file: crates/ksp-onchain-transport-lib/src/ws_settings.rs
// version: 2
const DEFAULT_WS_CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
const DEFAULT_WS_COMMAND_QUEUE_CAPACITY: usize = 128;
const DEFAULT_WS_COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const DEFAULT_WS_MAX_ACTIVE_SUBSCRIPTIONS: usize = 1_024;
const DEFAULT_WS_MAX_FRAME_SIZE_BYTES: usize = 16 * 1024 * 1024;
const DEFAULT_WS_MAX_MESSAGE_SIZE_BYTES: usize = 64 * 1024 * 1024;
const DEFAULT_WS_MAX_PENDING_REQUESTS: usize = 128;
const DEFAULT_WS_MAX_WRITE_BUFFER_SIZE_BYTES: usize = 1024 * 1024;
const DEFAULT_WS_NOTIFICATION_QUEUE_CAPACITY: usize = 256;
const DEFAULT_WS_RECONNECT_INITIAL_BACKOFF: std::time::Duration = std::time::Duration::from_millis(250);
const DEFAULT_WS_RECONNECT_MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(5);
const DEFAULT_WS_RECONNECT_MAX_RETRIES: u32 = 5;
/// Runtime WebSocket endpoint URL owned by Transport.
///
/// The actual URL can contain provider credentials. Its [`std::fmt::Debug`] implementation is intentionally redacted.
#[derive(Clone, Eq, PartialEq)]
pub struct WsEndpointUrl {
value: std::string::String,
}
impl WsEndpointUrl {
/// Parses and validates one WebSocket endpoint URL.
pub fn parse(value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, "validating WebSocket endpoint URL");
let value = value.into();
let parsed = match reqwest::Url::parse(value.as_str()) {
std::result::Result::Ok(parsed) => parsed,
std::result::Result::Err(error) => {
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = "ws_endpoints.url", "rejected invalid WebSocket endpoint URL");
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "WebSocket endpoint URL is invalid")
.with_context("field", "ws_endpoints.url")
.with_source(error),
);
},
};
if parsed.scheme() != "ws" && parsed.scheme() != "wss" {
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
field = "ws_endpoints.url",
scheme = parsed.scheme(),
"rejected WebSocket endpoint URL with unsupported scheme"
);
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "WebSocket endpoint URL must use ws or wss")
.with_context("field", "ws_endpoints.url")
.with_context("scheme", parsed.scheme()),
);
}
if parsed.host_str().is_none() {
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = "ws_endpoints.url", "rejected WebSocket endpoint URL without host");
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "WebSocket endpoint URL must contain a host")
.with_context("field", "ws_endpoints.url"),
);
}
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, scheme = parsed.scheme(), "validated WebSocket endpoint URL syntax");
return std::result::Result::Ok(Self { value });
}
/// Returns the sensitive runtime URL text.
///
/// Callers must not write this value to logs, generic diagnostics or snapshots.
#[must_use]
pub fn as_str(&self) -> &str {
return self.value.as_str();
}
}
impl std::fmt::Debug for WsEndpointUrl {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.write_str("WsEndpointUrl(<redacted>)");
}
}
/// Open provider descriptor used by WebSocket endpoint settings.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct WsProviderName {
value: std::string::String,
}
impl WsProviderName {
/// Creates an open provider descriptor. Validation is performed by [`WsTransportSettings::validate`].
#[must_use]
pub fn new(value: impl std::convert::Into<std::string::String>) -> Self {
return Self { value: value.into() };
}
/// Returns the provider descriptor text.
#[must_use]
pub fn as_str(&self) -> &str {
return self.value.as_str();
}
}
/// Open cluster or network descriptor used by WebSocket endpoint settings.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct WsClusterName {
value: std::string::String,
}
impl WsClusterName {
/// Creates an open cluster descriptor. Validation is performed by [`WsTransportSettings::validate`].
#[must_use]
pub fn new(value: impl std::convert::Into<std::string::String>) -> Self {
return Self { value: value.into() };
}
/// Returns the cluster descriptor text.
#[must_use]
pub fn as_str(&self) -> &str {
return self.value.as_str();
}
}
/// WebSocket protocol family understood by KSP Transport.
///
/// `0.2.7` exposes only standard Solana WebSocket. The non-exhaustive contract allows later provider-specific families without changing the common endpoint
/// container or injecting provider-only options into [`WsSessionSettings`].
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum WsProtocolKind {
/// Standard Solana JSON-RPC WebSocket PubSub.
SolanaStandard,
}
impl WsProtocolKind {
/// Returns the stable KSP descriptor for this protocol family.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::SolanaStandard => "solana_standard",
}
}
}
/// Bounded reconnect settings owned by the WebSocket transport runtime.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WsReconnectSettings {
max_retries: u32,
initial_backoff: std::time::Duration,
max_backoff: std::time::Duration,
}
impl WsReconnectSettings {
/// Creates bounded reconnect settings.
#[must_use]
pub const fn new(max_retries: u32, initial_backoff: std::time::Duration, max_backoff: std::time::Duration) -> Self {
return Self { max_retries, initial_backoff, max_backoff };
}
/// Returns the number of reconnect attempts allowed after the connection is lost.
#[must_use]
pub const fn max_retries(&self) -> u32 {
return self.max_retries;
}
/// Returns the initial reconnect backoff.
#[must_use]
pub const fn initial_backoff(&self) -> std::time::Duration {
return self.initial_backoff;
}
/// Returns the maximum reconnect backoff.
#[must_use]
pub const fn max_backoff(&self) -> std::time::Duration {
return self.max_backoff;
}
}
impl std::default::Default for WsReconnectSettings {
fn default() -> Self {
return Self::new(DEFAULT_WS_RECONNECT_MAX_RETRIES, DEFAULT_WS_RECONNECT_INITIAL_BACKOFF, DEFAULT_WS_RECONNECT_MAX_BACKOFF);
}
}
/// Policy controlling whether logical subscriptions are restored after a successful reconnect.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
pub enum WsResubscribePolicy {
/// Never restore subscriptions automatically after the physical connection is replaced.
Never,
/// Restore subscriptions that are still logically desired when reconnect completes.
#[default]
ActiveSubscriptions,
}
/// Runtime limits and lifecycle settings for one physical WebSocket session.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WsSessionSettings {
command_timeout: std::time::Duration,
close_timeout: std::time::Duration,
reconnect: crate::WsReconnectSettings,
resubscribe: crate::WsResubscribePolicy,
command_queue_capacity: usize,
notification_queue_capacity: usize,
max_active_subscriptions: usize,
max_pending_requests: usize,
max_message_size_bytes: usize,
max_frame_size_bytes: usize,
max_write_buffer_size_bytes: usize,
}
impl WsSessionSettings {
/// Creates complete runtime settings for one physical WebSocket session.
#[must_use]
#[allow(clippy::too_many_arguments)]
pub const fn new(
command_timeout: std::time::Duration,
close_timeout: std::time::Duration,
reconnect: crate::WsReconnectSettings,
resubscribe: crate::WsResubscribePolicy,
command_queue_capacity: usize,
notification_queue_capacity: usize,
max_active_subscriptions: usize,
max_pending_requests: usize,
max_message_size_bytes: usize,
max_frame_size_bytes: usize,
max_write_buffer_size_bytes: usize,
) -> Self {
return Self {
command_timeout,
close_timeout,
reconnect,
resubscribe,
command_queue_capacity,
notification_queue_capacity,
max_active_subscriptions,
max_pending_requests,
max_message_size_bytes,
max_frame_size_bytes,
max_write_buffer_size_bytes,
};
}
/// Returns the deadline applied to bounded session commands and JSON-RPC control requests.
#[must_use]
pub const fn command_timeout(&self) -> std::time::Duration {
return self.command_timeout;
}
/// Returns the total bounded close/shutdown deadline.
#[must_use]
pub const fn close_timeout(&self) -> std::time::Duration {
return self.close_timeout;
}
/// Returns the reconnect policy.
#[must_use]
pub const fn reconnect(&self) -> &crate::WsReconnectSettings {
return &self.reconnect;
}
/// Returns the resubscribe policy.
#[must_use]
pub const fn resubscribe(&self) -> crate::WsResubscribePolicy {
return self.resubscribe;
}
/// Returns the bounded session-command queue capacity.
#[must_use]
pub const fn command_queue_capacity(&self) -> usize {
return self.command_queue_capacity;
}
/// Returns the bounded notification queue capacity allocated per logical subscription.
#[must_use]
pub const fn notification_queue_capacity(&self) -> usize {
return self.notification_queue_capacity;
}
/// Returns the maximum number of logical subscriptions allowed on one physical session.
#[must_use]
pub const fn max_active_subscriptions(&self) -> usize {
return self.max_active_subscriptions;
}
/// Returns the maximum number of JSON-RPC requests allowed to await responses concurrently.
#[must_use]
pub const fn max_pending_requests(&self) -> usize {
return self.max_pending_requests;
}
/// Returns the maximum accepted complete WebSocket message size in bytes.
#[must_use]
pub const fn max_message_size_bytes(&self) -> usize {
return self.max_message_size_bytes;
}
/// Returns the maximum accepted WebSocket frame size in bytes.
#[must_use]
pub const fn max_frame_size_bytes(&self) -> usize {
return self.max_frame_size_bytes;
}
/// Returns the maximum WebSocket write-buffer size in bytes.
#[must_use]
pub const fn max_write_buffer_size_bytes(&self) -> usize {
return self.max_write_buffer_size_bytes;
}
/// Validates runtime bounds without reading Config or environment state.
pub fn validate(&self) -> ksp_core_lib::Result<()> {
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, "validating WebSocket session settings");
if self.command_timeout.is_zero() {
return ws_invalid_settings("WebSocket command timeout must be greater than zero", "ws_session.command_timeout");
}
if self.close_timeout.is_zero() {
return ws_invalid_settings("WebSocket close timeout must be greater than zero", "ws_session.close_timeout");
}
if self.reconnect.initial_backoff().is_zero() {
return ws_invalid_settings("initial WebSocket reconnect backoff must be greater than zero", "ws_session.reconnect.initial_backoff");
}
if self.reconnect.max_backoff().is_zero() {
return ws_invalid_settings("maximum WebSocket reconnect backoff must be greater than zero", "ws_session.reconnect.max_backoff");
}
if self.reconnect.max_backoff() < self.reconnect.initial_backoff() {
return ws_invalid_settings(
"maximum WebSocket reconnect backoff must not be lower than initial reconnect backoff",
"ws_session.reconnect.max_backoff",
);
}
validate_non_zero_bound(self.command_queue_capacity, "ws_session.command_queue_capacity")?;
validate_non_zero_bound(self.notification_queue_capacity, "ws_session.notification_queue_capacity")?;
validate_non_zero_bound(self.max_active_subscriptions, "ws_session.max_active_subscriptions")?;
validate_non_zero_bound(self.max_pending_requests, "ws_session.max_pending_requests")?;
validate_non_zero_bound(self.max_message_size_bytes, "ws_session.max_message_size_bytes")?;
validate_non_zero_bound(self.max_frame_size_bytes, "ws_session.max_frame_size_bytes")?;
validate_non_zero_bound(self.max_write_buffer_size_bytes, "ws_session.max_write_buffer_size_bytes")?;
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
command_queue_capacity = self.command_queue_capacity,
notification_queue_capacity = self.notification_queue_capacity,
max_active_subscriptions = self.max_active_subscriptions,
max_pending_requests = self.max_pending_requests,
max_message_size_bytes = self.max_message_size_bytes,
max_frame_size_bytes = self.max_frame_size_bytes,
max_write_buffer_size_bytes = self.max_write_buffer_size_bytes,
reconnect_max_retries = self.reconnect.max_retries(),
resubscribe = self.resubscribe.as_str(),
"validated WebSocket session settings"
);
return std::result::Result::Ok(());
}
}
impl std::default::Default for WsSessionSettings {
fn default() -> Self {
return Self::new(
DEFAULT_WS_COMMAND_TIMEOUT,
DEFAULT_WS_CLOSE_TIMEOUT,
crate::WsReconnectSettings::default(),
crate::WsResubscribePolicy::default(),
DEFAULT_WS_COMMAND_QUEUE_CAPACITY,
DEFAULT_WS_NOTIFICATION_QUEUE_CAPACITY,
DEFAULT_WS_MAX_ACTIVE_SUBSCRIPTIONS,
DEFAULT_WS_MAX_PENDING_REQUESTS,
DEFAULT_WS_MAX_MESSAGE_SIZE_BYTES,
DEFAULT_WS_MAX_FRAME_SIZE_BYTES,
DEFAULT_WS_MAX_WRITE_BUFFER_SIZE_BYTES,
);
}
}
impl WsResubscribePolicy {
/// Returns the stable KSP descriptor for this policy.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Never => "never",
Self::ActiveSubscriptions => "active_subscriptions",
}
}
}
/// Runtime settings for one named WebSocket endpoint.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WsEndpointSettings {
name: std::string::String,
enabled: bool,
provider: crate::WsProviderName,
cluster: crate::WsClusterName,
protocol: crate::WsProtocolKind,
url: crate::WsEndpointUrl,
session: crate::WsSessionSettings,
}
impl WsEndpointSettings {
/// Creates explicit settings for one logical WebSocket endpoint.
#[must_use]
pub fn new(
name: impl std::convert::Into<std::string::String>,
enabled: bool,
provider: crate::WsProviderName,
cluster: crate::WsClusterName,
protocol: crate::WsProtocolKind,
url: crate::WsEndpointUrl,
session: crate::WsSessionSettings,
) -> Self {
return Self { name: name.into(), enabled, provider, cluster, protocol, url, session };
}
/// Returns the logical endpoint name.
#[must_use]
pub fn name(&self) -> &str {
return self.name.as_str();
}
/// Returns whether this endpoint can be used to create physical sessions.
#[must_use]
pub const fn enabled(&self) -> bool {
return self.enabled;
}
/// Returns the open provider descriptor.
#[must_use]
pub const fn provider(&self) -> &crate::WsProviderName {
return &self.provider;
}
/// Returns the open cluster descriptor.
#[must_use]
pub const fn cluster(&self) -> &crate::WsClusterName {
return &self.cluster;
}
/// Returns the WebSocket protocol family.
#[must_use]
pub const fn protocol(&self) -> crate::WsProtocolKind {
return self.protocol;
}
/// Returns the sensitive WebSocket endpoint URL wrapper.
#[must_use]
pub const fn url(&self) -> &crate::WsEndpointUrl {
return &self.url;
}
/// Returns the effective settings applied to every physical session explicitly created from this endpoint.
#[must_use]
pub const fn session(&self) -> &crate::WsSessionSettings {
return &self.session;
}
}
/// Complete runtime settings consumed by the KSP WebSocket transport foundation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WsTransportSettings {
endpoints: std::vec::Vec<crate::WsEndpointSettings>,
}
impl WsTransportSettings {
/// Creates complete WebSocket transport runtime settings.
#[must_use]
pub fn new(endpoints: std::vec::Vec<crate::WsEndpointSettings>) -> Self {
return Self { endpoints };
}
/// Returns configured WebSocket endpoints in declaration order.
#[must_use]
pub fn endpoints(&self) -> &[crate::WsEndpointSettings] {
return self.endpoints.as_slice();
}
/// Validates structural runtime invariants without reading Config or environment state.
pub fn validate(&self) -> ksp_core_lib::Result<()> {
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, endpoint_count = self.endpoints.len(), "validating WebSocket transport settings");
if self.endpoints.is_empty() {
return ws_invalid_settings("at least one WebSocket endpoint must be configured", "ws_endpoints");
}
let mut enabled_endpoint_count = 0_usize;
for (endpoint_index, endpoint) in self.endpoints.iter().enumerate() {
validate_ws_endpoint(endpoint, endpoint_index)?;
if endpoint.enabled() {
enabled_endpoint_count += 1;
}
for previous in &self.endpoints[..endpoint_index] {
if previous.name() == endpoint.name() {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "WebSocket endpoint names must be unique")
.with_context("field", format!("ws_endpoints[{endpoint_index}].name"))
.with_context("endpoint_name", endpoint.name()),
);
}
}
}
if enabled_endpoint_count == 0 {
return ws_invalid_settings("at least one WebSocket endpoint must be enabled", "ws_endpoints.enabled");
}
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
endpoint_count = self.endpoints.len(),
enabled_endpoint_count,
"validated WebSocket transport settings"
);
return std::result::Result::Ok(());
}
}
fn validate_ws_endpoint(endpoint: &crate::WsEndpointSettings, endpoint_index: usize) -> ksp_core_lib::Result<()> {
validate_ws_descriptor(endpoint.name(), format!("ws_endpoints[{endpoint_index}].name").as_str())?;
validate_ws_descriptor(endpoint.provider().as_str(), format!("ws_endpoints[{endpoint_index}].provider").as_str())?;
validate_ws_descriptor(endpoint.cluster().as_str(), format!("ws_endpoints[{endpoint_index}].cluster").as_str())?;
endpoint.session().validate()?;
ksp_logging_lib::trace!(
target: crate::TRACING_TARGET,
endpoint_name = endpoint.name(),
provider = endpoint.provider().as_str(),
cluster = endpoint.cluster().as_str(),
protocol = endpoint.protocol().as_str(),
enabled = endpoint.enabled(),
"validated WebSocket endpoint settings"
);
return std::result::Result::Ok(());
}
fn validate_ws_descriptor(value: &str, field: &str) -> ksp_core_lib::Result<()> {
if value.trim().is_empty() {
return ws_invalid_settings("WebSocket transport descriptor must not be empty", field);
}
if value.trim() != value {
return ws_invalid_settings("WebSocket transport descriptor must not contain leading or trailing whitespace", field);
}
return std::result::Result::Ok(());
}
fn validate_non_zero_bound(value: usize, field: &str) -> ksp_core_lib::Result<()> {
if value == 0 {
return ws_invalid_settings("WebSocket runtime bound must be greater than zero", field);
}
return std::result::Result::Ok(());
}
fn ws_invalid_settings(message: &str, field: &str) -> ksp_core_lib::Result<()> {
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = field, reason = message, "rejected WebSocket transport settings");
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, message).with_context("field", field));
}
#[cfg(test)]
#[path = "../unit_tests/ws_settings.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs // file: crates/ksp-onchain-transport-lib/tests/public_api.rs
// version: 24 // version: 25
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract. //! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
@@ -521,3 +521,31 @@ fn public_v0_2_4_pre_009_all_52_current_typed_wrappers_and_legacy_forms_are_avai
let _get_block_legacy = ksp_onchain_transport_lib::HttpTransportPool::get_block_legacy; let _get_block_legacy = ksp_onchain_transport_lib::HttpTransportPool::get_block_legacy;
let _get_transaction_legacy = ksp_onchain_transport_lib::HttpTransportPool::get_transaction_legacy; let _get_transaction_legacy = ksp_onchain_transport_lib::HttpTransportPool::get_transaction_legacy;
} }
#[test]
fn public_v0_2_7_pre_002_websocket_settings_and_lifecycle_contracts_are_available_from_crate_root() {
let url = ksp_onchain_transport_lib::WsEndpointUrl::parse("wss://api.devnet.solana.com").expect("public WebSocket URL fixture must parse");
let session_settings = ksp_onchain_transport_lib::WsSessionSettings::default();
let endpoint = ksp_onchain_transport_lib::WsEndpointSettings::new(
"devnet_public",
true,
ksp_onchain_transport_lib::WsProviderName::new("solana-public"),
ksp_onchain_transport_lib::WsClusterName::new("devnet"),
ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard,
url,
session_settings,
);
let settings = ksp_onchain_transport_lib::WsTransportSettings::new(std::vec![endpoint]);
assert!(settings.validate().is_ok());
assert_eq!(settings.endpoints()[0].protocol(), ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard);
let session_id = ksp_onchain_transport_lib::WsSessionId::new(std::num::NonZeroU64::new(1).expect("public test ID must be non-zero"));
let subscription_id = ksp_onchain_transport_lib::WsSubscriptionId::new(std::num::NonZeroU64::new(2).expect("public test ID must be non-zero"));
assert_eq!(session_id.get(), 1);
assert_eq!(subscription_id.get(), 2);
assert_eq!(
ksp_onchain_transport_lib::WsSessionState::Reconnecting { attempt: 1 },
ksp_onchain_transport_lib::WsSessionState::Reconnecting { attempt: 1 }
);
assert_eq!(ksp_onchain_transport_lib::WsSubscriptionKind::Slot.as_str(), "slot");
assert_eq!(ksp_onchain_transport_lib::WsSubscriptionState::Requested, ksp_onchain_transport_lib::WsSubscriptionState::Requested);
}

View File

@@ -0,0 +1,70 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs
// version: 1
fn non_zero(value: u64) -> std::num::NonZeroU64 {
return std::num::NonZeroU64::new(value).expect("test ID must be non-zero");
}
#[test]
fn websocket_local_ids_preserve_ordering_and_numeric_identity() {
let first_session = crate::WsSessionId::new(non_zero(1));
let second_session = crate::WsSessionId::new(non_zero(2));
let subscription = crate::WsSubscriptionId::new(non_zero(7));
assert!(first_session < second_session);
assert_eq!(subscription.get(), 7);
}
#[test]
fn websocket_state_models_expose_concurrent_lifecycle_states() {
assert_eq!(crate::WsSessionState::Reconnecting { attempt: 3 }, crate::WsSessionState::Reconnecting { attempt: 3 });
assert_eq!(crate::WsSubscriptionState::Resubscribing, crate::WsSubscriptionState::Resubscribing);
assert_ne!(crate::WsSubscriptionState::Cancelling, crate::WsSubscriptionState::Closed);
}
#[test]
fn websocket_subscription_kinds_cover_all_nine_standard_families() {
let kinds = [
crate::WsSubscriptionKind::Account,
crate::WsSubscriptionKind::Block,
crate::WsSubscriptionKind::Logs,
crate::WsSubscriptionKind::Program,
crate::WsSubscriptionKind::Root,
crate::WsSubscriptionKind::Signature,
crate::WsSubscriptionKind::Slot,
crate::WsSubscriptionKind::SlotsUpdates,
crate::WsSubscriptionKind::Vote,
];
assert_eq!(kinds.len(), 9);
assert_eq!(kinds[7].as_str(), "slots_updates");
}
#[test]
fn websocket_snapshots_expose_safe_metadata_without_remote_ids_or_urls() {
let subscription = crate::WsSubscriptionSnapshot::new(
crate::WsSubscriptionId::new(non_zero(9)),
crate::WsSubscriptionKind::Slot,
crate::WsSubscriptionState::Active,
true,
);
let snapshot = crate::WsSessionSnapshot::new(
crate::WsSessionId::new(non_zero(3)),
"devnet_public",
crate::WsProviderName::new("solana-public"),
crate::WsClusterName::new("devnet"),
crate::WsProtocolKind::SolanaStandard,
crate::WsSessionState::Active,
2,
1,
0,
std::vec![subscription],
);
assert_eq!(snapshot.id().get(), 3);
assert_eq!(snapshot.endpoint_name(), "devnet_public");
assert_eq!(snapshot.pending_request_count(), 2);
assert_eq!(snapshot.continuity_gap_count(), 1);
assert_eq!(snapshot.subscription_count(), 1);
assert!(snapshot.subscriptions()[0].remote_bound());
let rendered = format!("{snapshot:?}");
assert!(!rendered.contains("wss://"));
assert!(!rendered.contains("remote_subscription_id"));
}

View File

@@ -0,0 +1,148 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_settings.rs
// version: 1
fn valid_endpoint(name: &str, url_text: &str) -> crate::WsEndpointSettings {
return crate::WsEndpointSettings::new(
name,
true,
crate::WsProviderName::new("solana-public"),
crate::WsClusterName::new("devnet"),
crate::WsProtocolKind::SolanaStandard,
crate::WsEndpointUrl::parse(url_text).expect("test WebSocket URL must parse"),
crate::WsSessionSettings::default(),
);
}
#[test]
fn websocket_endpoint_url_accepts_ws_and_wss() {
assert!(crate::WsEndpointUrl::parse("wss://api.devnet.solana.com").is_ok());
assert!(crate::WsEndpointUrl::parse("ws://127.0.0.1:8900").is_ok());
}
#[test]
fn websocket_endpoint_url_rejects_http_schemes() {
let result = crate::WsEndpointUrl::parse("https://api.devnet.solana.com");
let error = result.expect_err("HTTP URL must not be accepted by WebSocket settings");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
}
#[test]
fn websocket_endpoint_url_debug_redacts_secret_material() {
let url = crate::WsEndpointUrl::parse("wss://user:password@provider.invalid/path?api-key=SECRET-CANARY").expect("test URL must parse");
let rendered = format!("{url:?}");
assert_eq!(rendered, "WsEndpointUrl(<redacted>)");
assert!(!rendered.contains("SECRET-CANARY"));
assert!(!rendered.contains("provider.invalid"));
assert!(!rendered.contains("password"));
}
#[test]
fn websocket_endpoint_url_errors_do_not_echo_sensitive_url() {
let result = crate::WsEndpointUrl::parse("http://user:password@provider.invalid/path?api-key=SECRET-CANARY");
let error = result.expect_err("unsupported scheme must fail");
let rendered = format!("{error:?}");
assert!(!rendered.contains("SECRET-CANARY"));
assert!(!rendered.contains("provider.invalid"));
assert!(!rendered.contains("password"));
}
#[test]
fn websocket_protocol_kind_is_extensible_but_only_standard_is_available_now() {
assert_eq!(crate::WsProtocolKind::SolanaStandard.as_str(), "solana_standard");
}
#[test]
fn websocket_session_defaults_are_bounded_and_validate() {
let settings = crate::WsSessionSettings::default();
assert!(settings.validate().is_ok());
assert!(settings.command_queue_capacity() > 0);
assert!(settings.notification_queue_capacity() > 0);
assert!(settings.max_active_subscriptions() > 0);
assert!(settings.max_pending_requests() > 0);
assert!(settings.max_message_size_bytes() > 0);
assert!(settings.max_frame_size_bytes() > 0);
assert!(settings.max_write_buffer_size_bytes() > 0);
assert_eq!(settings.resubscribe(), crate::WsResubscribePolicy::ActiveSubscriptions);
}
#[test]
fn websocket_session_settings_reject_zero_runtime_bounds() {
let defaults = crate::WsSessionSettings::default();
let settings = crate::WsSessionSettings::new(
defaults.command_timeout(),
defaults.close_timeout(),
defaults.reconnect().clone(),
defaults.resubscribe(),
0,
defaults.notification_queue_capacity(),
defaults.max_active_subscriptions(),
defaults.max_pending_requests(),
defaults.max_message_size_bytes(),
defaults.max_frame_size_bytes(),
defaults.max_write_buffer_size_bytes(),
);
let error = settings.validate().expect_err("zero command queue capacity must fail");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
}
#[test]
fn websocket_session_settings_reject_reversed_reconnect_backoff() {
let defaults = crate::WsSessionSettings::default();
let settings = crate::WsSessionSettings::new(
defaults.command_timeout(),
defaults.close_timeout(),
crate::WsReconnectSettings::new(2, std::time::Duration::from_secs(2), std::time::Duration::from_secs(1)),
defaults.resubscribe(),
defaults.command_queue_capacity(),
defaults.notification_queue_capacity(),
defaults.max_active_subscriptions(),
defaults.max_pending_requests(),
defaults.max_message_size_bytes(),
defaults.max_frame_size_bytes(),
defaults.max_write_buffer_size_bytes(),
);
assert!(settings.validate().is_err());
}
#[test]
fn websocket_transport_settings_validate_unique_enabled_endpoints() {
let settings = crate::WsTransportSettings::new(std::vec![
valid_endpoint("devnet_primary", "wss://api.devnet.solana.com"),
valid_endpoint("devnet_secondary", "wss://example.invalid/ws"),
]);
assert!(settings.validate().is_ok());
}
#[test]
fn websocket_transport_settings_reject_duplicate_endpoint_names() {
let settings =
crate::WsTransportSettings::new(std::vec![valid_endpoint("duplicate", "wss://one.invalid/ws"), valid_endpoint("duplicate", "wss://two.invalid/ws"),]);
let error = settings.validate().expect_err("duplicate endpoint names must fail");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
}
#[test]
fn websocket_transport_settings_require_one_enabled_endpoint() {
let endpoint = crate::WsEndpointSettings::new(
"disabled",
false,
crate::WsProviderName::new("provider"),
crate::WsClusterName::new("devnet"),
crate::WsProtocolKind::SolanaStandard,
crate::WsEndpointUrl::parse("wss://provider.invalid/ws").expect("test URL must parse"),
crate::WsSessionSettings::default(),
);
let settings = crate::WsTransportSettings::new(std::vec![endpoint]);
assert!(settings.validate().is_err());
}
#[test]
fn websocket_transport_settings_debug_never_exposes_endpoint_url() {
let settings =
crate::WsTransportSettings::new(std::vec![valid_endpoint("secret_endpoint", "wss://user:password@provider.invalid/path?api-key=SECRET-CANARY",)]);
let rendered = format!("{settings:?}");
assert!(rendered.contains("WsEndpointUrl(<redacted>)"));
assert!(!rendered.contains("SECRET-CANARY"));
assert!(!rendered.contains("provider.invalid"));
assert!(!rendered.contains("password"));
}

263
deltas/0.2.7/pre.002.md Normal file
View File

@@ -0,0 +1,263 @@
<!-- file: deltas/0.2.7/pre.002.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.002` — WebSocket settings + lifecycle contracts
## 1. Objet
Cette tranche matérialise la première surface Rust WebSocket de `ksp-onchain-transport-lib` sans ouvrir encore de socket physique et sans ajouter de dépendance WebSocket externe.
Version workspace :
```text
0.2.7-pre.2
```
Livraison :
```text
0.2.7-pre.002
```
Commit attendu :
```text
v0.2.7-pre.002
```
Aucun tag prerelease.
## 2. Surface Transport ajoutée
Settings publics :
```text
WsEndpointUrl
WsProviderName
WsClusterName
WsProtocolKind
WsReconnectSettings
WsResubscribePolicy
WsSessionSettings
WsEndpointSettings
WsTransportSettings
```
Lifecycle public :
```text
WsSessionId
WsSubscriptionId
WsSessionState
WsSubscriptionState
WsSubscriptionKind
WsSessionSnapshot
WsSubscriptionSnapshot
```
`WsProtocolKind` est `#[non_exhaustive]` et ne fournit pour `0.2.7` que `SolanaStandard`. Cette forme prépare l'ajout futur d'une famille provider-specific telle que Helius Enhanced WebSocket sans ajouter de paramètres Helius dans les settings Solana standard.
Les IDs locaux reposent sur `NonZeroU64`. Aucun ID serveur WebSocket n'entre dans le contrat public de contrôle.
## 3. URL et secrets
`WsEndpointUrl` :
- accepte uniquement `ws://` et `wss://` ;
- exige un host ;
- conserve la valeur sensible uniquement pour le futur code de connexion ;
- rend `WsEndpointUrl(<redacted>)` en `Debug` ;
- ne copie pas la valeur URL dans les erreurs de validation.
`WsEndpointSettings` et `WsTransportSettings` peuvent conserver `Debug` dérivé car le sous-type URL est lui-même redacted.
Les snapshots ne contiennent jamais :
```text
URL complète
credential/query token
request body
raw notification
remote subscription id
```
## 4. Settings session bornés
Defaults initiaux Transport, explicitement policies KSP locales :
```text
command timeout 10 s
close timeout 5 s
reconnect retries 5
reconnect initial backoff 250 ms
reconnect maximum backoff 5 s
command queue 128
notification queue per sub 256
active subscriptions 1024
pending JSON-RPC requests 128
maximum message 64 MiB
maximum frame 16 MiB
maximum write buffer 1 MiB
resubscribe default ActiveSubscriptions
```
Ces valeurs ne sont pas présentées comme des limites Solana. `pre.004`/`pre.005` devront les appliquer réellement à l'actor/socket et pourront les recalibrer si les fixtures adversariales le justifient.
Validation structurelle :
- timeouts non nuls ;
- reconnect backoff non nul et ordonné ;
- capacités/limites strictement positives ;
- au moins un endpoint WS configuré et enabled ;
- noms endpoint uniques ;
- name/provider/cluster non vides et sans whitespace de bord.
## 5. Lifecycle et snapshots
États session matérialisés :
```text
Disconnected
Connecting
Active
Reconnecting { attempt }
Closing
Closed
Failed
```
États subscription matérialisés :
```text
Requested
Active
Resubscribing
Cancelling
Closed
Failed
```
`WsSubscriptionKind` couvre les neuf familles standard auditées : account, block, logs, program, root, signature, slot, slotsUpdates et vote.
`WsSessionSnapshot` expose uniquement des metadata sûres : local session ID, endpoint logical name, provider, cluster, protocol, state, pending request count, continuity gap count, overflow count et projections de subscriptions.
`WsSubscriptionSnapshot` expose local subscription ID, kind, state et `remote_bound`; l'ID distant reste interne et remappable.
Les constructeurs de snapshots sont crate-internal : les consumers ne peuvent pas fabriquer de faux états runtime.
## 6. Logging et tracing
La constante existante reste l'autorité crate-wide :
```rust
TRACING_TARGET = "ksp-onchain-transport-lib"
```
Elle est définie dans `crates/ksp-onchain-transport-lib/src/constants.rs`.
Toute nouvelle émission passe par `ksp-logging-lib` :
- `trace` pour entrée/succès de validations et metadata endpoint sûres ;
- `debug` pour settings validés, compteurs et bornes ;
- `warn` pour rejets de settings/URL ;
- aucun `error` artificiel pour une erreur de validation caller.
Aucun appel direct à `tracing` n'est ajouté. Les logs n'incluent jamais la valeur de `WsEndpointUrl`.
## 7. Tests ajoutés
Tests unitaires settings :
```text
ws/wss acceptés
HTTP rejeté
Debug URL redacted
erreur de scheme sans secret
protocol kind standard
settings defaults bornés
zero bound rejeté
reconnect backoff inversé rejeté
transport endpoints valides
endpoint names dupliqués rejetés
au moins un endpoint enabled
Debug transport sans URL/credential
```
Tests unitaires lifecycle :
```text
IDs locaux non-zéro et ordonnables
états reconnect/resubscribe/cancelling distincts
9 familles standard couvertes
snapshot sans URL ni remote subscription id
```
Un canari `tests/public_api.rs` vérifie l'accès crate-root aux nouveaux contrats.
## 8. Fichiers principaux
Nouveaux :
```text
crates/ksp-onchain-transport-lib/src/ws_settings.rs
crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_settings.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs
deltas/0.2.7/pre.002.md
```
Modifiés :
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/constants.rs
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/tests/public_api.rs
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
```
Aucune dependency externe n'est ajoutée dans cette tranche.
## 9. Validation disponible dans le sandbox
Exécuté après modifications :
```text
python3 scripts/audit_rust_workspace_rules.py
General Rust rule audit: clean
Rust export completeness audit: 0 candidate(s)
KSP workspace Rust rule audit: clean
```
Le sandbox ne fournit toujours pas `cargo`; les gates Rust compilés ne sont donc pas déclarés réussis ici.
Baseline opérateur reçue avant `pre.002` : `cargo fmt`, audit Python, `cargo check` et `cargo clippy` verts sur `0.2.7-pre.1`. `cargo test --workspace` n'échoue que sur le canari Config Desk qui compare encore la ressource packagée `0.2.6` à la version workspace `0.2.7-pre.1`; aucune régression WebSocket n'y est impliquée.
## 10. Gates opérateur avant commit
Après application du delta :
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
```
Le test workspace complet peut encore reproduire le canari de version packagée Config Desk tant que cette ressource n'est volontairement resynchronisée.
## 11. Suite
`0.2.7-pre.003` doit matérialiser :
```text
std.transport V2 HTTP + WS
backward read V1 HTTP-only
schema/fixtures V2
Config -> WsTransportSettings
ws_endpoints[].kind = solana_standard
```
La direction reste strictement `Config -> Transport`; aucun reverse dependency n'est autorisé.

View File

@@ -1,9 +1,9 @@
<!-- file: docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md --> <!-- file: docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md -->
<!-- version: 3 --> <!-- version: 4 -->
# Plan `0.2.7` — WebSocket Solana standard # Plan `0.2.7` — WebSocket Solana standard
> **Statut : actif, gate `0.2.7-pre.001`.** Cette première tranche fixe l'inventaire normatif, les frontières, le threat model, le modèle session/subscription, la stratégie de dépendances et le sizing. Elle ne matérialise pas encore le client WebSocket runtime. > **Statut : actif, `0.2.7-pre.002`.** Le gate `pre.001` est clos. `pre.002` matérialise les settings WebSocket Transport, la redaction URL, le discriminateur de protocole, les identités locales, les états lifecycle et les snapshots sûrs. Le socket physique reste différé à `pre.004`.
## 1. Objet et base vérifiée ## 1. Objet et base vérifiée
@@ -412,6 +412,40 @@ Les wrappers `WsProviderName`/`WsClusterName` sont parallèles aux `Http*` pour
`WsSessionSettings` porte les bornes runtime nécessaires : command timeout, close timeout, reconnect, resubscribe, capacités de queues, maximum de subscriptions actives, maximum de requests JSON-RPC en vol, maximum de message/frame/write buffer. Pas de rôle HTTP ni de scheduler. `WsSessionSettings` porte les bornes runtime nécessaires : command timeout, close timeout, reconnect, resubscribe, capacités de queues, maximum de subscriptions actives, maximum de requests JSON-RPC en vol, maximum de message/frame/write buffer. Pas de rôle HTTP ni de scheduler.
Checkpoint `pre.002` matérialisé :
```text
WsEndpointUrl
WsProviderName
WsClusterName
WsProtocolKind
WsReconnectSettings
WsResubscribePolicy
WsSessionSettings
WsEndpointSettings
WsTransportSettings
```
Defaults Transport initiaux, explicitement **policy KSP locale** et non limites Solana :
```text
command timeout 10 s
close timeout 5 s
reconnect retries 5
reconnect initial backoff 250 ms
reconnect maximum backoff 5 s
command queue 128
notification queue per sub 256
active subscriptions 1024
pending JSON-RPC requests 128
maximum message 64 MiB
maximum frame 16 MiB
maximum write buffer 1 MiB
resubscribe default ActiveSubscriptions
```
Les bornes message/frame/write buffer sont seulement **contractuelles** dans `pre.002`; leur raccordement à la crate WebSocket et les tests oversized restent les gates `pre.004`/`pre.005`. Elles peuvent être recalibrées avant `rel.001` si les fixtures adversariales ou les formes `blockSubscribe` démontrent qu'une autre valeur KSP est préférable.
### 9.2 Cardinalité ### 9.2 Cardinalité
Contrat : Contrat :
@@ -446,6 +480,8 @@ WsSubscriptionId # KSP local, stable malgré reconnect
remote id u64 # strictement runtime/interne, remappable remote id u64 # strictement runtime/interne, remappable
``` ```
`pre.002` matérialise les deux IDs locaux comme wrappers de `NonZeroU64`. La génération est volontairement laissée au futur actor ; aucun compteur global ni singleton n'est introduit dans cette tranche.
Une `WsSubscription<T>` typée contient l'identité locale, la projection de lifecycle et un receiver bounded de notifications typées. L'ID remote ne devient pas un identifiant métier/public de contrôle. Une `WsSubscription<T>` typée contient l'identité locale, la projection de lifecycle et un receiver bounded de notifications typées. L'ID remote ne devient pas un identifiant métier/public de contrôle.
## 10. State machines retenues ## 10. State machines retenues
@@ -680,7 +716,18 @@ remote request body complet
Snapshot public cible : état session, endpoint name/provider/cluster, counts, reconnect state, continuity gaps, subscription IDs locaux + kinds + states. L'ID serveur peut rester interne ; un booléen `remote_bound` suffit pour diagnostiquer le binding sans faire croire à sa stabilité. Snapshot public cible : état session, endpoint name/provider/cluster, counts, reconnect state, continuity gaps, subscription IDs locaux + kinds + states. L'ID serveur peut rester interne ; un booléen `remote_bound` suffit pour diagnostiquer le binding sans faire croire à sa stabilité.
Tests dédiés : URL `wss://user:pass@host/path?api-key=secret` ne doit apparaître ni dans `Debug`, ni erreurs, ni logs capturés, ni snapshots. `pre.002` matérialise `WsSessionSnapshot` et `WsSubscriptionSnapshot`. Leurs constructeurs restent crate-internal : les consumers ne peuvent pas fabriquer une fausse projection runtime. Aucun des deux types ne contient URL, credential, request body, raw notification ou remote subscription id.
Observabilité `pre.002` :
- `constants.rs` conserve le `TRACING_TARGET = "ksp-onchain-transport-lib"` crate-wide ;
- toute émission ajoutée passe par les macros `ksp-logging-lib`, jamais par `tracing` directement ;
- `trace` couvre les validations de settings et metadata sûres ;
- `debug` confirme les settings validés avec uniquement des compteurs/bornes et descriptors sûrs ;
- `warn` qualifie les rejets de settings/URL sans inclure la valeur URL ;
- aucun `error` n'est ajouté artificiellement pour une simple erreur de validation caller. Les erreurs runtime terminales seront instrumentées quand l'actor/socket existera.
Tests dédiés : URL `wss://user:pass@host/path?api-key=secret` ne doit apparaître ni dans `Debug`, ni erreurs, ni logs capturés, ni snapshots. `pre.002` couvre déjà `Debug`, erreur de validation et forme des snapshots ; la capture de logs runtime reste à compléter avec l'actor.
## 16. DTOs et losslessness ## 16. DTOs et losslessness
@@ -805,7 +852,7 @@ L'inventaire officiel n'impose que 9 familles de subscriptions, mais le lifecycl
```text ```text
pre.001 audit interne/externe + matrice 18 méthodes + bot3 + dependencies + threat model + plan/sizing pre.001 audit interne/externe + matrice 18 méthodes + bot3 + dependencies + threat model + plan/sizing
pre.002 settings WS Transport + URL redaction + IDs/states/snapshots + tests de settings pre.002 DONE — settings WS Transport + URL redaction + IDs/states/snapshots + tests de settings
pre.003 std.transport V2 HTTP+WS + backward V1 + discriminateur WS + schema/fixtures + Config -> WsTransportSettings pre.003 std.transport V2 HTTP+WS + backward V1 + discriminateur WS + schema/fixtures + Config -> WsTransportSettings
pre.004 deps tokio-tungstenite/futures-util + actor physique + handshake/read/write + pending JSON-RPC + serveur local pre.004 deps tokio-tungstenite/futures-util + actor physique + handshake/read/write + pending JSON-RPC + serveur local
pre.005 limites frame/message/request + control frames + cancellation/close/shutdown + adversarial socket tests pre.005 limites frame/message/request + control frames + cancellation/close/shutdown + adversarial socket tests

View File

@@ -1,9 +1,9 @@
<!-- file: docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md --> <!-- file: docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md -->
<!-- version: 3 --> <!-- version: 4 -->
# Validation `0.2.7` — WebSocket Solana standard # Validation `0.2.7` — WebSocket Solana standard
> **Statut : matrice initiale ouverte par `0.2.7-pre.001`.** Les colonnes de preuve seront consolidées au fil des prereleases puis fermées avant `0.2.7-rel.001`. > **Statut : matrice active, `0.2.7-pre.002`.** Le gate normatif `pre.001` est clos ; les settings, identités, états lifecycle et snapshots sûrs sont maintenant matérialisés. Les preuves runtime socket/subscription restent ouvertes.
## 1. Baseline normative ## 1. Baseline normative
@@ -195,8 +195,8 @@ Les votes observés sont gossip/pre-consensus ; aucune garantie d'entrée dans l
| plusieurs subs / session | registry actor par session | `pre.006` | | plusieurs subs / session | registry actor par session | `pre.006` |
| ID public subscription | local KSP stable | `pre.006` | | ID public subscription | local KSP stable | `pre.006` |
| ID serveur | éphémère interne et remappé | `pre.006`/`pre.007` | | ID serveur | éphémère interne et remappé | `pre.006`/`pre.007` |
| session states | Disconnected/Connecting/Active/Reconnecting/Closing/Closed/Failed | `pre.002` | | session states | Disconnected/Connecting/Active/Reconnecting/Closing/Closed/Failed | **Done `pre.002`** |
| subscription states | Requested/Active/Resubscribing/Cancelling/Closed/Failed | `pre.002` | | subscription states | Requested/Active/Resubscribing/Cancelling/Closed/Failed | **Done `pre.002`** |
| reconnect | physique uniquement, budget/backoff finis | `pre.007` | | reconnect | physique uniquement, budget/backoff finis | `pre.007` |
| resubscribe | policy `Never` ou `ActiveSubscriptions`, ordre local déterministe | `pre.007` | | resubscribe | policy `Never` ou `ActiveSubscriptions`, ordre local déterministe | `pre.007` |
| continuity | gap observable, aucune promesse lossless | `pre.007` | | continuity | gap observable, aucune promesse lossless | `pre.007` |
@@ -206,22 +206,22 @@ Les votes observés sont gossip/pre-consensus ; aucune garantie d'entrée dans l
## 7. Threat/security compliance initiale ## 7. Threat/security compliance initiale
| Invariant | Preuve attendue | Statut | | Invariant | Preuve attendue | Statut |
|---------------------------------------------------|-----------------------------------|---------| |---------------------------------------------------|---------------------------------------------|--------------------|
| URL/credentials absents de `Debug` | unit tests URL wrapper | Planned | | URL/credentials absents de `Debug` | unit tests URL wrapper | **Done `pre.002`** |
| URL/credentials absents des erreurs | adversarial connection errors | Planned | | URL/credentials absents des erreurs | validation URL + future connection errors | Partial `pre.002` |
| URL/credentials absents des logs | writer/capture KSP logging | Planned | | URL/credentials absents des logs | safe fields `pre.002`, capture actor future | Partial `pre.002` |
| snapshots sans URL/raw payload | public API canary | Planned | | snapshots sans URL/raw payload | unit shape + public contract | **Done `pre.002`** |
| frame/message finis | oversized server fixture | Planned | | frame/message finis | settings bornés, enforcement socket futur | Partial `pre.002` |
| JSON borné indirectement par message | oversized + malformed fixture | Planned | | JSON borné indirectement par message | oversized + malformed fixture | Planned |
| queues notifications bornées | slow consumer fixture | Planned | | queues notifications bornées | capacité settings, channel futur | Partial `pre.002` |
| pending RPC borné + timeout | no-response fixture | Planned | | pending RPC borné + timeout | settings bornés, runtime futur | Partial `pre.002` |
| reconnect loop bornée | repeated disconnect fixture | Planned | | reconnect loop bornée | repeated disconnect fixture | Planned |
| unsubscribe pendant reconnect ne resubscribe pas | race fixture | Planned | | unsubscribe pendant reconnect ne resubscribe pas | race fixture | Planned |
| signature terminale ne resubscribe pas | terminal fixture | Planned | | signature terminale ne resubscribe pas | terminal fixture | Planned |
| shutdown ne bloque pas | peer hostile/no close ack fixture | Planned | | shutdown ne bloque pas | peer hostile/no close ack fixture | Planned |
| no Store/Program/Wallet/Config dep dans Transport | cargo tree + source canary | Planned | | no Store/Program/Wallet/Config dep dans Transport | cargo tree + source canary | Planned |
| no direct `tracing` dans Transport | workspace audit/canary | Planned | | no direct `tracing` dans Transport | workspace audit + source audit | **Done `pre.002`** |
## 8. Dependency compliance initiale ## 8. Dependency compliance initiale
@@ -268,6 +268,51 @@ Le type Transport correspondant au discriminateur est prévu `#[non_exhaustive]`
La schema V1 n'est pas assouplie. Une schema V2 explicite remplace la fixture standard au moment où l'adapter est matérialisé. La schema V1 n'est pas assouplie. Une schema V2 explicite remplace la fixture standard au moment où l'adapter est matérialisé.
## 9.1 Checkpoint settings/lifecycle `pre.002`
Surface publique matérialisée :
```text
WsEndpointUrl
WsProviderName
WsClusterName
WsProtocolKind::SolanaStandard
WsReconnectSettings
WsResubscribePolicy::{Never, ActiveSubscriptions}
WsSessionSettings
WsEndpointSettings
WsTransportSettings
WsSessionId
WsSubscriptionId
WsSessionState
WsSubscriptionState
WsSubscriptionKind
WsSessionSnapshot
WsSubscriptionSnapshot
```
Gates déterministes ajoutés :
```text
ws:// et wss:// acceptés
HTTP rejeté par WsEndpointUrl
Debug URL redacted
erreur de scheme sans URL/credential
settings session default bornés
zero runtime bound rejeté
reconnect backoff inversé rejeté
endpoint names uniques
au moins un endpoint enabled
Debug WsTransportSettings sans URL/credential
9 familles standard WsSubscriptionKind
snapshots sans URL ni remote subscription id
public API canary crate-root
```
Logging : `TRACING_TARGET` reste défini dans `constants.rs`; les nouveaux événements utilisent exclusivement `ksp_logging_lib::trace!`, `debug!` et `warn!` avec des fields sûrs. Aucun `tracing` direct n'est ajouté.
Les defaults de taille/queue sont des **policies KSP locales**, pas des limites Solana. Leur enforcement réel et leurs tests oversized/slow-consumer restent attendus dans les tranches socket/backpressure.
## 10. Validation du gate `pre.001` ## 10. Validation du gate `pre.001`
Exécuté dans le sandbox : Exécuté dans le sandbox :