diff --git a/Cargo.toml b/Cargo.toml index 5e0b054..74da8be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 207 +# version: 208 [workspace] 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"] [workspace.package] -version = "0.2.7-pre.9.fix.2" +version = "0.2.7-pre.10" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-onchain-transport-lib/README.md b/crates/ksp-onchain-transport-lib/README.md index cdf69af..369cf3e 100644 --- a/crates/ksp-onchain-transport-lib/README.md +++ b/crates/ksp-onchain-transport-lib/README.md @@ -1,5 +1,5 @@ - + # `ksp-onchain-transport-lib` @@ -164,6 +164,22 @@ logs_subscribe -> WsSubscription> L'unsubscribe de ces trois familles passe toujours par `WsSubscription::unsubscribe()`: le caller ne voit ni ne fournit l'ID serveur. Les paramètres initiaux restent conservés par l'actor pour le resubscribe déterministe acquis en `pre.007`, et toutes les règles de backpressure/terminal error acquises en `pre.008` s'appliquent sans branche spéciale aux nouveaux DTOs publics. +### Wrappers stables lot B `0.2.7-pre.010` + +Le second lot stable complète les subscriptions standard non instables : + +```text +signature_subscribe -> WsSubscription> +slot_subscribe -> WsSubscription +root_subscribe -> WsSubscription +``` + +`SolanaSignatureSubscribeConfig` conserve séparément `commitment` et `enableReceivedNotification`, y compris la différence entre option omise et booléen explicitement faux. `SolanaSignatureNotification` représente les deux formes wire : `ReceivedSignature` pour l'événement précoce optionnel et `Processed { err }` pour la notification terminale. Après livraison de `Processed`, l'actor ferme localement la subscription avec `terminal_error_code() == None`, retire son binding et ne la remet jamais dans le set de resubscribe, conformément au caractère one-shot du serveur Solana. + +Une cancellation effectuée avant cette terminaison continue d'utiliser `WsSubscription::unsubscribe()` et émet `signatureUnsubscribe` avec le remote ID détenu uniquement par l'actor. Après la notification terminale, `unsubscribe()` devient local-only et retourne `false`, puisque la subscription est déjà fermée côté serveur et côté KSP. + +`slot_subscribe()` et `root_subscribe()` n'acceptent aucun paramètre. `SolanaSlotNotification` conserve exactement `slot`, `parent` et `root`; `root_subscribe()` délivre directement le root `u64`. Ces deux subscriptions restent continues et utilisent donc le reconnect/resubscribe standard de `pre.007`. + ## Résilience L'admission est calculée par couple endpoint/rôle. Le pool applique : diff --git a/crates/ksp-onchain-transport-lib/USAGE.md b/crates/ksp-onchain-transport-lib/USAGE.md index 37ccd6f..7df59fa 100644 --- a/crates/ksp-onchain-transport-lib/USAGE.md +++ b/crates/ksp-onchain-transport-lib/USAGE.md @@ -1,5 +1,5 @@ - + # Utilisation de `ksp-onchain-transport-lib` @@ -126,6 +126,34 @@ La même session expose `program_subscribe()` avec `SolanaProgramSubscribeConfig Les trois wrappers retournent le même handle `WsSubscription` : reconnect, resubscribe, overflow, cause terminale et unsubscribe restent donc uniformes. Aucun wrapper public n'accepte un nom de méthode JSON-RPC arbitraire ni un remote subscription ID. +### Lot stable B : signature, slot et root + +Depuis `0.2.7-pre.010`, la session expose également : + +```rust +let signature_config = ksp_onchain_transport_lib::SolanaSignatureSubscribeConfig::new( + Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized), + Some(true), +); +let mut signature_subscription = match session.signature_subscribe("", Some(&signature_config)).await { + Ok(value) => value, + Err(error) => return Err(error), +}; +while let Some(notification) = signature_subscription.recv().await { + let notification = match notification { + Ok(value) => value, + Err(error) => return Err(error), + }; + if notification.value().is_terminal() { + break; + } +} +``` + +Avec `enableReceivedNotification = true`, `ReceivedSignature` peut arriver avant la variante terminale `Processed { err }`. La variante terminale ferme automatiquement le handle KSP, sans `signatureUnsubscribe` supplémentaire et sans resubscribe lors d'une reconnexion ultérieure. Une cancellation explicite avant cette notification terminale reste possible via `unsubscribe().await`. + +`slot_subscribe().await` retourne un `WsSubscription` dont les getters exposent `slot`, `parent` et `root`. `root_subscribe().await` retourne un `WsSubscription`. Ces deux méthodes n'acceptent aucune configuration ni aucun paramètre RPC. + ### Reconnect automatique borné Depuis `0.2.7-pre.007`, les settings de session contrôlent réellement le reconnect physique. Une perte de socket publie `Reconnecting { attempt }`, invalide les remote IDs et incrémente `continuity_gap_count`. Avec la policy par défaut `ActiveSubscriptions`, les handles logiques gardent leur `WsSubscriptionId` et passent temporairement en `Resubscribing`; l'actor recrée leurs subscriptions dans l'ordre local avant de republier `Active`. @@ -150,7 +178,7 @@ Le consumer doit traiter `overflow_count` et `continuity_gap_count` comme deux s ```rust let session = ksp_onchain_transport_lib::WsSession::connect(endpoint).await?; -// ... account_subscribe/program_subscribe/logs_subscribe puis recv()/unsubscribe() ... +// ... wrappers standard puis recv()/unsubscribe() ... session.close().await?; ``` diff --git a/crates/ksp-onchain-transport-lib/src/lib.rs b/crates/ksp-onchain-transport-lib/src/lib.rs index c0e56a9..7eac269 100644 --- a/crates/ksp-onchain-transport-lib/src/lib.rs +++ b/crates/ksp-onchain-transport-lib/src/lib.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/src/lib.rs -// version: 26 +// version: 27 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -44,6 +44,7 @@ mod rpc_tokens; mod rpc_transactions; mod settings; mod ws_accounts; +mod ws_cluster; mod ws_lifecycle; mod ws_session; mod ws_settings; @@ -320,6 +321,8 @@ pub use self::ws_accounts::SolanaAccountSubscribeConfig; pub use self::ws_accounts::SolanaProgramNotification; /// Configuration accepted by the standard Solana `programSubscribe` WebSocket method. pub use self::ws_accounts::SolanaProgramSubscribeConfig; +/// Slot relationship reported by the standard Solana `slotNotification` WebSocket method. +pub use self::ws_cluster::SolanaSlotNotification; /// Stable local identity assigned to one physical WebSocket session. pub use self::ws_lifecycle::WsSessionId; /// Safe runtime snapshot for one physical WebSocket session. @@ -360,6 +363,10 @@ pub use self::ws_subscription::WsSubscription; pub use self::ws_transactions::SolanaLogsNotification; /// Filter accepted by the standard Solana `logsSubscribe` WebSocket method. pub use self::ws_transactions::SolanaLogsSubscribeFilter; +/// Typed value carried by standard Solana `signatureNotification` messages. +pub use self::ws_transactions::SolanaSignatureNotification; +/// Optional configuration accepted by standard Solana `signatureSubscribe`. +pub use self::ws_transactions::SolanaSignatureSubscribeConfig; /// Owning tracing target for events emitted by the on-chain transport crate. pub(crate) use self::constants::TRACING_TARGET; @@ -387,3 +394,5 @@ pub(crate) use self::ws_subscription::WsSubscriptionRegistration; pub(crate) use self::ws_subscription::WsSubscriptionRuntime; /// Crate-internal constructor for bounded typed notification channels. pub(crate) use self::ws_subscription::typed_notification_channel; +/// Crate-internal constructor for bounded typed notification channels with terminal-value classification. +pub(crate) use self::ws_subscription::typed_notification_channel_with_completion; diff --git a/crates/ksp-onchain-transport-lib/src/ws_cluster.rs b/crates/ksp-onchain-transport-lib/src/ws_cluster.rs new file mode 100644 index 0000000..839031c --- /dev/null +++ b/crates/ksp-onchain-transport-lib/src/ws_cluster.rs @@ -0,0 +1,70 @@ +// file: crates/ksp-onchain-transport-lib/src/ws_cluster.rs +// version: 1 + +/// Slot relationship reported by the standard Solana `slotNotification` WebSocket method. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SolanaSlotNotification { + slot: u64, + parent: u64, + root: u64, +} + +impl SolanaSlotNotification { + /// Returns the newly processed slot. + #[must_use] + pub const fn slot(&self) -> u64 { + return self.slot; + } + + /// Returns the parent slot reported by the validator. + #[must_use] + pub const fn parent(&self) -> u64 { + return self.parent; + } + + /// Returns the current root slot reported alongside this slot update. + #[must_use] + pub const fn root(&self) -> u64 { + return self.root; + } +} + +impl crate::WsSession { + /// Subscribes to standard Solana slot-processing notifications through `slotSubscribe`. + pub async fn slot_subscribe(&self) -> ksp_core_lib::Result> { + return self + .subscribe_typed(crate::WsSubscriptionKind::Slot, std::vec::Vec::new(), |value| { + return decode_slot_notification("slotSubscribe", value); + }) + .await; + } + + /// Subscribes to standard Solana root-slot notifications through `rootSubscribe`. + pub async fn root_subscribe(&self) -> ksp_core_lib::Result> { + return self + .subscribe_typed(crate::WsSubscriptionKind::Root, std::vec::Vec::new(), |value| { + return crate::decode_wire_json::("rootSubscribe", value); + }) + .await; + } +} + +#[derive(serde::Deserialize)] +struct WireSlotNotification { + slot: u64, + parent: u64, + root: u64, +} + +fn decode_slot_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result { + let decoded = crate::decode_wire_json::(method, value); + let wire = match decoded { + std::result::Result::Ok(wire) => wire, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(crate::SolanaSlotNotification { slot: wire.slot, parent: wire.parent, root: wire.root }); +} + +#[cfg(test)] +#[path = "../unit_tests/ws_cluster.rs"] +mod tests; diff --git a/crates/ksp-onchain-transport-lib/src/ws_session.rs b/crates/ksp-onchain-transport-lib/src/ws_session.rs index 074ef2c..adbf891 100644 --- a/crates/ksp-onchain-transport-lib/src/ws_session.rs +++ b/crates/ksp-onchain-transport-lib/src/ws_session.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/src/ws_session.rs -// version: 10 +// version: 11 use futures_util::SinkExt; // rust-rules: trait-import use futures_util::StreamExt; // rust-rules: trait-import @@ -167,11 +167,27 @@ impl WsSession { where T: std::marker::Send + 'static, F: Fn(serde_json::Value) -> ksp_core_lib::Result + std::marker::Send + std::marker::Sync + 'static, + { + return self.subscribe_typed_with_completion(kind, params, decoder, |_| return false).await; + } + + /// Creates one crate-internal typed subscription whose decoder can identify a delivered terminal notification. + pub(crate) async fn subscribe_typed_with_completion( + &self, + kind: crate::WsSubscriptionKind, + params: std::vec::Vec, + decoder: F, + is_terminal: C, + ) -> ksp_core_lib::Result> + where + T: std::marker::Send + 'static, + F: Fn(serde_json::Value) -> ksp_core_lib::Result + std::marker::Send + std::marker::Sync + 'static, + C: Fn(&T) -> bool + std::marker::Send + std::marker::Sync + 'static, { if self.state() != crate::WsSessionState::Active { return std::result::Result::Err(ws_session_closed_error(self.id, "WebSocket session is not active")); } - let (dispatcher, notification_rx) = crate::typed_notification_channel(self.notification_queue_capacity, decoder); + let (dispatcher, notification_rx) = crate::typed_notification_channel_with_completion(self.notification_queue_capacity, decoder, is_terminal); let (response_tx, response_rx) = tokio::sync::oneshot::channel(); let command = WsSessionCommand::Subscribe { kind, params, dispatcher, response_tx }; let send_wait = tokio::time::timeout(self.command_timeout, self.command_tx.send(command)).await; @@ -1901,6 +1917,17 @@ fn handle_subscription_notification( let dispatch = (runtime.dispatcher)(result); return match dispatch { crate::WsNotificationDispatchOutcome::Delivered => WsActorIoOutcome::Continue, + crate::WsNotificationDispatchOutcome::DeliveredTerminal => { + close_local_subscription(subscriptions, remote_to_local, local_id, crate::WsSubscriptionState::Closed); + ksp_logging_lib::debug!( + target: crate::TRACING_TARGET, + session_id = id.get(), + subscription_id = local_id.get(), + subscription_kind = subscription_kind.as_str(), + "logical WebSocket subscription observed its server-terminal notification" + ); + WsActorIoOutcome::Continue + }, crate::WsNotificationDispatchOutcome::ReceiverClosed => { close_local_subscription(subscriptions, remote_to_local, local_id, crate::WsSubscriptionState::Closed); ksp_logging_lib::debug!( diff --git a/crates/ksp-onchain-transport-lib/src/ws_subscription.rs b/crates/ksp-onchain-transport-lib/src/ws_subscription.rs index 5101569..96c4644 100644 --- a/crates/ksp-onchain-transport-lib/src/ws_subscription.rs +++ b/crates/ksp-onchain-transport-lib/src/ws_subscription.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/src/ws_subscription.rs -// version: 3 +// version: 4 /// Typed handle for one logical Solana WebSocket subscription. /// @@ -124,6 +124,7 @@ impl std::fmt::Debug for WsSubscription { /// Internal result of dispatching one decoded wire notification into a bounded typed channel. pub(crate) enum WsNotificationDispatchOutcome { Delivered, + DeliveredTerminal, ReceiverClosed, QueueFull, DecodeFailed { code: ksp_core_lib::ErrorCode }, @@ -137,6 +138,20 @@ pub(crate) fn typed_notification_channel(capacity: usize, decoder: F) -> ( where T: std::marker::Send + 'static, F: Fn(serde_json::Value) -> ksp_core_lib::Result + std::marker::Send + std::marker::Sync + 'static, +{ + return typed_notification_channel_with_completion(capacity, decoder, |_| return false); +} + +/// Creates one bounded typed notification receiver whose dispatcher can mark a successfully delivered value as terminal. +pub(crate) fn typed_notification_channel_with_completion( + capacity: usize, + decoder: F, + is_terminal: C, +) -> (WsNotificationDispatcher, tokio::sync::mpsc::Receiver>) +where + T: std::marker::Send + 'static, + F: Fn(serde_json::Value) -> ksp_core_lib::Result + std::marker::Send + std::marker::Sync + 'static, + C: Fn(&T) -> bool + std::marker::Send + std::marker::Sync + 'static, { let (notification_tx, notification_rx) = tokio::sync::mpsc::channel(capacity); let dispatcher = move |value: serde_json::Value| -> WsNotificationDispatchOutcome { @@ -151,7 +166,11 @@ where }; return match decoder(value) { std::result::Result::Ok(notification) => { + let terminal = is_terminal(¬ification); permit.send(std::result::Result::Ok(notification)); + if terminal { + return WsNotificationDispatchOutcome::DeliveredTerminal; + } WsNotificationDispatchOutcome::Delivered }, std::result::Result::Err(error) => { diff --git a/crates/ksp-onchain-transport-lib/src/ws_transactions.rs b/crates/ksp-onchain-transport-lib/src/ws_transactions.rs index a0fc2d4..d7d4fb3 100644 --- a/crates/ksp-onchain-transport-lib/src/ws_transactions.rs +++ b/crates/ksp-onchain-transport-lib/src/ws_transactions.rs @@ -1,5 +1,79 @@ // file: crates/ksp-onchain-transport-lib/src/ws_transactions.rs -// version: 2 +// version: 3 + +/// Optional configuration accepted by standard Solana `signatureSubscribe`. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct SolanaSignatureSubscribeConfig { + commitment: std::option::Option, + enable_received_notification: std::option::Option, +} + +impl SolanaSignatureSubscribeConfig { + /// Creates an explicit signature-subscription configuration. + #[must_use] + pub const fn new(commitment: std::option::Option, enable_received_notification: std::option::Option) -> Self { + return Self { commitment, enable_received_notification }; + } + + /// Returns the optional commitment level. + #[must_use] + pub const fn commitment(&self) -> std::option::Option { + return self.commitment; + } + + /// Returns whether the server should emit the early `receivedSignature` notification when explicitly configured. + #[must_use] + pub const fn enable_received_notification(&self) -> std::option::Option { + return self.enable_received_notification; + } + + fn is_empty(&self) -> bool { + return self.commitment.is_none() && self.enable_received_notification.is_none(); + } + + fn to_json_value(self) -> serde_json::Value { + let mut object = serde_json::Map::new(); + if let std::option::Option::Some(commitment) = self.commitment { + object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned())); + } + if let std::option::Option::Some(enable_received_notification) = self.enable_received_notification { + object.insert("enableReceivedNotification".to_owned(), serde_json::Value::Bool(enable_received_notification)); + } + return serde_json::Value::Object(object); + } +} + +/// Typed value carried by standard Solana `signatureNotification` messages. +#[derive(Clone, Debug, PartialEq)] +pub enum SolanaSignatureNotification { + /// Early notification emitted when the RPC node first receives the signature and `enableReceivedNotification` is enabled. + ReceivedSignature, + /// Terminal processing notification emitted when the configured commitment is reached. + Processed { + /// Nullable transaction-error wire value; `None` means the transaction succeeded at the requested commitment. + err: std::option::Option, + }, +} + +impl SolanaSignatureNotification { + /// Returns whether this notification terminates the server-side one-shot subscription. + #[must_use] + pub const fn is_terminal(&self) -> bool { + return match self { + Self::ReceivedSignature => false, + Self::Processed { .. } => true, + }; + } + + /// Returns the transaction-error wire value for a terminal processing notification when present. + #[must_use] + pub const fn err(&self) -> std::option::Option<&serde_json::Value> { + return match self { + Self::ReceivedSignature | Self::Processed { err: std::option::Option::None } => std::option::Option::None, + Self::Processed { err: std::option::Option::Some(err) } => std::option::Option::Some(err), + }; + } +} /// Filter accepted by the standard Solana `logsSubscribe` WebSocket method. #[derive(Clone, Debug, Eq, PartialEq)] @@ -51,6 +125,31 @@ impl SolanaLogsNotification { } impl crate::WsSession { + /// Subscribes to one Solana transaction signature through standard `signatureSubscribe`. + /// + /// The server automatically terminates this subscription after the terminal processed notification. When + /// `enableReceivedNotification` is enabled, an earlier `ReceivedSignature` value may be delivered first without closing the logical handle. + pub async fn signature_subscribe( + &self, + signature: &str, + config: std::option::Option<&crate::SolanaSignatureSubscribeConfig>, + ) -> ksp_core_lib::Result>> { + let mut params = std::vec![serde_json::Value::String(signature.to_owned())]; + if let std::option::Option::Some(config) = config + && !config.is_empty() + { + params.push((*config).to_json_value()); + } + return self + .subscribe_typed_with_completion( + crate::WsSubscriptionKind::Signature, + params, + |value| return decode_signature_notification("signatureSubscribe", value), + |notification| return notification.value().is_terminal(), + ) + .await; + } + /// Subscribes to Solana transaction logs through standard `logsSubscribe`. pub async fn logs_subscribe( &self, @@ -67,6 +166,24 @@ impl crate::WsSession { } } +#[derive(serde::Deserialize)] +#[serde(untagged)] +enum WireSignatureNotification { + Received(std::string::String), + Processed(WireSignatureProcessed), +} + +#[derive(serde::Deserialize)] +struct WireSignatureProcessed { + err: serde_json::Value, +} + +#[derive(serde::Deserialize)] +struct WireRpcResponseSignature { + context: serde_json::Value, + value: WireSignatureNotification, +} + #[derive(serde::Deserialize)] struct WireRpcResponse { context: serde_json::Value, @@ -80,6 +197,36 @@ struct WireLogsNotification { logs: std::vec::Vec, } +fn decode_signature_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result> { + let decoded = crate::decode_wire_json::(method, value); + let wire = match decoded { + std::result::Result::Ok(wire) => wire, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let context = crate::SolanaRpcContext::decode_wire(method, wire.context); + let context = match context { + std::result::Result::Ok(context) => context, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let notification = match wire.value { + WireSignatureNotification::Received(value) if value == "receivedSignature" => crate::SolanaSignatureNotification::ReceivedSignature, + WireSignatureNotification::Received(_) => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "signatureSubscribe notification contains an unknown string variant") + .with_context("rpc_method", method), + ); + }, + WireSignatureNotification::Processed(processed) => { + let err = match processed.err { + serde_json::Value::Null => std::option::Option::None, + value => std::option::Option::Some(value), + }; + crate::SolanaSignatureNotification::Processed { err } + }, + }; + return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, notification)); +} + fn decode_logs_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result> { let decoded = crate::decode_wire_json::(method, value); let wire = match decoded { diff --git a/crates/ksp-onchain-transport-lib/tests/public_api.rs b/crates/ksp-onchain-transport-lib/tests/public_api.rs index 9ddb57e..512957e 100644 --- a/crates/ksp-onchain-transport-lib/tests/public_api.rs +++ b/crates/ksp-onchain-transport-lib/tests/public_api.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/tests/public_api.rs -// version: 30 +// version: 31 //! Integration tests for the public `ksp-onchain-transport-lib` consumer contract. @@ -610,3 +610,20 @@ fn public_v0_2_7_pre_009_stable_websocket_lot_a_wrappers_and_dtos_are_available_ let _program_notification = std::any::type_name::(); let _logs_notification = std::any::type_name::(); } + +#[test] +fn public_v0_2_7_pre_010_stable_websocket_lot_b_wrappers_and_dtos_are_available_from_crate_root() { + let _signature_subscribe = ksp_onchain_transport_lib::WsSession::signature_subscribe; + let _slot_subscribe = ksp_onchain_transport_lib::WsSession::slot_subscribe; + let _root_subscribe = ksp_onchain_transport_lib::WsSession::root_subscribe; + let signature_config = ksp_onchain_transport_lib::SolanaSignatureSubscribeConfig::new( + std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized), + std::option::Option::Some(true), + ); + assert_eq!(signature_config.commitment(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized)); + assert_eq!(signature_config.enable_received_notification(), std::option::Option::Some(true)); + let terminal = ksp_onchain_transport_lib::SolanaSignatureNotification::Processed { err: std::option::Option::None }; + assert!(terminal.is_terminal()); + assert!(terminal.err().is_none()); + let _slot_notification = std::any::type_name::(); +} diff --git a/crates/ksp-onchain-transport-lib/unit_tests/ws_cluster.rs b/crates/ksp-onchain-transport-lib/unit_tests/ws_cluster.rs new file mode 100644 index 0000000..29656ce --- /dev/null +++ b/crates/ksp-onchain-transport-lib/unit_tests/ws_cluster.rs @@ -0,0 +1,100 @@ +// file: crates/ksp-onchain-transport-lib/unit_tests/ws_cluster.rs +// version: 1 + +use futures_util::SinkExt; // rust-rules: trait-import +use futures_util::StreamExt; // rust-rules: trait-import + +fn local_endpoint(url: &str) -> crate::WsEndpointSettings { + return crate::WsEndpointSettings::new( + "local_ws_cluster", + true, + crate::WsProviderName::new("local-fixture"), + crate::WsClusterName::new("local"), + crate::WsProtocolKind::SolanaStandard, + crate::WsEndpointUrl::parse(url).expect("local test WebSocket URL must parse"), + crate::WsSessionSettings::default(), + ); +} + +async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind"); + let address = listener.local_addr().expect("local listener must expose address"); + return (listener, format!("ws://{address}")); +} + +async fn read_request(websocket: &mut tokio_tungstenite::WebSocketStream) -> serde_json::Value { + let message = websocket.next().await.expect("request message must exist").expect("request message must decode"); + let text = message.to_text().expect("request must be text"); + return serde_json::from_str(text).expect("request must contain JSON"); +} + +async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream, request: &serde_json::Value, result: serde_json::Value) { + let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric"); + let response = serde_json::json!({"jsonrpc":"2.0","id":id,"result":result}); + websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send"); +} + +async fn send_notification(websocket: &mut tokio_tungstenite::WebSocketStream, method: &str, remote_id: u64, result: serde_json::Value) { + let notification = serde_json::json!({"jsonrpc":"2.0","method":method,"params":{"result":result,"subscription":remote_id}}); + websocket.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into())).await.expect("local notification must send"); +} + +async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream) { + loop { + let message = websocket.next().await; + match message { + std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => return, + std::option::Option::Some(std::result::Result::Ok(_)) => {}, + std::option::Option::Some(std::result::Result::Err(_)) | std::option::Option::None => return, + } + } +} + +#[test] +fn slot_notification_decoder_preserves_slot_parent_and_root() { + let notification = + super::decode_slot_notification("slotSubscribe", serde_json::json!({"slot":76,"parent":75,"root":44})).expect("slot notification must decode"); + assert_eq!(notification.slot(), 76); + assert_eq!(notification.parent(), 75); + assert_eq!(notification.root(), 44); + assert!(super::decode_slot_notification("slotSubscribe", serde_json::json!({"slot":76,"parent":75})).is_err()); +} + +#[tokio::test(flavor = "current_thread")] +async fn stable_slot_and_root_wrappers_use_no_params_decode_exact_notifications_and_unsubscribe_by_handle() { + let (listener, url) = bind_local_listener().await; + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("local server must accept client"); + let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed"); + let slot_subscribe = read_request(&mut websocket).await; + assert_eq!(slot_subscribe["method"], serde_json::json!("slotSubscribe")); + assert_eq!(slot_subscribe["params"], serde_json::json!([])); + send_result(&mut websocket, &slot_subscribe, serde_json::json!(201)).await; + send_notification(&mut websocket, "slotNotification", 201, serde_json::json!({"slot":76,"parent":75,"root":44})).await; + let root_subscribe = read_request(&mut websocket).await; + assert_eq!(root_subscribe["method"], serde_json::json!("rootSubscribe")); + assert_eq!(root_subscribe["params"], serde_json::json!([])); + send_result(&mut websocket, &root_subscribe, serde_json::json!(202)).await; + send_notification(&mut websocket, "rootNotification", 202, serde_json::json!(42)).await; + let slot_unsubscribe = read_request(&mut websocket).await; + assert_eq!(slot_unsubscribe["method"], serde_json::json!("slotUnsubscribe")); + assert_eq!(slot_unsubscribe["params"], serde_json::json!([201])); + send_result(&mut websocket, &slot_unsubscribe, serde_json::json!(true)).await; + let root_unsubscribe = read_request(&mut websocket).await; + assert_eq!(root_unsubscribe["method"], serde_json::json!("rootUnsubscribe")); + assert_eq!(root_unsubscribe["params"], serde_json::json!([202])); + send_result(&mut websocket, &root_unsubscribe, serde_json::json!(true)).await; + wait_for_close_frame(&mut websocket).await; + }); + let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed"); + let mut slot_subscription = session.slot_subscribe().await.expect("slotSubscribe must register"); + let slot = slot_subscription.recv().await.expect("slot notification must arrive").expect("slot notification must decode"); + assert_eq!((slot.slot(), slot.parent(), slot.root()), (76, 75, 44)); + let mut root_subscription = session.root_subscribe().await.expect("rootSubscribe must register"); + let root = root_subscription.recv().await.expect("root notification must arrive").expect("root notification must decode"); + assert_eq!(root, 42); + assert!(slot_subscription.unsubscribe().await.expect("slot unsubscribe must complete")); + assert!(root_subscription.unsubscribe().await.expect("root unsubscribe must complete")); + session.close().await.expect("session close must complete"); + server.await.expect("local server task must complete"); +} diff --git a/crates/ksp-onchain-transport-lib/unit_tests/ws_transactions.rs b/crates/ksp-onchain-transport-lib/unit_tests/ws_transactions.rs index 1e32ee9..8ed5f1d 100644 --- a/crates/ksp-onchain-transport-lib/unit_tests/ws_transactions.rs +++ b/crates/ksp-onchain-transport-lib/unit_tests/ws_transactions.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/unit_tests/ws_transactions.rs -// version: 1 +// version: 2 use futures_util::SinkExt; // rust-rules: trait-import use futures_util::StreamExt; // rust-rules: trait-import @@ -120,3 +120,127 @@ async fn stable_logs_wrapper_uses_exact_filter_config_notification_and_handle_un session.close().await.expect("session close must complete"); server.await.expect("local server task must complete"); } + +#[test] +fn signature_subscribe_config_and_decoder_preserve_all_documented_wire_variants() { + let config = crate::SolanaSignatureSubscribeConfig::new(std::option::Option::Some(crate::SolanaCommitment::Confirmed), std::option::Option::Some(false)); + assert_eq!(config.commitment(), std::option::Option::Some(crate::SolanaCommitment::Confirmed)); + assert_eq!(config.enable_received_notification(), std::option::Option::Some(false)); + assert_eq!(config.to_json_value(), serde_json::json!({"commitment":"confirmed","enableReceivedNotification":false})); + let received = super::decode_signature_notification("signatureSubscribe", serde_json::json!({"context":{"slot":90},"value":"receivedSignature"})) + .expect("receivedSignature notification must decode"); + assert_eq!(received.context().slot(), 90); + assert_eq!(*received.value(), crate::SolanaSignatureNotification::ReceivedSignature); + assert!(!received.value().is_terminal()); + let success = super::decode_signature_notification("signatureSubscribe", serde_json::json!({"context":{"slot":91},"value":{"err":null}})) + .expect("terminal successful signature notification must decode"); + assert!(success.value().is_terminal()); + assert!(success.value().err().is_none()); + let failure = super::decode_signature_notification( + "signatureSubscribe", + serde_json::json!({"context":{"slot":92},"value":{"err":{"InstructionError":[0,"Custom"]}}}), + ) + .expect("terminal failed signature notification must decode"); + assert!(failure.value().is_terminal()); + assert!(failure.value().err().is_some()); + assert!(super::decode_signature_notification("signatureSubscribe", serde_json::json!({"context":{"slot":93},"value":"futureVariant"})).is_err()); + assert!(super::decode_signature_notification("signatureSubscribe", serde_json::json!({"context":{"slot":94},"value":{}})).is_err()); +} + +#[tokio::test(flavor = "current_thread")] +async fn signature_unsubscribe_before_terminal_notification_uses_current_remote_id() { + let (listener, url) = bind_local_listener().await; + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("local server must accept client"); + let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed"); + let subscribe = read_request(&mut websocket).await; + assert_eq!(subscribe["method"], serde_json::json!("signatureSubscribe")); + assert_eq!(subscribe["params"], serde_json::json!(["fixture-signature"])); + send_result(&mut websocket, &subscribe, serde_json::json!(301)).await; + let unsubscribe = read_request(&mut websocket).await; + assert_eq!(unsubscribe["method"], serde_json::json!("signatureUnsubscribe")); + assert_eq!(unsubscribe["params"], serde_json::json!([301])); + send_result(&mut websocket, &unsubscribe, serde_json::json!(true)).await; + wait_for_close_frame(&mut websocket).await; + }); + let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed"); + let empty_config = crate::SolanaSignatureSubscribeConfig::default(); + let mut subscription = + session.signature_subscribe("fixture-signature", std::option::Option::Some(&empty_config)).await.expect("signatureSubscribe must register"); + assert!(subscription.unsubscribe().await.expect("signature unsubscribe must complete")); + assert_eq!(subscription.state(), crate::WsSubscriptionState::Closed); + session.close().await.expect("session close must complete"); + server.await.expect("local server task must complete"); +} + +#[tokio::test(flavor = "current_thread")] +async fn signature_terminal_notification_closes_handle_and_is_not_resubscribed_after_reconnect() { + let (listener, url) = bind_local_listener().await; + let (send_terminal_tx, send_terminal_rx) = tokio::sync::oneshot::channel(); + let (replacement_ready_tx, replacement_ready_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("local server must accept initial client"); + let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("initial WebSocket handshake must succeed"); + let subscribe = read_request(&mut websocket).await; + assert_eq!(subscribe["method"], serde_json::json!("signatureSubscribe")); + assert_eq!(subscribe["params"], serde_json::json!(["fixture-signature",{"commitment":"finalized","enableReceivedNotification":true}])); + send_result(&mut websocket, &subscribe, serde_json::json!(401)).await; + let received = serde_json::json!({ + "jsonrpc":"2.0", + "method":"signatureNotification", + "params":{"result":{"context":{"slot":100},"value":"receivedSignature"},"subscription":401} + }); + websocket + .send(tokio_tungstenite::tungstenite::Message::Text(received.to_string().into())) + .await + .expect("receivedSignature notification must send"); + send_terminal_rx.await.expect("client must observe early signature notification before terminal send"); + let terminal = serde_json::json!({ + "jsonrpc":"2.0", + "method":"signatureNotification", + "params":{"result":{"context":{"slot":101},"value":{"err":null}},"subscription":401} + }); + websocket + .send(tokio_tungstenite::tungstenite::Message::Text(terminal.to_string().into())) + .await + .expect("terminal signature notification must send"); + let unexpected_cleanup = tokio::time::timeout(std::time::Duration::from_millis(100), websocket.next()).await; + assert!(unexpected_cleanup.is_err(), "server-terminal signature notification must not trigger signatureUnsubscribe"); + drop(websocket); + let (replacement_stream, _) = listener.accept().await.expect("local server must accept replacement client"); + let mut replacement = tokio_tungstenite::accept_async(replacement_stream).await.expect("replacement WebSocket handshake must succeed"); + let unexpected = tokio::time::timeout(std::time::Duration::from_millis(100), replacement.next()).await; + assert!(unexpected.is_err(), "terminal signature subscription must not be replayed after reconnect"); + replacement_ready_tx.send(()).expect("replacement-ready signal must send"); + wait_for_close_frame(&mut replacement).await; + }); + let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed"); + let config = crate::SolanaSignatureSubscribeConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized), std::option::Option::Some(true)); + let mut subscription = + session.signature_subscribe("fixture-signature", std::option::Option::Some(&config)).await.expect("signatureSubscribe must register"); + let received = subscription.recv().await.expect("receivedSignature must arrive").expect("receivedSignature must decode"); + assert_eq!(*received.value(), crate::SolanaSignatureNotification::ReceivedSignature); + assert_eq!(subscription.state(), crate::WsSubscriptionState::Active); + send_terminal_tx.send(()).expect("terminal-send signal must reach fixture"); + let terminal = subscription.recv().await.expect("terminal signature notification must arrive").expect("terminal signature notification must decode"); + assert!(terminal.value().is_terminal()); + assert!(terminal.value().err().is_none()); + let closed = tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if subscription.state() == crate::WsSubscriptionState::Closed { + return; + } + tokio::task::yield_now().await; + } + }) + .await; + assert!(closed.is_ok()); + assert!(subscription.recv().await.is_none()); + assert!(subscription.terminal_error_code().is_none()); + assert!(!subscription.unsubscribe().await.expect("already terminal signature unsubscribe must be local-only")); + replacement_ready_rx.await.expect("replacement connection must be observed without signature replay"); + assert_eq!(session.snapshot().subscription_count(), 0); + assert!(session.snapshot().continuity_gap_count() >= 1); + session.close().await.expect("session close must complete"); + server.await.expect("local server task must complete"); +} diff --git a/deltas/0.2.7/pre.010.md b/deltas/0.2.7/pre.010.md new file mode 100644 index 0000000..d4c234e --- /dev/null +++ b/deltas/0.2.7/pre.010.md @@ -0,0 +1,189 @@ + + + +# Delta `0.2.7-pre.010` — wrappers WebSocket stables lot B + +## Base + +Base requise : + +```text +0.2.7-pre.009-fix.002 +workspace.package.version = 0.2.7-pre.9.fix.2 +``` + +Le checkpoint opérateur de cette base est entièrement vert : `cargo fmt --all`, audit Python KSP, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, 295 tests unitaires Transport, 33 tests d'API publique, 22 tests de release completeness puis `cargo test --workspace`. + +Le canari ajouté par `pre.009-fix.002` pour une première connexion HTTP locale abandonnée passe dans le run Transport isolé comme dans le run workspace. + +## Signal de version + +```text +livraison = 0.2.7-pre.010 +workspace.package.version = 0.2.7-pre.10 +commit = v0.2.7-pre.010 +tag = aucun +``` + +## Objectif + +Compléter les six familles WebSocket standard non instables en ajoutant le lot B : + +```text +signatureSubscribe / signatureUnsubscribe +slotSubscribe / slotUnsubscribe +rootSubscribe / rootUnsubscribe +``` + +Les trois familles unstable `block`, `slotsUpdates` et `vote` restent explicitement différées à `pre.011`. + +## `signatureSubscribe` + +Nouvelle configuration publique : + +```text +SolanaSignatureSubscribeConfig + commitment + enable_received_notification +``` + +Les deux options sont indépendamment optionnelles. `enableReceivedNotification = false` explicite est préservé comme distinct de l'option omise ; un config explicitement vide est canonisé en absence du second paramètre. + +Le résultat typed conserve les deux variantes wire actuelles : + +```text +SolanaRpcResponse + +SolanaSignatureNotification::ReceivedSignature +SolanaSignatureNotification::Processed { err } +``` + +`ReceivedSignature` correspond au littéral wire `receivedSignature` et reste non terminal. `Processed { err }` est terminal ; `err = None` représente un succès et `err = Some(Value)` conserve sans interprétation locale le `TransactionError` wire. + +Un littéral string inconnu ou un objet terminal sans champ `err` est rejeté comme `invalid_response` pour la subscription concernée, sans faire tomber une session physique autrement saine. + +## Terminaison one-shot signature + +Le serveur Solana annule automatiquement `signatureSubscribe` après la notification terminale. Le runtime KSP doit donc fermer le handle au même instant logique, sans envoyer d'unsubscribe redondant et surtout sans restaurer cette subscription après une reconnexion ultérieure. + +Le moteur typed acquiert pour cela une classification interne : + +```text +Delivered +DeliveredTerminal +ReceiverClosed +QueueFull +DecodeFailed +``` + +La notification terminale est d'abord insérée dans la queue typed, puis l'actor retire la subscription du registry et du mapping remote/local et publie `WsSubscriptionState::Closed` avec `terminal_error_code = None`. + +Cette séquence garantit : + +```text +consumer reçoit la valeur terminale +-> handle Closed +-> canal se ferme après la valeur déjà queueée +-> aucune signatureUnsubscribe automatique +-> aucune présence dans une sélection de resubscribe future +``` + +Une cancellation explicite avant la notification terminale conserve le chemin générique `WsSubscription::unsubscribe()` et émet `signatureUnsubscribe` avec le remote ID détenu uniquement par l'actor. Après la terminaison observée, `unsubscribe()` retourne `false` localement. + +## `slotSubscribe` + +Nouveau DTO public : + +```text +SolanaSlotNotification + slot + parent + root +``` + +`WsSession::slot_subscribe()` n'accepte aucun paramètre et retourne : + +```text +WsSubscription +``` + +La subscription est continue et utilise normalement reconnect, resubscribe, backpressure et cancellation. + +## `rootSubscribe` + +`WsSession::root_subscribe()` n'accepte aucun paramètre et retourne directement : + +```text +WsSubscription +``` + +Le `u64` conserve le dernier root slot rapporté par `rootNotification`. La subscription est continue et son unsubscribe passe par le handle générique. + +## Tests déterministes ajoutés + +Cinq tests unitaires supplémentaires couvrent : + +```text +signature config commitment + enableReceivedNotification omitted/false/true +signature decoder receivedSignature + succès terminal + erreur transactionnelle terminale +signature variants invalides -> invalid_response typed +signatureUnsubscribe exact avant terminaison +signature terminale -> valeur livrée puis Closed sans terminal_error_code +signature terminale -> aucun signatureUnsubscribe redondant +perte physique après signature terminale -> session reconnectée, aucune resubscription signature +slotNotification -> slot/parent/root exacts +slotSubscribe/rootSubscribe -> params vides et notifications typed exactes +slotUnsubscribe/rootUnsubscribe -> remote IDs internes via handles +``` + +Un canari d'API publique supplémentaire vérifie les trois nouvelles méthodes et les DTOs depuis la racine de crate. + +Comptages attendus après compilation : + +```text +Transport unit tests = 300 +Transport public API tests = 34 +release completeness = 22 +``` + +## Sécurité / observabilité + +Aucun remote subscription ID n'est ajouté à l'API publique. La signature fournie au wrapper n'est pas ajoutée aux logs de lifecycle. Le log de terminaison one-shot contient uniquement `session_id`, `subscription_id` local et `subscription_kind`. + +La valeur `err` terminale reste accessible au consumer dans le DTO typed mais n'est jamais projetée dans `terminal_error_code`, car une transaction échouée reste une notification métier valide et non une erreur Transport. + +## Fichiers ajoutés ou modifiés + +```text +Cargo.toml +crates/ksp-onchain-transport-lib/README.md +crates/ksp-onchain-transport-lib/USAGE.md +crates/ksp-onchain-transport-lib/src/lib.rs +crates/ksp-onchain-transport-lib/src/ws_cluster.rs +crates/ksp-onchain-transport-lib/src/ws_session.rs +crates/ksp-onchain-transport-lib/src/ws_subscription.rs +crates/ksp-onchain-transport-lib/src/ws_transactions.rs +crates/ksp-onchain-transport-lib/tests/public_api.rs +crates/ksp-onchain-transport-lib/unit_tests/ws_cluster.rs +crates/ksp-onchain-transport-lib/unit_tests/ws_transactions.rs +docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md +docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md +deltas/0.2.7/pre.010.md +``` + +`ROADMAP.md` et `CHANGELOG.md` restent inchangés pendant cette tranche. + +## Validation opérateur requise + +```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 +cargo test --workspace +``` + +## Tranche suivante + +Si ce checkpoint est vert, `0.2.7-pre.011` ouvre les trois familles unstable : `blockSubscribe`, `slotsUpdatesSubscribe` et `voteSubscribe`, avec warnings centralisés, variantes wire évolutives et compliance `KSP-TRANSPORT-007`. diff --git a/docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md b/docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md index 25fcb71..b53e682 100644 --- a/docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md +++ b/docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md @@ -1,5 +1,5 @@ - + # Plan `0.2.7` — WebSocket Solana standard @@ -810,6 +810,32 @@ Les wrappers publics sont `WsSession::account_subscribe`, `WsSession::program_su La forme `Mentions(Pubkey)` rend la cardinalité `mentions == 1` vraie par construction. Les contraintes déterministes déjà retenues pour les filtres programme sont conservées côté WS : maximum quatre filtres et maximum 128 octets pour la variante raw `Bytes` de `memcmp`; les formes encodées restent laissées au runtime upstream comme sur la surface HTTP existante. +### 16.2 Checkpoint wrappers stables lot B `pre.010` + +Le second lot stable conserve les formes wire restantes sans introduire les familles unstable : + +```text +signatureSubscribe + signature opaque en première position + commitment optionnel via le type SolanaCommitment partagé + enableReceivedNotification omitted/false/true + notification = RpcResponse + receivedSignature = précoce, non terminal + { err: ... } = terminal, fermeture locale normale, jamais resubscribe + +slotSubscribe + aucun paramètre + notification = { slot, parent, root } + +rootSubscribe + aucun paramètre + notification = root u64 +``` + +Le moteur typed acquiert une classification interne `DeliveredTerminal` utilisée par `signatureSubscribe` uniquement lorsque la notification `Processed` a déjà été livrée au consumer. L'actor retire alors immédiatement la subscription du registry et du mapping remote/local, publie `Closed` sans code d'erreur et ne tente pas de `signatureUnsubscribe`, puisque le serveur Solana annule lui-même cette subscription one-shot après la notification terminale. + +La variante précoce `receivedSignature` ne ferme pas la subscription. Une cancellation explicite avant la notification terminale continue d'utiliser le chemin générique `WsSubscription::unsubscribe()` et préserve le booléen `signatureUnsubscribe`. Les subscriptions `slot` et `root` restent continues et réutilisent sans branche spéciale le reconnect/resubscribe déterministe acquis en `pre.007`. + ## 17. API générique provider-specific Le moteur interne doit encoder une spec générique `subscribe_method + unsubscribe_method + params + decoder`, afin qu'une release provider-specific puisse réutiliser la session. @@ -918,7 +944,7 @@ pre.006 DONE — registry subscriptions + IDs locaux + generic subscribe/unsubs pre.007 DONE — reconnect borné + resubscribe déterministe + continuity gap + races unsubscribe/reconnect pre.008 DONE — backpressure per-sub + overflow/limits + causes terminales sûres + leak/lifecycle adversarial tests pre.009 DONE — wrappers stable lot A : account + program + logs, DTOs/options/KSP-TRANSPORT-007 -pre.010 wrappers stable lot B : signature + slot + root, terminaison signature/KSP-TRANSPORT-007 +pre.010 DONE — wrappers stable lot B : signature + slot + root, terminaison signature/KSP-TRANSPORT-007 pre.011 unstable : block + slotsUpdates + vote, warnings + fallbacks wire/KSP-TRANSPORT-007 pre.012 compliance 18/18 + canaries public API + composition Config + régressions HTTP pre.013 smoke live opt-in + README/USAGE + cargo tree/duplicates + dependency audit final diff --git a/docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md b/docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md index a80de40..a5d6125 100644 --- a/docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md +++ b/docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md @@ -1,9 +1,9 @@ - + # Validation `0.2.7` — WebSocket Solana standard -> **Statut : matrice active, `0.2.7-pre.007`.** Settings/lifecycle `pre.002`, Config V2 `pre.003`, session physique `pre.004`, durcissement `pre.005`, registry `pre.006` et reconnect/resubscribe `pre.007` sont matérialisés. Backpressure per-sub et wrappers typed restent ouverts. +> **Statut : matrice active, `0.2.7-pre.010`.** Settings/lifecycle `pre.002`, Config V2 `pre.003`, session physique `pre.004`, durcissement `pre.005`, registry `pre.006`, reconnect/resubscribe `pre.007`, backpressure `pre.008` et les six familles stables des lots `pre.009`–`pre.010` sont matérialisés. Les trois familles unstable restent ouvertes pour `pre.011`. ## 1. Baseline normative @@ -35,20 +35,20 @@ Pour une paire unstable, l'unsubscribe associé est classé `Unstable pair` dans | # | Méthode | Type | Statut `pre.001` | Paramètres / résultat essentiels | Notification / paire | Stratégie de test | Source officielle | Compliance | |---:|---------------------------|-------------|-------------------|--------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------|---------------------------------------------------------------|-----------------------------------------------------------------|-------------------| -| 1 | `accountSubscribe` | subscribe | Stable/documented | pubkey ; config `commitment`, `encoding`, `dataSlice` ; result numeric id ; `minContextSlot` upstream actuellement ignoré, donc non promis | `accountNotification` | fixture encodings/config + subscribe/notify | `https://solana.com/docs/rpc/websocket/accountsubscribe` | Planned `pre.009` | -| 2 | `accountUnsubscribe` | unsubscribe | Stable/documented | remote id ; `true` or RPC error unknown id | account pair | handle local -> remote id fixture | `https://solana.com/docs/rpc/websocket/accountunsubscribe` | Planned `pre.009` | +| 1 | `accountSubscribe` | subscribe | Stable/documented | pubkey ; config `commitment`, `encoding`, `dataSlice` ; result numeric id ; `minContextSlot` upstream actuellement ignoré, donc non promis | `accountNotification` | fixture encodings/config + subscribe/notify | `https://solana.com/docs/rpc/websocket/accountsubscribe` | Done `pre.009` | +| 2 | `accountUnsubscribe` | unsubscribe | Stable/documented | remote id ; `true` or RPC error unknown id | account pair | handle local -> remote id fixture | `https://solana.com/docs/rpc/websocket/accountunsubscribe` | Done `pre.009` | | 3 | `blockSubscribe` | subscribe | **Unstable** | `all`/mentions filter ; confirmed/finalized ; encoding ; tx details ; max tx version ; showRewards | `blockNotification` | all options + null block/error + validator capability fixture | `https://solana.com/docs/rpc/websocket/blocksubscribe` | Planned `pre.011` | | 4 | `blockUnsubscribe` | unsubscribe | **Unstable pair** | remote id ; boolean/error | block pair | generic registry unsubscribe | `https://solana.com/docs/rpc/websocket/blockunsubscribe` | Planned `pre.011` | -| 5 | `logsSubscribe` | subscribe | Stable/documented | `all`, `allWithVotes`, exactly one `mentions`; commitment | `logsNotification` | 3 filters + invalid multi-mention + notification | `https://solana.com/docs/rpc/websocket/logssubscribe` | Planned `pre.009` | -| 6 | `logsUnsubscribe` | unsubscribe | Stable/documented | remote id ; boolean/error | logs pair | generic registry unsubscribe | `https://solana.com/docs/rpc/websocket/logsunsubscribe` | Planned `pre.009` | -| 7 | `programSubscribe` | subscribe | Stable/documented | program pubkey ; commitment ; filters ; encoding ; dataSlice ; `withContext` | `programNotification` | contexted/non-contexted fixtures + filters | `https://solana.com/docs/rpc/websocket/programsubscribe` | Planned `pre.009` | -| 8 | `programUnsubscribe` | unsubscribe | Stable/documented | remote id ; boolean/error | program pair | generic registry unsubscribe | `https://solana.com/docs/rpc/websocket/programunsubscribe` | Planned `pre.009` | -| 9 | `rootSubscribe` | subscribe | Stable/documented | no params ; numeric id | `rootNotification` => `u64` | exact root fixture | `https://solana.com/docs/rpc/websocket/rootsubscribe` | Planned `pre.010` | -| 10 | `rootUnsubscribe` | unsubscribe | Stable/documented | remote id ; boolean/error | root pair | generic registry unsubscribe | `https://solana.com/docs/rpc/websocket/rootunsubscribe` | Planned `pre.010` | -| 11 | `signatureSubscribe` | subscribe | Stable/documented | first transaction signature ; commitment ; `enableReceivedNotification` | `signatureNotification` early string or terminal error object | early + terminal + auto-close/no-resubscribe | `https://solana.com/docs/rpc/websocket/signaturesubscribe` | Planned `pre.010` | -| 12 | `signatureUnsubscribe` | unsubscribe | Stable/documented | remote id before terminal fire ; boolean/error | signature pair | cancel before terminal + stale after terminal | `https://solana.com/docs/rpc/websocket/signatureunsubscribe` | Planned `pre.010` | -| 13 | `slotSubscribe` | subscribe | Stable/documented | no params ; numeric id | `slotNotification` `{slot,parent,root}` | exact fixture + live smoke candidate | `https://solana.com/docs/rpc/websocket/slotsubscribe` | Planned `pre.010` | -| 14 | `slotUnsubscribe` | unsubscribe | Stable/documented | remote id ; boolean/error | slot pair | generic registry unsubscribe | `https://solana.com/docs/rpc/websocket/slotunsubscribe` | Planned `pre.010` | +| 5 | `logsSubscribe` | subscribe | Stable/documented | `all`, `allWithVotes`, exactly one `mentions`; commitment | `logsNotification` | 3 filters + invalid multi-mention + notification | `https://solana.com/docs/rpc/websocket/logssubscribe` | Done `pre.009` | +| 6 | `logsUnsubscribe` | unsubscribe | Stable/documented | remote id ; boolean/error | logs pair | generic registry unsubscribe | `https://solana.com/docs/rpc/websocket/logsunsubscribe` | Done `pre.009` | +| 7 | `programSubscribe` | subscribe | Stable/documented | program pubkey ; commitment ; filters ; encoding ; dataSlice ; `withContext` | `programNotification` | contexted/non-contexted fixtures + filters | `https://solana.com/docs/rpc/websocket/programsubscribe` | Done `pre.009` | +| 8 | `programUnsubscribe` | unsubscribe | Stable/documented | remote id ; boolean/error | program pair | generic registry unsubscribe | `https://solana.com/docs/rpc/websocket/programunsubscribe` | Done `pre.009` | +| 9 | `rootSubscribe` | subscribe | Stable/documented | no params ; numeric id | `rootNotification` => `u64` | exact root fixture | `https://solana.com/docs/rpc/websocket/rootsubscribe` | Done `pre.010` | +| 10 | `rootUnsubscribe` | unsubscribe | Stable/documented | remote id ; boolean/error | root pair | generic registry unsubscribe | `https://solana.com/docs/rpc/websocket/rootunsubscribe` | Done `pre.010` | +| 11 | `signatureSubscribe` | subscribe | Stable/documented | first transaction signature ; commitment ; `enableReceivedNotification` | `signatureNotification` early string or terminal error object | early + terminal + auto-close/no-resubscribe | `https://solana.com/docs/rpc/websocket/signaturesubscribe` | Done `pre.010` | +| 12 | `signatureUnsubscribe` | unsubscribe | Stable/documented | remote id before terminal fire ; boolean/error | signature pair | cancel before terminal + stale after terminal | `https://solana.com/docs/rpc/websocket/signatureunsubscribe` | Done `pre.010` | +| 13 | `slotSubscribe` | subscribe | Stable/documented | no params ; numeric id | `slotNotification` `{slot,parent,root}` | exact fixture + live smoke candidate | `https://solana.com/docs/rpc/websocket/slotsubscribe` | Done `pre.010` | +| 14 | `slotUnsubscribe` | unsubscribe | Stable/documented | remote id ; boolean/error | slot pair | generic registry unsubscribe | `https://solana.com/docs/rpc/websocket/slotunsubscribe` | Done `pre.010` | | 15 | `slotsUpdatesSubscribe` | subscribe | **Unstable** | no params ; numeric id | tagged `slotsUpdatesNotification` | each known variant + unknown fallback | `https://solana.com/docs/rpc/websocket/slotsupdatessubscribe` | Planned `pre.011` | | 16 | `slotsUpdatesUnsubscribe` | unsubscribe | **Unstable pair** | remote id ; boolean/error | slotsUpdates pair | generic registry unsubscribe | `https://solana.com/docs/rpc/websocket/slotsupdatesunsubscribe` | Planned `pre.011` | | 17 | `voteSubscribe` | subscribe | **Unstable** | no params ; validator flag required | `voteNotification` | fields + timestamp omitted/null/value + warning | `https://solana.com/docs/rpc/websocket/votesubscribe` | Planned `pre.011` | @@ -518,7 +518,44 @@ logs handle unsubscribe -> logsUnsubscribe exact API publique -> aucun subscribe(method, raw params) exposé ``` -Les trois wrappers passent par le même moteur actor/registry acquis en `pre.006`–`pre.008`; les remote IDs ne deviennent donc pas publics et les paramètres typés initiaux restent les specs rejouées lors d'un resubscribe `ActiveSubscriptions`. Le lot B (`signature`, `slot`, `root`) reste explicitement différé à `pre.010`. +Les trois wrappers passent par le même moteur actor/registry acquis en `pre.006`–`pre.008`; les remote IDs ne deviennent donc pas publics et les paramètres typés initiaux restent les specs rejouées lors d'un resubscribe `ActiveSubscriptions`. + +## 9.8 Checkpoint wrappers stables lot B `pre.010` + +Surface publique ajoutée : + +```text +WsSession::signature_subscribe +SolanaSignatureSubscribeConfig = commitment + enableReceivedNotification +SolanaSignatureNotification = ReceivedSignature | Processed { err } + +WsSession::slot_subscribe +SolanaSlotNotification = slot + parent + root + +WsSession::root_subscribe +notification = u64 +``` + +Gates déterministes ajoutés : + +```text +signatureSubscribe -> signature + config commitment/enableReceivedNotification exacts +config signature vide explicite -> second paramètre omis +receivedSignature -> notification typed non terminale, handle reste Active +Processed { err:null } -> notification terminale success, handle Closed sans terminal_error_code +Processed { err:object } -> transaction error wire préservée comme valeur terminale, pas erreur Transport +string signature inconnue ou objet sans err -> invalid_response typed +cancellation avant terminal -> signatureUnsubscribe exact avec remote ID interne +terminal déjà observé -> unsubscribe local-only false, aucun remote cleanup inutile +perte physique après terminal -> reconnect session possible mais signature jamais resubscribe +slotSubscribe -> params vides + {slot,parent,root} exact +slotUnsubscribe -> remote ID interne via handle +rootSubscribe -> params vides + root u64 exact +rootUnsubscribe -> remote ID interne via handle +API publique -> aucun remote subscription ID ni méthode raw arbitraire exposés +``` + +La terminaison signature est classée après livraison dans la queue typed : le consumer reçoit donc toujours la valeur terminale avant fermeture du canal. Le registry retire ensuite la subscription one-shot avant toute future sélection de resubscribe. `slot` et `root` restent des subscriptions continues et conservent les règles génériques de reconnect, backpressure et cancellation. ## 10. Validation du gate `pre.001`