From 1c8d69778bcb8c37484b231754af17cbe3d91fef Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Sun, 23 Aug 2026 15:41:54 +0200 Subject: [PATCH] v0.2.8-pre.006 --- Cargo.toml | 4 +- crates/ksp-onchain-transport-lib/src/lib.rs | 16 +- .../src/ws_helius_transactions.rs | 215 +++++++++++--- .../src/ws_lifecycle.rs | 22 +- .../src/ws_protocol_session.rs | 8 +- .../src/ws_subscription.rs | 12 +- .../tests/public_api.rs | 11 +- .../tests/release_completeness.rs | 16 +- .../unit_tests/ws_helius_transactions.rs | 281 +++++++++++++++--- .../unit_tests/ws_lifecycle.rs | 12 +- deltas/0.2.8/pre.006.md | 235 +++++++++++++++ ...0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md | 79 ++++- ...011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md | 72 ++++- 13 files changed, 862 insertions(+), 121 deletions(-) create mode 100644 deltas/0.2.8/pre.006.md diff --git a/Cargo.toml b/Cargo.toml index 5c5f72a..9673995 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 225 +# version: 226 [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.8-pre.5.fix.2" +version = "0.2.8-pre.6" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-onchain-transport-lib/src/lib.rs b/crates/ksp-onchain-transport-lib/src/lib.rs index 7b9c11f..c55ad80 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: 32 +// version: 33 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -27,8 +27,8 @@ //! provider-extension subscription API. `0.2.8-pre.002` adds a Helius LaserStream WebSocket protocol discriminator and two typed protocol facades while //! keeping the `WsSession` actor/socket implementation unique and the historical generic constructor standard-only. //! `0.2.8-pre.003` exposes the six standard families Helius supports through the provider facade, while `0.2.8-pre.005` adds the typed Helius -//! `transactionSubscribe` request contract, provider filter/options validation and exact subscribe/unsubscribe control-wire helpers without exposing a live -//! transaction subscription handle before actor integration. +//! `transactionSubscribe` request contract and provider filter/options validation. `0.2.8-pre.006` integrates the live transaction handle and typed +//! `transactionNotification` union into the same actor-owned registry, remote-ID remap, unsubscribe-race handling and per-subscription backpressure path. mod client; mod constants; @@ -342,8 +342,14 @@ pub use self::ws_cluster::SolanaSlotUpdate; pub use self::ws_cluster::SolanaSlotUpdateStats; /// Typed unstable gossip-vote notification delivered by standard Solana `voteSubscribe`. pub use self::ws_cluster::SolanaVoteNotification; +/// Full/accounts-mode notification delivered by Helius `transactionSubscribe`. +pub use self::ws_helius_transactions::HeliusFullTransactionNotification; /// Helius `tokenAccounts` expansion mode accepted by `transactionSubscribe`. pub use self::ws_helius_transactions::HeliusTokenAccountsFilter; +/// Typed Helius `transactionNotification` payload union. +pub use self::ws_helius_transactions::HeliusTransactionNotification; +/// Signatures-mode notification delivered by Helius `transactionSubscribe`. +pub use self::ws_helius_transactions::HeliusTransactionSignatureNotification; /// Transaction encoding accepted by Helius `transactionSubscribe`. pub use self::ws_helius_transactions::HeliusTransactionSubscribeEncoding; /// Helius-specific filter object accepted as the first `transactionSubscribe` parameter. @@ -360,7 +366,7 @@ pub use self::ws_lifecycle::WsSessionSnapshot; pub use self::ws_lifecycle::WsSessionState; /// Stable local identity assigned to one logical WebSocket subscription. pub use self::ws_lifecycle::WsSubscriptionId; -/// Standard Solana subscription family represented by one logical WebSocket subscription. +/// WebSocket subscription family represented by one logical subscription. pub use self::ws_lifecycle::WsSubscriptionKind; /// Safe lifecycle projection for one logical WebSocket subscription. pub use self::ws_lifecycle::WsSubscriptionSnapshot; @@ -390,7 +396,7 @@ pub use self::ws_settings::WsResubscribePolicy; pub use self::ws_settings::WsSessionSettings; /// Complete runtime settings consumed by the KSP WebSocket transport foundation. pub use self::ws_settings::WsTransportSettings; -/// Typed handle for one logical Solana WebSocket subscription. +/// Typed handle for one logical WebSocket subscription. pub use self::ws_subscription::WsSubscription; /// Typed value carried by a contextual Solana `logsNotification`. pub use self::ws_transactions::SolanaLogsNotification; diff --git a/crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs b/crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs index d320a40..a1942a6 100644 --- a/crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs +++ b/crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs -// version: 3 +// version: 4 const MAX_HELIUS_TRANSACTION_FILTER_ACCOUNTS: usize = 50_000; @@ -150,7 +150,6 @@ impl HeliusTransactionSubscribeFilter { return std::result::Result::Ok(()); } - #[cfg(test)] fn to_json_value(&self) -> serde_json::Value { let mut object = serde_json::Map::new(); if let std::option::Option::Some(vote) = self.vote { @@ -261,7 +260,6 @@ impl HeliusTransactionSubscribeOptions { return std::result::Result::Ok(()); } - #[cfg(test)] fn to_json_value(self) -> serde_json::Value { let mut object = serde_json::Map::new(); if let std::option::Option::Some(commitment) = self.commitment { @@ -283,11 +281,10 @@ impl HeliusTransactionSubscribeOptions { } } -/// Complete typed request contract for Helius `transactionSubscribe` before actor registration. +/// Complete typed request contract for Helius `transactionSubscribe`. /// -/// The request owns the exact provider filter and optional result-shaping object. `pre.005` deliberately does not expose a public live subscription method: -/// actor-owned registration, notification delivery, reconnect and unsubscribe races are added atomically in `pre.006` so callers never receive an incomplete -/// provider subscription handle. +/// The request owns the exact provider filter and optional result-shaping object. Validation and serialization occur before actor registration so deterministic +/// provider constraints fail without WebSocket I/O. #[derive(Clone, Eq, PartialEq)] pub struct HeliusTransactionSubscribeRequest { filter: crate::HeliusTransactionSubscribeFilter, @@ -335,7 +332,6 @@ impl std::fmt::Debug for HeliusTransactionSubscribeRequest { } } -#[cfg(test)] fn helius_transaction_subscribe_params(request: &crate::HeliusTransactionSubscribeRequest) -> ksp_core_lib::Result> { let validation = request.validate(); if let std::result::Result::Err(error) = validation { @@ -348,41 +344,189 @@ fn helius_transaction_subscribe_params(request: &crate::HeliusTransactionSubscri return std::result::Result::Ok(params); } -#[cfg(test)] -const fn helius_transaction_subscribe_method() -> &'static str { - return "transactionSubscribe"; +/// Full/accounts-mode notification delivered by Helius `transactionSubscribe`. +/// +/// The nested transaction payload is deliberately retained as JSON because its exact Solana wire representation depends on the requested encoding and detail +/// mode. KSP types the stable provider envelope while preserving the full nested payload without Program-specific decoding. +#[derive(Clone, Debug, PartialEq)] +pub struct HeliusFullTransactionNotification { + transaction: serde_json::Value, + signature: std::string::String, + slot: u64, + transaction_index: u64, } -#[cfg(test)] -const fn helius_transaction_unsubscribe_method() -> &'static str { - return "transactionUnsubscribe"; +impl HeliusFullTransactionNotification { + /// Returns the provider transaction/status payload without interpreting Program-specific contents. + #[must_use] + pub const fn transaction(&self) -> &serde_json::Value { + return &self.transaction; + } + + /// Returns the base58 transaction signature reported by Helius. + #[must_use] + pub fn signature(&self) -> &str { + return self.signature.as_str(); + } + + /// Returns the slot in which the transaction was processed. + #[must_use] + pub const fn slot(&self) -> u64 { + return self.slot; + } + + /// Returns the zero-based transaction position within the block. + #[must_use] + pub const fn transaction_index(&self) -> u64 { + return self.transaction_index; + } } -#[cfg(test)] -fn decode_helius_transaction_subscribe_result(value: serde_json::Value) -> ksp_core_lib::Result { - return match value.as_u64() { - std::option::Option::Some(remote_id) => std::result::Result::Ok(remote_id), - std::option::Option::None => std::result::Result::Err( - ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "Helius transactionSubscribe acknowledgement must contain a numeric subscription id") - .with_context("rpc_method", "transactionSubscribe"), - ), - }; +/// Signatures-mode notification delivered by Helius `transactionSubscribe`. +#[derive(Clone, Debug, PartialEq)] +pub struct HeliusTransactionSignatureNotification { + signature: std::string::String, + slot: u64, + transaction_index: u64, + err: crate::SolanaWireField, + memo: crate::SolanaWireField, + block_time: crate::SolanaWireField, + confirmation_status: crate::SolanaWireField, } -#[cfg(test)] -fn helius_transaction_unsubscribe_params(remote_id: u64) -> std::vec::Vec { - return std::vec![serde_json::Value::Number(remote_id.into())]; +impl HeliusTransactionSignatureNotification { + /// Returns the base58 transaction signature reported by Helius. + #[must_use] + pub fn signature(&self) -> &str { + return self.signature.as_str(); + } + + /// Returns the slot in which the transaction was processed. + #[must_use] + pub const fn slot(&self) -> u64 { + return self.slot; + } + + /// Returns the zero-based transaction position within the block. + #[must_use] + pub const fn transaction_index(&self) -> u64 { + return self.transaction_index; + } + + /// Returns the optional transaction error while preserving omitted/null/value wire states. + #[must_use] + pub const fn err(&self) -> &crate::SolanaWireField { + return &self.err; + } + + /// Returns the optional memo while preserving omitted/null/value wire states. + #[must_use] + pub const fn memo(&self) -> &crate::SolanaWireField { + return &self.memo; + } + + /// Returns the optional block time while preserving omitted/null/value wire states. + #[must_use] + pub const fn block_time(&self) -> &crate::SolanaWireField { + return &self.block_time; + } + + /// Returns the optional confirmation-status label while preserving omitted/null/value wire states. + #[must_use] + pub const fn confirmation_status(&self) -> &crate::SolanaWireField { + return &self.confirmation_status; + } } -#[cfg(test)] -fn decode_helius_transaction_unsubscribe_result(value: serde_json::Value) -> ksp_core_lib::Result { - return match value.as_bool() { - std::option::Option::Some(unsubscribed) => std::result::Result::Ok(unsubscribed), - std::option::Option::None => std::result::Result::Err( - ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "Helius transactionUnsubscribe acknowledgement must contain a boolean result") - .with_context("rpc_method", "transactionUnsubscribe"), - ), - }; +/// Typed Helius `transactionNotification` payload union. +/// +/// `Full` also covers the provider `accounts` detail mode because both contain the nested `transaction` member. `Signature` covers the lightweight +/// signatures mode. `Unknown` preserves `none` mode and forward-compatible provider shapes instead of failing the logical subscription. +#[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] +pub enum HeliusTransactionNotification { + /// Full/accounts notification carrying the nested transaction payload. + Full(crate::HeliusFullTransactionNotification), + /// Lightweight signatures notification. + Signature(crate::HeliusTransactionSignatureNotification), + /// Provider shape not currently typed by KSP, preserved losslessly. + Unknown(serde_json::Value), +} + +impl crate::HeliusLaserStreamWsSession { + /// Opens one Helius `transactionSubscribe` logical subscription through the shared physical actor. + /// + /// The returned handle keeps a stable local identity across physical reconnects. Helius remote subscription IDs stay actor-private and are remapped after + /// resubscribe. Calling [`crate::WsSubscription::unsubscribe`] removes the remote mapping before sending `transactionUnsubscribe`, so provider messages + /// already in flight after cancellation are ignored without reactivating the logical subscription. + pub async fn transaction_subscribe( + &self, + request: &crate::HeliusTransactionSubscribeRequest, + ) -> ksp_core_lib::Result> { + let params = helius_transaction_subscribe_params(request); + let params = match params { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return self + .physical_session() + .subscribe_typed(crate::WsSubscriptionKind::HeliusTransaction, params, |value| return decode_helius_transaction_notification(value)) + .await; + } +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct WireHeliusFullTransactionNotification { + transaction: serde_json::Value, + signature: std::string::String, + slot: u64, + transaction_index: u64, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct WireHeliusTransactionSignatureNotification { + signature: std::string::String, + slot: u64, + transaction_index: u64, + #[serde(default)] + err: crate::SolanaWireField, + #[serde(default)] + memo: crate::SolanaWireField, + #[serde(default)] + block_time: crate::SolanaWireField, + #[serde(default)] + confirmation_status: crate::SolanaWireField, +} + +fn decode_helius_transaction_notification(value: serde_json::Value) -> ksp_core_lib::Result { + if value.get("transaction").is_some() { + let decoded = crate::decode_wire_json::("transactionNotification", value.clone()); + if let std::result::Result::Ok(decoded) = decoded { + return std::result::Result::Ok(crate::HeliusTransactionNotification::Full(crate::HeliusFullTransactionNotification { + transaction: decoded.transaction, + signature: decoded.signature, + slot: decoded.slot, + transaction_index: decoded.transaction_index, + })); + } + } + if value.get("signature").is_some() && value.get("slot").is_some() && value.get("transactionIndex").is_some() { + let decoded = crate::decode_wire_json::("transactionNotification", value.clone()); + if let std::result::Result::Ok(decoded) = decoded { + return std::result::Result::Ok(crate::HeliusTransactionNotification::Signature(crate::HeliusTransactionSignatureNotification { + signature: decoded.signature, + slot: decoded.slot, + transaction_index: decoded.transaction_index, + err: decoded.err, + memo: decoded.memo, + block_time: decoded.block_time, + confirmation_status: decoded.confirmation_status, + })); + } + } + return std::result::Result::Ok(crate::HeliusTransactionNotification::Unknown(value)); } fn validate_account_list(field: &'static str, accounts: std::option::Option<&[ksp_core_lib::Pubkey]>) -> ksp_core_lib::Result<()> { @@ -400,7 +544,6 @@ fn validate_account_list(field: &'static str, accounts: std::option::Option<&[ks return std::result::Result::Ok(()); } -#[cfg(test)] fn insert_account_list( object: &mut serde_json::Map, field: &'static str, diff --git a/crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs b/crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs index 764f147..94bf260 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: 6 +// version: 7 /// Stable local identity assigned to one physical WebSocket session. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] @@ -76,7 +76,7 @@ pub enum WsSubscriptionState { Failed, } -/// Standard Solana subscription family represented by one logical WebSocket subscription. +/// WebSocket subscription family represented by one logical subscription. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[non_exhaustive] pub enum WsSubscriptionKind { @@ -98,10 +98,12 @@ pub enum WsSubscriptionKind { SlotsUpdates, /// `voteSubscribe` family. Vote, + /// Helius LaserStream WebSocket `transactionSubscribe` extension family. + HeliusTransaction, } impl WsSubscriptionKind { - /// Returns the stable KSP descriptor for this standard subscription family. + /// Returns the stable KSP descriptor for this WebSocket subscription family. #[must_use] pub const fn as_str(self) -> &'static str { return match self { @@ -114,10 +116,11 @@ impl WsSubscriptionKind { Self::Slot => "slot", Self::SlotsUpdates => "slots_updates", Self::Vote => "vote", + Self::HeliusTransaction => "helius_transaction", }; } - /// Returns the exact standard Solana subscribe JSON-RPC method for this family. + /// Returns the exact subscribe JSON-RPC method for this family. pub(crate) const fn subscribe_method(self) -> &'static str { return match self { Self::Account => "accountSubscribe", @@ -129,10 +132,11 @@ impl WsSubscriptionKind { Self::Slot => "slotSubscribe", Self::SlotsUpdates => "slotsUpdatesSubscribe", Self::Vote => "voteSubscribe", + Self::HeliusTransaction => "transactionSubscribe", }; } - /// Returns the exact standard Solana unsubscribe JSON-RPC method for this family. + /// Returns the exact unsubscribe JSON-RPC method for this family. pub(crate) const fn unsubscribe_method(self) -> &'static str { return match self { Self::Account => "accountUnsubscribe", @@ -144,10 +148,11 @@ impl WsSubscriptionKind { Self::Slot => "slotUnsubscribe", Self::SlotsUpdates => "slotsUpdatesUnsubscribe", Self::Vote => "voteUnsubscribe", + Self::HeliusTransaction => "transactionUnsubscribe", }; } - /// Returns the exact standard Solana notification method emitted for this family. + /// Returns the exact notification method emitted for this family. pub(crate) const fn notification_method(self) -> &'static str { return match self { Self::Account => "accountNotification", @@ -159,10 +164,13 @@ impl WsSubscriptionKind { Self::Slot => "slotNotification", Self::SlotsUpdates => "slotsUpdatesNotification", Self::Vote => "voteNotification", + Self::HeliusTransaction => "transactionNotification", }; } /// Returns whether Solana documents this standard subscription family as unstable. + /// + /// Provider extensions are stable here unless explicitly classified otherwise. pub(crate) const fn is_unstable(self) -> bool { return matches!(self, Self::Block | Self::SlotsUpdates | Self::Vote); } @@ -211,7 +219,7 @@ impl WsSubscriptionSnapshot { return self.id; } - /// Returns the standard subscription family. + /// Returns the logical WebSocket subscription family. #[must_use] pub const fn kind(&self) -> crate::WsSubscriptionKind { return self.kind; diff --git a/crates/ksp-onchain-transport-lib/src/ws_protocol_session.rs b/crates/ksp-onchain-transport-lib/src/ws_protocol_session.rs index 457753b..abcb287 100644 --- a/crates/ksp-onchain-transport-lib/src/ws_protocol_session.rs +++ b/crates/ksp-onchain-transport-lib/src/ws_protocol_session.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/src/ws_protocol_session.rs -// version: 3 +// version: 4 /// Typed facade for one standard Solana WebSocket physical session. /// @@ -58,9 +58,9 @@ impl std::fmt::Debug for SolanaStandardWsSession { /// Typed facade for one Helius LaserStream WebSocket physical session. /// -/// The facade exposes the six standard Solana subscription families that Helius documents as supported. `0.2.8-pre.005` also publishes the typed Helius -/// transaction request/filter/options contract, but the live transaction-subscription method remains intentionally absent until `pre.006` integrates -/// `transactionNotification`, reconnect and unsubscribe races into the shared actor. No public inner handle is exposed, so callers cannot bypass the +/// The facade exposes the six standard Solana subscription families that Helius documents as supported plus the Helius-specific typed +/// `transactionSubscribe` lifecycle. Transaction notifications, reconnect/resubscribe, unsubscribe races and bounded backpressure all delegate to the same +/// shared [`crate::WsSession`] actor; the facade owns no second socket, registry or queue. No public inner handle is exposed, so callers cannot bypass the /// provider-specific surface by recovering a generic [`crate::WsSession`]. /// /// ```compile_fail diff --git a/crates/ksp-onchain-transport-lib/src/ws_subscription.rs b/crates/ksp-onchain-transport-lib/src/ws_subscription.rs index 184f9ea..3bf75f3 100644 --- a/crates/ksp-onchain-transport-lib/src/ws_subscription.rs +++ b/crates/ksp-onchain-transport-lib/src/ws_subscription.rs @@ -1,7 +1,7 @@ // file: crates/ksp-onchain-transport-lib/src/ws_subscription.rs -// version: 5 +// version: 6 -/// Typed handle for one logical Solana WebSocket subscription. +/// Typed handle for one logical WebSocket subscription. /// /// The handle owns the bounded typed notification receiver while the physical session actor owns the remote subscription identity and socket. The remote /// numeric subscription identifier is intentionally never exposed because it is transient and is remapped by the session actor after reconnect. @@ -43,7 +43,7 @@ impl WsSubscription { return self.id; } - /// Returns the standard Solana subscription family. + /// Returns the logical WebSocket subscription family. #[must_use] pub const fn kind(&self) -> crate::WsSubscriptionKind { return self.kind; @@ -71,7 +71,7 @@ impl WsSubscription { return self.notification_rx.recv().await; } - /// Cancels this logical subscription and sends the matching Solana unsubscribe request when a remote binding still exists. + /// Cancels this logical subscription and sends the matching protocol unsubscribe request when a remote binding still exists. /// /// The returned boolean preserves the standard Solana unsubscribe result when the current remote binding is reachable. Local cancellation is terminal /// for this handle; during reconnect it wins before resubscribe selection, and a late remote acknowledgement is cleaned up best-effort without @@ -198,11 +198,11 @@ impl WsSubscriptionRegistration { pub(crate) struct WsSubscriptionRuntime { /// Stable local identity. pub(crate) id: crate::WsSubscriptionId, - /// Standard Solana subscription family. + /// WebSocket subscription family. pub(crate) kind: crate::WsSubscriptionKind, /// Current logical lifecycle state. pub(crate) state: crate::WsSubscriptionState, - /// Original standard subscribe parameters retained internally for deterministic resubscribe. + /// Original subscribe parameters retained internally for deterministic resubscribe. pub(crate) params: std::vec::Vec, /// Current transient remote subscription identity when bound. pub(crate) remote_id: std::option::Option, diff --git a/crates/ksp-onchain-transport-lib/tests/public_api.rs b/crates/ksp-onchain-transport-lib/tests/public_api.rs index 41d8323..c618642 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: 36 +// version: 37 //! Integration tests for the public `ksp-onchain-transport-lib` consumer contract. @@ -744,3 +744,12 @@ fn public_v0_2_8_pre_005_helius_transaction_request_contract_is_available_withou assert_eq!(ksp_onchain_transport_lib::HeliusTokenAccountsFilter::BalanceChanged.as_str(), "balanceChanged"); assert_eq!(ksp_onchain_transport_lib::HeliusTokenAccountsFilter::All.as_str(), "all"); } + +#[test] +fn public_v0_2_8_pre_006_helius_transaction_live_handle_and_notification_types_are_available() { + let _subscribe = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::transaction_subscribe; + let _notification = std::any::type_name::(); + let _full = std::any::type_name::(); + let _signature = std::any::type_name::(); + assert_eq!(ksp_onchain_transport_lib::WsSubscriptionKind::HeliusTransaction.as_str(), "helius_transaction"); +} diff --git a/crates/ksp-onchain-transport-lib/tests/release_completeness.rs b/crates/ksp-onchain-transport-lib/tests/release_completeness.rs index b6111b6..842e944 100644 --- a/crates/ksp-onchain-transport-lib/tests/release_completeness.rs +++ b/crates/ksp-onchain-transport-lib/tests/release_completeness.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs -// version: 27 +// version: 28 //! Release-level completeness canaries for staged HTTP and WebSocket Transport coverage. @@ -836,3 +836,17 @@ fn release_v0_2_8_pre_005_helius_transaction_request_surface_is_typed_before_act assert!(!facade_source.contains("pub async fn transaction_subscribe")); assert_eq!(ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream.as_str(), "helius_laserstream"); } + +#[test] +fn release_v0_2_8_pre_006_helius_transaction_lifecycle_is_actor_integrated_without_advancing_heartbeat() { + let _transaction = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::transaction_subscribe; + assert_eq!(ksp_onchain_transport_lib::WsSubscriptionKind::HeliusTransaction.as_str(), "helius_transaction"); + let source = include_str!("../src/ws_helius_transactions.rs"); + assert!(source.contains("transactionNotification")); + assert!(source.contains("WsSubscriptionKind::HeliusTransaction")); + assert!(!source.contains("tokio_tungstenite::connect_async")); + let protocol_source = include_str!("../src/ws_protocol_session.rs"); + assert!(protocol_source.contains("unsupported_block")); + assert!(protocol_source.contains("unsupported_slots_updates")); + assert!(protocol_source.contains("unsupported_vote")); +} diff --git a/crates/ksp-onchain-transport-lib/unit_tests/ws_helius_transactions.rs b/crates/ksp-onchain-transport-lib/unit_tests/ws_helius_transactions.rs index b98b309..af85fa3 100644 --- a/crates/ksp-onchain-transport-lib/unit_tests/ws_helius_transactions.rs +++ b/crates/ksp-onchain-transport-lib/unit_tests/ws_helius_transactions.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/unit_tests/ws_helius_transactions.rs -// version: 3 +// version: 4 use futures_util::SinkExt; // rust-rules: trait-import use futures_util::StreamExt; // rust-rules: trait-import @@ -29,6 +29,10 @@ fn assert_oversized_filter_rejected(filter: crate::HeliusTransactionSubscribeFil } fn helius_endpoint(url: &str) -> crate::WsEndpointSettings { + return helius_endpoint_with_session(url, crate::WsSessionSettings::default()); +} + +fn helius_endpoint_with_session(url: &str, session: crate::WsSessionSettings) -> crate::WsEndpointSettings { return crate::WsEndpointSettings::new( "local_helius_transaction_fixture", true, @@ -36,7 +40,41 @@ fn helius_endpoint(url: &str) -> crate::WsEndpointSettings { crate::WsClusterName::new("local"), crate::WsProtocolKind::HeliusLaserStream, crate::WsEndpointUrl::parse(url).expect("local Helius WebSocket URL must parse"), - crate::WsSessionSettings::default(), + session, + ); +} + +fn reconnect_session_settings(backoff: std::time::Duration) -> crate::WsSessionSettings { + let defaults = crate::WsSessionSettings::default(); + return crate::WsSessionSettings::new( + std::time::Duration::from_millis(250), + std::time::Duration::from_millis(200), + crate::WsReconnectSettings::new(2, backoff, backoff), + crate::WsResubscribePolicy::ActiveSubscriptions, + defaults.command_queue_capacity(), + defaults.notification_queue_capacity(), + defaults.max_active_subscriptions(), + defaults.max_pending_requests(), + defaults.max_message_size_bytes(), + defaults.max_frame_size_bytes(), + defaults.max_write_buffer_size_bytes(), + ); +} + +fn backpressure_session_settings() -> crate::WsSessionSettings { + let defaults = crate::WsSessionSettings::default(); + return crate::WsSessionSettings::new( + std::time::Duration::from_millis(250), + std::time::Duration::from_millis(200), + crate::WsReconnectSettings::new(0, std::time::Duration::from_millis(10), std::time::Duration::from_millis(10)), + crate::WsResubscribePolicy::ActiveSubscriptions, + defaults.command_queue_capacity(), + 1, + 2, + defaults.max_pending_requests(), + defaults.max_message_size_bytes(), + defaults.max_frame_size_bytes(), + defaults.max_write_buffer_size_bytes(), ); } @@ -59,6 +97,54 @@ async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream, subscription: u64, result: serde_json::Value) { + let notification = serde_json::json!({"jsonrpc":"2.0","method":"transactionNotification","params":{"subscription":subscription,"result":result}}); + websocket.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into())).await.expect("local notification must send"); + return; +} + +async fn send_root_notification(websocket: &mut tokio_tungstenite::WebSocketStream, subscription: u64, root: u64) { + let notification = serde_json::json!({"jsonrpc":"2.0","method":"rootNotification","params":{"subscription":subscription,"result":root}}); + websocket + .send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into())) + .await + .expect("local root notification must send"); + return; +} + +async fn wait_for_gap_count(session: &crate::HeliusLaserStreamWsSession, expected: u64) { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + if session.snapshot().continuity_gap_count() >= expected { + return; + } + assert!(tokio::time::Instant::now() < deadline, "Helius session continuity gap count must advance before timeout"); + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } +} + +async fn wait_for_overflow_count(session: &crate::HeliusLaserStreamWsSession, expected: u64) { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + if session.snapshot().overflow_count() >= expected { + return; + } + assert!(tokio::time::Instant::now() < deadline, "Helius session overflow count must advance before timeout"); + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } +} + +async fn wait_for_subscription_state(subscription: &crate::WsSubscription, expected: crate::WsSubscriptionState) { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + if subscription.state() == expected { + return; + } + assert!(tokio::time::Instant::now() < deadline, "Helius logical subscription state must advance before timeout"); + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } +} + async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream) { loop { let message = websocket.next().await; @@ -237,24 +323,48 @@ fn helius_transaction_details_require_max_supported_version_only_for_accounts_an } #[test] -fn helius_transaction_subscribe_and_unsubscribe_control_wire_is_exact() { - assert_eq!(super::helius_transaction_subscribe_method(), "transactionSubscribe"); - assert_eq!(super::helius_transaction_unsubscribe_method(), "transactionUnsubscribe"); - assert_eq!( - super::decode_helius_transaction_subscribe_result(serde_json::json!(4_743_323_479_349_712_u64)).expect("numeric ack must decode"), - 4_743_323_479_349_712 - ); - assert_eq!(super::helius_transaction_unsubscribe_params(4_743_323_479_349_712), std::vec![serde_json::json!(4_743_323_479_349_712_u64)]); - assert!(super::decode_helius_transaction_unsubscribe_result(serde_json::json!(true)).expect("boolean true must decode")); - assert!(!super::decode_helius_transaction_unsubscribe_result(serde_json::json!(false)).expect("boolean false must decode")); - assert_eq!( - super::decode_helius_transaction_subscribe_result(serde_json::json!("not-an-id")).expect_err("non-numeric subscribe ack must fail").code(), - crate::ERROR_CODE_INVALID_RESPONSE - ); - assert_eq!( - super::decode_helius_transaction_unsubscribe_result(serde_json::json!(1)).expect_err("non-boolean unsubscribe ack must fail").code(), - crate::ERROR_CODE_INVALID_RESPONSE - ); +fn helius_transaction_notification_decoder_preserves_full_signature_and_unknown_shapes() { + let full_value = serde_json::json!({ + "transaction":{"transaction":["AAAA","base64"],"meta":{"err":null}}, + "signature":"full-signature", + "slot":224341380, + "transactionIndex":42 + }); + let full = super::decode_helius_transaction_notification(full_value.clone()).expect("full Helius notification must decode"); + match full { + crate::HeliusTransactionNotification::Full(notification) => { + assert_eq!(notification.transaction(), &full_value["transaction"]); + assert_eq!(notification.signature(), "full-signature"); + assert_eq!(notification.slot(), 224341380); + assert_eq!(notification.transaction_index(), 42); + }, + _ => panic!("transaction member must select the full Helius notification variant"), + } + let signature_value = serde_json::json!({ + "signature":"signature-only", + "slot":224341381, + "transactionIndex":43, + "err":null, + "memo":"memo-canary", + "blockTime":1720000000, + "confirmationStatus":"confirmed" + }); + let signature = super::decode_helius_transaction_notification(signature_value).expect("signature Helius notification must decode"); + match signature { + crate::HeliusTransactionNotification::Signature(notification) => { + assert_eq!(notification.signature(), "signature-only"); + assert_eq!(notification.slot(), 224341381); + assert_eq!(notification.transaction_index(), 43); + assert!(matches!(notification.err(), crate::SolanaWireField::Null)); + assert!(matches!(notification.memo(), crate::SolanaWireField::Value(value) if value == "memo-canary")); + assert!(matches!(notification.block_time(), crate::SolanaWireField::Value(1720000000))); + assert!(matches!(notification.confirmation_status(), crate::SolanaWireField::Value(value) if value == "confirmed")); + }, + _ => panic!("signature envelope must select the lightweight Helius notification variant"), + } + let unknown_value = serde_json::json!({"futureProviderShape":{"value":7}}); + let unknown = super::decode_helius_transaction_notification(unknown_value.clone()).expect("unknown Helius notification must remain forward-compatible"); + assert!(matches!(unknown, crate::HeliusTransactionNotification::Unknown(value) if value == unknown_value)); } #[test] @@ -271,7 +381,7 @@ fn helius_transaction_filter_debug_omits_signature_and_account_values() { } #[tokio::test(flavor = "current_thread")] -async fn helius_transaction_control_wire_round_trips_through_shared_physical_actor() { +async fn helius_transaction_live_handle_decodes_notification_and_unsubscribes_through_shared_actor() { let (listener, url) = bind_local_listener().await; let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.expect("local server must accept client"); @@ -286,6 +396,17 @@ async fn helius_transaction_control_wire_round_trips_through_shared_physical_act ]) ); send_result(&mut websocket, &subscribe, serde_json::json!(4242)).await; + send_notification( + &mut websocket, + 4242, + serde_json::json!({ + "transaction":{"transaction":["AAAA","base64"],"meta":{"err":null}}, + "signature":"live-signature", + "slot":99, + "transactionIndex":7 + }), + ) + .await; let unsubscribe = read_request(&mut websocket).await; assert_eq!(unsubscribe["method"], serde_json::json!("transactionUnsubscribe")); assert_eq!(unsubscribe["params"], serde_json::json!([4242])); @@ -310,20 +431,110 @@ async fn helius_transaction_control_wire_round_trips_through_shared_physical_act std::option::Option::Some(0), ); let request = crate::HeliusTransactionSubscribeRequest::new(filter, std::option::Option::Some(options)); - let params = super::helius_transaction_subscribe_params(&request).expect("typed Helius request must validate before I/O"); - let subscribe_result = session - .physical_session() - .execute_json_rpc(super::helius_transaction_subscribe_method(), params) - .await - .expect("transactionSubscribe acknowledgement must arrive"); - let remote_id = super::decode_helius_transaction_subscribe_result(subscribe_result).expect("transactionSubscribe id must decode"); - assert_eq!(remote_id, 4242); - let unsubscribe_result = session - .physical_session() - .execute_json_rpc(super::helius_transaction_unsubscribe_method(), super::helius_transaction_unsubscribe_params(remote_id)) - .await - .expect("transactionUnsubscribe acknowledgement must arrive"); - assert!(super::decode_helius_transaction_unsubscribe_result(unsubscribe_result).expect("transactionUnsubscribe boolean must decode")); + let mut subscription = session.transaction_subscribe(&request).await.expect("public Helius transaction subscription must register"); + assert_eq!(subscription.kind(), crate::WsSubscriptionKind::HeliusTransaction); + let notification = subscription.recv().await.expect("Helius transaction notification must arrive").expect("Helius notification must decode"); + match notification { + crate::HeliusTransactionNotification::Full(notification) => { + assert_eq!(notification.signature(), "live-signature"); + assert_eq!(notification.slot(), 99); + assert_eq!(notification.transaction_index(), 7); + }, + _ => panic!("full live payload must decode as HeliusTransactionNotification::Full"), + } + assert!(subscription.unsubscribe().await.expect("transactionUnsubscribe must complete")); + assert_eq!(subscription.state(), crate::WsSubscriptionState::Closed); session.close().await.expect("Helius fixture session must close"); server.await.expect("local Helius transaction server must finish"); } + +#[tokio::test(flavor = "current_thread")] +async fn helius_transaction_reconnect_remaps_remote_id_and_ignores_late_notification_after_unsubscribe() { + let (listener, url) = bind_local_listener().await; + let server = tokio::spawn(async move { + let (first_stream, _) = listener.accept().await.expect("initial Helius client must connect"); + let mut first = tokio_tungstenite::accept_async(first_stream).await.expect("initial Helius handshake must succeed"); + let first_subscribe = read_request(&mut first).await; + assert_eq!(first_subscribe["method"], serde_json::json!("transactionSubscribe")); + send_result(&mut first, &first_subscribe, serde_json::json!(41)).await; + send_notification(&mut first, 41, serde_json::json!({"signature":"generation-one","slot":1,"transactionIndex":0})).await; + drop(first); + let (second_stream, _) = listener.accept().await.expect("replacement Helius client must connect"); + let mut second = tokio_tungstenite::accept_async(second_stream).await.expect("replacement Helius handshake must succeed"); + let second_subscribe = read_request(&mut second).await; + assert_eq!(second_subscribe["method"], serde_json::json!("transactionSubscribe")); + assert_eq!(second_subscribe["params"], first_subscribe["params"]); + send_result(&mut second, &second_subscribe, serde_json::json!(99)).await; + send_notification(&mut second, 99, serde_json::json!({"signature":"generation-two","slot":2,"transactionIndex":1})).await; + let unsubscribe = read_request(&mut second).await; + assert_eq!(unsubscribe["method"], serde_json::json!("transactionUnsubscribe")); + assert_eq!(unsubscribe["params"], serde_json::json!([99])); + send_notification(&mut second, 99, serde_json::json!({"signature":"late-after-cancel","slot":3,"transactionIndex":2})).await; + send_result(&mut second, &unsubscribe, serde_json::json!(true)).await; + wait_for_close_frame(&mut second).await; + }); + let settings = reconnect_session_settings(std::time::Duration::from_millis(20)); + let session = crate::HeliusLaserStreamWsSession::connect(helius_endpoint_with_session(url.as_str(), settings)).await.expect("Helius facade must connect"); + let request = crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::None); + let mut subscription = session.transaction_subscribe(&request).await.expect("initial Helius transaction subscription must register"); + let stable_id = subscription.id(); + let first = subscription.recv().await.expect("first generation notification must arrive").expect("first generation notification must decode"); + assert!(matches!(first, crate::HeliusTransactionNotification::Signature(ref value) if value.signature() == "generation-one")); + let second = tokio::time::timeout(std::time::Duration::from_secs(2), subscription.recv()) + .await + .expect("resubscribed Helius notification must remain bounded") + .expect("resubscribed Helius channel must remain open") + .expect("resubscribed Helius notification must decode"); + assert!(matches!(second, crate::HeliusTransactionNotification::Signature(ref value) if value.signature() == "generation-two")); + assert_eq!(subscription.id(), stable_id); + assert_eq!(subscription.state(), crate::WsSubscriptionState::Active); + wait_for_gap_count(&session, 1).await; + assert_eq!(session.snapshot().continuity_gap_count(), 1); + assert!(subscription.unsubscribe().await.expect("Helius transaction cancellation must complete")); + assert_eq!(subscription.state(), crate::WsSubscriptionState::Closed); + assert!(tokio::time::timeout(std::time::Duration::from_millis(100), subscription.recv()).await.expect("closed Helius channel must settle").is_none()); + session.close().await.expect("Helius fixture session must close"); + server.await.expect("local reconnect Helius server must finish"); +} + +#[tokio::test(flavor = "current_thread")] +async fn helius_transaction_backpressure_fails_only_slow_subscription_and_uses_transaction_unsubscribe_cleanup() { + let (listener, url) = bind_local_listener().await; + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("local server must accept Helius client"); + let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local Helius handshake must succeed"); + let transaction_subscribe = read_request(&mut websocket).await; + assert_eq!(transaction_subscribe["method"], serde_json::json!("transactionSubscribe")); + send_result(&mut websocket, &transaction_subscribe, serde_json::json!(41)).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!(42)).await; + send_notification(&mut websocket, 41, serde_json::json!({"signature":"queued","slot":1,"transactionIndex":0})).await; + send_notification(&mut websocket, 41, serde_json::json!({"signature":"overflow","slot":2,"transactionIndex":1})).await; + let cleanup = read_request(&mut websocket).await; + assert_eq!(cleanup["method"], serde_json::json!("transactionUnsubscribe")); + assert_eq!(cleanup["params"], serde_json::json!([41])); + send_result(&mut websocket, &cleanup, serde_json::json!(true)).await; + send_root_notification(&mut websocket, 42, 99).await; + wait_for_close_frame(&mut websocket).await; + }); + let session = crate::HeliusLaserStreamWsSession::connect(helius_endpoint_with_session(url.as_str(), backpressure_session_settings())) + .await + .expect("Helius facade must connect"); + let request = crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::None); + let mut slow = session.transaction_subscribe(&request).await.expect("slow Helius transaction subscription must register"); + let mut healthy = session.root_subscribe().await.expect("healthy Helius root subscription must register"); + wait_for_subscription_state(&slow, crate::WsSubscriptionState::Failed).await; + wait_for_overflow_count(&session, 1).await; + assert_eq!(slow.terminal_error_code(), std::option::Option::Some(crate::ERROR_CODE_WS_BACKPRESSURE_OVERFLOW)); + assert_eq!(session.state(), crate::WsSessionState::Active); + assert_eq!(session.snapshot().subscription_count(), 1); + let queued = slow.recv().await.expect("first Helius notification must remain queued").expect("queued Helius notification must decode"); + assert!(matches!(queued, crate::HeliusTransactionNotification::Signature(ref value) if value.signature() == "queued")); + assert!(slow.recv().await.is_none()); + assert_eq!(healthy.recv().await.expect("healthy root notification must arrive").expect("healthy root notification must decode"), 99); + assert_eq!(healthy.state(), crate::WsSubscriptionState::Active); + assert_eq!(healthy.terminal_error_code(), std::option::Option::None); + session.close().await.expect("Helius fixture session must close"); + server.await.expect("local Helius backpressure server must finish"); +} 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 2a6244b..6756623 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: 4 +// version: 5 fn non_zero(value: u64) -> std::num::NonZeroU64 { return std::num::NonZeroU64::new(value).expect("test ID must be non-zero"); @@ -123,3 +123,13 @@ fn websocket_unstable_subscription_partition_is_exact() { assert_eq!(kind.is_unstable(), unstable); } } + +#[test] +fn helius_transaction_subscription_kind_maps_exact_provider_method_triplet_without_expanding_standard_partition() { + let kind = crate::WsSubscriptionKind::HeliusTransaction; + assert_eq!(kind.as_str(), "helius_transaction"); + assert_eq!(kind.subscribe_method(), "transactionSubscribe"); + assert_eq!(kind.unsubscribe_method(), "transactionUnsubscribe"); + assert_eq!(kind.notification_method(), "transactionNotification"); + assert!(!kind.is_unstable()); +} diff --git a/deltas/0.2.8/pre.006.md b/deltas/0.2.8/pre.006.md new file mode 100644 index 0000000..33561bc --- /dev/null +++ b/deltas/0.2.8/pre.006.md @@ -0,0 +1,235 @@ + + + +# Delta `0.2.8-pre.006` — Helius transactionNotification + lifecycle actor + +## 1. Base et objet + +Base appliquée : + +```text +0.2.8-pre.5.fix.2 +``` + +Le checkpoint opérateur de cette base est intégralement vert et sans warning : `cargo fmt`, audit Rust, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, tests Transport et `cargo test --workspace` passent. Transport compte alors `322` tests unitaires, `39` tests public API, `27` tests release-completeness et `4` doctests compile-fail. + +Cette tranche transforme le contrat de requête Helius préparé en `pre.005` en une souscription live complète, sans créer de second moteur WebSocket : + +```text +transactionSubscribe + -> registry actor existant + -> WsSubscription + -> transactionNotification + -> reconnect/resubscribe/remap remote ID + -> transactionUnsubscribe +``` + +## 2. Version technique + +```text +workspace.package.version = 0.2.8-pre.6 +commit attendu = v0.2.8-pre.006 +Git tag = aucun tag prerelease +``` + +Le header root `Cargo.toml` passe en version `226`. + +## 3. Subscription kind provider + +`WsSubscriptionKind` gagne : + +```text +HeliusTransaction +``` + +avec le triplet exact : + +```text +as_str helius_transaction +subscribe_method transactionSubscribe +unsubscribe_method transactionUnsubscribe +notification_method transactionNotification +``` + +Cette extension ne modifie pas la partition standard Solana de neuf familles et reste hors des trois familles standard classées unstable (`Block`, `SlotsUpdates`, `Vote`). + +Le generic actor existant reste l'unique propriétaire : + +- du socket physique ; +- du pending map JSON-RPC ; +- des local IDs ; +- des remote IDs ; +- du registry de subscriptions ; +- du reconnect/resubscribe ; +- des queues de notifications ; +- du cleanup unsubscribe ; +- du shutdown. + +## 4. Handle live Helius + +`HeliusLaserStreamWsSession` expose maintenant : + +```rust +transaction_subscribe( + &self, + request: &HeliusTransactionSubscribeRequest, +) -> Result> +``` + +La validation déterministe et la sérialisation de `pre.005` restent exécutées avant l'enregistrement actor. Le helper de sérialisation et ses sous-helpers redeviennent du code de production uniquement parce qu'ils ont désormais un consommateur réel ; ils restent strictement privés au module. + +Aucune visibilité n'est élargie pour les tests. Les canaris du sous-module accèdent aux helpers privés avec `super::Item`; les contrats publics sont consommés via `crate::Item`. + +## 5. Notification typed + +Trois formes publiques sont exposées au crate-root. + +### 5.1 Full/accounts + +`HeliusFullTransactionNotification` conserve : + +```text +transaction serde_json::Value +signature String +slot u64 +transactionIndex u64 +``` + +Le nested `transaction` reste lossless en JSON, car sa forme dépend de `encoding` et `transactionDetails`; Transport ne décode pas les Programs. + +### 5.2 Signatures + +`HeliusTransactionSignatureNotification` conserve : + +```text +signature String +slot u64 +transactionIndex u64 +err Omitted | Null | Value(JSON) +memo Omitted | Null | Value(String) +blockTime Omitted | Null | Value(i64) +confirmationStatus Omitted | Null | Value(String) +``` + +Les champs optionnels réutilisent `SolanaWireField` afin de ne pas confondre omission et `null`. + +### 5.3 Union publique + +```text +HeliusTransactionNotification::Full(...) +HeliusTransactionNotification::Signature(...) +HeliusTransactionNotification::Unknown(JSON) +``` + +`Unknown` conserve uniquement le `params.result` provider. L'enveloppe JSON-RPC complète et `params.subscription` ne franchissent pas le boundary public. Cette forme couvre notamment un `transactionDetails=none` ou une évolution provider non encore typée sans tuer arbitrairement la logical subscription. + +## 6. Reconnect, unsubscribe tardif et backpressure + +Le support Helius s'appuie directement sur les garanties du moteur `0.2.7` : + +- les params `transactionSubscribe` originaux sont conservés par le registry ; +- après reconnect, un nouvel ID remote remplace l'ancien ; +- le `WsSubscriptionId` local reste stable ; +- le remote ID n'est jamais public ; +- au début d'un unsubscribe, le mapping remote -> local est retiré avant l'émission de `transactionUnsubscribe` ; +- une notification provider déjà en vol après cancellation est donc ignorée ; +- un overflow de queue échoue seulement la logical subscription lente ; +- le cleanup best-effort utilise automatiquement `transactionUnsubscribe` grâce au nouveau `WsSubscriptionKind`. + +Cette sémantique correspond au contrat Helius actuel qui précise que quelques messages en vol peuvent encore arriver brièvement après `transactionUnsubscribe`. + +## 7. Canaris ajoutés/actualisés + +Les tests Helius transaction couvrent maintenant : + +```text +notification Full / Signature / Unknown +live transactionSubscribe exact via façade publique +transactionNotification routée vers WsSubscription +transactionUnsubscribe exact via handle public +reconnect : remote ID 41 -> 99 +resubscribe : params identiques +stable local WsSubscriptionId +late transactionNotification après demande unsubscribe ignorée +overflow transaction : handle lent Failed + ERROR_CODE_WS_BACKPRESSURE_OVERFLOW +cleanup overflow : transactionUnsubscribe [remote_id] +Helius root sain reste Active et reçoit encore sa notification +``` + +Un canari lifecycle verrouille aussi le triplet exact du nouveau `WsSubscriptionKind::HeliusTransaction`. + +Les public/release canaries gagnent : + +- le symbole public `HeliusLaserStreamWsSession::transaction_subscribe` ; +- les trois types publics de notification ; +- la présence du kind provider ; +- l'absence de second `connect_async`/actor dans le module Helius ; +- la conservation des compile-fail Helius block/slotsUpdates/vote/escape-hatch. + +Comptages attendus : + +```text +Transport unit 325 +Transport public API 40 +release completeness 28 +doctests compile-fail 4 +``` + +## 8. Documentation + +Le plan `015` : + +- ferme `pre.005`, `fix.001` et `fix.002` après preuve opérateur sans warning ; +- marque `pre.006` PREPARED ; +- documente l'union notification, le remap remote/local et les nouveaux canaris lifecycle. + +La validation `011` : + +- enregistre le checkpoint final `pre.005` ; +- ouvre la gate `pre.006` ; +- conserve heartbeat, adversarial élargi et smoke live dans leurs tranches prévues. + +## 9. Hors scope + +Restent explicitement hors de `pre.006` : + +```text +heartbeat / idle timer pre.007 +provider adversarial/security élargi pre.008 +compliance finale pre.009 +smoke Helius live opt-in pre.010 +LaserStream gRPC future transport séparé +``` + +Aucune nouvelle dépendance n'est ajoutée. + +## 10. Fichiers modifiés + +```text +Cargo.toml +crates/ksp-onchain-transport-lib/src/lib.rs +crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs +crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs +crates/ksp-onchain-transport-lib/src/ws_protocol_session.rs +crates/ksp-onchain-transport-lib/src/ws_subscription.rs +crates/ksp-onchain-transport-lib/tests/public_api.rs +crates/ksp-onchain-transport-lib/tests/release_completeness.rs +crates/ksp-onchain-transport-lib/unit_tests/ws_helius_transactions.rs +crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs +docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md +docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md +deltas/0.2.8/pre.006.md +``` + +## 11. Gate opérateur + +```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 +``` + +Critère de fermeture : aucune erreur, aucun warning nouveau, audit Rust clean et tous les nouveaux canaris lifecycle Helius verts. diff --git a/docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md b/docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md index bcc20c7..3ae7aeb 100644 --- a/docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md +++ b/docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md @@ -1,9 +1,9 @@ - + # Plan `0.2.8` — Helius LaserStream WebSocket -> **Statut : `pre.004` et ses deux fixes sont validés. `pre.005-fix.001` corrige les erreurs de type et la visibilité test/private, mais son checkpoint reste non fermable à cause de neuf warnings `dead_code` sur des helpers wire strictement privés qui n'ont pas encore de consommateur de production. `0.2.8-pre.005-fix.002` les borne à `#[cfg(test)]` jusqu'à leur consommation réelle par l'actor en `pre.006`, sans `allow(dead_code)` ni élargissement de visibilité.** +> **Statut : `pre.005` + `fix.001` + `fix.002` sont validés sans warning. `0.2.8-pre.006` prépare maintenant le handle live Helius `transactionSubscribe`, le décodage `transactionNotification` et son intégration au registry/reconnect/backpressure de l'actor WebSocket unique.** ## 1. Objet, base et état courant @@ -30,12 +30,13 @@ pre.003 six familles standard Helius validées pre.004 Config V2 Helius validé pre.004-fix.001 redaction segmentaire + couverture Devnet Helius validées pre.004-fix.002 provenance composée validée -pre.005 contrat typed transactionSubscribe/unsubscribe ; fix requis après checkpoint -pre.005-fix.001 correction types de canaris + visibilité/tests/règles appliquée -pre.005-fix.002 suppression warnings dead_code via cfg(test) des helpers wire préparée +pre.005 contrat typed transactionSubscribe/unsubscribe validé +pre.005-fix.001 correction types de canaris + visibilité/tests/règles validée +pre.005-fix.002 suppression warnings dead_code via cfg(test) validée +pre.006 transactionNotification + lifecycle actor préparé -workspace.package.version courant = 0.2.8-pre.5.fix.2 -commit attendu = v0.2.8-pre.005-fix.002 +workspace.package.version courant = 0.2.8-pre.6 +commit attendu = v0.2.8-pre.006 aucun tag prerelease ``` @@ -59,12 +60,12 @@ pre.004 DONE — Config V2 helius_laserstream + schema/fixtures + mapping Confi + secret/redaction Helius mainnet/devnet validés fix.001 DONE — redaction segmentaire corrigée + représentation Devnet Helius ajoutée fix.002 DONE — provenance composée `DocumentLiteral` + `EnvironmentProcess` corrigée -pre.005 FIX REQUIRED — transactionSubscribe request typed + filters/options/tokenAccounts + transactionUnsubscribe +pre.005 DONE — transactionSubscribe request typed + filters/options/tokenAccounts + transactionUnsubscribe + bounds 50k + maxSupportedTransactionVersion conditionnel ; live handle différé à pre.006 - fix.001 APPLIED — assertions Vec/Value corrigées + helpers test-only privés + audit super/crate durci - fix.002 PREPARED — helpers wire non consommés en production bornés à #[cfg(test)] ; zéro allow/dead-code compensatoire -pre.006 transactionNotification + actor integration + reconnect/resubscribe/unsubscribe races - + late notifications + backpressure ciblée + fix.001 DONE — assertions Vec/Value corrigées + helpers test-only privés + audit super/crate durci + fix.002 DONE — helpers wire non consommés en production bornés à #[cfg(test)] ; zéro warning dead_code +pre.006 PREPARED — transactionNotification + handle live + actor registry/reconnect/resubscribe/unsubscribe races + + late notifications + backpressure ciblée, sans second actor/socket pre.007 heartbeat Helius WebSocket/idle + timers + interaction reconnect/control frames/shutdown pre.008 provider adversarial lifecycle + capability guards + payload/backpressure + security/redaction pre.009 compliance Helius WebSocket + non-régressions Solana standard 18/18 + HTTP 52/14 @@ -1024,3 +1025,57 @@ Ces éléments ne sont pas encore consommés par un chemin de production en `pre Leur promotion éventuelle en code de production est différée à `pre.006`, exactement au moment où l'actor WebSocket les consommera réellement ; si cette promotion exige `pub(crate)`, elle suivra alors la façade crate-root et les appels `crate::Item`. +## 14. Fermeture `pre.005` et préparation `pre.006` + +Le checkpoint opérateur reçu après `pre.005-fix.002` ferme entièrement la tranche transaction-request : + +```text +cargo fmt --all OK +python3 scripts/audit_rust_workspace_rules.py clean +cargo check --workspace OK, sans warning +cargo clippy --workspace --all-targets OK, sans warning +cargo test -p ksp-onchain-transport-lib OK 322 unit + 39 public API + 27 completeness + 4 doctests +cargo test --workspace OK +``` + +`pre.006` promeut uniquement les helpers désormais réellement consommés par le chemin live. Ils restent strictement privés dans leur module lorsque leur consommation est locale ; aucun élargissement de visibilité n'est effectué pour les tests. Les tests continuent d'accéder aux privés du parent via `super::Item`, tandis que les types publics passent par la façade crate-root `crate::Item`. + +La tranche ajoute : + +```text +WsSubscriptionKind::HeliusTransaction +subscribe method transactionSubscribe +unsubscribe method transactionUnsubscribe +notification method transactionNotification + +HeliusLaserStreamWsSession::transaction_subscribe(request) + -> WsSubscription + +HeliusTransactionNotification + Full transaction + signature + slot + transactionIndex + Signature signature + slot + transactionIndex + champs optionnels conservés + Unknown payload provider futur/none conservé sans faire tomber la souscription +``` + +Le variant `Unknown` est volontairement borné à la valeur `result` de `transactionNotification` : KSP ne publie ni l'enveloppe JSON-RPC complète ni l'identifiant remote de subscription. Le remote ID reste propriété exclusive de l'actor et peut donc être remappé après reconnexion sans changer l'identité locale du handle. + +Les canaris locaux `pre.006` doivent prouver ensemble : + +```text +[ ] requête live exacte via HeliusLaserStreamWsSession::transaction_subscribe +[ ] transactionNotification Full décodée par le handle public +[ ] formes Signature et Unknown préservées +[ ] transactionUnsubscribe utilise le remote ID courant +[ ] reconnexion renvoie le même params transactionSubscribe +[ ] remote ID 41 -> 99 remappé sans changer WsSubscriptionId +[ ] notification tardive après demande unsubscribe ignorée +[ ] overflow transaction échoue seulement la souscription lente +[ ] cleanup overflow appelle transactionUnsubscribe +[ ] une souscription Helius root saine continue de recevoir ses notifications +[ ] standard 9 familles / 18 opérations non régressées +[ ] aucun heartbeat ajouté avant pre.007 +[ ] aucun second actor/socket/registry/queue +[ ] aucune nouvelle dépendance +``` + +Hors scope inchangé : heartbeat/idle (`pre.007`), adversarial provider/security élargi (`pre.008`), smoke Helius live (`pre.010`) et LaserStream gRPC. diff --git a/docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md b/docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md index eff6bcf..0ea30ec 100644 --- a/docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md +++ b/docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md @@ -1,9 +1,9 @@ - + # Validation `0.2.8` — Helius LaserStream WebSocket -> **Statut : `pre.004` + ses fixes sont validés. `pre.005-fix.001` corrige les erreurs de type et les chemins de visibilité, mais son checkpoint émet encore neuf warnings `dead_code` sur des helpers wire privés non consommés en production. `pre.005-fix.002` les compile uniquement sous `#[cfg(test)]` jusqu'à `pre.006`, sans modifier le contrat public Helius.** +> **Statut : `pre.005` + ses deux fixes sont validés sans warning. `pre.006` est préparé avec `transactionNotification`, handle live, remap reconnect, late-notification handling et backpressure ciblée sur l'actor WebSocket unique.** ## 1. Références @@ -23,6 +23,7 @@ pre.004 provenance fix deltas/0.2.8/pre.004-fix.002.md pre.005 deltas/0.2.8/pre.005.md pre.005 visibility/test fix deltas/0.2.8/pre.005-fix.001.md pre.005 dead-code fix deltas/0.2.8/pre.005-fix.002.md +pre.006 actor transaction deltas/0.2.8/pre.006.md validation standard WS docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md HTTP compliance docs/validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md KSP-TRANSPORT-007 docs/validation/005-V0_2_3_KSP_TRANSPORT_007_RETRO_AUDIT.md @@ -136,7 +137,7 @@ Verdict : **gate `0.2.8-pre.001` positif ; `pre.002` + `fix.001` et `pre.003` so ### 4.2 Invariants architecture -| Critère | Décision | Preuve cible | État | +| Critère | Décision | Preuve cible | État | |-------------------------------------|------------------------------------------------------------------|---------------------------------|--------------------| | actor physique | un seul `WsSession` actor partagé | source/runtime canary | pre.002 implémenté | | façade standard | `SolanaStandardWsSession` | public API canary | pre.002 implémenté | @@ -209,21 +210,21 @@ aucun payload brut dans logs/snapshots [x] Helius facade n'expose aucun escape hatch vers le handle générique [x] protocol mismatch rejeté avant I/O — fixture opérateur verte [x] 6 familles standard Helius utilisent le wire standard exact — fixture `pre.003` opérateur verte -[ ] transaction subscribe/ack exact — fixture `pre.005` préparée, gate opérateur requis -[ ] transaction unsubscribe/result exact — fixture `pre.005` préparée, gate opérateur requis -[ ] transaction notification dispatch exact +[x] transaction subscribe/ack exact — fixture `pre.005` validée puis consommée par le handle live `pre.006` +[x] transaction unsubscribe/result exact — fixture `pre.005` validée puis registry actor `pre.006` +[ ] transaction notification dispatch exact — canari `pre.006` préparé [ ] notification decode failure isole la logical subscription [ ] provider RPC application error ne tue pas la session -[ ] late notification après unsubscribe ne réactive rien -[ ] reconnect invalide/remappe remote ids -[ ] resubscribe garde local id +[ ] late notification après unsubscribe ne réactive rien — canari `pre.006` préparé +[ ] reconnect invalide/remappe remote ids — canari `pre.006` 41 -> 99 préparé +[ ] resubscribe garde local id — canari `pre.006` préparé [ ] unsubscribe pendant reconnect gagne [ ] heartbeat n'écrit qu'en Active [ ] heartbeat est annulé au close [ ] heartbeat write failure suit reconnect borné [ ] shutdown interrompt heartbeat/backoff [ ] oversized inbound frame reste borné -[ ] queue overflow d'une transaction subscription n'affecte pas les autres +[ ] queue overflow d'une transaction subscription n'affecte pas les autres — canari transaction + root `pre.006` préparé [ ] payload/filters/api-key absents de Debug/snapshots sûrs ``` @@ -519,5 +520,54 @@ Critères `pre.005-fix.002` : [ ] tests Transport/workspace verts ``` -Verdict courant : **`pre.005` FIX REQUIRED ; `pre.005-fix.001` APPLIED ; `pre.005-fix.002` PREPARED.** +Verdict après checkpoint final : **`pre.005` DONE ; `pre.005-fix.001` DONE ; `pre.005-fix.002` DONE.** +## 16. Gate `pre.005-fix.002` fermé et préparation `pre.006` + +Checkpoint opérateur final `pre.005` reçu le 2026-08-23 : + +```text +[x] cargo fmt --all +[x] python3 scripts/audit_rust_workspace_rules.py = clean +[x] cargo check --workspace = sans warning +[x] cargo clippy --workspace --all-targets = sans warning +[x] cargo test -p ksp-onchain-transport-lib = 322 unit + 39 public API + 27 completeness + 4 doctests +[x] cargo test --workspace = vert +``` + +Verdict : **`pre.005` et ses deux fixes sont fermés.** + +Critères préparés pour `pre.006` : + +```text +[ ] WsSubscriptionKind::HeliusTransaction mappe exactement subscribe/unsubscribe/notification +[ ] HeliusLaserStreamWsSession::transaction_subscribe retourne WsSubscription +[ ] notification Full conserve transaction/signature/slot/transactionIndex +[ ] notification Signature conserve signature/slot/transactionIndex et états optionnels +[ ] forme inconnue reste HeliusTransactionNotification::Unknown sans casser le handle +[ ] l'identité locale du handle reste stable après reconnect/resubscribe +[ ] le remote ID est remappé et jamais exposé publiquement +[ ] notification Helius en vol après transactionUnsubscribe est ignorée après retrait du mapping +[ ] overflow du handle transaction est terminal seulement pour ce handle +[ ] cleanup overflow envoie transactionUnsubscribe avec le remote ID courant +[ ] une souscription Helius root saine reste active pendant l'overflow transaction +[ ] quatre compile-fail Helius block/slotsUpdates/vote/escape-hatch restent verts +[ ] aucun heartbeat avant pre.007 +[ ] aucun second actor/socket/registry/queue +[ ] aucun nouvel accès non canonique private/pub/pub(crate) dans unit_tests +``` + +Gate opérateur `pre.006` : + +```text +[ ] cargo fmt --all +[ ] python3 scripts/audit_rust_workspace_rules.py = clean +[ ] cargo check --workspace = sans warning +[ ] cargo clippy --workspace --all-targets = sans warning +[ ] cargo test -p ksp-onchain-transport-lib +[ ] cargo test --workspace +``` + +Comptages attendus si les nouveaux canaris passent : **325 tests unitaires Transport**, **40 public API**, **28 release-completeness**, **4 doctests compile-fail**. + +Verdict courant : **`pre.006` PREPARED.**