// file: crates/ksp-onchain-transport-lib/src/ws_accounts.rs // version: 3 const MAX_PROGRAM_SUBSCRIBE_FILTERS: usize = 4; const MAX_PROGRAM_SUBSCRIBE_RAW_MEMCMP_BYTES: usize = 128; /// Configuration accepted by the standard Solana `accountSubscribe` WebSocket method. /// /// `minContextSlot` is deliberately absent: Agave `v4.2.1` carries that field in the shared account config but the PubSub handler ignores it, so KSP does /// not expose it as an effective WebSocket option. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct SolanaAccountSubscribeConfig { encoding: std::option::Option, data_slice: std::option::Option, commitment: std::option::Option, } impl SolanaAccountSubscribeConfig { /// Creates an explicit `accountSubscribe` configuration. #[must_use] pub const fn new( encoding: std::option::Option, data_slice: std::option::Option, commitment: std::option::Option, ) -> Self { return Self { encoding, data_slice, commitment }; } /// Returns the optional account-data encoding. #[must_use] pub const fn encoding(&self) -> std::option::Option { return self.encoding; } /// Returns the optional account-data slice. #[must_use] pub const fn data_slice(&self) -> std::option::Option { return self.data_slice; } /// Returns the optional commitment level. #[must_use] pub const fn commitment(&self) -> std::option::Option { return self.commitment; } fn is_empty(&self) -> bool { return self.encoding.is_none() && self.data_slice.is_none() && self.commitment.is_none(); } fn to_json_value(&self) -> serde_json::Value { let mut object = serde_json::Map::new(); 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(data_slice) = self.data_slice { object.insert("dataSlice".to_owned(), data_slice.to_json_value()); } if let std::option::Option::Some(commitment) = self.commitment { object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned())); } return serde_json::Value::Object(object); } } /// Configuration accepted by the standard Solana `programSubscribe` WebSocket method. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct SolanaProgramSubscribeConfig { account: crate::SolanaAccountSubscribeConfig, filters: std::vec::Vec, with_context: std::option::Option, } impl SolanaProgramSubscribeConfig { /// Creates an explicit `programSubscribe` configuration. #[must_use] pub fn new( account: crate::SolanaAccountSubscribeConfig, filters: std::vec::Vec, with_context: std::option::Option, ) -> Self { return Self { account, filters, with_context }; } /// Returns the shared WebSocket account configuration. #[must_use] pub const fn account(&self) -> &crate::SolanaAccountSubscribeConfig { return &self.account; } /// Returns the ordered program-account filters. #[must_use] pub fn filters(&self) -> &[crate::SolanaProgramAccountFilter] { return self.filters.as_slice(); } /// Returns the optional `withContext` request; omission uses the upstream default `false`. #[must_use] pub const fn with_context(&self) -> std::option::Option { return self.with_context; } fn is_empty(&self) -> bool { return self.account.is_empty() && self.filters.is_empty() && self.with_context.is_none(); } fn to_json_value(&self) -> serde_json::Value { let account = self.account.to_json_value(); let mut object = match account { serde_json::Value::Object(object) => object, _ => serde_json::Map::new(), }; if !self.filters.is_empty() { let filters = self.filters.iter().map(crate::SolanaProgramAccountFilter::to_json_value).collect::>(); object.insert("filters".to_owned(), serde_json::Value::Array(filters)); } if let std::option::Option::Some(with_context) = self.with_context { object.insert("withContext".to_owned(), serde_json::Value::Bool(with_context)); } return serde_json::Value::Object(object); } } /// One `programNotification` payload, preserving whether the upstream wire result was contextualized. #[derive(Clone, Debug, PartialEq)] pub enum SolanaProgramNotification { /// Program account payload without a surrounding RPC context. Account(crate::SolanaKeyedAccount), /// Program account payload wrapped in an RPC context. Context(crate::SolanaRpcResponse), } impl SolanaProgramNotification { /// Returns the program account regardless of the upstream context-wrapper form. #[must_use] pub const fn account(&self) -> &crate::SolanaKeyedAccount { return match self { Self::Account(account) => account, Self::Context(response) => response.value(), }; } /// Returns the RPC context when the upstream notification included one. #[must_use] pub const fn context(&self) -> std::option::Option<&crate::SolanaRpcContext> { return match self { Self::Account(_) => std::option::Option::None, Self::Context(response) => std::option::Option::Some(response.context()), }; } } impl crate::WsSession { /// Subscribes to changes for one Solana account through standard `accountSubscribe`. pub async fn account_subscribe( &self, account: &ksp_core_lib::Pubkey, config: std::option::Option<&crate::SolanaAccountSubscribeConfig>, ) -> ksp_core_lib::Result>> { let mut params = std::vec![serde_json::Value::String(account.to_string())]; if let std::option::Option::Some(config) = config && !config.is_empty() { params.push(config.to_json_value()); } return self .subscribe_typed(crate::WsSubscriptionKind::Account, params, |value| return decode_account_notification("accountSubscribe", value)) .await; } /// Subscribes to account changes owned by one Solana program through standard `programSubscribe`. pub async fn program_subscribe( &self, program_id: &ksp_core_lib::Pubkey, config: std::option::Option<&crate::SolanaProgramSubscribeConfig>, ) -> ksp_core_lib::Result> { if let std::option::Option::Some(config) = config { let validation = validate_program_subscribe_filters(config.filters()); if let std::result::Result::Err(error) = validation { return std::result::Result::Err(error); } } let mut params = std::vec![serde_json::Value::String(program_id.to_string())]; if let std::option::Option::Some(config) = config && !config.is_empty() { params.push(config.to_json_value()); } return self .subscribe_typed(crate::WsSubscriptionKind::Program, params, |value| return decode_program_notification("programSubscribe", value)) .await; } } #[derive(serde::Deserialize)] struct WireRpcResponse { context: serde_json::Value, value: serde_json::Value, } #[derive(serde::Deserialize)] #[serde(untagged)] enum WireProgramNotification { Context(WireRpcResponse), Account(serde_json::Value), } fn decode_account_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result> { let decoded = crate::decode_wire_json::(method, value); let wire = match decoded { std::result::Result::Ok(wire) => wire, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let context = crate::SolanaRpcContext::decode_wire(method, wire.context); let context = match context { std::result::Result::Ok(context) => context, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let account = crate::SolanaAccount::decode_wire(method, wire.value); let account = match account { std::result::Result::Ok(account) => account, std::result::Result::Err(error) => return std::result::Result::Err(error), }; return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, account)); } fn decode_program_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result { let decoded = crate::decode_wire_json::(method, value); let wire = match decoded { std::result::Result::Ok(wire) => wire, std::result::Result::Err(error) => return std::result::Result::Err(error), }; return match wire { WireProgramNotification::Account(value) => { let account = crate::SolanaKeyedAccount::decode_wire(method, value); match account { std::result::Result::Ok(account) => std::result::Result::Ok(crate::SolanaProgramNotification::Account(account)), std::result::Result::Err(error) => std::result::Result::Err(error), } }, WireProgramNotification::Context(wire) => { let context = crate::SolanaRpcContext::decode_wire(method, wire.context); let context = match context { std::result::Result::Ok(context) => context, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let account = crate::SolanaKeyedAccount::decode_wire(method, wire.value); let account = match account { std::result::Result::Ok(account) => account, std::result::Result::Err(error) => return std::result::Result::Err(error), }; std::result::Result::Ok(crate::SolanaProgramNotification::Context(crate::SolanaRpcResponse::new(context, account))) }, }; } fn validate_program_subscribe_filters(filters: &[crate::SolanaProgramAccountFilter]) -> ksp_core_lib::Result<()> { if filters.len() > MAX_PROGRAM_SUBSCRIBE_FILTERS { return std::result::Result::Err( ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, "programSubscribe accepts at most 4 filters on the targeted Agave runtime") .with_context("rpc_method", "programSubscribe") .with_context("filter_count", filters.len().to_string()), ); } for filter in filters { if let crate::SolanaProgramAccountFilter::Memcmp(memcmp) = filter && let crate::SolanaMemcmpBytes::Bytes(bytes) = memcmp.bytes() && bytes.len() > MAX_PROGRAM_SUBSCRIBE_RAW_MEMCMP_BYTES { return std::result::Result::Err( ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, "raw programSubscribe memcmp data accepts at most 128 bytes") .with_context("rpc_method", "programSubscribe") .with_context("memcmp_byte_count", bytes.len().to_string()), ); } } return std::result::Result::Ok(()); } impl crate::SolanaStandardWsSession { /// Subscribes to changes for one Solana account through standard `accountSubscribe`. pub async fn account_subscribe( &self, account: &ksp_core_lib::Pubkey, config: std::option::Option<&crate::SolanaAccountSubscribeConfig>, ) -> ksp_core_lib::Result>> { return self.physical_session().account_subscribe(account, config).await; } /// Subscribes to account changes owned by one Solana program through standard `programSubscribe`. pub async fn program_subscribe( &self, program_id: &ksp_core_lib::Pubkey, config: std::option::Option<&crate::SolanaProgramSubscribeConfig>, ) -> ksp_core_lib::Result> { return self.physical_session().program_subscribe(program_id, config).await; } } impl crate::HeliusLaserStreamWsSession { /// Subscribes to account changes through the standard `accountSubscribe` wire supported by Helius LaserStream WebSocket. pub async fn account_subscribe( &self, account: &ksp_core_lib::Pubkey, config: std::option::Option<&crate::SolanaAccountSubscribeConfig>, ) -> ksp_core_lib::Result>> { return self.physical_session().account_subscribe(account, config).await; } /// Subscribes to program-owned account changes through the standard `programSubscribe` wire supported by Helius LaserStream WebSocket. pub async fn program_subscribe( &self, program_id: &ksp_core_lib::Pubkey, config: std::option::Option<&crate::SolanaProgramSubscribeConfig>, ) -> ksp_core_lib::Result> { return self.physical_session().program_subscribe(program_id, config).await; } } #[cfg(test)] #[path = "../unit_tests/ws_accounts.rs"] mod tests;