From 92224e5ac64ef8d5fb62d37b4dac2068276b4494 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Sun, 23 Aug 2026 14:49:56 +0200 Subject: [PATCH] v0.2.8-pre.005 --- Cargo.toml | 4 +- crates/ksp-onchain-transport-lib/src/lib.rs | 26 +- .../src/ws_helius_transactions.rs | 421 ++++++++++++++++++ .../src/ws_protocol_session.rs | 9 +- .../tests/public_api.rs | 33 +- .../tests/release_completeness.rs | 27 +- .../unit_tests/ws_helius_transactions.rs | 326 ++++++++++++++ deltas/0.2.8/pre.005.md | 277 ++++++++++++ ...0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md | 72 +-- ...011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md | 103 +++-- 10 files changed, 1238 insertions(+), 60 deletions(-) create mode 100644 crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs create mode 100644 crates/ksp-onchain-transport-lib/unit_tests/ws_helius_transactions.rs create mode 100644 deltas/0.2.8/pre.005.md diff --git a/Cargo.toml b/Cargo.toml index 65ef57e..4c593ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 222 +# version: 223 [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.4.fix.2" +version = "0.2.8-pre.5" 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 7df406c..e723ba7 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: 30 +// version: 31 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -26,6 +26,9 @@ //! `0.2.7-pre.009` opens the first stable typed WebSocket wrappers for account, program-account and transaction-log subscriptions without exposing a raw //! 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. mod client; mod constants; @@ -47,6 +50,7 @@ mod settings; mod ws_accounts; mod ws_blocks; mod ws_cluster; +mod ws_helius_transactions; mod ws_lifecycle; mod ws_protocol_session; mod ws_session; @@ -338,6 +342,16 @@ 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; +/// Helius `tokenAccounts` expansion mode accepted by `transactionSubscribe`. +pub use self::ws_helius_transactions::HeliusTokenAccountsFilter; +/// Transaction encoding accepted by Helius `transactionSubscribe`. +pub use self::ws_helius_transactions::HeliusTransactionSubscribeEncoding; +/// Helius-specific filter object accepted as the first `transactionSubscribe` parameter. +pub use self::ws_helius_transactions::HeliusTransactionSubscribeFilter; +/// Optional Helius `transactionSubscribe` result-shaping configuration. +pub use self::ws_helius_transactions::HeliusTransactionSubscribeOptions; +/// Complete typed request contract for Helius `transactionSubscribe` before actor registration. +pub use self::ws_helius_transactions::HeliusTransactionSubscribeRequest; /// Stable local identity assigned to one physical WebSocket session. pub use self::ws_lifecycle::WsSessionId; /// Safe runtime snapshot for one physical WebSocket session. @@ -401,6 +415,16 @@ pub(crate) use self::rpc_common::decode_wire_json; pub(crate) use self::rpc_common::parse_wire_pubkey; /// Validates endpoint settings. pub(crate) use self::settings::validate_endpoint_settings; +/// Crate-internal decoder for Helius transaction-subscribe acknowledgement IDs. +pub(crate) use self::ws_helius_transactions::decode_helius_transaction_subscribe_result; +/// Crate-internal decoder for Helius transaction-unsubscribe boolean results. +pub(crate) use self::ws_helius_transactions::decode_helius_transaction_unsubscribe_result; +/// Crate-internal exact Helius transaction-subscribe method descriptor. +pub(crate) use self::ws_helius_transactions::helius_transaction_subscribe_method; +/// Crate-internal exact Helius transaction-unsubscribe method descriptor. +pub(crate) use self::ws_helius_transactions::helius_transaction_unsubscribe_method; +/// Crate-internal Helius transaction-unsubscribe parameter encoder. +pub(crate) use self::ws_helius_transactions::helius_transaction_unsubscribe_params; /// Crate-internal command surface shared by the physical session and typed subscription handle. pub(crate) use self::ws_session::WsSessionCommand; /// Crate-internal notification dispatch result. diff --git a/crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs b/crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs new file mode 100644 index 0000000..4598784 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs @@ -0,0 +1,421 @@ +// file: crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs +// version: 1 + +const MAX_HELIUS_TRANSACTION_FILTER_ACCOUNTS: usize = 50_000; + +/// Helius `tokenAccounts` expansion mode accepted by `transactionSubscribe`. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum HeliusTokenAccountsFilter { + /// Disable token-account owner expansion explicitly; equivalent to omitting `tokenAccounts`. + None, + /// Match transactions where a token balance owned by an included account changes or its token account closes. + BalanceChanged, + /// Match transactions referencing any token account owned by an included account, even if the balance does not change. + All, +} + +impl HeliusTokenAccountsFilter { + /// Returns the exact Helius WebSocket wire string. + #[must_use] + pub const fn as_str(self) -> &'static str { + return match self { + Self::None => "none", + Self::BalanceChanged => "balanceChanged", + Self::All => "all", + }; + } +} + +/// Transaction encoding accepted by Helius `transactionSubscribe`. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum HeliusTransactionSubscribeEncoding { + /// Base58 encoded transaction bytes. + Base58, + /// Base64 encoded transaction bytes. + Base64, + /// Parsed JSON transaction representation. + JsonParsed, +} + +impl HeliusTransactionSubscribeEncoding { + /// Returns the exact Helius WebSocket wire string. + #[must_use] + pub const fn as_str(self) -> &'static str { + return match self { + Self::Base58 => "base58", + Self::Base64 => "base64", + Self::JsonParsed => "jsonParsed", + }; + } +} + +/// Helius-specific filter object accepted as the first `transactionSubscribe` parameter. +/// +/// Debug output intentionally exposes only filter presence, modes and account counts. Transaction signatures and account values are omitted so routine +/// diagnostics cannot accidentally disclose the caller's complete provider filter payload. +#[derive(Clone, Default, Eq, PartialEq)] +pub struct HeliusTransactionSubscribeFilter { + vote: std::option::Option, + failed: std::option::Option, + signature: std::option::Option, + account_include: std::option::Option>, + account_exclude: std::option::Option>, + account_required: std::option::Option>, + token_accounts: std::option::Option, +} + +impl HeliusTransactionSubscribeFilter { + /// Creates a complete Helius transaction filter while preserving omitted versus explicitly empty account arrays. + #[must_use] + #[allow(clippy::too_many_arguments)] + pub fn new( + vote: std::option::Option, + failed: std::option::Option, + signature: std::option::Option, + account_include: std::option::Option>, + account_exclude: std::option::Option>, + account_required: std::option::Option>, + token_accounts: std::option::Option, + ) -> Self { + return Self { vote, failed, signature, account_include, account_exclude, account_required, token_accounts }; + } + + /// Returns the optional vote-transaction filter flag. + #[must_use] + pub const fn vote(&self) -> std::option::Option { + return self.vote; + } + + /// Returns the optional failed-transaction filter flag. + #[must_use] + pub const fn failed(&self) -> std::option::Option { + return self.failed; + } + + /// Returns the optional exact transaction signature filter. + #[must_use] + pub fn signature(&self) -> std::option::Option<&str> { + return match self.signature.as_ref() { + std::option::Option::Some(signature) => std::option::Option::Some(signature.as_str()), + std::option::Option::None => std::option::Option::None, + }; + } + + /// Returns the optional OR-style account inclusion list. + #[must_use] + pub fn account_include(&self) -> std::option::Option<&[ksp_core_lib::Pubkey]> { + return match self.account_include.as_ref() { + std::option::Option::Some(accounts) => std::option::Option::Some(accounts.as_slice()), + std::option::Option::None => std::option::Option::None, + }; + } + + /// Returns the optional account exclusion list. + #[must_use] + pub fn account_exclude(&self) -> std::option::Option<&[ksp_core_lib::Pubkey]> { + return match self.account_exclude.as_ref() { + std::option::Option::Some(accounts) => std::option::Option::Some(accounts.as_slice()), + std::option::Option::None => std::option::Option::None, + }; + } + + /// Returns the optional AND-style required-account list. + #[must_use] + pub fn account_required(&self) -> std::option::Option<&[ksp_core_lib::Pubkey]> { + return match self.account_required.as_ref() { + std::option::Option::Some(accounts) => std::option::Option::Some(accounts.as_slice()), + std::option::Option::None => std::option::Option::None, + }; + } + + /// Returns the optional Helius token-account owner-expansion mode. + #[must_use] + pub const fn token_accounts(&self) -> std::option::Option { + return self.token_accounts; + } + + fn validate(&self) -> ksp_core_lib::Result<()> { + let include = validate_account_list("accountInclude", self.account_include.as_deref()); + if let std::result::Result::Err(error) = include { + return std::result::Result::Err(error); + } + let exclude = validate_account_list("accountExclude", self.account_exclude.as_deref()); + if let std::result::Result::Err(error) = exclude { + return std::result::Result::Err(error); + } + let required = validate_account_list("accountRequired", self.account_required.as_deref()); + if let std::result::Result::Err(error) = required { + return std::result::Result::Err(error); + } + 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(vote) = self.vote { + object.insert("vote".to_owned(), serde_json::Value::Bool(vote)); + } + if let std::option::Option::Some(failed) = self.failed { + object.insert("failed".to_owned(), serde_json::Value::Bool(failed)); + } + if let std::option::Option::Some(signature) = self.signature.as_ref() { + object.insert("signature".to_owned(), serde_json::Value::String(signature.clone())); + } + insert_account_list(&mut object, "accountInclude", self.account_include.as_deref()); + insert_account_list(&mut object, "accountExclude", self.account_exclude.as_deref()); + insert_account_list(&mut object, "accountRequired", self.account_required.as_deref()); + if let std::option::Option::Some(token_accounts) = self.token_accounts { + object.insert("tokenAccounts".to_owned(), serde_json::Value::String(token_accounts.as_str().to_owned())); + } + return serde_json::Value::Object(object); + } +} + +impl std::fmt::Debug for HeliusTransactionSubscribeFilter { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter + .debug_struct("HeliusTransactionSubscribeFilter") + .field("vote", &self.vote) + .field("failed", &self.failed) + .field("signature_present", &self.signature.is_some()) + .field("account_include_count", &self.account_include.as_ref().map(std::vec::Vec::len)) + .field("account_exclude_count", &self.account_exclude.as_ref().map(std::vec::Vec::len)) + .field("account_required_count", &self.account_required.as_ref().map(std::vec::Vec::len)) + .field("token_accounts", &self.token_accounts) + .finish(); + } +} + +/// Optional Helius `transactionSubscribe` result-shaping configuration. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct HeliusTransactionSubscribeOptions { + commitment: std::option::Option, + encoding: std::option::Option, + transaction_details: std::option::Option, + show_rewards: std::option::Option, + max_supported_transaction_version: std::option::Option, +} + +impl HeliusTransactionSubscribeOptions { + /// Creates a complete optional Helius transaction-subscription configuration. + #[must_use] + pub const fn new( + commitment: std::option::Option, + encoding: std::option::Option, + transaction_details: std::option::Option, + show_rewards: std::option::Option, + max_supported_transaction_version: std::option::Option, + ) -> Self { + return Self { commitment, encoding, transaction_details, show_rewards, max_supported_transaction_version }; + } + + /// Returns the optional commitment level. + #[must_use] + pub const fn commitment(&self) -> std::option::Option { + return self.commitment; + } + + /// Returns the optional Helius 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 whether rewards were explicitly requested. + #[must_use] + pub const fn show_rewards(&self) -> std::option::Option { + return self.show_rewards; + } + + /// 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; + } + + fn validate(&self) -> ksp_core_lib::Result<()> { + let requires_version = + matches!(self.transaction_details, std::option::Option::Some(crate::SolanaTransactionDetails::Full | crate::SolanaTransactionDetails::Accounts)); + if requires_version && self.max_supported_transaction_version.is_none() { + let detail = match self.transaction_details { + std::option::Option::Some(detail) => detail.as_str(), + std::option::Option::None => "omitted", + }; + return std::result::Result::Err( + ksp_core_lib::Error::new( + crate::ERROR_CODE_INVALID_RPC_PARAMETERS, + "Helius transactionSubscribe requires maxSupportedTransactionVersion for full or accounts transaction details", + ) + .with_context("rpc_method", "transactionSubscribe") + .with_context("field", "maxSupportedTransactionVersion") + .with_context("transaction_details", detail), + ); + } + 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(show_rewards) = self.show_rewards { + object.insert("showRewards".to_owned(), serde_json::Value::Bool(show_rewards)); + } + if let std::option::Option::Some(version) = self.max_supported_transaction_version { + object.insert("maxSupportedTransactionVersion".to_owned(), serde_json::Value::Number(version.into())); + } + return serde_json::Value::Object(object); + } +} + +/// Complete typed request contract for Helius `transactionSubscribe` before actor registration. +/// +/// 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. +#[derive(Clone, Eq, PartialEq)] +pub struct HeliusTransactionSubscribeRequest { + filter: crate::HeliusTransactionSubscribeFilter, + options: std::option::Option, +} + +impl HeliusTransactionSubscribeRequest { + /// Creates one typed Helius transaction-subscription request. + #[must_use] + pub fn new(filter: crate::HeliusTransactionSubscribeFilter, options: std::option::Option) -> Self { + return Self { filter, options }; + } + + /// Returns the provider transaction filter. + #[must_use] + pub const fn filter(&self) -> &crate::HeliusTransactionSubscribeFilter { + return &self.filter; + } + + /// Returns the optional provider result-shaping configuration. + #[must_use] + pub const fn options(&self) -> std::option::Option<&crate::HeliusTransactionSubscribeOptions> { + return self.options.as_ref(); + } + + /// Validates deterministic Helius request constraints before any WebSocket I/O. + pub fn validate(&self) -> ksp_core_lib::Result<()> { + let filter = self.filter.validate(); + if let std::result::Result::Err(error) = filter { + return std::result::Result::Err(error); + } + if let std::option::Option::Some(options) = self.options { + let options = options.validate(); + if let std::result::Result::Err(error) = options { + return std::result::Result::Err(error); + } + } + return std::result::Result::Ok(()); + } + + /// Builds the exact JSON-RPC params array after deterministic validation. + #[allow(dead_code)] // Consumed by actor-owned transaction subscription registration in pre.006. + pub(crate) fn to_params(&self) -> ksp_core_lib::Result> { + let validation = self.validate(); + if let std::result::Result::Err(error) = validation { + return std::result::Result::Err(error); + } + let mut params = std::vec![self.filter.to_json_value()]; + if let std::option::Option::Some(options) = self.options { + params.push(options.to_json_value()); + } + return std::result::Result::Ok(params); + } +} + +impl std::fmt::Debug for HeliusTransactionSubscribeRequest { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter.debug_struct("HeliusTransactionSubscribeRequest").field("filter", &self.filter).field("options", &self.options).finish(); + } +} + +/// Returns the exact Helius transaction-subscribe JSON-RPC method name. +#[allow(dead_code)] // Consumed by actor-owned transaction subscription registration in pre.006. +pub(crate) const fn helius_transaction_subscribe_method() -> &'static str { + return "transactionSubscribe"; +} + +/// Returns the exact Helius transaction-unsubscribe JSON-RPC method name. +#[allow(dead_code)] // Consumed by actor-owned transaction subscription cleanup in pre.006. +pub(crate) const fn helius_transaction_unsubscribe_method() -> &'static str { + return "transactionUnsubscribe"; +} + +/// Decodes a successful Helius transaction-subscribe acknowledgement without exposing the remote ID publicly. +#[allow(dead_code)] // Consumed by actor-owned transaction subscription registration in pre.006. +pub(crate) 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"), + ), + }; +} + +/// Builds the exact Helius transaction-unsubscribe params array for one actor-owned remote subscription ID. +#[allow(dead_code)] // Consumed by actor-owned transaction subscription cleanup in pre.006. +pub(crate) fn helius_transaction_unsubscribe_params(remote_id: u64) -> std::vec::Vec { + return std::vec![serde_json::Value::Number(remote_id.into())]; +} + +/// Decodes the boolean Helius transaction-unsubscribe result. +#[allow(dead_code)] // Consumed by actor-owned transaction subscription cleanup in pre.006. +pub(crate) 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"), + ), + }; +} + +fn validate_account_list(field: &'static str, accounts: std::option::Option<&[ksp_core_lib::Pubkey]>) -> ksp_core_lib::Result<()> { + if let std::option::Option::Some(accounts) = accounts + && accounts.len() > MAX_HELIUS_TRANSACTION_FILTER_ACCOUNTS + { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, "Helius transactionSubscribe account filter exceeds the provider limit") + .with_context("rpc_method", "transactionSubscribe") + .with_context("field", field) + .with_context("actual_count", accounts.len().to_string()) + .with_context("max_count", MAX_HELIUS_TRANSACTION_FILTER_ACCOUNTS.to_string()), + ); + } + return std::result::Result::Ok(()); +} + +fn insert_account_list( + object: &mut serde_json::Map, + field: &'static str, + accounts: std::option::Option<&[ksp_core_lib::Pubkey]>, +) { + if let std::option::Option::Some(accounts) = accounts { + let values = accounts.iter().map(|account| return serde_json::Value::String(account.to_string())).collect::>(); + object.insert(field.to_owned(), serde_json::Value::Array(values)); + } + return; +} + +#[cfg(test)] +#[path = "../unit_tests/ws_helius_transactions.rs"] +mod tests; 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 84df7de..457753b 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: 2 +// version: 3 /// Typed facade for one standard Solana WebSocket physical session. /// @@ -58,9 +58,10 @@ impl std::fmt::Debug for SolanaStandardWsSession { /// Typed facade for one Helius LaserStream WebSocket physical session. /// -/// The facade exposes only the six standard Solana subscription families that Helius documents as supported. Provider-specific transaction subscription -/// support is intentionally deferred to a later tranche. No public inner handle is exposed, so callers cannot bypass the provider-specific surface by -/// recovering a generic [`crate::WsSession`]. +/// 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 +/// provider-specific surface by recovering a generic [`crate::WsSession`]. /// /// ```compile_fail /// async fn unsupported_block(session: &ksp_onchain_transport_lib::HeliusLaserStreamWsSession) { diff --git a/crates/ksp-onchain-transport-lib/tests/public_api.rs b/crates/ksp-onchain-transport-lib/tests/public_api.rs index 337e629..41d8323 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: 35 +// version: 36 //! Integration tests for the public `ksp-onchain-transport-lib` consumer contract. @@ -713,3 +713,34 @@ fn public_v0_2_8_pre_003_helius_standard_surface_reuses_shared_typed_contracts() let _shared_signature_config = std::any::type_name::(); let _shared_slot_notification = std::any::type_name::(); } + +#[test] +fn public_v0_2_8_pre_005_helius_transaction_request_contract_is_available_without_live_handle() { + let account = "11111111111111111111111111111111".parse::().expect("fixture pubkey must parse"); + let filter = ksp_onchain_transport_lib::HeliusTransactionSubscribeFilter::new( + std::option::Option::Some(false), + std::option::Option::Some(false), + std::option::Option::Some("fixture-signature".to_owned()), + std::option::Option::Some(std::vec![account]), + std::option::Option::None, + std::option::Option::None, + std::option::Option::Some(ksp_onchain_transport_lib::HeliusTokenAccountsFilter::BalanceChanged), + ); + let options = ksp_onchain_transport_lib::HeliusTransactionSubscribeOptions::new( + std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed), + std::option::Option::Some(ksp_onchain_transport_lib::HeliusTransactionSubscribeEncoding::JsonParsed), + std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionDetails::Full), + std::option::Option::Some(false), + std::option::Option::Some(0), + ); + let request = ksp_onchain_transport_lib::HeliusTransactionSubscribeRequest::new(filter, std::option::Option::Some(options)); + assert!(request.validate().is_ok()); + assert_eq!(request.filter().token_accounts(), std::option::Option::Some(ksp_onchain_transport_lib::HeliusTokenAccountsFilter::BalanceChanged)); + assert_eq!( + request.options().and_then(ksp_onchain_transport_lib::HeliusTransactionSubscribeOptions::encoding), + std::option::Option::Some(ksp_onchain_transport_lib::HeliusTransactionSubscribeEncoding::JsonParsed) + ); + assert_eq!(ksp_onchain_transport_lib::HeliusTokenAccountsFilter::None.as_str(), "none"); + assert_eq!(ksp_onchain_transport_lib::HeliusTokenAccountsFilter::BalanceChanged.as_str(), "balanceChanged"); + assert_eq!(ksp_onchain_transport_lib::HeliusTokenAccountsFilter::All.as_str(), "all"); +} diff --git a/crates/ksp-onchain-transport-lib/tests/release_completeness.rs b/crates/ksp-onchain-transport-lib/tests/release_completeness.rs index d5f0499..b6111b6 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: 26 +// version: 27 //! Release-level completeness canaries for staged HTTP and WebSocket Transport coverage. @@ -811,3 +811,28 @@ fn release_v0_2_8_pre_003_helius_surface_is_exactly_six_standard_families_before assert!(!source.contains("transaction_subscribe")); assert_eq!(ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream.as_str(), "helius_laserstream"); } + +#[test] +fn release_v0_2_8_pre_005_helius_transaction_request_surface_is_typed_before_actor_integration() { + let filter = ksp_onchain_transport_lib::HeliusTransactionSubscribeFilter::new( + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::Some(ksp_onchain_transport_lib::HeliusTokenAccountsFilter::All), + ); + let options = ksp_onchain_transport_lib::HeliusTransactionSubscribeOptions::new( + std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Processed), + std::option::Option::Some(ksp_onchain_transport_lib::HeliusTransactionSubscribeEncoding::Base64), + std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionDetails::Signatures), + std::option::Option::Some(true), + std::option::Option::None, + ); + let request = ksp_onchain_transport_lib::HeliusTransactionSubscribeRequest::new(filter, std::option::Option::Some(options)); + assert!(request.validate().is_ok()); + let facade_source = include_str!("../src/ws_protocol_session.rs"); + assert!(!facade_source.contains("pub async fn transaction_subscribe")); + assert_eq!(ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream.as_str(), "helius_laserstream"); +} 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 new file mode 100644 index 0000000..7c2fe81 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/unit_tests/ws_helius_transactions.rs @@ -0,0 +1,326 @@ +// file: crates/ksp-onchain-transport-lib/unit_tests/ws_helius_transactions.rs +// version: 2 + +use futures_util::SinkExt; // rust-rules: trait-import +use futures_util::StreamExt; // rust-rules: trait-import + +fn pubkey(value: &str) -> ksp_core_lib::Pubkey { + return value.parse::().expect("fixture public key must parse"); +} + +fn base_filter() -> crate::HeliusTransactionSubscribeFilter { + return crate::HeliusTransactionSubscribeFilter::new( + std::option::Option::Some(false), + std::option::Option::Some(false), + std::option::Option::Some("fixture-signature-secret-canary".to_owned()), + std::option::Option::Some(std::vec![pubkey("11111111111111111111111111111111")]), + std::option::Option::Some(std::vec![pubkey("SysvarC1ock11111111111111111111111111111111")]), + std::option::Option::Some(std::vec![pubkey("Vote111111111111111111111111111111111111111")]), + std::option::Option::Some(crate::HeliusTokenAccountsFilter::BalanceChanged), + ); +} + +fn assert_oversized_filter_rejected(filter: crate::HeliusTransactionSubscribeFilter) { + let request = crate::HeliusTransactionSubscribeRequest::new(filter, std::option::Option::None); + let error = request.validate().expect_err("50,001 Helius account filters must fail before I/O"); + assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS); + assert!(error.to_string().contains("invalid_rpc_parameters")); + assert!(!error.to_string().contains("11111111111111111111111111111111")); +} + +fn helius_endpoint(url: &str) -> crate::WsEndpointSettings { + return crate::WsEndpointSettings::new( + "local_helius_transaction_fixture", + true, + crate::WsProviderName::new("helius"), + crate::WsClusterName::new("local"), + crate::WsProtocolKind::HeliusLaserStream, + crate::WsEndpointUrl::parse(url).expect("local Helius 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"); + return; +} + +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 helius_transaction_filter_and_option_enums_match_documented_wire_labels() { + assert_eq!(crate::HeliusTokenAccountsFilter::None.as_str(), "none"); + assert_eq!(crate::HeliusTokenAccountsFilter::BalanceChanged.as_str(), "balanceChanged"); + assert_eq!(crate::HeliusTokenAccountsFilter::All.as_str(), "all"); + assert_eq!(crate::HeliusTransactionSubscribeEncoding::Base58.as_str(), "base58"); + assert_eq!(crate::HeliusTransactionSubscribeEncoding::Base64.as_str(), "base64"); + assert_eq!(crate::HeliusTransactionSubscribeEncoding::JsonParsed.as_str(), "jsonParsed"); +} + +#[test] +fn helius_transaction_subscribe_request_serializes_complete_documented_filter_and_options() { + let filter = base_filter(); + let options = crate::HeliusTransactionSubscribeOptions::new( + std::option::Option::Some(crate::SolanaCommitment::Confirmed), + std::option::Option::Some(crate::HeliusTransactionSubscribeEncoding::JsonParsed), + std::option::Option::Some(crate::SolanaTransactionDetails::Accounts), + std::option::Option::Some(true), + std::option::Option::Some(0), + ); + let request = crate::HeliusTransactionSubscribeRequest::new(filter, std::option::Option::Some(options)); + let params = request.to_params().expect("complete documented Helius request must validate"); + assert_eq!( + params, + serde_json::json!([ + { + "vote": false, + "failed": false, + "signature": "fixture-signature-secret-canary", + "accountInclude": ["11111111111111111111111111111111"], + "accountExclude": ["SysvarC1ock11111111111111111111111111111111"], + "accountRequired": ["Vote111111111111111111111111111111111111111"], + "tokenAccounts": "balanceChanged" + }, + { + "commitment": "confirmed", + "encoding": "jsonParsed", + "transactionDetails": "accounts", + "showRewards": true, + "maxSupportedTransactionVersion": 0 + } + ]) + ); + assert_eq!(request.filter().vote(), std::option::Option::Some(false)); + assert_eq!(request.filter().failed(), std::option::Option::Some(false)); + assert_eq!(request.filter().signature(), std::option::Option::Some("fixture-signature-secret-canary")); + assert_eq!(request.filter().account_include().map(<[ksp_core_lib::Pubkey]>::len), std::option::Option::Some(1)); + assert_eq!(request.filter().account_exclude().map(<[ksp_core_lib::Pubkey]>::len), std::option::Option::Some(1)); + assert_eq!(request.filter().account_required().map(<[ksp_core_lib::Pubkey]>::len), std::option::Option::Some(1)); + assert_eq!(request.filter().token_accounts(), std::option::Option::Some(crate::HeliusTokenAccountsFilter::BalanceChanged)); + let options = request.options().expect("options must remain available"); + assert_eq!(options.commitment(), std::option::Option::Some(crate::SolanaCommitment::Confirmed)); + assert_eq!(options.encoding(), std::option::Option::Some(crate::HeliusTransactionSubscribeEncoding::JsonParsed)); + assert_eq!(options.transaction_details(), std::option::Option::Some(crate::SolanaTransactionDetails::Accounts)); + assert_eq!(options.show_rewards(), std::option::Option::Some(true)); + assert_eq!(options.max_supported_transaction_version(), std::option::Option::Some(0)); +} + +#[test] +fn helius_transaction_request_preserves_omitted_explicit_empty_and_explicit_none_states() { + let omitted = crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::None); + assert_eq!(omitted.to_params().expect("fully omitted optional request must validate"), serde_json::json!([{}])); + let explicit = crate::HeliusTransactionSubscribeRequest::new( + crate::HeliusTransactionSubscribeFilter::new( + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::Some(std::vec::Vec::new()), + std::option::Option::Some(std::vec::Vec::new()), + std::option::Option::Some(std::vec::Vec::new()), + std::option::Option::Some(crate::HeliusTokenAccountsFilter::None), + ), + std::option::Option::Some(crate::HeliusTransactionSubscribeOptions::default()), + ); + assert_eq!( + explicit.to_params().expect("explicit empty Helius request states must validate"), + serde_json::json!([{"accountInclude":[],"accountExclude":[],"accountRequired":[],"tokenAccounts":"none"}, {}]) + ); +} + +#[test] +fn helius_transaction_filter_enforces_each_documented_fifty_thousand_account_bound() { + let key = pubkey("11111111111111111111111111111111"); + let maximum = std::vec![key; 50_000]; + let accepted = crate::HeliusTransactionSubscribeRequest::new( + crate::HeliusTransactionSubscribeFilter::new( + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::Some(maximum), + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + ), + std::option::Option::None, + ); + assert!(accepted.validate().is_ok()); + assert_oversized_filter_rejected(crate::HeliusTransactionSubscribeFilter::new( + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::Some(std::vec![key; 50_001]), + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + )); + assert_oversized_filter_rejected(crate::HeliusTransactionSubscribeFilter::new( + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::Some(std::vec![key; 50_001]), + std::option::Option::None, + std::option::Option::None, + )); + assert_oversized_filter_rejected(crate::HeliusTransactionSubscribeFilter::new( + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::Some(std::vec![key; 50_001]), + std::option::Option::None, + )); +} + +#[test] +fn helius_transaction_details_require_max_supported_version_only_for_accounts_and_full() { + for details in [crate::SolanaTransactionDetails::Full, crate::SolanaTransactionDetails::Accounts] { + let options = crate::HeliusTransactionSubscribeOptions::new( + std::option::Option::None, + std::option::Option::None, + std::option::Option::Some(details), + std::option::Option::None, + std::option::Option::None, + ); + let request = crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::Some(options)); + let error = request.validate().expect_err("full/accounts details must require maxSupportedTransactionVersion"); + assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS); + } + for details in [crate::SolanaTransactionDetails::Signatures, crate::SolanaTransactionDetails::None] { + let options = crate::HeliusTransactionSubscribeOptions::new( + std::option::Option::None, + std::option::Option::None, + std::option::Option::Some(details), + std::option::Option::None, + std::option::Option::None, + ); + let request = crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::Some(options)); + assert!(request.validate().is_ok()); + } + let full_with_version = crate::HeliusTransactionSubscribeOptions::new( + std::option::Option::None, + std::option::Option::None, + std::option::Option::Some(crate::SolanaTransactionDetails::Full), + std::option::Option::None, + std::option::Option::Some(0), + ); + let request = + crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::Some(full_with_version)); + assert!(request.validate().is_ok()); +} + +#[test] +fn helius_transaction_subscribe_and_unsubscribe_control_wire_is_exact() { + assert_eq!(crate::helius_transaction_subscribe_method(), "transactionSubscribe"); + assert_eq!(crate::helius_transaction_unsubscribe_method(), "transactionUnsubscribe"); + assert_eq!( + crate::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!(crate::helius_transaction_unsubscribe_params(4_743_323_479_349_712), serde_json::json!([4_743_323_479_349_712_u64])); + assert!(crate::decode_helius_transaction_unsubscribe_result(serde_json::json!(true)).expect("boolean true must decode")); + assert!(!crate::decode_helius_transaction_unsubscribe_result(serde_json::json!(false)).expect("boolean false must decode")); + assert_eq!( + crate::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!( + crate::decode_helius_transaction_unsubscribe_result(serde_json::json!(1)).expect_err("non-boolean unsubscribe ack must fail").code(), + crate::ERROR_CODE_INVALID_RESPONSE + ); +} + +#[test] +fn helius_transaction_filter_debug_omits_signature_and_account_values() { + let filter = base_filter(); + let request = crate::HeliusTransactionSubscribeRequest::new(filter, std::option::Option::None); + let debug = format!("{request:?}"); + assert!(debug.contains("signature_present")); + assert!(debug.contains("account_include_count")); + assert!(!debug.contains("fixture-signature-secret-canary")); + assert!(!debug.contains("11111111111111111111111111111111")); + assert!(!debug.contains("SysvarC1ock11111111111111111111111111111111")); + assert!(!debug.contains("Vote111111111111111111111111111111111111111")); +} + +#[tokio::test(flavor = "current_thread")] +async fn helius_transaction_control_wire_round_trips_through_shared_physical_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"); + 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!("transactionSubscribe")); + assert_eq!( + subscribe["params"], + serde_json::json!([ + {"failed":false,"accountInclude":["11111111111111111111111111111111"],"tokenAccounts":"balanceChanged"}, + {"commitment":"confirmed","encoding":"jsonParsed","transactionDetails":"full","showRewards":false,"maxSupportedTransactionVersion":0} + ]) + ); + send_result(&mut websocket, &subscribe, serde_json::json!(4242)).await; + let unsubscribe = read_request(&mut websocket).await; + assert_eq!(unsubscribe["method"], serde_json::json!("transactionUnsubscribe")); + assert_eq!(unsubscribe["params"], serde_json::json!([4242])); + send_result(&mut websocket, &unsubscribe, serde_json::json!(true)).await; + wait_for_close_frame(&mut websocket).await; + }); + let session = crate::HeliusLaserStreamWsSession::connect(helius_endpoint(url.as_str())).await.expect("Helius facade must connect"); + let filter = crate::HeliusTransactionSubscribeFilter::new( + std::option::Option::None, + std::option::Option::Some(false), + std::option::Option::None, + std::option::Option::Some(std::vec![pubkey("11111111111111111111111111111111")]), + std::option::Option::None, + std::option::Option::None, + std::option::Option::Some(crate::HeliusTokenAccountsFilter::BalanceChanged), + ); + let options = crate::HeliusTransactionSubscribeOptions::new( + std::option::Option::Some(crate::SolanaCommitment::Confirmed), + std::option::Option::Some(crate::HeliusTransactionSubscribeEncoding::JsonParsed), + std::option::Option::Some(crate::SolanaTransactionDetails::Full), + std::option::Option::Some(false), + std::option::Option::Some(0), + ); + let request = crate::HeliusTransactionSubscribeRequest::new(filter, std::option::Option::Some(options)); + let params = request.to_params().expect("typed Helius request must validate before I/O"); + let subscribe_result = session + .physical_session() + .execute_json_rpc(crate::helius_transaction_subscribe_method(), params) + .await + .expect("transactionSubscribe acknowledgement must arrive"); + let remote_id = crate::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(crate::helius_transaction_unsubscribe_method(), crate::helius_transaction_unsubscribe_params(remote_id)) + .await + .expect("transactionUnsubscribe acknowledgement must arrive"); + assert!(crate::decode_helius_transaction_unsubscribe_result(unsubscribe_result).expect("transactionUnsubscribe boolean must decode")); + session.close().await.expect("Helius fixture session must close"); + server.await.expect("local Helius transaction server must finish"); +} diff --git a/deltas/0.2.8/pre.005.md b/deltas/0.2.8/pre.005.md new file mode 100644 index 0000000..9367b73 --- /dev/null +++ b/deltas/0.2.8/pre.005.md @@ -0,0 +1,277 @@ + + + +# Delta `0.2.8-pre.005` — contrat typed Helius `transactionSubscribe` + +## 1. Objet + +Cette tranche matérialise le contrat de requête Helius LaserStream WebSocket `transactionSubscribe` : filtres, options, `tokenAccounts`, validations déterministes, acknowledgement numérique et wire `transactionUnsubscribe`. + +Elle ne publie volontairement **pas encore** de handle live transaction : `transactionNotification`, le registry actor, les remaps de remote IDs et les races reconnect/unsubscribe doivent arriver atomiquement en `pre.006` afin de ne jamais exposer un abonnement public incapable de livrer correctement ses notifications. + +Le checkpoint opérateur de `pre.004-fix.002` est intégralement vert. + +Version workspace : + +```text +0.2.8-pre.5 +``` + +Livraison / commit attendu : + +```text +0.2.8-pre.005 +v0.2.8-pre.005 +``` + +Aucun tag prerelease. + +## 2. Audit Helius courant verrouillé + +La documentation Helius relue le 2026-08-23 confirme pour `transactionSubscribe` : + +```text +filter.vote bool optionnel +filter.failed bool optionnel +filter.signature signature exacte optionnelle +filter.accountInclude liste OR, <= 50_000 adresses +filter.accountExclude liste d'exclusion, <= 50_000 adresses +filter.accountRequired liste AND, <= 50_000 adresses +filter.tokenAccounts none | balanceChanged | all + +options.commitment processed | confirmed | finalized +options.encoding base58 | base64 | jsonParsed +options.transactionDetails full | signatures | accounts | none +options.showRewards bool optionnel +options.maxSupportedTransactionVersion + requis pour transactionDetails = accounts | full + +subscribe result integer subscription id +unsubscribe params [subscriptionId] +unsubscribe result bool +late notifications possibles brièvement après unsubscribe +``` + +`tokenAccounts = none` est équivalent à l'omission du champ. `balanceChanged` et `all` étendent le matching d'un `accountInclude` wallet aux token accounts qu'il possède selon les règles Helius documentées. + +## 3. Contrat public typed + +Nouveau module ciblé : + +```text +crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs +``` + +Il publie depuis le crate-root : + +```text +HeliusTokenAccountsFilter +HeliusTransactionSubscribeEncoding +HeliusTransactionSubscribeFilter +HeliusTransactionSubscribeOptions +HeliusTransactionSubscribeRequest +``` + +Le contrat réutilise les types KSP existants lorsqu'ils sont wire-identiques : + +```text +commitment -> SolanaCommitment +transactionDetails -> SolanaTransactionDetails +account filters -> ksp_core_lib::Pubkey +``` + +Aucun DTO Solana commun n'est recopié sous un nom Helius sans nécessité wire. + +## 4. Sémantique des filtres et options + +`HeliusTransactionSubscribeFilter` conserve explicitement la différence entre : + +```text +champ omis +liste présente mais vide [] +liste présente avec valeurs +``` + +pour `accountInclude`, `accountExclude` et `accountRequired`. + +Chaque liste est validée indépendamment avec la limite Helius : + +```text +0 ..= 50_000 accepté +50_001 rejeté avant I/O +``` + +Les erreurs déterministes n'incluent aucune signature ni adresse du filtre ; elles transportent uniquement le nom du champ et les cardinalités sûres. + +`HeliusTransactionSubscribeOptions` impose avant I/O : + +```text +transactionDetails = full -> maxSupportedTransactionVersion requis +transactionDetails = accounts -> maxSupportedTransactionVersion requis +transactionDetails = signatures -> version optionnelle +transactionDetails = none -> version optionnelle +``` + +La distinction suivante est préservée sur le wire : + +```text +options = None -> params = [filter] +options = Some(default) -> params = [filter, {}] +``` + +## 5. Wire subscribe/unsubscribe préparé + +Les helpers crate-private préparés pour l'intégration actor de `pre.006` verrouillent : + +```text +transactionSubscribe +transactionUnsubscribe +subscribe result integer -> u64 +unsubscribe params -> [remote_subscription_id] +unsubscribe result -> bool +``` + +Les décodeurs refusent les formes de réponse d'un type différent au lieu de les coercer. + +Ces helpers restent crate-private : aucun raw provider-extension API public n'est introduit. + +## 6. Réutilisation du moteur physique + +Une fixture locale passe réellement : + +```text +HeliusLaserStreamWsSession + -> physical_session() crate-private + -> WsSession::execute_json_rpc + -> actor/socket unique existant + -> transactionSubscribe + -> transactionUnsubscribe +``` + +Elle vérifie les méthodes, params, acknowledgements et résultat d'unsubscribe exacts contre un peer WebSocket local. + +Aucun second : + +```text +actor +socket +pending map +reconnect loop +subscription engine +``` + +n'est ajouté. + +## 7. Sécurité et surface différée + +`Debug` pour le filtre/requête expose seulement des indicateurs, modes et cardinalités ; il ne rend ni la signature exacte ni les valeurs des comptes filtrés. + +`HeliusLaserStreamWsSession` n'expose toujours pas : + +```text +pub async fn transaction_subscribe(...) +``` + +Cette absence est verrouillée par release-completeness. Le handle live arrive en `pre.006` avec : + +```text +transactionNotification +registry local/remote +reconnect + resubscribe +unsubscribe races +late notifications +backpressure ciblée +``` + +Le heartbeat Helius reste réservé à `pre.007`. + +## 8. Canaries et non-régressions + +Les nouveaux tests couvrent : + +```text +strings wire exactes tokenAccounts/encoding +serialization complète filtre/options +omission vs [] explicite +borne 50_000 / rejet 50_001 pour les trois listes +règle conditionnelle maxSupportedTransactionVersion +ack subscribe numérique strict +wire/result unsubscribe strict +Debug sans signature/adresses +round-trip local via actor physique partagé +public API des nouveaux types +absence du live handle avant pre.006 +``` + +Les surfaces acquises restent inchangées : + +```text +SolanaStandardWsSession 9 familles standard +HeliusLaserStreamWsSession 6 familles standard supportées +HTTP 52 current + 14 historiques +Config Helius mainnet + devnet, secret/redaction/provenance validés +``` + +Aucune nouvelle dépendance Rust n'est ajoutée. + +## 9. Preuve opérateur héritée + +Le checkpoint `pre.004-fix.002` fourni le 2026-08-23 est intégralement vert : + +```text +cargo fmt --all OK +python3 scripts/audit_rust_workspace_rules.py clean +cargo check --workspace OK +cargo clippy --workspace --all-targets OK +cargo test -p ksp-config-lib OK 110/110 + ownership/public API +cargo test -p ksp-onchain-transport-lib OK 314 unit + 38 public + 26 completeness + 4 doctests +cargo test --workspace OK +``` + +`pre.004`, `pre.004-fix.001` et `pre.004-fix.002` sont donc `DONE` avant cette tranche. + +## 10. Fichiers de la livraison + +Nouveaux : + +```text +crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs +crates/ksp-onchain-transport-lib/unit_tests/ws_helius_transactions.rs +deltas/0.2.8/pre.005.md +``` + +Modifiés : + +```text +Cargo.toml +crates/ksp-onchain-transport-lib/src/lib.rs +crates/ksp-onchain-transport-lib/src/ws_protocol_session.rs +crates/ksp-onchain-transport-lib/tests/public_api.rs +crates/ksp-onchain-transport-lib/tests/release_completeness.rs +docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md +docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md +``` + +Aucun fichier Config, schema, `.env`, README/USAGE, ROADMAP ou CHANGELOG n'est modifié. + +## 11. Validation de préparation et gate opérateur + +Validation statique disponible dans le sandbox de préparation : + +```text +python3 scripts/audit_rust_workspace_rules.py +General Rust rule audit: clean +Rust export completeness audit: 0 candidate(s) +KSP workspace Rust rule audit: clean +``` + +Le sandbox ne fournit pas Cargo/rustfmt ; la tranche reste donc `PREPARED` jusqu'au checkpoint opérateur suivant : + +```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 +``` 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 8c1f2bc..9c34539 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 : `0.2.8-pre.003` est validé ; `pre.004` est fonctionnellement implémenté mais son gate Config a nécessité deux corrections de canari. `pre.004-fix.001` a corrigé la redaction segmentaire et ajouté Devnet ; son checkpoint a ensuite révélé une seconde hypothèse de test erronée sur la provenance d'une chaîne composée. `pre.004-fix.002` corrige uniquement cette cardinalité/provenance et reste préparé.** Le runtime Config/Transport, les profils mainnet/devnet, le schema et le mapping Helius restent inchangés. +> **Statut : `pre.004` et ses deux fixes sont validés par checkpoint opérateur complet. `0.2.8-pre.005` est préparé : contrat typed `transactionSubscribe`, filtres/options Helius, `tokenAccounts`, limites 50k et wire `transactionUnsubscribe`, sans exposer encore de handle transaction avant l'intégration actor de `pre.006`.** ## 1. Objet, base et état courant @@ -27,12 +27,13 @@ pre.001-fix.002 forecast compact + namespace LaserStream WS/gRPC pre.002 socle protocolaire validé pre.002-fix.001 correction Clippy + normalisation structure documentaire validées pre.003 six familles standard Helius validées -pre.004 Config V2 Helius implémenté ; checkpoint bloqué par un faux négatif de test -pre.004-fix.001 correction safe_value + couverture Devnet Helius ; checkpoint bloqué par provenance -pre.004-fix.002 correction du canari de provenance composée préparée +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 préparé -workspace.package.version courant = 0.2.8-pre.4.fix.2 -commit attendu = v0.2.8-pre.004-fix.002 +workspace.package.version courant = 0.2.8-pre.5 +commit attendu = v0.2.8-pre.005 aucun tag prerelease ``` @@ -52,14 +53,12 @@ pre.002 DONE — socle protocolaire : WsProtocolKind::HeliusLaserStream + faça fix.001 DONE — correction Clippy `implicit_return` + audit/normalisation structure docs pre.003 DONE — surface Helius standard supportée : account/logs/program/root/signature/slot + absence typée de block/slotsUpdates/vote sur Helius + non-régression standard 9/9 -pre.004 CHECKPOINT — Config V2 helius_laserstream + schema/fixtures + mapping Config -> Transport - + stratégie de secret Helius et redaction URL ; runtime/check/clippy/Transport verts - fix.001 CHECKPOINT — redaction segmentaire corrigée + représentation Devnet Helius ajoutée - + gate Config bloqué uniquement par une attente de provenance `1` au lieu de `2` - fix.002 PREPARED — provenance composée attend `DocumentLiteral` + `EnvironmentProcess` - + aucun changement runtime/schema/fixture/profil -pre.005 transactionSubscribe request typed + filters/options/tokenAccounts + transactionUnsubscribe - + bounds 50k + maxSupportedTransactionVersion conditionnel +pre.004 DONE — Config V2 helius_laserstream + schema/fixtures + mapping Config -> Transport + + 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 PREPARED — transactionSubscribe request typed + filters/options/tokenAccounts + transactionUnsubscribe + + bounds 50k + maxSupportedTransactionVersion conditionnel ; live handle différé à pre.006 pre.006 transactionNotification + actor integration + reconnect/resubscribe/unsubscribe races + late notifications + backpressure ciblée pre.007 heartbeat Helius WebSocket/idle + timers + interaction reconnect/control frames/shutdown @@ -897,7 +896,7 @@ validation finale fermée prompt 0.2.9 prêt ``` -## 13. Checkpoints `pre.004`, `fix.001` et `fix.002` +## 13. Checkpoints `pre.004`, fixes et préparation `pre.005` Le checkpoint `pre.003` est fermé par preuve opérateur : @@ -962,16 +961,37 @@ provenance attendue = 1 segment Pour une URL composée `littéral + ${KSP_SECRET_HELIUS_API_KEY}`, le contrat Config enregistre correctement deux provenances ordonnées : `DocumentLiteral`, puis `EnvironmentProcess(KSP_SECRET_HELIUS_API_KEY)`. `pre.004-fix.002` corrige seulement cette attente pour mainnet et devnet ; aucune donnée Config ni logique runtime n'est modifiée. -Le checkpoint à rejouer avant de déclarer `pre.004` + `fix.001` + `fix.002` `DONE` est : +Le checkpoint opérateur reçu après `pre.004-fix.002` ferme cette tranche : -```bash -cargo fmt --all -python3 scripts/audit_rust_workspace_rules.py -cargo check --workspace -cargo clippy --workspace --all-targets -cargo test -p ksp-config-lib -cargo test -p ksp-onchain-transport-lib -cargo test --workspace +```text +cargo fmt --all OK +python3 scripts/audit_rust_workspace_rules.py clean +cargo check --workspace OK +cargo clippy --workspace --all-targets OK +cargo test -p ksp-config-lib OK 110/110 + ownership/public API +cargo test -p ksp-onchain-transport-lib OK 314 unit + 38 public + 26 completeness + 4 doctests +cargo test --workspace OK ``` -Si ce checkpoint est vert, `pre.004` et ses deux fixes peuvent être fermés, puis `pre.005` peut ouvrir la requête typed `transactionSubscribe` / `transactionUnsubscribe`, ses filtres/options et validations déterministes. +Les profils Helius mainnet/devnet, la redaction segmentaire, la provenance composée, le mapping Config -> Transport et l'absence de nouvelle dépendance sont donc acquis avant `pre.005`. + +`pre.005` prépare maintenant : + +```text +HeliusTransactionSubscribeFilter vote/failed/signature/accountInclude/accountExclude/accountRequired/tokenAccounts +HeliusTokenAccountsFilter none / balanceChanged / all +HeliusTransactionSubscribeOptions commitment/encoding/transactionDetails/showRewards/maxSupportedTransactionVersion +HeliusTransactionSubscribeRequest validation déterministe + params exacts +accountInclude/Exclude/Required <= 50_000 chacun +transactionDetails accounts/full maxSupportedTransactionVersion obligatoire +transactionSubscribe ack remote id numérique décodé en interne +transactionUnsubscribe méthode/params/résultat bool exacts +Debug filtre/request signature et valeurs d'adresses non rendues +actor/socket chemin physique existant réutilisé dans fixture locale +live transaction handle volontairement absent jusqu'à pre.006 +transactionNotification toujours hors scope pre.005 +heartbeat toujours hors scope pre.005 +new dependency aucune +``` + +La séparation `pre.005` / `pre.006` est normative : publier dès maintenant un `transaction_subscribe()` public sans registry de notification/reconnect produirait un handle transitoire qui perdrait les notifications. Le contrat public de requête est donc stable dès `pre.005`, tandis que l'abonnement live est ajouté atomiquement avec l'actor integration en `pre.006`. 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 1f542b5..120f5db 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.003` validé ; `pre.004` est implémenté mais son gate Config a exposé deux hypothèses de test incorrectes. `pre.004-fix.001` a corrigé `safe_value` et ajouté Devnet ; son checkpoint est vert sur `fmt`, audit, `check`, `clippy` et Transport mais échoue encore sur la cardinalité de provenance. `pre.004-fix.002` corrige uniquement ce canari : une URL composée porte `DocumentLiteral` puis `EnvironmentProcess`.** +> **Statut : `pre.004` + `fix.001` + `fix.002` validés par checkpoint opérateur complet. `pre.005` est préparé : contrat public typed de requête Helius transaction, validations 50k/version, `tokenAccounts`, wire subscribe/unsubscribe et canari actor local ; le handle live reste volontairement différé à `pre.006`.** ## 1. Références @@ -20,6 +20,7 @@ pre.003 deltas/0.2.8/pre.003.md pre.004 deltas/0.2.8/pre.004.md pre.004 redaction/devnet fix deltas/0.2.8/pre.004-fix.001.md pre.004 provenance fix deltas/0.2.8/pre.004-fix.002.md +pre.005 deltas/0.2.8/pre.005.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 @@ -133,22 +134,22 @@ 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é | | façade Helius | `HeliusLaserStreamWsSession` | public API canary | pre.002 implémenté | -| namespace LaserStream WS | `WsProtocolKind` + `ws_endpoints` possèdent `helius_laserstream` | API/Config/docs canary | pre.004 préparé | -| LaserStream gRPC | backend/type/Config distincts, hors `0.2.8` | absence de réutilisation WS | décidé | +| namespace LaserStream WS | `WsProtocolKind` + `ws_endpoints` possèdent `helius_laserstream` | API/Config/docs canary | pre.004 validé | +| LaserStream gRPC | backend/type/Config distincts, hors `0.2.8` | absence de réutilisation WS | décidé | | escape hatch Helius | aucun `inner()`/`into_inner()` public | compile-fail/source canary | pre.002 implémenté | | generic Helius `WsSession::connect` | ne doit pas permettre de contourner la façade | invalid protocol pre-I/O canary | pre.002 implémenté | -| Helius unsupported | absent de la façade | compile-fail/API absence canary | pre.003 validé | -| standard Helius commun | délégation vers le même wire/actor | exact fixture | pre.003 validé | -| DTO duplication | seulement si wire/sémantique divergent | public/source audit | décidé | -| Config direction | Config -> Transport uniquement | ownership tests | pre.004 préparé | -| heartbeat | Helius-only, actor commun | deterministic timers | décidé | -| secret | query URL derrière `WsEndpointUrl` | redaction canaries | pre.004 préparé | -| new Rust dependency | aucune | manifest/tree audit | décidé | +| Helius unsupported | absent de la façade | compile-fail/API absence canary | pre.003 validé | +| standard Helius commun | délégation vers le même wire/actor | exact fixture | pre.003 validé | +| DTO duplication | seulement si wire/sémantique divergent | public/source audit | décidé | +| Config direction | Config -> Transport uniquement | ownership tests | pre.004 validé | +| heartbeat | Helius-only, actor commun | deterministic timers | décidé | +| secret | query URL derrière `WsEndpointUrl` | redaction canaries | pre.004 validé | +| new Rust dependency | aucune | manifest/tree audit | décidé | ## 5. Contrat `transactionSubscribe` à valider @@ -206,8 +207,8 @@ 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 -[ ] transaction unsubscribe/result exact +[ ] 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 [ ] notification decode failure isole la logical subscription [ ] provider RPC application error ne tue pas la session @@ -262,7 +263,7 @@ Statut initial : **non exécuté**. Contrat cible : opt-in, Config résout l'api-key, Transport ne lit pas l'environnement. Aucun secret n'est versionné ou affiché. Un échec lié au plan/credential provider doit rester distinguable d'une panne du moteur. -## 10. Preuves `pre.003` et préparation `pre.004` +## 10. Preuves `pre.003` et `pre.004` Checkpoint `pre.003` reçu : @@ -299,18 +300,18 @@ Surface `pre.004` préparée : [x] schema `ws_endpoints[].kind` accepte `helius_laserstream` [x] adapter Config mappe `helius_laserstream` vers `WsProtocolKind::HeliusLaserStream` [x] fixture Config matérialise un endpoint Helius mainnet typé -[ ] fixture Config matérialise un profil Helius devnet distinct — ajouté par fix.001, checkpoint à rejouer +[x] fixture Config matérialise un profil Helius devnet distinct — fix.001/fix.002 validés [x] exemple Transport matérialise Helius mainnet sans credential réel -[ ] exemple Transport matérialise Helius devnet dans un profil distinct — ajouté par fix.001, checkpoint à rejouer +[x] exemple Transport matérialise Helius devnet dans un profil distinct — fix.001/fix.002 validés [x] `.env.example` inventorie `KSP_SECRET_HELIUS_API_KEY` [x] interpolation du secret produit l'URL runtime Helius attendue -[ ] safe_value conserve les littéraux Helius mainnet/devnet et redige uniquement l'api-key (`...api-key=********`) — fix.001 à rejouer -[ ] Debug de `ResolvedTransportConfig` n'expose pas la clé — assertion à rejouer après fix.001 +[x] safe_value conserve les littéraux Helius mainnet/devnet et redige uniquement l'api-key (`...api-key=********`) +[x] Debug de `ResolvedTransportConfig` n'expose pas la clé [x] Config produit un endpoint composable avec `HeliusLaserStreamWsSession` [x] `config/std.transport.json` canonique reste standard-only ``` -Aucun `transactionSubscribe`, heartbeat, changement du moteur WebSocket ou nouvelle dépendance n'est introduit dans cette tranche. +`pre.004` n'introduisait aucun `transactionSubscribe`, heartbeat, changement du moteur WebSocket ou nouvelle dépendance ; son checkpoint final est désormais vert. ## 11. Audit structurel des documents actifs @@ -338,7 +339,6 @@ Constats `pre.002-fix.001` : Aucun split en fichiers supplémentaires n'est retenu : les plans historiques `0.2.5`–`0.2.7` sont de taille comparable ou supérieure, et le contenu du plan `015` reste entièrement centré sur une seule release. Le problème identifié était **l'ordre interne et la duplication de responsabilités**, pas la nécessité d'un nouveau type de document. - ## 12. Gates opérateur `pre.004` / `fix.001` / `fix.002` Premier passage `pre.004` reçu le 2026-08-23 : @@ -389,16 +389,69 @@ Le comportement runtime est correct. Une chaîne `wss://...api-key=${KSP_SECRET_ `pre.004-fix.002` modifie uniquement le canari mainnet/devnet pour vérifier ces deux segments. Les profils, URLs, redaction, schema et mapping restent inchangés. -À rejouer après `pre.004-fix.002` : +Checkpoint reçu après `pre.004-fix.002` : + +```text +[x] cargo fmt --all +[x] python3 scripts/audit_rust_workspace_rules.py = clean +[x] cargo check --workspace +[x] cargo clippy --workspace --all-targets +[x] cargo test -p ksp-config-lib — 110/110 + ownership/public API +[x] cargo test -p ksp-onchain-transport-lib — 314 unit + 38 public + 26 completeness + 4 doctests +[x] cargo test --workspace +``` + +Verdict : **`pre.004` DONE ; `pre.004-fix.001` DONE ; `pre.004-fix.002` DONE.** + +## 13. Préparation `pre.005` — contrat Helius transaction + +Sources matérialisées : + +```text +crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs +crates/ksp-onchain-transport-lib/unit_tests/ws_helius_transactions.rs +crates/ksp-onchain-transport-lib/tests/public_api.rs +crates/ksp-onchain-transport-lib/tests/release_completeness.rs +``` + +Contrats préparés : + +```text +[x] HeliusTransactionSubscribeFilter couvre vote/failed/signature/accountInclude/accountExclude/accountRequired/tokenAccounts +[x] accountInclude/accountExclude/accountRequired conservent omission vs [] explicite +[x] chacune des trois listes est bornée localement à 50_000 +[x] HeliusTokenAccountsFilter = none/balanceChanged/all +[x] HeliusTransactionSubscribeEncoding = base58/base64/jsonParsed +[x] transactionDetails réutilise SolanaTransactionDetails = full/signatures/accounts/none +[x] maxSupportedTransactionVersion est exigé pour accounts/full +[x] options absentes vs objet {} explicite restent distinctes +[x] transactionSubscribe ack numérique et transactionUnsubscribe booléen ont des décodeurs stricts +[x] transactionUnsubscribe encode exactement [remoteSubscriptionId] +[x] Debug filtre/request expose des comptes et indicateurs, jamais signature/adresses +[x] fixture locale route subscribe/unsubscribe via le WsSession actor existant +[x] aucun second actor/socket +[x] aucun transaction_subscribe public avant pre.006 +[x] aucune nouvelle dépendance +``` + +Validation statique disponible dans l'environnement de préparation : + +```text +python3 scripts/audit_rust_workspace_rules.py +General Rust rule audit: clean +Rust export completeness audit: 0 candidate(s) +KSP workspace Rust rule audit: clean +``` + +Gates opérateur `pre.005` à exécuter : ```text [ ] cargo fmt --all [ ] python3 scripts/audit_rust_workspace_rules.py = clean [ ] cargo check --workspace [ ] cargo clippy --workspace --all-targets -[ ] cargo test -p ksp-config-lib [ ] cargo test -p ksp-onchain-transport-lib [ ] cargo test --workspace ``` -Verdict courant : **`pre.003` DONE ; `pre.004` fonctionnellement implémenté mais non fermé ; `pre.004-fix.001` CHECKPOINT ; `pre.004-fix.002` PREPARED.** +Verdict courant : **`pre.004` et ses fixes DONE ; `pre.005` PREPARED.**