From 9eb0e19d81f0ce24d52ca1f918385e9b985d726b Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Sun, 23 Aug 2026 08:58:03 +0200 Subject: [PATCH] v0.2.7-pre.011 --- Cargo.toml | 4 +- crates/ksp-onchain-transport-lib/README.md | 20 +- crates/ksp-onchain-transport-lib/USAGE.md | 31 +- crates/ksp-onchain-transport-lib/src/lib.rs | 15 +- .../src/ws_blocks.rs | 215 +++++++++++++ .../src/ws_cluster.rs | 292 +++++++++++++++++- .../src/ws_lifecycle.rs | 21 +- .../src/ws_session.rs | 3 +- .../tests/public_api.rs | 25 +- .../unit_tests/ws_blocks.rs | 222 +++++++++++++ .../unit_tests/ws_cluster.rs | 135 +++++++- .../unit_tests/ws_lifecycle.rs | 20 +- deltas/0.2.7/pre.011.md | 214 +++++++++++++ .../014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md | 6 +- .../010-V0_2_7_ONCHAIN_WEBSOCKET.md | 66 +++- 15 files changed, 1269 insertions(+), 20 deletions(-) create mode 100644 crates/ksp-onchain-transport-lib/src/ws_blocks.rs create mode 100644 crates/ksp-onchain-transport-lib/unit_tests/ws_blocks.rs create mode 100644 deltas/0.2.7/pre.011.md diff --git a/Cargo.toml b/Cargo.toml index 44fe0c1..e092a6d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 209 +# version: 210 [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.10.fix.1" +version = "0.2.7-pre.11" 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 369cf3e..68fe8f0 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` @@ -180,6 +180,24 @@ Une cancellation effectuée avant cette terminaison continue d'utiliser `WsSubsc `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`. +### Wrappers unstable `0.2.7-pre.011` + +Les trois familles standard restantes complètent désormais l'inventaire **9/9 subscribe + 9/9 unsubscribe via handles** : + +```text +block_subscribe -> WsSubscription> +slots_updates_subscribe -> WsSubscription +vote_subscribe -> WsSubscription +``` + +Ces familles restent explicitement **unstable**. Le moteur commun `subscribe_typed_with_completion` émet un warning KSP centralisé pour `Block`, `SlotsUpdates` et `Vote` avant l'ouverture logique, sans recopier filtres, pubkeys, payloads ou URL dans les logs. + +`SolanaBlockSubscribeConfig` conserve `commitment`, `encoding`, `transactionDetails`, `maxSupportedTransactionVersion` et `showRewards`. Le filtre représente `All` ou `MentionsAccountOrProgram(Pubkey)`. Un commitment `processed` explicitement fourni est rejeté avant I/O ; la notification réutilise `SolanaConfirmedBlock` pour le block nullable et conserve l'erreur publication nullable sans interprétation métier. Le numéro de version transaction supporté reste un `u8` générique et n'est pas durci à `0`. + +`SolanaSlotUpdate` représente les sept variantes courantes `firstShredReceived`, `completed`, `createdBank`, `frozen`, `dead`, `optimisticConfirmation` et `root`. Une variante upstream inconnue devient `Unknown { update_type, raw }` au lieu de faire tomber la session. Le `raw` reste borné par `max_message_size_bytes` avant le parse JSON. + +`SolanaVoteNotification` conserve `votePubkey`, `slots`, `hash`, `timestamp` et `signature`. Le timestamp reste optionnel : omission et `null` deviennent `None`, tandis qu'une valeur `i64` est préservée. Transport ne transforme pas ces votes gossip pre-consensus en vérité ledger. + ## 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 7df59fa..5831550 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` @@ -154,6 +154,35 @@ Avec `enableReceivedNotification = true`, `ReceivedSignature` peut arriver avant `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. +### Familles unstable : block, slotsUpdates et vote + +Depuis `0.2.7-pre.011`, les trois familles unstable standard sont également typées. Leur utilisation déclenche un warning KSP centralisé : + +```rust +let block_config = ksp_onchain_transport_lib::SolanaBlockSubscribeConfig::new( + Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed), + Some(ksp_onchain_transport_lib::SolanaTransactionEncoding::Base64), + Some(ksp_onchain_transport_lib::SolanaTransactionDetails::Signatures), + Some(0), + Some(false), +); +let mut blocks = match session + .block_subscribe(&ksp_onchain_transport_lib::SolanaBlockSubscribeFilter::All, Some(&block_config)) + .await +{ + Ok(value) => value, + Err(error) => return Err(error), +}; +``` + +`blockSubscribe` requiert un validator qui active la capability upstream correspondante. Une erreur applicative RPC liée à cette capability est renvoyée au caller sans reconnect de la session. `processed` est refusé localement ; `confirmed` et `finalized` sont admis. `maxSupportedTransactionVersion` n'est pas limité artificiellement à `0`. + +`slots_updates_subscribe().await` délivre `SolanaSlotUpdate`. Les sept variantes courantes sont structurées ; une variante inconnue reste consommable via `Unknown` et `unknown_raw()`, sous la borne de taille WebSocket déjà appliquée avant décodage. + +`vote_subscribe().await` délivre `SolanaVoteNotification`. `timestamp()` retourne `Option` pour conserver omission/null/value. Ce flux reste gossip et pre-consensus : le consumer ne doit pas l'assimiler à une confirmation ledger. + +Les trois familles utilisent le même `WsSubscription::unsubscribe().await`; aucun remote subscription ID n'entre dans l'API publique. + ### 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`. diff --git a/crates/ksp-onchain-transport-lib/src/lib.rs b/crates/ksp-onchain-transport-lib/src/lib.rs index 2d599d2..8af0367 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: 28 +// version: 29 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -44,6 +44,7 @@ mod rpc_tokens; mod rpc_transactions; mod settings; mod ws_accounts; +mod ws_blocks; mod ws_cluster; mod ws_lifecycle; mod ws_session; @@ -321,8 +322,20 @@ 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; +/// Typed value carried inside an unstable Solana `blockNotification` response. +pub use self::ws_blocks::SolanaBlockNotification; +/// Optional configuration accepted by unstable Solana `blockSubscribe`. +pub use self::ws_blocks::SolanaBlockSubscribeConfig; +/// Filter accepted by unstable Solana `blockSubscribe`. +pub use self::ws_blocks::SolanaBlockSubscribeFilter; /// Slot relationship reported by the standard Solana `slotNotification` WebSocket method. pub use self::ws_cluster::SolanaSlotNotification; +/// Typed unstable Solana slot-lifecycle update with an unknown-variant fallback. +pub use self::ws_cluster::SolanaSlotUpdate; +/// Execution statistics attached to unstable Solana `slotsUpdatesNotification` frozen updates. +pub use self::ws_cluster::SolanaSlotUpdateStats; +/// Typed unstable gossip-vote notification delivered by standard Solana `voteSubscribe`. +pub use self::ws_cluster::SolanaVoteNotification; /// Stable local identity assigned to one physical WebSocket session. pub use self::ws_lifecycle::WsSessionId; /// Safe runtime snapshot for one physical WebSocket session. diff --git a/crates/ksp-onchain-transport-lib/src/ws_blocks.rs b/crates/ksp-onchain-transport-lib/src/ws_blocks.rs new file mode 100644 index 0000000..62b73f9 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/src/ws_blocks.rs @@ -0,0 +1,215 @@ +// file: crates/ksp-onchain-transport-lib/src/ws_blocks.rs +// version: 1 + +/// Filter accepted by unstable Solana `blockSubscribe`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SolanaBlockSubscribeFilter { + /// Subscribe to every block that reaches the configured commitment. + All, + /// Subscribe only to blocks containing a transaction that mentions the account or program. + MentionsAccountOrProgram(ksp_core_lib::Pubkey), +} + +impl SolanaBlockSubscribeFilter { + fn to_json_value(&self) -> serde_json::Value { + return match self { + Self::All => serde_json::Value::String("all".to_owned()), + Self::MentionsAccountOrProgram(pubkey) => serde_json::json!({"mentionsAccountOrProgram": pubkey.to_string()}), + }; + } +} + +/// Optional configuration accepted by unstable Solana `blockSubscribe`. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct SolanaBlockSubscribeConfig { + commitment: std::option::Option, + encoding: std::option::Option, + transaction_details: std::option::Option, + max_supported_transaction_version: std::option::Option, + show_rewards: std::option::Option, +} + +impl SolanaBlockSubscribeConfig { + /// Creates an explicit unstable block-subscription configuration. + #[must_use] + pub const fn new( + commitment: std::option::Option, + encoding: std::option::Option, + transaction_details: std::option::Option, + max_supported_transaction_version: std::option::Option, + show_rewards: std::option::Option, + ) -> Self { + return Self { commitment, encoding, transaction_details, max_supported_transaction_version, show_rewards }; + } + + /// Returns the optional commitment level. + #[must_use] + pub const fn commitment(&self) -> std::option::Option { + return self.commitment; + } + + /// Returns the optional transaction encoding. + #[must_use] + pub const fn encoding(&self) -> std::option::Option { + return self.encoding; + } + + /// Returns the optional transaction detail level. + #[must_use] + pub const fn transaction_details(&self) -> std::option::Option { + return self.transaction_details; + } + + /// Returns the highest transaction version the caller declares it can consume. + #[must_use] + pub const fn max_supported_transaction_version(&self) -> std::option::Option { + return self.max_supported_transaction_version; + } + + /// Returns whether rewards were explicitly requested for block notifications. + #[must_use] + pub const fn show_rewards(&self) -> std::option::Option { + return self.show_rewards; + } + + fn is_empty(&self) -> bool { + return self.commitment.is_none() + && self.encoding.is_none() + && self.transaction_details.is_none() + && self.max_supported_transaction_version.is_none() + && self.show_rewards.is_none(); + } + + fn validate(&self) -> ksp_core_lib::Result<()> { + if self.commitment == std::option::Option::Some(crate::SolanaCommitment::Processed) { + return std::result::Result::Err( + ksp_core_lib::Error::new( + crate::ERROR_CODE_INVALID_RPC_PARAMETERS, + "blockSubscribe commitment must be confirmed or finalized when explicitly provided", + ) + .with_context("rpc_method", "blockSubscribe") + .with_context("commitment", "processed"), + ); + } + return std::result::Result::Ok(()); + } + + 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(encoding) = self.encoding { + object.insert("encoding".to_owned(), serde_json::Value::String(encoding.as_str().to_owned())); + } + if let std::option::Option::Some(transaction_details) = self.transaction_details { + object.insert("transactionDetails".to_owned(), serde_json::Value::String(transaction_details.as_str().to_owned())); + } + if let std::option::Option::Some(version) = self.max_supported_transaction_version { + object.insert("maxSupportedTransactionVersion".to_owned(), serde_json::Value::Number(version.into())); + } + if let std::option::Option::Some(show_rewards) = self.show_rewards { + object.insert("showRewards".to_owned(), serde_json::Value::Bool(show_rewards)); + } + return serde_json::Value::Object(object); + } +} + +/// Typed value carried inside an unstable Solana `blockNotification` response. +#[derive(Clone, Debug, PartialEq)] +pub struct SolanaBlockNotification { + slot: u64, + block: std::option::Option, + err: std::option::Option, +} + +impl SolanaBlockNotification { + /// Returns the slot associated with this block update. + #[must_use] + pub const fn slot(&self) -> u64 { + return self.slot; + } + + /// Returns the decoded block when the unstable notification contains one. + #[must_use] + pub const fn block(&self) -> std::option::Option<&crate::SolanaConfirmedBlock> { + return self.block.as_ref(); + } + + /// Returns the nullable publication error without interpreting its unstable wire shape. + #[must_use] + pub const fn err(&self) -> std::option::Option<&serde_json::Value> { + return self.err.as_ref(); + } +} + +impl crate::WsSession { + /// Subscribes to unstable standard Solana block notifications through `blockSubscribe`. + /// + /// Solana documents this method as unstable and requires validator-side block-subscription support. KSP emits a warning through its logging facade when + /// this family is requested. An explicitly supplied commitment must be `confirmed` or `finalized`. + pub async fn block_subscribe( + &self, + filter: &crate::SolanaBlockSubscribeFilter, + config: std::option::Option<&crate::SolanaBlockSubscribeConfig>, + ) -> ksp_core_lib::Result>> { + if let std::option::Option::Some(config) = config { + let validated = config.validate(); + if let std::result::Result::Err(error) = validated { + return std::result::Result::Err(error); + } + } + let mut params = std::vec![filter.to_json_value()]; + if let std::option::Option::Some(config) = config + && !config.is_empty() + { + params.push((*config).to_json_value()); + } + return self.subscribe_typed(crate::WsSubscriptionKind::Block, params, |value| return decode_block_notification("blockSubscribe", value)).await; + } +} + +#[derive(serde::Deserialize)] +struct WireBlockNotification { + slot: u64, + block: std::option::Option, + err: serde_json::Value, +} + +fn decode_block_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 block = match wire.value.block { + std::option::Option::Some(value) => { + let decoded = crate::SolanaConfirmedBlock::decode_wire(method, value); + match decoded { + std::result::Result::Ok(block) => std::option::Option::Some(block), + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + }, + std::option::Option::None => std::option::Option::None, + }; + let err = match wire.value.err { + serde_json::Value::Null => std::option::Option::None, + value => std::option::Option::Some(value), + }; + return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, crate::SolanaBlockNotification { slot: wire.value.slot, block, err })); +} + +#[derive(serde::Deserialize)] +struct WireRpcResponse { + context: serde_json::Value, + value: WireBlockNotification, +} + +#[cfg(test)] +#[path = "../unit_tests/ws_blocks.rs"] +mod tests; diff --git a/crates/ksp-onchain-transport-lib/src/ws_cluster.rs b/crates/ksp-onchain-transport-lib/src/ws_cluster.rs index 839031c..eef8c3f 100644 --- a/crates/ksp-onchain-transport-lib/src/ws_cluster.rs +++ b/crates/ksp-onchain-transport-lib/src/ws_cluster.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/src/ws_cluster.rs -// version: 1 +// version: 2 /// Slot relationship reported by the standard Solana `slotNotification` WebSocket method. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -29,6 +29,162 @@ impl SolanaSlotNotification { } } +/// Execution statistics attached to unstable Solana `slotsUpdatesNotification` frozen updates. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SolanaSlotUpdateStats { + max_transactions_per_entry: u64, + num_failed_transactions: u64, + num_successful_transactions: u64, + num_transaction_entries: u64, +} + +impl SolanaSlotUpdateStats { + /// Returns the maximum transactions per entry observed for the frozen bank. + #[must_use] + pub const fn max_transactions_per_entry(&self) -> u64 { + return self.max_transactions_per_entry; + } + + /// Returns the failed transaction count. + #[must_use] + pub const fn num_failed_transactions(&self) -> u64 { + return self.num_failed_transactions; + } + + /// Returns the successful transaction count. + #[must_use] + pub const fn num_successful_transactions(&self) -> u64 { + return self.num_successful_transactions; + } + + /// Returns the transaction-entry count. + #[must_use] + pub const fn num_transaction_entries(&self) -> u64 { + return self.num_transaction_entries; + } +} + +/// Typed unstable Solana slot-lifecycle update with an unknown-variant fallback. +#[derive(Clone, Debug, PartialEq)] +pub enum SolanaSlotUpdate { + /// The first shred for a slot was received. + FirstShredReceived { slot: u64, timestamp: i64 }, + /// All shreds for a slot were received. + Completed { slot: u64, timestamp: i64 }, + /// A bank was created for the slot. + CreatedBank { slot: u64, timestamp: i64, parent: u64 }, + /// A bank was frozen and execution statistics are available. + Frozen { slot: u64, timestamp: i64, stats: crate::SolanaSlotUpdateStats }, + /// The slot was marked dead. + Dead { slot: u64, timestamp: i64, error: std::string::String }, + /// The slot reached the current unstable optimistic-confirmation marker. + OptimisticConfirmation { slot: u64, timestamp: i64 }, + /// The slot became root. + Root { slot: u64, timestamp: i64 }, + /// A future upstream variant that KSP does not yet interpret. + /// + /// `raw` is bounded by the physical session's configured inbound WebSocket message limit before JSON decoding. + Unknown { update_type: std::string::String, raw: serde_json::Value }, +} + +impl SolanaSlotUpdate { + /// Returns the slot for known variants, or the optional slot found in an unknown raw variant. + #[must_use] + pub fn slot(&self) -> std::option::Option { + return match self { + Self::FirstShredReceived { slot, .. } + | Self::Completed { slot, .. } + | Self::CreatedBank { slot, .. } + | Self::Frozen { slot, .. } + | Self::Dead { slot, .. } + | Self::OptimisticConfirmation { slot, .. } + | Self::Root { slot, .. } => std::option::Option::Some(*slot), + Self::Unknown { raw, .. } => raw.get("slot").and_then(serde_json::Value::as_u64), + }; + } + + /// Returns the millisecond Unix timestamp for known variants, or an optional timestamp from an unknown raw variant. + #[must_use] + pub fn timestamp(&self) -> std::option::Option { + return match self { + Self::FirstShredReceived { timestamp, .. } + | Self::Completed { timestamp, .. } + | Self::CreatedBank { timestamp, .. } + | Self::Frozen { timestamp, .. } + | Self::Dead { timestamp, .. } + | Self::OptimisticConfirmation { timestamp, .. } + | Self::Root { timestamp, .. } => std::option::Option::Some(*timestamp), + Self::Unknown { raw, .. } => raw.get("timestamp").and_then(serde_json::Value::as_i64), + }; + } + + /// Returns the upstream `type` string, including unknown future values. + #[must_use] + pub fn update_type(&self) -> &str { + return match self { + Self::FirstShredReceived { .. } => "firstShredReceived", + Self::Completed { .. } => "completed", + Self::CreatedBank { .. } => "createdBank", + Self::Frozen { .. } => "frozen", + Self::Dead { .. } => "dead", + Self::OptimisticConfirmation { .. } => "optimisticConfirmation", + Self::Root { .. } => "root", + Self::Unknown { update_type, .. } => update_type.as_str(), + }; + } + + /// Returns the bounded raw object only for an unknown future upstream variant. + #[must_use] + pub const fn unknown_raw(&self) -> std::option::Option<&serde_json::Value> { + return match self { + Self::Unknown { raw, .. } => std::option::Option::Some(raw), + _ => std::option::Option::None, + }; + } +} + +/// Typed unstable gossip-vote notification delivered by standard Solana `voteSubscribe`. +#[derive(Clone, Debug, PartialEq)] +pub struct SolanaVoteNotification { + vote_pubkey: ksp_core_lib::Pubkey, + slots: std::vec::Vec, + hash: std::string::String, + timestamp: std::option::Option, + signature: std::string::String, +} + +impl SolanaVoteNotification { + /// Returns the vote-account public key. + #[must_use] + pub const fn vote_pubkey(&self) -> &ksp_core_lib::Pubkey { + return &self.vote_pubkey; + } + + /// Returns the ordered slots covered by the observed vote. + #[must_use] + pub fn slots(&self) -> &[u64] { + return self.slots.as_slice(); + } + + /// Returns the vote hash exactly as reported by the unstable upstream wire. + #[must_use] + pub fn hash(&self) -> &str { + return self.hash.as_str(); + } + + /// Returns the optional vote timestamp, preserving omitted and explicit-null wire forms as `None`. + #[must_use] + pub const fn timestamp(&self) -> std::option::Option { + return self.timestamp; + } + + /// Returns the vote transaction signature exactly as reported by the unstable upstream wire. + #[must_use] + pub fn signature(&self) -> &str { + return self.signature.as_str(); + } +} + impl crate::WsSession { /// Subscribes to standard Solana slot-processing notifications through `slotSubscribe`. pub async fn slot_subscribe(&self) -> ksp_core_lib::Result> { @@ -47,6 +203,24 @@ impl crate::WsSession { }) .await; } + + /// Subscribes to unstable standard Solana slot-lifecycle notifications through `slotsUpdatesSubscribe`. + pub async fn slots_updates_subscribe(&self) -> ksp_core_lib::Result> { + return self + .subscribe_typed(crate::WsSubscriptionKind::SlotsUpdates, std::vec::Vec::new(), |value| { + return decode_slots_update_notification("slotsUpdatesSubscribe", value); + }) + .await; + } + + /// Subscribes to unstable pre-consensus gossip vote notifications through `voteSubscribe`. + pub async fn vote_subscribe(&self) -> ksp_core_lib::Result> { + return self + .subscribe_typed(crate::WsSubscriptionKind::Vote, std::vec::Vec::new(), |value| { + return decode_vote_notification("voteSubscribe", value); + }) + .await; + } } #[derive(serde::Deserialize)] @@ -65,6 +239,122 @@ fn decode_slot_notification(method: &str, value: serde_json::Value) -> ksp_core_ return std::result::Result::Ok(crate::SolanaSlotNotification { slot: wire.slot, parent: wire.parent, root: wire.root }); } +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct WireSlotUpdateStats { + max_transactions_per_entry: u64, + num_failed_transactions: u64, + num_successful_transactions: u64, + num_transaction_entries: u64, +} + +#[derive(serde::Deserialize)] +struct WireVoteNotification { + #[serde(rename = "votePubkey")] + vote_pubkey: std::string::String, + slots: std::vec::Vec, + hash: std::string::String, + #[serde(default)] + timestamp: std::option::Option, + signature: std::string::String, +} + +fn decode_slots_update_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result { + let object = match value.as_object() { + std::option::Option::Some(object) => object, + std::option::Option::None => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "slotsUpdatesSubscribe notification must be an object") + .with_context("rpc_method", method), + ); + }, + }; + let update_type = match object.get("type").and_then(serde_json::Value::as_str) { + std::option::Option::Some(update_type) => update_type, + std::option::Option::None => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "slotsUpdatesSubscribe notification is missing a string type") + .with_context("rpc_method", method), + ); + }, + }; + if !matches!(update_type, "firstShredReceived" | "completed" | "createdBank" | "frozen" | "dead" | "optimisticConfirmation" | "root") { + return std::result::Result::Ok(crate::SolanaSlotUpdate::Unknown { update_type: update_type.to_owned(), raw: value.clone() }); + } + let slot = match object.get("slot").and_then(serde_json::Value::as_u64) { + std::option::Option::Some(slot) => slot, + std::option::Option::None => return invalid_slots_update(method, "known slots update is missing numeric slot"), + }; + let timestamp = match object.get("timestamp").and_then(serde_json::Value::as_i64) { + std::option::Option::Some(timestamp) => timestamp, + std::option::Option::None => return invalid_slots_update(method, "known slots update is missing numeric timestamp"), + }; + return match update_type { + "firstShredReceived" => std::result::Result::Ok(crate::SolanaSlotUpdate::FirstShredReceived { slot, timestamp }), + "completed" => std::result::Result::Ok(crate::SolanaSlotUpdate::Completed { slot, timestamp }), + "createdBank" => match object.get("parent").and_then(serde_json::Value::as_u64) { + std::option::Option::Some(parent) => std::result::Result::Ok(crate::SolanaSlotUpdate::CreatedBank { slot, timestamp, parent }), + std::option::Option::None => invalid_slots_update(method, "createdBank update is missing numeric parent"), + }, + "frozen" => { + let stats = match object.get("stats") { + std::option::Option::Some(stats) => crate::decode_wire_json::(method, stats.clone()), + std::option::Option::None => return invalid_slots_update(method, "frozen update is missing stats"), + }; + let stats = match stats { + std::result::Result::Ok(stats) => stats, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + std::result::Result::Ok(crate::SolanaSlotUpdate::Frozen { + slot, + timestamp, + stats: crate::SolanaSlotUpdateStats { + max_transactions_per_entry: stats.max_transactions_per_entry, + num_failed_transactions: stats.num_failed_transactions, + num_successful_transactions: stats.num_successful_transactions, + num_transaction_entries: stats.num_transaction_entries, + }, + }) + }, + "dead" => match object.get("err").and_then(serde_json::Value::as_str) { + std::option::Option::Some(error) => std::result::Result::Ok(crate::SolanaSlotUpdate::Dead { slot, timestamp, error: error.to_owned() }), + std::option::Option::None => invalid_slots_update(method, "dead update is missing string err"), + }, + "optimisticConfirmation" => std::result::Result::Ok(crate::SolanaSlotUpdate::OptimisticConfirmation { slot, timestamp }), + "root" => std::result::Result::Ok(crate::SolanaSlotUpdate::Root { slot, timestamp }), + _ => invalid_slots_update(method, "known slots update type dispatch failed"), + }; +} + +fn invalid_slots_update(method: &str, message: &'static str) -> ksp_core_lib::Result { + return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, message).with_context("rpc_method", method)); +} + +fn decode_vote_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 vote_pubkey = wire.vote_pubkey.parse::(); + let vote_pubkey = match vote_pubkey { + std::result::Result::Ok(vote_pubkey) => vote_pubkey, + std::result::Result::Err(_) => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "voteSubscribe notification contains an invalid votePubkey") + .with_context("rpc_method", method), + ); + }, + }; + return std::result::Result::Ok(crate::SolanaVoteNotification { + vote_pubkey, + slots: wire.slots, + hash: wire.hash, + timestamp: wire.timestamp, + signature: wire.signature, + }); +} + #[cfg(test)] #[path = "../unit_tests/ws_cluster.rs"] mod tests; diff --git a/crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs b/crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs index 2415523..764f147 100644 --- a/crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs +++ b/crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs -// version: 5 +// version: 6 /// Stable local identity assigned to one physical WebSocket session. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] @@ -161,6 +161,25 @@ impl WsSubscriptionKind { Self::Vote => "voteNotification", }; } + + /// Returns whether Solana documents this standard subscription family as unstable. + pub(crate) const fn is_unstable(self) -> bool { + return matches!(self, Self::Block | Self::SlotsUpdates | Self::Vote); + } + + /// Emits the centralized KSP warning required before opening an unstable standard subscription. + pub(crate) fn warn_if_unstable(self) { + if self.is_unstable() { + ksp_logging_lib::warn!( + target: crate::TRACING_TARGET, + rpc_method = self.subscribe_method(), + subscription_kind = self.as_str(), + documentation_status = "unstable", + "unstable Solana WebSocket subscription requested" + ); + } + return; + } } /// Safe lifecycle projection for one logical WebSocket subscription. diff --git a/crates/ksp-onchain-transport-lib/src/ws_session.rs b/crates/ksp-onchain-transport-lib/src/ws_session.rs index adbf891..1442e15 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: 11 +// version: 12 use futures_util::SinkExt; // rust-rules: trait-import use futures_util::StreamExt; // rust-rules: trait-import @@ -187,6 +187,7 @@ impl WsSession { if self.state() != crate::WsSessionState::Active { return std::result::Result::Err(ws_session_closed_error(self.id, "WebSocket session is not active")); } + kind.warn_if_unstable(); 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 }; diff --git a/crates/ksp-onchain-transport-lib/tests/public_api.rs b/crates/ksp-onchain-transport-lib/tests/public_api.rs index 512957e..207eed0 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: 31 +// version: 32 //! Integration tests for the public `ksp-onchain-transport-lib` consumer contract. @@ -627,3 +627,26 @@ fn public_v0_2_7_pre_010_stable_websocket_lot_b_wrappers_and_dtos_are_available_ assert!(terminal.err().is_none()); let _slot_notification = std::any::type_name::(); } + +#[test] +fn public_v0_2_7_pre_011_unstable_websocket_wrappers_and_dtos_are_available_from_crate_root() { + let _block_subscribe = ksp_onchain_transport_lib::WsSession::block_subscribe; + let _slots_updates_subscribe = ksp_onchain_transport_lib::WsSession::slots_updates_subscribe; + let _vote_subscribe = ksp_onchain_transport_lib::WsSession::vote_subscribe; + let pubkey = "11111111111111111111111111111111".parse::().expect("fixture pubkey must parse"); + let filter = ksp_onchain_transport_lib::SolanaBlockSubscribeFilter::MentionsAccountOrProgram(pubkey); + assert!(matches!(filter, ksp_onchain_transport_lib::SolanaBlockSubscribeFilter::MentionsAccountOrProgram(_))); + let config = ksp_onchain_transport_lib::SolanaBlockSubscribeConfig::new( + std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed), + std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionEncoding::Base64), + std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionDetails::Full), + std::option::Option::Some(0), + std::option::Option::Some(true), + ); + assert_eq!(config.commitment(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed)); + assert_eq!(config.show_rewards(), std::option::Option::Some(true)); + let _block_notification = std::any::type_name::(); + let _slot_update = std::any::type_name::(); + let _slot_update_stats = std::any::type_name::(); + let _vote_notification = std::any::type_name::(); +} diff --git a/crates/ksp-onchain-transport-lib/unit_tests/ws_blocks.rs b/crates/ksp-onchain-transport-lib/unit_tests/ws_blocks.rs new file mode 100644 index 0000000..3962956 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/unit_tests/ws_blocks.rs @@ -0,0 +1,222 @@ +// file: crates/ksp-onchain-transport-lib/unit_tests/ws_blocks.rs +// version: 1 + +use futures_util::SinkExt; // rust-rules: trait-import +use futures_util::StreamExt; // rust-rules: trait-import + +fn fixture_pubkey() -> ksp_core_lib::Pubkey { + return "11111111111111111111111111111111".parse::().expect("fixture pubkey must parse"); +} + +fn local_endpoint(url: &str) -> crate::WsEndpointSettings { + return crate::WsEndpointSettings::new( + "local_ws_blocks", + 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_error(websocket: &mut tokio_tungstenite::WebSocketStream, request: &serde_json::Value, code: i64, message: &str) { + 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,"error":{"code":code,"message":message}}); + websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local error response must send"); +} + +async fn send_notification(websocket: &mut tokio_tungstenite::WebSocketStream, remote_id: u64, result: serde_json::Value) { + let notification = serde_json::json!({"jsonrpc":"2.0","method":"blockNotification","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 block_subscribe_config_preserves_all_unstable_options_and_rejects_processed_commitment() { + let config = crate::SolanaBlockSubscribeConfig::new( + std::option::Option::Some(crate::SolanaCommitment::Confirmed), + std::option::Option::Some(crate::SolanaTransactionEncoding::JsonParsed), + std::option::Option::Some(crate::SolanaTransactionDetails::Accounts), + std::option::Option::Some(7), + std::option::Option::Some(true), + ); + assert_eq!(config.commitment(), std::option::Option::Some(crate::SolanaCommitment::Confirmed)); + assert_eq!(config.encoding(), std::option::Option::Some(crate::SolanaTransactionEncoding::JsonParsed)); + assert_eq!(config.transaction_details(), std::option::Option::Some(crate::SolanaTransactionDetails::Accounts)); + assert_eq!(config.max_supported_transaction_version(), std::option::Option::Some(7)); + assert_eq!(config.show_rewards(), std::option::Option::Some(true)); + assert_eq!( + config.to_json_value(), + serde_json::json!({ + "commitment": "confirmed", + "encoding": "jsonParsed", + "transactionDetails": "accounts", + "maxSupportedTransactionVersion": 7, + "showRewards": true + }) + ); + let invalid = crate::SolanaBlockSubscribeConfig::new( + std::option::Option::Some(crate::SolanaCommitment::Processed), + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + ); + assert_eq!(invalid.validate().expect_err("processed commitment must be rejected").code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS); +} + +#[test] +fn block_notification_decoder_preserves_nulls_and_shared_confirmed_block_shape() { + let nulls = super::decode_block_notification( + "blockSubscribe", + serde_json::json!({"context":{"slot":51},"value":{"slot":51,"block":null,"err":{"reason":"missing"}}}), + ) + .expect("nullable block notification must decode"); + assert_eq!(nulls.context().slot(), 51); + assert_eq!(nulls.value().slot(), 51); + assert!(nulls.value().block().is_none()); + assert_eq!(nulls.value().err(), std::option::Option::Some(&serde_json::json!({"reason":"missing"}))); + let block = super::decode_block_notification( + "blockSubscribe", + serde_json::json!({ + "context":{"slot":52}, + "value":{ + "slot":52, + "block":{ + "previousBlockhash":"prev", + "blockhash":"current", + "parentSlot":51, + "signatures":["sig-a"], + "rewards":null, + "numRewardPartitions":4, + "blockTime":123, + "blockHeight":9 + }, + "err":null + } + }), + ) + .expect("shared confirmed block shape must decode"); + assert_eq!(block.value().block().expect("block must exist").blockhash(), "current"); + assert!(block.value().err().is_none()); + let large_transaction = "A".repeat(2_048); + let large_wire = serde_json::json!({ + "context":{"slot":53}, + "value":{ + "slot":53, + "block":{ + "previousBlockhash":"prev", + "blockhash":"large-current", + "parentSlot":52, + "transactions":[{"transaction":[large_transaction,"base64"],"meta":{"err":null,"fee":5000},"version":"legacy"}], + "rewards":[], + "blockTime":123, + "blockHeight":10 + }, + "err":null + } + }); + assert!(large_wire.to_string().len() > 1_232); + assert!(super::decode_block_notification("blockSubscribe", large_wire).is_ok()); +} + +#[tokio::test(flavor = "current_thread")] +async fn unstable_block_wrapper_uses_exact_request_notification_and_handle_unsubscribe() { + let (listener, url) = bind_local_listener().await; + let pubkey = fixture_pubkey(); + let server_pubkey = pubkey; + 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!("blockSubscribe")); + assert_eq!( + subscribe["params"], + serde_json::json!([ + {"mentionsAccountOrProgram":server_pubkey.to_string()}, + {"commitment":"confirmed","encoding":"base64","transactionDetails":"signatures","maxSupportedTransactionVersion":3,"showRewards":false} + ]) + ); + send_result(&mut websocket, &subscribe, serde_json::json!(301)).await; + send_notification(&mut websocket, 301, serde_json::json!({"context":{"slot":77},"value":{"slot":77,"block":null,"err":null}})).await; + let unsubscribe = read_request(&mut websocket).await; + assert_eq!(unsubscribe["method"], serde_json::json!("blockUnsubscribe")); + 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 config = crate::SolanaBlockSubscribeConfig::new( + std::option::Option::Some(crate::SolanaCommitment::Confirmed), + std::option::Option::Some(crate::SolanaTransactionEncoding::Base64), + std::option::Option::Some(crate::SolanaTransactionDetails::Signatures), + std::option::Option::Some(3), + std::option::Option::Some(false), + ); + let mut subscription = session + .block_subscribe(&crate::SolanaBlockSubscribeFilter::MentionsAccountOrProgram(pubkey), std::option::Option::Some(&config)) + .await + .expect("blockSubscribe must register"); + let notification = subscription.recv().await.expect("block notification must arrive").expect("block notification must decode"); + assert_eq!(notification.value().slot(), 77); + assert!(notification.value().block().is_none()); + assert!(subscription.unsubscribe().await.expect("block unsubscribe must complete")); + session.close().await.expect("session close must complete"); + server.await.expect("local server task must complete"); +} + +#[tokio::test(flavor = "current_thread")] +async fn unstable_block_validator_capability_rpc_error_does_not_fail_physical_session() { + 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 block_subscribe = read_request(&mut websocket).await; + assert_eq!(block_subscribe["method"], serde_json::json!("blockSubscribe")); + send_error(&mut websocket, &block_subscribe, -32601, "block subscription disabled").await; + let root_subscribe = read_request(&mut websocket).await; + assert_eq!(root_subscribe["method"], serde_json::json!("rootSubscribe")); + send_result(&mut websocket, &root_subscribe, serde_json::json!(302)).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 error = session + .block_subscribe(&crate::SolanaBlockSubscribeFilter::All, std::option::Option::None) + .await + .expect_err("validator capability application error must surface to the caller"); + assert_eq!(error.code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR); + assert_eq!(session.state(), crate::WsSessionState::Active); + let root = session.root_subscribe().await.expect("session must remain usable after block application error"); + assert_eq!(root.state(), crate::WsSubscriptionState::Active); + 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_cluster.rs b/crates/ksp-onchain-transport-lib/unit_tests/ws_cluster.rs index 29656ce..2ec2b8f 100644 --- a/crates/ksp-onchain-transport-lib/unit_tests/ws_cluster.rs +++ b/crates/ksp-onchain-transport-lib/unit_tests/ws_cluster.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/unit_tests/ws_cluster.rs -// version: 1 +// version: 2 use futures_util::SinkExt; // rust-rules: trait-import use futures_util::StreamExt; // rust-rules: trait-import @@ -98,3 +98,136 @@ async fn stable_slot_and_root_wrappers_use_no_params_decode_exact_notifications_ session.close().await.expect("session close must complete"); server.await.expect("local server task must complete"); } + +#[test] +fn slots_update_decoder_preserves_all_known_variants_and_unknown_bounded_fallback() { + let cases = [ + (serde_json::json!({"slot":1,"timestamp":10,"type":"firstShredReceived"}), "firstShredReceived"), + (serde_json::json!({"slot":2,"timestamp":20,"type":"completed"}), "completed"), + (serde_json::json!({"slot":3,"timestamp":30,"type":"createdBank","parent":2}), "createdBank"), + ( + serde_json::json!({ + "slot":4, + "timestamp":40, + "type":"frozen", + "stats":{"maxTransactionsPerEntry":64,"numFailedTransactions":1,"numSuccessfulTransactions":9,"numTransactionEntries":3} + }), + "frozen", + ), + (serde_json::json!({"slot":5,"timestamp":50,"type":"dead","err":"fixture dead"}), "dead"), + (serde_json::json!({"slot":6,"timestamp":60,"type":"optimisticConfirmation"}), "optimisticConfirmation"), + (serde_json::json!({"slot":7,"timestamp":70,"type":"root"}), "root"), + ]; + for (wire, expected_type) in cases { + let update = super::decode_slots_update_notification("slotsUpdatesSubscribe", wire).expect("known slot update must decode"); + assert_eq!(update.update_type(), expected_type); + assert!(update.slot().is_some()); + assert!(update.timestamp().is_some()); + assert!(update.unknown_raw().is_none()); + } + let frozen = super::decode_slots_update_notification( + "slotsUpdatesSubscribe", + serde_json::json!({ + "slot":4, + "timestamp":40, + "type":"frozen", + "stats":{"maxTransactionsPerEntry":64,"numFailedTransactions":1,"numSuccessfulTransactions":9,"numTransactionEntries":3} + }), + ) + .expect("frozen update must decode"); + match frozen { + crate::SolanaSlotUpdate::Frozen { stats, .. } => { + assert_eq!(stats.max_transactions_per_entry(), 64); + assert_eq!(stats.num_failed_transactions(), 1); + assert_eq!(stats.num_successful_transactions(), 9); + assert_eq!(stats.num_transaction_entries(), 3); + }, + _ => panic!("fixture must decode as frozen"), + } + let unknown_wire = serde_json::json!({"slot":8,"timestamp":80,"type":"futureBankState","futureField":{"x":1}}); + let unknown = super::decode_slots_update_notification("slotsUpdatesSubscribe", unknown_wire.clone()).expect("unknown update must remain consumable"); + assert_eq!(unknown.update_type(), "futureBankState"); + assert_eq!(unknown.slot(), std::option::Option::Some(8)); + assert_eq!(unknown.timestamp(), std::option::Option::Some(80)); + assert_eq!(unknown.unknown_raw(), std::option::Option::Some(&unknown_wire)); +} + +#[test] +fn slots_update_known_variants_require_their_variant_specific_fields() { + assert!(super::decode_slots_update_notification("slotsUpdatesSubscribe", serde_json::json!({"slot":3,"timestamp":30,"type":"createdBank"})).is_err()); + assert!(super::decode_slots_update_notification("slotsUpdatesSubscribe", serde_json::json!({"slot":4,"timestamp":40,"type":"frozen"})).is_err()); + assert!(super::decode_slots_update_notification("slotsUpdatesSubscribe", serde_json::json!({"slot":5,"timestamp":50,"type":"dead"})).is_err()); +} + +#[test] +fn vote_notification_decoder_preserves_timestamp_omitted_null_and_value() { + let pubkey = "11111111111111111111111111111111"; + for (wire, expected_timestamp) in [ + (serde_json::json!({"votePubkey":pubkey,"slots":[1,2],"hash":"hash-a","signature":"sig-a"}), std::option::Option::None), + (serde_json::json!({"votePubkey":pubkey,"slots":[1,2],"hash":"hash-b","timestamp":null,"signature":"sig-b"}), std::option::Option::None), + (serde_json::json!({"votePubkey":pubkey,"slots":[1,2],"hash":"hash-c","timestamp":123,"signature":"sig-c"}), std::option::Option::Some(123)), + ] { + let vote = super::decode_vote_notification("voteSubscribe", wire).expect("vote notification must decode"); + assert_eq!(vote.vote_pubkey().to_string(), pubkey); + assert_eq!(vote.slots(), &[1, 2]); + assert_eq!(vote.timestamp(), expected_timestamp); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn unstable_slots_updates_and_vote_wrappers_use_no_params_and_handle_unsubscribe() { + 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 slots_subscribe = read_request(&mut websocket).await; + assert_eq!(slots_subscribe["method"], serde_json::json!("slotsUpdatesSubscribe")); + assert_eq!(slots_subscribe["params"], serde_json::json!([])); + send_result(&mut websocket, &slots_subscribe, serde_json::json!(401)).await; + send_notification( + &mut websocket, + "slotsUpdatesNotification", + 401, + serde_json::json!({"slot":76,"timestamp":1625081266243_i64,"type":"optimisticConfirmation"}), + ) + .await; + let vote_subscribe = read_request(&mut websocket).await; + assert_eq!(vote_subscribe["method"], serde_json::json!("voteSubscribe")); + assert_eq!(vote_subscribe["params"], serde_json::json!([])); + send_result(&mut websocket, &vote_subscribe, serde_json::json!(402)).await; + send_notification( + &mut websocket, + "voteNotification", + 402, + serde_json::json!({ + "votePubkey":"11111111111111111111111111111111", + "slots":[75,76], + "hash":"fixture-hash", + "timestamp":null, + "signature":"fixture-signature" + }), + ) + .await; + let slots_unsubscribe = read_request(&mut websocket).await; + assert_eq!(slots_unsubscribe["method"], serde_json::json!("slotsUpdatesUnsubscribe")); + assert_eq!(slots_unsubscribe["params"], serde_json::json!([401])); + send_result(&mut websocket, &slots_unsubscribe, serde_json::json!(true)).await; + let vote_unsubscribe = read_request(&mut websocket).await; + assert_eq!(vote_unsubscribe["method"], serde_json::json!("voteUnsubscribe")); + assert_eq!(vote_unsubscribe["params"], serde_json::json!([402])); + send_result(&mut websocket, &vote_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 slots_subscription = session.slots_updates_subscribe().await.expect("slotsUpdatesSubscribe must register"); + let update = slots_subscription.recv().await.expect("slots update must arrive").expect("slots update must decode"); + assert_eq!(update.update_type(), "optimisticConfirmation"); + let mut vote_subscription = session.vote_subscribe().await.expect("voteSubscribe must register"); + let vote = vote_subscription.recv().await.expect("vote notification must arrive").expect("vote notification must decode"); + assert_eq!(vote.slots(), &[75, 76]); + assert!(vote.timestamp().is_none()); + assert!(slots_subscription.unsubscribe().await.expect("slots update unsubscribe must complete")); + assert!(vote_subscription.unsubscribe().await.expect("vote 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_lifecycle.rs b/crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs index ce09a40..2a6244b 100644 --- a/crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs +++ b/crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs -// version: 3 +// version: 4 fn non_zero(value: u64) -> std::num::NonZeroU64 { return std::num::NonZeroU64::new(value).expect("test ID must be non-zero"); @@ -105,3 +105,21 @@ fn websocket_subscription_kinds_map_exact_standard_method_triplets() { assert_eq!(kind.notification_method(), notification); } } + +#[test] +fn websocket_unstable_subscription_partition_is_exact() { + let cases = [ + (crate::WsSubscriptionKind::Account, false), + (crate::WsSubscriptionKind::Block, true), + (crate::WsSubscriptionKind::Logs, false), + (crate::WsSubscriptionKind::Program, false), + (crate::WsSubscriptionKind::Root, false), + (crate::WsSubscriptionKind::Signature, false), + (crate::WsSubscriptionKind::Slot, false), + (crate::WsSubscriptionKind::SlotsUpdates, true), + (crate::WsSubscriptionKind::Vote, true), + ]; + for (kind, unstable) in cases { + assert_eq!(kind.is_unstable(), unstable); + } +} diff --git a/deltas/0.2.7/pre.011.md b/deltas/0.2.7/pre.011.md new file mode 100644 index 0000000..2f89d1c --- /dev/null +++ b/deltas/0.2.7/pre.011.md @@ -0,0 +1,214 @@ + + + +# Delta `0.2.7-pre.011` — familles WebSocket unstable : block + slotsUpdates + vote + +## Base + +Base requise : + +```text +0.2.7-pre.010-fix.001 +workspace.package.version = 0.2.7-pre.10.fix.1 +``` + +Le checkpoint opérateur reçu sur cette base est vert et permet de poursuivre la série. Le correctif `pre.010-fix.001` a supprimé le helper typed devenu mort sans modifier le lifecycle WebSocket. + +## Signal technique + +```text +livraison = 0.2.7-pre.011 +workspace.package.version = 0.2.7-pre.11 +commit = v0.2.7-pre.011 +tag = aucun +``` + +## Objectif + +Compléter les neuf familles standard Solana avec les trois paires officiellement instables : + +```text +blockSubscribe / blockUnsubscribe +slotsUpdatesSubscribe / slotsUpdatesUnsubscribe +voteSubscribe / voteUnsubscribe +``` + +La surface promise par `0.2.7` atteint ainsi : + +```text +9/9 subscribe wrappers publics typed +9/9 unsubscribe via WsSubscription::unsubscribe() +18/18 opérations standard représentées +``` + +La compliance finale et les canaris transverses 18/18 restent le scope de `pre.012`. + +## `blockSubscribe` + +Nouveaux contrats publics : + +```text +SolanaBlockSubscribeFilter + All + MentionsAccountOrProgram(Pubkey) + +SolanaBlockSubscribeConfig + commitment + encoding + transaction_details + max_supported_transaction_version + show_rewards + +SolanaBlockNotification + slot + block : Option + err : Option +``` + +Le config sérialise les noms wire exacts : `commitment`, `encoding`, `transactionDetails`, `maxSupportedTransactionVersion`, `showRewards`. + +Un commitment `processed` explicitement fourni est rejeté avant I/O. `confirmed` et `finalized` sont acceptés. Les cinq encodings documentés et les quatre niveaux de transaction details réutilisent les enums HTTP déjà acquis. + +`maxSupportedTransactionVersion` reste un `u8` numérique générique : KSP ne durcit pas la valeur à `0` et reste compatible avec de futures versions numériques supportées par le runtime ciblé. + +La notification réutilise `SolanaConfirmedBlock::decode_wire`, ce qui conserve les formes `full`, `accounts`, `signatures`, `none`, les encodings modern/legacy déjà acquis, les rewards et les extensions SIMD déjà couvertes côté HTTP. `block` et `err` restent indépendamment nullables. + +Un fixture couvre un payload de notification supérieur à 1232 octets tout en restant sous la limite WebSocket KSP, conformément à la décision de ne jamais dériver la taille maximale WS de l'ancienne limite transaction legacy. + +Une erreur RPC applicative simulant un validator sans capability block est renvoyée au caller sans teardown ni reconnect de la session physique. + +## `slotsUpdatesSubscribe` + +Nouveaux contrats publics : + +```text +SolanaSlotUpdateStats + +SolanaSlotUpdate + FirstShredReceived + Completed + CreatedBank + Frozen + Dead + OptimisticConfirmation + Root + Unknown +``` + +Les sept variantes courantes conservent leurs champs spécifiques. `createdBank` exige `parent`, `frozen` exige `stats`, `dead` exige `err`. + +Une nouvelle valeur upstream du champ `type` devient : + +```text +Unknown { update_type, raw } +``` + +au lieu de faire échouer la subscription. Le `raw` est déjà borné par `WsSessionSettings.max_message_size_bytes` avant le parse JSON. Le fallback ne masque pas les violations d'une variante déjà connue : une forme connue mais structurellement invalide reste `invalid_response` pour la subscription concernée. + +## `voteSubscribe` + +Nouveau DTO public : + +```text +SolanaVoteNotification + vote_pubkey : Pubkey + slots : Vec + hash : String + timestamp : Option + signature : String +``` + +`timestamp` conserve de manière tolérante les trois formes wire retenues par l'audit : omitted, null et valeur `i64`. Omission et null deviennent `None`; une valeur devient `Some(i64)`. + +Les votes restent des observations gossip pre-consensus. Transport ne leur attribue aucune sémantique de confirmation ledger. + +## Warning unstable centralisé + +`WsSubscriptionKind` connaît désormais exactement la partition unstable : + +```text +Block +SlotsUpdates +Vote +``` + +Le point commun `WsSession::subscribe_typed_with_completion` appelle `WsSubscriptionKind::warn_if_unstable()` avant la création logique. Le warning passe exclusivement par `ksp-logging-lib` et contient seulement : + +```text +rpc_method +subscription_kind +documentation_status = unstable +``` + +Il n'inclut jamais filtre, pubkey, signature, payload, remote subscription ID, URL ou credential. Le warning n'est pas répété à chaque notification. + +## Tests + +Neuf tests unitaires supplémentaires couvrent : + +```text +block config complet + processed rejeté +block notification null/block + shared SolanaConfirmedBlock +block payload > 1232 octets sous borne WS +block request exact + notification + blockUnsubscribe +block RPC capability error sans échec session +7 variantes slotsUpdates + Unknown raw +champs obligatoires createdBank/frozen/dead +vote timestamp omitted/null/value +slotsUpdates + vote end-to-end + unsubscribe +partition unstable exacte +``` + +Le nombre de fonctions `#[test]` supplémentaires est neuf parce que le test block notification agrège aussi la preuve de payload >1232 et le test lifecycle agrège la partition warning. Un canari d'API publique supplémentaire vérifie les nouveaux wrappers et DTOs depuis la racine de crate. + +Comptages attendus après compilation : + +```text +Transport unit tests = 309 +Transport public API tests = 35 +release completeness = 22 +``` + +## Fichiers ajoutés + +```text +crates/ksp-onchain-transport-lib/src/ws_blocks.rs +crates/ksp-onchain-transport-lib/unit_tests/ws_blocks.rs +deltas/0.2.7/pre.011.md +``` + +## Fichiers 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_lifecycle.rs +crates/ksp-onchain-transport-lib/src/ws_session.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_lifecycle.rs +docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md +docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md +``` + +`ROADMAP.md` et `CHANGELOG.md` restent inchangés. + +## Validation de préparation + +Le sandbox de préparation ne dispose pas de Cargo/rustc. Les contrôles statiques KSP sont exécutés avant packaging ; les gates compilées restent opérateur. + +## 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 +``` + +Si ce checkpoint est vert, `pre.012` réalise la compliance WebSocket **18/18**, les canaris de composition Config et les régressions HTTP finales prévues par le plan. 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 b53e682..7398dec 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 @@ -876,6 +876,8 @@ warning centralisé pour unstable à la création, pas à chaque notification Le serveur local est un fixture de Transport ; aucune application Tauri n'est modifiée pour tester WebSocket. +Checkpoint `pre.011` : les trois familles unstable sont désormais matérialisées. `blockSubscribe` réutilise le DTO bloc HTTP lossless, rejette `processed`, conserve un `maxSupportedTransactionVersion` numérique non figé à zéro et couvre une notification sensiblement supérieure à 1232 octets sous les bornes KSP. `slotsUpdatesSubscribe` couvre les sept variantes connues et un fallback `Unknown` raw borné par la limite de message. `voteSubscribe` conserve `timestamp` omitted/null/value. Le warning unstable est émis au point commun de création des subscriptions typed et non dans chaque wrapper. + ## 19. Smoke live opt-in Après stabilisation : un test `#[ignore]` Transport pur sur Devnet, préférentiellement une subscription stable simple (`slotSubscribe`) : @@ -945,7 +947,7 @@ pre.007 DONE — reconnect borné + resubscribe déterministe + continuity gap 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 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.011 DONE — 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 pre.014 validation workspace finale + docs/compliance + prompt 0.2.8 diff --git a/docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md b/docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md index a5d6125..f39ec13 100644 --- a/docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md +++ b/docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md @@ -1,5 +1,5 @@ - + # Validation `0.2.7` — WebSocket Solana standard @@ -37,8 +37,8 @@ Pour une paire unstable, l'unsubscribe associé est classé `Unstable pair` dans |---:|---------------------------|-------------|-------------------|--------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------|---------------------------------------------------------------|-----------------------------------------------------------------|-------------------| | 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` | +| 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` | Done `pre.011` | +| 4 | `blockUnsubscribe` | unsubscribe | **Unstable pair** | remote id ; boolean/error | block pair | generic registry unsubscribe | `https://solana.com/docs/rpc/websocket/blockunsubscribe` | Done `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` | 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` | @@ -49,10 +49,10 @@ Pour une paire unstable, l'unsubscribe associé est classé `Unstable pair` dans | 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` | -| 18 | `voteUnsubscribe` | unsubscribe | **Unstable pair** | remote id ; boolean/error | vote pair | generic registry unsubscribe | `https://solana.com/docs/rpc/websocket/voteunsubscribe` | Planned `pre.011` | +| 15 | `slotsUpdatesSubscribe` | subscribe | **Unstable** | no params ; numeric id | tagged `slotsUpdatesNotification` | each known variant + unknown fallback | `https://solana.com/docs/rpc/websocket/slotsupdatessubscribe` | Done `pre.011` | +| 16 | `slotsUpdatesUnsubscribe` | unsubscribe | **Unstable pair** | remote id ; boolean/error | slotsUpdates pair | generic registry unsubscribe | `https://solana.com/docs/rpc/websocket/slotsupdatesunsubscribe` | Done `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` | Done `pre.011` | +| 18 | `voteUnsubscribe` | unsubscribe | **Unstable pair** | remote id ; boolean/error | vote pair | generic registry unsubscribe | `https://solana.com/docs/rpc/websocket/voteunsubscribe` | Done `pre.011` | ## 3. Notification matrix @@ -557,6 +557,58 @@ 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. +## 9.9 Checkpoint familles unstable `pre.011` + +Surface publique ajoutée : + +```text +WsSession::block_subscribe +SolanaBlockSubscribeFilter = All | MentionsAccountOrProgram(Pubkey) +SolanaBlockSubscribeConfig = commitment + encoding + transactionDetails + maxSupportedTransactionVersion + showRewards +SolanaBlockNotification = slot + block nullable + err nullable + +WsSession::slots_updates_subscribe +SolanaSlotUpdate = 7 variantes connues + Unknown raw borné +SolanaSlotUpdateStats + +WsSession::vote_subscribe +SolanaVoteNotification = votePubkey + slots + hash + timestamp optionnel + signature +``` + +Gates déterministes ajoutés : + +```text +partition unstable exacte = block + slotsUpdates + vote +warning centralisé dans le moteur typed avant création d'une famille unstable +block filter all + mentionsAccountOrProgram +block config -> confirmed/finalized seulement ; processed rejeté avant I/O +block config -> binary/base58/base64/json/jsonParsed + full/accounts/signatures/none +block maxSupportedTransactionVersion -> valeur numérique générique, pas de hardcode 0 +block notification -> context + slot + block nullable + err nullable +block payload > 1232 octets -> accepté tant qu'il reste sous les limites WS KSP +block capability RPC error -> erreur applicative caller, session physique reste Active +blockUnsubscribe -> remote ID interne via handle +slotsUpdates -> 7 variantes actuelles exactes +slotsUpdates future type -> Unknown avec raw conservé sous la borne message +createdBank/frozen/dead -> champs spécifiques requis +slotsUpdatesSubscribe/Unsubscribe -> params vides + remote ID interne +vote timestamp omitted/null/value -> None/None/Some(i64) +votePubkey -> Pubkey typed ; slots ordonnés ; hash/signature opaques +voteSubscribe/Unsubscribe -> params vides + remote ID interne +``` + +La protection `Unknown` de `slotsUpdates` est volontairement limitée aux **nouvelles variantes de type**. Une variante connue avec des champs obligatoires invalides reste `invalid_response` pour la subscription concernée : KSP ne masque pas une rupture du contrat déjà audité. + +Le warning unstable ne contient ni filtre, ni pubkey, ni payload, ni remote subscription ID, ni URL. Les trois familles restent continues et réutilisent le lifecycle générique reconnect/resubscribe/backpressure/cancellation. + +Comptages attendus après compilation : + +```text +Transport unit tests = 309 +Transport public API tests = 35 +release completeness = 22 +``` + ## 10. Validation du gate `pre.001` Exécuté dans le sandbox :