v0.2.7-pre.011

This commit is contained in:
2026-08-23 08:58:03 +02:00
parent 74686892e9
commit 9eb0e19d81
15 changed files with 1269 additions and 20 deletions

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-onchain-transport-lib/README.md -->
<!-- version: 16 -->
<!-- version: 17 -->
# `ksp-onchain-transport-lib`
@@ -180,6 +180,24 @@ Une cancellation effectuée avant cette terminaison continue d'utiliser `WsSubsc
`slot_subscribe()` et `root_subscribe()` n'acceptent aucun paramètre. `SolanaSlotNotification` conserve exactement `slot`, `parent` et `root`; `root_subscribe()` délivre directement le root `u64`. Ces deux subscriptions restent continues et utilisent donc le reconnect/resubscribe standard de `pre.007`.
### Wrappers unstable `0.2.7-pre.011`
Les trois familles standard restantes complètent désormais l'inventaire **9/9 subscribe + 9/9 unsubscribe via handles** :
```text
block_subscribe -> WsSubscription<SolanaRpcResponse<SolanaBlockNotification>>
slots_updates_subscribe -> WsSubscription<SolanaSlotUpdate>
vote_subscribe -> WsSubscription<SolanaVoteNotification>
```
Ces familles restent explicitement **unstable**. Le moteur commun `subscribe_typed_with_completion` émet un warning KSP centralisé pour `Block`, `SlotsUpdates` et `Vote` avant l'ouverture logique, sans recopier filtres, pubkeys, payloads ou URL dans les logs.
`SolanaBlockSubscribeConfig` conserve `commitment`, `encoding`, `transactionDetails`, `maxSupportedTransactionVersion` et `showRewards`. Le filtre représente `All` ou `MentionsAccountOrProgram(Pubkey)`. Un commitment `processed` explicitement fourni est rejeté avant I/O ; la notification réutilise `SolanaConfirmedBlock` pour le block nullable et conserve l'erreur publication nullable sans interprétation métier. Le numéro de version transaction supporté reste un `u8` générique et n'est pas durci à `0`.
`SolanaSlotUpdate` représente les sept variantes courantes `firstShredReceived`, `completed`, `createdBank`, `frozen`, `dead`, `optimisticConfirmation` et `root`. Une variante upstream inconnue devient `Unknown { update_type, raw }` au lieu de faire tomber la session. Le `raw` reste borné par `max_message_size_bytes` avant le parse JSON.
`SolanaVoteNotification` conserve `votePubkey`, `slots`, `hash`, `timestamp` et `signature`. Le timestamp reste optionnel : omission et `null` deviennent `None`, tandis qu'une valeur `i64` est préservée. Transport ne transforme pas ces votes gossip pre-consensus en vérité ledger.
## Résilience
L'admission est calculée par couple endpoint/rôle. Le pool applique :

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-onchain-transport-lib/USAGE.md -->
<!-- version: 16 -->
<!-- version: 17 -->
# Utilisation de `ksp-onchain-transport-lib`
@@ -154,6 +154,35 @@ Avec `enableReceivedNotification = true`, `ReceivedSignature` peut arriver avant
`slot_subscribe().await` retourne un `WsSubscription<SolanaSlotNotification>` dont les getters exposent `slot`, `parent` et `root`. `root_subscribe().await` retourne un `WsSubscription<u64>`. Ces deux méthodes n'acceptent aucune configuration ni aucun paramètre RPC.
### Familles unstable : block, slotsUpdates et vote
Depuis `0.2.7-pre.011`, les trois familles unstable standard sont également typées. Leur utilisation déclenche un warning KSP centralisé :
```rust
let block_config = ksp_onchain_transport_lib::SolanaBlockSubscribeConfig::new(
Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
Some(ksp_onchain_transport_lib::SolanaTransactionEncoding::Base64),
Some(ksp_onchain_transport_lib::SolanaTransactionDetails::Signatures),
Some(0),
Some(false),
);
let mut blocks = match session
.block_subscribe(&ksp_onchain_transport_lib::SolanaBlockSubscribeFilter::All, Some(&block_config))
.await
{
Ok(value) => value,
Err(error) => return Err(error),
};
```
`blockSubscribe` requiert un validator qui active la capability upstream correspondante. Une erreur applicative RPC liée à cette capability est renvoyée au caller sans reconnect de la session. `processed` est refusé localement ; `confirmed` et `finalized` sont admis. `maxSupportedTransactionVersion` n'est pas limité artificiellement à `0`.
`slots_updates_subscribe().await` délivre `SolanaSlotUpdate`. Les sept variantes courantes sont structurées ; une variante inconnue reste consommable via `Unknown` et `unknown_raw()`, sous la borne de taille WebSocket déjà appliquée avant décodage.
`vote_subscribe().await` délivre `SolanaVoteNotification`. `timestamp()` retourne `Option<i64>` pour conserver omission/null/value. Ce flux reste gossip et pre-consensus : le consumer ne doit pas l'assimiler à une confirmation ledger.
Les trois familles utilisent le même `WsSubscription::unsubscribe().await`; aucun remote subscription ID n'entre dans l'API publique.
### Reconnect automatique borné
Depuis `0.2.7-pre.007`, les settings de session contrôlent réellement le reconnect physique. Une perte de socket publie `Reconnecting { attempt }`, invalide les remote IDs et incrémente `continuity_gap_count`. Avec la policy par défaut `ActiveSubscriptions`, les handles logiques gardent leur `WsSubscriptionId` et passent temporairement en `Resubscribing`; l'actor recrée leurs subscriptions dans l'ordre local avant de republier `Active`.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/lib.rs
// version: 28
// version: 29
#![warn(missing_docs)]
#![deny(unreachable_pub)]
@@ -44,6 +44,7 @@ mod rpc_tokens;
mod rpc_transactions;
mod settings;
mod ws_accounts;
mod ws_blocks;
mod ws_cluster;
mod ws_lifecycle;
mod ws_session;
@@ -321,8 +322,20 @@ pub use self::ws_accounts::SolanaAccountSubscribeConfig;
pub use self::ws_accounts::SolanaProgramNotification;
/// Configuration accepted by the standard Solana `programSubscribe` WebSocket method.
pub use self::ws_accounts::SolanaProgramSubscribeConfig;
/// Typed value carried inside an unstable Solana `blockNotification` response.
pub use self::ws_blocks::SolanaBlockNotification;
/// Optional configuration accepted by unstable Solana `blockSubscribe`.
pub use self::ws_blocks::SolanaBlockSubscribeConfig;
/// Filter accepted by unstable Solana `blockSubscribe`.
pub use self::ws_blocks::SolanaBlockSubscribeFilter;
/// Slot relationship reported by the standard Solana `slotNotification` WebSocket method.
pub use self::ws_cluster::SolanaSlotNotification;
/// Typed unstable Solana slot-lifecycle update with an unknown-variant fallback.
pub use self::ws_cluster::SolanaSlotUpdate;
/// Execution statistics attached to unstable Solana `slotsUpdatesNotification` frozen updates.
pub use self::ws_cluster::SolanaSlotUpdateStats;
/// Typed unstable gossip-vote notification delivered by standard Solana `voteSubscribe`.
pub use self::ws_cluster::SolanaVoteNotification;
/// Stable local identity assigned to one physical WebSocket session.
pub use self::ws_lifecycle::WsSessionId;
/// Safe runtime snapshot for one physical WebSocket session.

View File

@@ -0,0 +1,215 @@
// file: crates/ksp-onchain-transport-lib/src/ws_blocks.rs
// version: 1
/// Filter accepted by unstable Solana `blockSubscribe`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SolanaBlockSubscribeFilter {
/// Subscribe to every block that reaches the configured commitment.
All,
/// Subscribe only to blocks containing a transaction that mentions the account or program.
MentionsAccountOrProgram(ksp_core_lib::Pubkey),
}
impl SolanaBlockSubscribeFilter {
fn to_json_value(&self) -> serde_json::Value {
return match self {
Self::All => serde_json::Value::String("all".to_owned()),
Self::MentionsAccountOrProgram(pubkey) => serde_json::json!({"mentionsAccountOrProgram": pubkey.to_string()}),
};
}
}
/// Optional configuration accepted by unstable Solana `blockSubscribe`.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct SolanaBlockSubscribeConfig {
commitment: std::option::Option<crate::SolanaCommitment>,
encoding: std::option::Option<crate::SolanaTransactionEncoding>,
transaction_details: std::option::Option<crate::SolanaTransactionDetails>,
max_supported_transaction_version: std::option::Option<u8>,
show_rewards: std::option::Option<bool>,
}
impl SolanaBlockSubscribeConfig {
/// Creates an explicit unstable block-subscription configuration.
#[must_use]
pub const fn new(
commitment: std::option::Option<crate::SolanaCommitment>,
encoding: std::option::Option<crate::SolanaTransactionEncoding>,
transaction_details: std::option::Option<crate::SolanaTransactionDetails>,
max_supported_transaction_version: std::option::Option<u8>,
show_rewards: std::option::Option<bool>,
) -> Self {
return Self { commitment, encoding, transaction_details, max_supported_transaction_version, show_rewards };
}
/// Returns the optional commitment level.
#[must_use]
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
return self.commitment;
}
/// Returns the optional transaction encoding.
#[must_use]
pub const fn encoding(&self) -> std::option::Option<crate::SolanaTransactionEncoding> {
return self.encoding;
}
/// Returns the optional transaction detail level.
#[must_use]
pub const fn transaction_details(&self) -> std::option::Option<crate::SolanaTransactionDetails> {
return self.transaction_details;
}
/// Returns the highest transaction version the caller declares it can consume.
#[must_use]
pub const fn max_supported_transaction_version(&self) -> std::option::Option<u8> {
return self.max_supported_transaction_version;
}
/// Returns whether rewards were explicitly requested for block notifications.
#[must_use]
pub const fn show_rewards(&self) -> std::option::Option<bool> {
return self.show_rewards;
}
fn is_empty(&self) -> bool {
return self.commitment.is_none()
&& self.encoding.is_none()
&& self.transaction_details.is_none()
&& self.max_supported_transaction_version.is_none()
&& self.show_rewards.is_none();
}
fn validate(&self) -> ksp_core_lib::Result<()> {
if self.commitment == std::option::Option::Some(crate::SolanaCommitment::Processed) {
return std::result::Result::Err(
ksp_core_lib::Error::new(
crate::ERROR_CODE_INVALID_RPC_PARAMETERS,
"blockSubscribe commitment must be confirmed or finalized when explicitly provided",
)
.with_context("rpc_method", "blockSubscribe")
.with_context("commitment", "processed"),
);
}
return std::result::Result::Ok(());
}
fn to_json_value(self) -> serde_json::Value {
let mut object = serde_json::Map::new();
if let std::option::Option::Some(commitment) = self.commitment {
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
}
if let std::option::Option::Some(encoding) = self.encoding {
object.insert("encoding".to_owned(), serde_json::Value::String(encoding.as_str().to_owned()));
}
if let std::option::Option::Some(transaction_details) = self.transaction_details {
object.insert("transactionDetails".to_owned(), serde_json::Value::String(transaction_details.as_str().to_owned()));
}
if let std::option::Option::Some(version) = self.max_supported_transaction_version {
object.insert("maxSupportedTransactionVersion".to_owned(), serde_json::Value::Number(version.into()));
}
if let std::option::Option::Some(show_rewards) = self.show_rewards {
object.insert("showRewards".to_owned(), serde_json::Value::Bool(show_rewards));
}
return serde_json::Value::Object(object);
}
}
/// Typed value carried inside an unstable Solana `blockNotification` response.
#[derive(Clone, Debug, PartialEq)]
pub struct SolanaBlockNotification {
slot: u64,
block: std::option::Option<crate::SolanaConfirmedBlock>,
err: std::option::Option<serde_json::Value>,
}
impl SolanaBlockNotification {
/// Returns the slot associated with this block update.
#[must_use]
pub const fn slot(&self) -> u64 {
return self.slot;
}
/// Returns the decoded block when the unstable notification contains one.
#[must_use]
pub const fn block(&self) -> std::option::Option<&crate::SolanaConfirmedBlock> {
return self.block.as_ref();
}
/// Returns the nullable publication error without interpreting its unstable wire shape.
#[must_use]
pub const fn err(&self) -> std::option::Option<&serde_json::Value> {
return self.err.as_ref();
}
}
impl crate::WsSession {
/// Subscribes to unstable standard Solana block notifications through `blockSubscribe`.
///
/// Solana documents this method as unstable and requires validator-side block-subscription support. KSP emits a warning through its logging facade when
/// this family is requested. An explicitly supplied commitment must be `confirmed` or `finalized`.
pub async fn block_subscribe(
&self,
filter: &crate::SolanaBlockSubscribeFilter,
config: std::option::Option<&crate::SolanaBlockSubscribeConfig>,
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaBlockNotification>>> {
if let std::option::Option::Some(config) = config {
let validated = config.validate();
if let std::result::Result::Err(error) = validated {
return std::result::Result::Err(error);
}
}
let mut params = std::vec![filter.to_json_value()];
if let std::option::Option::Some(config) = config
&& !config.is_empty()
{
params.push((*config).to_json_value());
}
return self.subscribe_typed(crate::WsSubscriptionKind::Block, params, |value| return decode_block_notification("blockSubscribe", value)).await;
}
}
#[derive(serde::Deserialize)]
struct WireBlockNotification {
slot: u64,
block: std::option::Option<serde_json::Value>,
err: serde_json::Value,
}
fn decode_block_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaBlockNotification>> {
let decoded = crate::decode_wire_json::<WireRpcResponse>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let block = match wire.value.block {
std::option::Option::Some(value) => {
let decoded = crate::SolanaConfirmedBlock::decode_wire(method, value);
match decoded {
std::result::Result::Ok(block) => std::option::Option::Some(block),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
},
std::option::Option::None => std::option::Option::None,
};
let err = match wire.value.err {
serde_json::Value::Null => std::option::Option::None,
value => std::option::Option::Some(value),
};
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, crate::SolanaBlockNotification { slot: wire.value.slot, block, err }));
}
#[derive(serde::Deserialize)]
struct WireRpcResponse {
context: serde_json::Value,
value: WireBlockNotification,
}
#[cfg(test)]
#[path = "../unit_tests/ws_blocks.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/ws_cluster.rs
// version: 1
// version: 2
/// Slot relationship reported by the standard Solana `slotNotification` WebSocket method.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -29,6 +29,162 @@ impl SolanaSlotNotification {
}
}
/// Execution statistics attached to unstable Solana `slotsUpdatesNotification` frozen updates.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SolanaSlotUpdateStats {
max_transactions_per_entry: u64,
num_failed_transactions: u64,
num_successful_transactions: u64,
num_transaction_entries: u64,
}
impl SolanaSlotUpdateStats {
/// Returns the maximum transactions per entry observed for the frozen bank.
#[must_use]
pub const fn max_transactions_per_entry(&self) -> u64 {
return self.max_transactions_per_entry;
}
/// Returns the failed transaction count.
#[must_use]
pub const fn num_failed_transactions(&self) -> u64 {
return self.num_failed_transactions;
}
/// Returns the successful transaction count.
#[must_use]
pub const fn num_successful_transactions(&self) -> u64 {
return self.num_successful_transactions;
}
/// Returns the transaction-entry count.
#[must_use]
pub const fn num_transaction_entries(&self) -> u64 {
return self.num_transaction_entries;
}
}
/// Typed unstable Solana slot-lifecycle update with an unknown-variant fallback.
#[derive(Clone, Debug, PartialEq)]
pub enum SolanaSlotUpdate {
/// The first shred for a slot was received.
FirstShredReceived { slot: u64, timestamp: i64 },
/// All shreds for a slot were received.
Completed { slot: u64, timestamp: i64 },
/// A bank was created for the slot.
CreatedBank { slot: u64, timestamp: i64, parent: u64 },
/// A bank was frozen and execution statistics are available.
Frozen { slot: u64, timestamp: i64, stats: crate::SolanaSlotUpdateStats },
/// The slot was marked dead.
Dead { slot: u64, timestamp: i64, error: std::string::String },
/// The slot reached the current unstable optimistic-confirmation marker.
OptimisticConfirmation { slot: u64, timestamp: i64 },
/// The slot became root.
Root { slot: u64, timestamp: i64 },
/// A future upstream variant that KSP does not yet interpret.
///
/// `raw` is bounded by the physical session's configured inbound WebSocket message limit before JSON decoding.
Unknown { update_type: std::string::String, raw: serde_json::Value },
}
impl SolanaSlotUpdate {
/// Returns the slot for known variants, or the optional slot found in an unknown raw variant.
#[must_use]
pub fn slot(&self) -> std::option::Option<u64> {
return match self {
Self::FirstShredReceived { slot, .. }
| Self::Completed { slot, .. }
| Self::CreatedBank { slot, .. }
| Self::Frozen { slot, .. }
| Self::Dead { slot, .. }
| Self::OptimisticConfirmation { slot, .. }
| Self::Root { slot, .. } => std::option::Option::Some(*slot),
Self::Unknown { raw, .. } => raw.get("slot").and_then(serde_json::Value::as_u64),
};
}
/// Returns the millisecond Unix timestamp for known variants, or an optional timestamp from an unknown raw variant.
#[must_use]
pub fn timestamp(&self) -> std::option::Option<i64> {
return match self {
Self::FirstShredReceived { timestamp, .. }
| Self::Completed { timestamp, .. }
| Self::CreatedBank { timestamp, .. }
| Self::Frozen { timestamp, .. }
| Self::Dead { timestamp, .. }
| Self::OptimisticConfirmation { timestamp, .. }
| Self::Root { timestamp, .. } => std::option::Option::Some(*timestamp),
Self::Unknown { raw, .. } => raw.get("timestamp").and_then(serde_json::Value::as_i64),
};
}
/// Returns the upstream `type` string, including unknown future values.
#[must_use]
pub fn update_type(&self) -> &str {
return match self {
Self::FirstShredReceived { .. } => "firstShredReceived",
Self::Completed { .. } => "completed",
Self::CreatedBank { .. } => "createdBank",
Self::Frozen { .. } => "frozen",
Self::Dead { .. } => "dead",
Self::OptimisticConfirmation { .. } => "optimisticConfirmation",
Self::Root { .. } => "root",
Self::Unknown { update_type, .. } => update_type.as_str(),
};
}
/// Returns the bounded raw object only for an unknown future upstream variant.
#[must_use]
pub const fn unknown_raw(&self) -> std::option::Option<&serde_json::Value> {
return match self {
Self::Unknown { raw, .. } => std::option::Option::Some(raw),
_ => std::option::Option::None,
};
}
}
/// Typed unstable gossip-vote notification delivered by standard Solana `voteSubscribe`.
#[derive(Clone, Debug, PartialEq)]
pub struct SolanaVoteNotification {
vote_pubkey: ksp_core_lib::Pubkey,
slots: std::vec::Vec<u64>,
hash: std::string::String,
timestamp: std::option::Option<i64>,
signature: std::string::String,
}
impl SolanaVoteNotification {
/// Returns the vote-account public key.
#[must_use]
pub const fn vote_pubkey(&self) -> &ksp_core_lib::Pubkey {
return &self.vote_pubkey;
}
/// Returns the ordered slots covered by the observed vote.
#[must_use]
pub fn slots(&self) -> &[u64] {
return self.slots.as_slice();
}
/// Returns the vote hash exactly as reported by the unstable upstream wire.
#[must_use]
pub fn hash(&self) -> &str {
return self.hash.as_str();
}
/// Returns the optional vote timestamp, preserving omitted and explicit-null wire forms as `None`.
#[must_use]
pub const fn timestamp(&self) -> std::option::Option<i64> {
return self.timestamp;
}
/// Returns the vote transaction signature exactly as reported by the unstable upstream wire.
#[must_use]
pub fn signature(&self) -> &str {
return self.signature.as_str();
}
}
impl crate::WsSession {
/// Subscribes to standard Solana slot-processing notifications through `slotSubscribe`.
pub async fn slot_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotNotification>> {
@@ -47,6 +203,24 @@ impl crate::WsSession {
})
.await;
}
/// Subscribes to unstable standard Solana slot-lifecycle notifications through `slotsUpdatesSubscribe`.
pub async fn slots_updates_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotUpdate>> {
return self
.subscribe_typed(crate::WsSubscriptionKind::SlotsUpdates, std::vec::Vec::new(), |value| {
return decode_slots_update_notification("slotsUpdatesSubscribe", value);
})
.await;
}
/// Subscribes to unstable pre-consensus gossip vote notifications through `voteSubscribe`.
pub async fn vote_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaVoteNotification>> {
return self
.subscribe_typed(crate::WsSubscriptionKind::Vote, std::vec::Vec::new(), |value| {
return decode_vote_notification("voteSubscribe", value);
})
.await;
}
}
#[derive(serde::Deserialize)]
@@ -65,6 +239,122 @@ fn decode_slot_notification(method: &str, value: serde_json::Value) -> ksp_core_
return std::result::Result::Ok(crate::SolanaSlotNotification { slot: wire.slot, parent: wire.parent, root: wire.root });
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireSlotUpdateStats {
max_transactions_per_entry: u64,
num_failed_transactions: u64,
num_successful_transactions: u64,
num_transaction_entries: u64,
}
#[derive(serde::Deserialize)]
struct WireVoteNotification {
#[serde(rename = "votePubkey")]
vote_pubkey: std::string::String,
slots: std::vec::Vec<u64>,
hash: std::string::String,
#[serde(default)]
timestamp: std::option::Option<i64>,
signature: std::string::String,
}
fn decode_slots_update_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaSlotUpdate> {
let object = match value.as_object() {
std::option::Option::Some(object) => object,
std::option::Option::None => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "slotsUpdatesSubscribe notification must be an object")
.with_context("rpc_method", method),
);
},
};
let update_type = match object.get("type").and_then(serde_json::Value::as_str) {
std::option::Option::Some(update_type) => update_type,
std::option::Option::None => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "slotsUpdatesSubscribe notification is missing a string type")
.with_context("rpc_method", method),
);
},
};
if !matches!(update_type, "firstShredReceived" | "completed" | "createdBank" | "frozen" | "dead" | "optimisticConfirmation" | "root") {
return std::result::Result::Ok(crate::SolanaSlotUpdate::Unknown { update_type: update_type.to_owned(), raw: value.clone() });
}
let slot = match object.get("slot").and_then(serde_json::Value::as_u64) {
std::option::Option::Some(slot) => slot,
std::option::Option::None => return invalid_slots_update(method, "known slots update is missing numeric slot"),
};
let timestamp = match object.get("timestamp").and_then(serde_json::Value::as_i64) {
std::option::Option::Some(timestamp) => timestamp,
std::option::Option::None => return invalid_slots_update(method, "known slots update is missing numeric timestamp"),
};
return match update_type {
"firstShredReceived" => std::result::Result::Ok(crate::SolanaSlotUpdate::FirstShredReceived { slot, timestamp }),
"completed" => std::result::Result::Ok(crate::SolanaSlotUpdate::Completed { slot, timestamp }),
"createdBank" => match object.get("parent").and_then(serde_json::Value::as_u64) {
std::option::Option::Some(parent) => std::result::Result::Ok(crate::SolanaSlotUpdate::CreatedBank { slot, timestamp, parent }),
std::option::Option::None => invalid_slots_update(method, "createdBank update is missing numeric parent"),
},
"frozen" => {
let stats = match object.get("stats") {
std::option::Option::Some(stats) => crate::decode_wire_json::<WireSlotUpdateStats>(method, stats.clone()),
std::option::Option::None => return invalid_slots_update(method, "frozen update is missing stats"),
};
let stats = match stats {
std::result::Result::Ok(stats) => stats,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
std::result::Result::Ok(crate::SolanaSlotUpdate::Frozen {
slot,
timestamp,
stats: crate::SolanaSlotUpdateStats {
max_transactions_per_entry: stats.max_transactions_per_entry,
num_failed_transactions: stats.num_failed_transactions,
num_successful_transactions: stats.num_successful_transactions,
num_transaction_entries: stats.num_transaction_entries,
},
})
},
"dead" => match object.get("err").and_then(serde_json::Value::as_str) {
std::option::Option::Some(error) => std::result::Result::Ok(crate::SolanaSlotUpdate::Dead { slot, timestamp, error: error.to_owned() }),
std::option::Option::None => invalid_slots_update(method, "dead update is missing string err"),
},
"optimisticConfirmation" => std::result::Result::Ok(crate::SolanaSlotUpdate::OptimisticConfirmation { slot, timestamp }),
"root" => std::result::Result::Ok(crate::SolanaSlotUpdate::Root { slot, timestamp }),
_ => invalid_slots_update(method, "known slots update type dispatch failed"),
};
}
fn invalid_slots_update(method: &str, message: &'static str) -> ksp_core_lib::Result<crate::SolanaSlotUpdate> {
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, message).with_context("rpc_method", method));
}
fn decode_vote_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaVoteNotification> {
let decoded = crate::decode_wire_json::<WireVoteNotification>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let vote_pubkey = wire.vote_pubkey.parse::<ksp_core_lib::Pubkey>();
let vote_pubkey = match vote_pubkey {
std::result::Result::Ok(vote_pubkey) => vote_pubkey,
std::result::Result::Err(_) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "voteSubscribe notification contains an invalid votePubkey")
.with_context("rpc_method", method),
);
},
};
return std::result::Result::Ok(crate::SolanaVoteNotification {
vote_pubkey,
slots: wire.slots,
hash: wire.hash,
timestamp: wire.timestamp,
signature: wire.signature,
});
}
#[cfg(test)]
#[path = "../unit_tests/ws_cluster.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
// version: 5
// version: 6
/// Stable local identity assigned to one physical WebSocket session.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
@@ -161,6 +161,25 @@ impl WsSubscriptionKind {
Self::Vote => "voteNotification",
};
}
/// Returns whether Solana documents this standard subscription family as unstable.
pub(crate) const fn is_unstable(self) -> bool {
return matches!(self, Self::Block | Self::SlotsUpdates | Self::Vote);
}
/// Emits the centralized KSP warning required before opening an unstable standard subscription.
pub(crate) fn warn_if_unstable(self) {
if self.is_unstable() {
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
rpc_method = self.subscribe_method(),
subscription_kind = self.as_str(),
documentation_status = "unstable",
"unstable Solana WebSocket subscription requested"
);
}
return;
}
}
/// Safe lifecycle projection for one logical WebSocket subscription.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/ws_session.rs
// version: 11
// version: 12
use futures_util::SinkExt; // rust-rules: trait-import
use futures_util::StreamExt; // rust-rules: trait-import
@@ -187,6 +187,7 @@ impl WsSession {
if self.state() != crate::WsSessionState::Active {
return std::result::Result::Err(ws_session_closed_error(self.id, "WebSocket session is not active"));
}
kind.warn_if_unstable();
let (dispatcher, notification_rx) = crate::typed_notification_channel_with_completion(self.notification_queue_capacity, decoder, is_terminal);
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
let command = WsSessionCommand::Subscribe { kind, params, dispatcher, response_tx };

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs
// version: 31
// version: 32
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
@@ -627,3 +627,26 @@ fn public_v0_2_7_pre_010_stable_websocket_lot_b_wrappers_and_dtos_are_available_
assert!(terminal.err().is_none());
let _slot_notification = std::any::type_name::<ksp_onchain_transport_lib::SolanaSlotNotification>();
}
#[test]
fn public_v0_2_7_pre_011_unstable_websocket_wrappers_and_dtos_are_available_from_crate_root() {
let _block_subscribe = ksp_onchain_transport_lib::WsSession::block_subscribe;
let _slots_updates_subscribe = ksp_onchain_transport_lib::WsSession::slots_updates_subscribe;
let _vote_subscribe = ksp_onchain_transport_lib::WsSession::vote_subscribe;
let pubkey = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let filter = ksp_onchain_transport_lib::SolanaBlockSubscribeFilter::MentionsAccountOrProgram(pubkey);
assert!(matches!(filter, ksp_onchain_transport_lib::SolanaBlockSubscribeFilter::MentionsAccountOrProgram(_)));
let config = ksp_onchain_transport_lib::SolanaBlockSubscribeConfig::new(
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionEncoding::Base64),
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionDetails::Full),
std::option::Option::Some(0),
std::option::Option::Some(true),
);
assert_eq!(config.commitment(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed));
assert_eq!(config.show_rewards(), std::option::Option::Some(true));
let _block_notification = std::any::type_name::<ksp_onchain_transport_lib::SolanaBlockNotification>();
let _slot_update = std::any::type_name::<ksp_onchain_transport_lib::SolanaSlotUpdate>();
let _slot_update_stats = std::any::type_name::<ksp_onchain_transport_lib::SolanaSlotUpdateStats>();
let _vote_notification = std::any::type_name::<ksp_onchain_transport_lib::SolanaVoteNotification>();
}

View File

@@ -0,0 +1,222 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_blocks.rs
// version: 1
use futures_util::SinkExt; // rust-rules: trait-import
use futures_util::StreamExt; // rust-rules: trait-import
fn fixture_pubkey() -> ksp_core_lib::Pubkey {
return "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
}
fn local_endpoint(url: &str) -> crate::WsEndpointSettings {
return crate::WsEndpointSettings::new(
"local_ws_blocks",
true,
crate::WsProviderName::new("local-fixture"),
crate::WsClusterName::new("local"),
crate::WsProtocolKind::SolanaStandard,
crate::WsEndpointUrl::parse(url).expect("local test WebSocket URL must parse"),
crate::WsSessionSettings::default(),
);
}
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
let address = listener.local_addr().expect("local listener must expose address");
return (listener, format!("ws://{address}"));
}
async fn read_request(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> 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<tokio::net::TcpStream>, request: &serde_json::Value, result: serde_json::Value) {
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"result":result});
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send");
}
async fn send_error(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, code: i64, message: &str) {
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"error":{"code":code,"message":message}});
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local error response must send");
}
async fn send_notification(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, remote_id: u64, result: serde_json::Value) {
let notification = serde_json::json!({"jsonrpc":"2.0","method":"blockNotification","params":{"result":result,"subscription":remote_id}});
websocket.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into())).await.expect("local notification must send");
}
async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
loop {
let message = websocket.next().await;
match message {
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => return,
std::option::Option::Some(std::result::Result::Ok(_)) => {},
std::option::Option::Some(std::result::Result::Err(_)) | std::option::Option::None => return,
}
}
}
#[test]
fn block_subscribe_config_preserves_all_unstable_options_and_rejects_processed_commitment() {
let config = crate::SolanaBlockSubscribeConfig::new(
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
std::option::Option::Some(crate::SolanaTransactionEncoding::JsonParsed),
std::option::Option::Some(crate::SolanaTransactionDetails::Accounts),
std::option::Option::Some(7),
std::option::Option::Some(true),
);
assert_eq!(config.commitment(), std::option::Option::Some(crate::SolanaCommitment::Confirmed));
assert_eq!(config.encoding(), std::option::Option::Some(crate::SolanaTransactionEncoding::JsonParsed));
assert_eq!(config.transaction_details(), std::option::Option::Some(crate::SolanaTransactionDetails::Accounts));
assert_eq!(config.max_supported_transaction_version(), std::option::Option::Some(7));
assert_eq!(config.show_rewards(), std::option::Option::Some(true));
assert_eq!(
config.to_json_value(),
serde_json::json!({
"commitment": "confirmed",
"encoding": "jsonParsed",
"transactionDetails": "accounts",
"maxSupportedTransactionVersion": 7,
"showRewards": true
})
);
let invalid = crate::SolanaBlockSubscribeConfig::new(
std::option::Option::Some(crate::SolanaCommitment::Processed),
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
);
assert_eq!(invalid.validate().expect_err("processed commitment must be rejected").code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
}
#[test]
fn block_notification_decoder_preserves_nulls_and_shared_confirmed_block_shape() {
let nulls = super::decode_block_notification(
"blockSubscribe",
serde_json::json!({"context":{"slot":51},"value":{"slot":51,"block":null,"err":{"reason":"missing"}}}),
)
.expect("nullable block notification must decode");
assert_eq!(nulls.context().slot(), 51);
assert_eq!(nulls.value().slot(), 51);
assert!(nulls.value().block().is_none());
assert_eq!(nulls.value().err(), std::option::Option::Some(&serde_json::json!({"reason":"missing"})));
let block = super::decode_block_notification(
"blockSubscribe",
serde_json::json!({
"context":{"slot":52},
"value":{
"slot":52,
"block":{
"previousBlockhash":"prev",
"blockhash":"current",
"parentSlot":51,
"signatures":["sig-a"],
"rewards":null,
"numRewardPartitions":4,
"blockTime":123,
"blockHeight":9
},
"err":null
}
}),
)
.expect("shared confirmed block shape must decode");
assert_eq!(block.value().block().expect("block must exist").blockhash(), "current");
assert!(block.value().err().is_none());
let large_transaction = "A".repeat(2_048);
let large_wire = serde_json::json!({
"context":{"slot":53},
"value":{
"slot":53,
"block":{
"previousBlockhash":"prev",
"blockhash":"large-current",
"parentSlot":52,
"transactions":[{"transaction":[large_transaction,"base64"],"meta":{"err":null,"fee":5000},"version":"legacy"}],
"rewards":[],
"blockTime":123,
"blockHeight":10
},
"err":null
}
});
assert!(large_wire.to_string().len() > 1_232);
assert!(super::decode_block_notification("blockSubscribe", large_wire).is_ok());
}
#[tokio::test(flavor = "current_thread")]
async fn unstable_block_wrapper_uses_exact_request_notification_and_handle_unsubscribe() {
let (listener, url) = bind_local_listener().await;
let pubkey = fixture_pubkey();
let server_pubkey = pubkey;
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("local server must accept client");
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
let subscribe = read_request(&mut websocket).await;
assert_eq!(subscribe["method"], serde_json::json!("blockSubscribe"));
assert_eq!(
subscribe["params"],
serde_json::json!([
{"mentionsAccountOrProgram":server_pubkey.to_string()},
{"commitment":"confirmed","encoding":"base64","transactionDetails":"signatures","maxSupportedTransactionVersion":3,"showRewards":false}
])
);
send_result(&mut websocket, &subscribe, serde_json::json!(301)).await;
send_notification(&mut websocket, 301, serde_json::json!({"context":{"slot":77},"value":{"slot":77,"block":null,"err":null}})).await;
let unsubscribe = read_request(&mut websocket).await;
assert_eq!(unsubscribe["method"], serde_json::json!("blockUnsubscribe"));
assert_eq!(unsubscribe["params"], serde_json::json!([301]));
send_result(&mut websocket, &unsubscribe, serde_json::json!(true)).await;
wait_for_close_frame(&mut websocket).await;
});
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
let config = crate::SolanaBlockSubscribeConfig::new(
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
std::option::Option::Some(crate::SolanaTransactionEncoding::Base64),
std::option::Option::Some(crate::SolanaTransactionDetails::Signatures),
std::option::Option::Some(3),
std::option::Option::Some(false),
);
let mut subscription = session
.block_subscribe(&crate::SolanaBlockSubscribeFilter::MentionsAccountOrProgram(pubkey), std::option::Option::Some(&config))
.await
.expect("blockSubscribe must register");
let notification = subscription.recv().await.expect("block notification must arrive").expect("block notification must decode");
assert_eq!(notification.value().slot(), 77);
assert!(notification.value().block().is_none());
assert!(subscription.unsubscribe().await.expect("block unsubscribe must complete"));
session.close().await.expect("session close must complete");
server.await.expect("local server task must complete");
}
#[tokio::test(flavor = "current_thread")]
async fn unstable_block_validator_capability_rpc_error_does_not_fail_physical_session() {
let (listener, url) = bind_local_listener().await;
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("local server must accept client");
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
let block_subscribe = read_request(&mut websocket).await;
assert_eq!(block_subscribe["method"], serde_json::json!("blockSubscribe"));
send_error(&mut websocket, &block_subscribe, -32601, "block subscription disabled").await;
let root_subscribe = read_request(&mut websocket).await;
assert_eq!(root_subscribe["method"], serde_json::json!("rootSubscribe"));
send_result(&mut websocket, &root_subscribe, serde_json::json!(302)).await;
wait_for_close_frame(&mut websocket).await;
});
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
let error = session
.block_subscribe(&crate::SolanaBlockSubscribeFilter::All, std::option::Option::None)
.await
.expect_err("validator capability application error must surface to the caller");
assert_eq!(error.code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR);
assert_eq!(session.state(), crate::WsSessionState::Active);
let root = session.root_subscribe().await.expect("session must remain usable after block application error");
assert_eq!(root.state(), crate::WsSubscriptionState::Active);
session.close().await.expect("session close must complete");
server.await.expect("local server task must complete");
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_cluster.rs
// version: 1
// version: 2
use futures_util::SinkExt; // rust-rules: trait-import
use futures_util::StreamExt; // rust-rules: trait-import
@@ -98,3 +98,136 @@ async fn stable_slot_and_root_wrappers_use_no_params_decode_exact_notifications_
session.close().await.expect("session close must complete");
server.await.expect("local server task must complete");
}
#[test]
fn slots_update_decoder_preserves_all_known_variants_and_unknown_bounded_fallback() {
let cases = [
(serde_json::json!({"slot":1,"timestamp":10,"type":"firstShredReceived"}), "firstShredReceived"),
(serde_json::json!({"slot":2,"timestamp":20,"type":"completed"}), "completed"),
(serde_json::json!({"slot":3,"timestamp":30,"type":"createdBank","parent":2}), "createdBank"),
(
serde_json::json!({
"slot":4,
"timestamp":40,
"type":"frozen",
"stats":{"maxTransactionsPerEntry":64,"numFailedTransactions":1,"numSuccessfulTransactions":9,"numTransactionEntries":3}
}),
"frozen",
),
(serde_json::json!({"slot":5,"timestamp":50,"type":"dead","err":"fixture dead"}), "dead"),
(serde_json::json!({"slot":6,"timestamp":60,"type":"optimisticConfirmation"}), "optimisticConfirmation"),
(serde_json::json!({"slot":7,"timestamp":70,"type":"root"}), "root"),
];
for (wire, expected_type) in cases {
let update = super::decode_slots_update_notification("slotsUpdatesSubscribe", wire).expect("known slot update must decode");
assert_eq!(update.update_type(), expected_type);
assert!(update.slot().is_some());
assert!(update.timestamp().is_some());
assert!(update.unknown_raw().is_none());
}
let frozen = super::decode_slots_update_notification(
"slotsUpdatesSubscribe",
serde_json::json!({
"slot":4,
"timestamp":40,
"type":"frozen",
"stats":{"maxTransactionsPerEntry":64,"numFailedTransactions":1,"numSuccessfulTransactions":9,"numTransactionEntries":3}
}),
)
.expect("frozen update must decode");
match frozen {
crate::SolanaSlotUpdate::Frozen { stats, .. } => {
assert_eq!(stats.max_transactions_per_entry(), 64);
assert_eq!(stats.num_failed_transactions(), 1);
assert_eq!(stats.num_successful_transactions(), 9);
assert_eq!(stats.num_transaction_entries(), 3);
},
_ => panic!("fixture must decode as frozen"),
}
let unknown_wire = serde_json::json!({"slot":8,"timestamp":80,"type":"futureBankState","futureField":{"x":1}});
let unknown = super::decode_slots_update_notification("slotsUpdatesSubscribe", unknown_wire.clone()).expect("unknown update must remain consumable");
assert_eq!(unknown.update_type(), "futureBankState");
assert_eq!(unknown.slot(), std::option::Option::Some(8));
assert_eq!(unknown.timestamp(), std::option::Option::Some(80));
assert_eq!(unknown.unknown_raw(), std::option::Option::Some(&unknown_wire));
}
#[test]
fn slots_update_known_variants_require_their_variant_specific_fields() {
assert!(super::decode_slots_update_notification("slotsUpdatesSubscribe", serde_json::json!({"slot":3,"timestamp":30,"type":"createdBank"})).is_err());
assert!(super::decode_slots_update_notification("slotsUpdatesSubscribe", serde_json::json!({"slot":4,"timestamp":40,"type":"frozen"})).is_err());
assert!(super::decode_slots_update_notification("slotsUpdatesSubscribe", serde_json::json!({"slot":5,"timestamp":50,"type":"dead"})).is_err());
}
#[test]
fn vote_notification_decoder_preserves_timestamp_omitted_null_and_value() {
let pubkey = "11111111111111111111111111111111";
for (wire, expected_timestamp) in [
(serde_json::json!({"votePubkey":pubkey,"slots":[1,2],"hash":"hash-a","signature":"sig-a"}), std::option::Option::None),
(serde_json::json!({"votePubkey":pubkey,"slots":[1,2],"hash":"hash-b","timestamp":null,"signature":"sig-b"}), std::option::Option::None),
(serde_json::json!({"votePubkey":pubkey,"slots":[1,2],"hash":"hash-c","timestamp":123,"signature":"sig-c"}), std::option::Option::Some(123)),
] {
let vote = super::decode_vote_notification("voteSubscribe", wire).expect("vote notification must decode");
assert_eq!(vote.vote_pubkey().to_string(), pubkey);
assert_eq!(vote.slots(), &[1, 2]);
assert_eq!(vote.timestamp(), expected_timestamp);
}
}
#[tokio::test(flavor = "current_thread")]
async fn unstable_slots_updates_and_vote_wrappers_use_no_params_and_handle_unsubscribe() {
let (listener, url) = bind_local_listener().await;
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("local server must accept client");
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
let slots_subscribe = read_request(&mut websocket).await;
assert_eq!(slots_subscribe["method"], serde_json::json!("slotsUpdatesSubscribe"));
assert_eq!(slots_subscribe["params"], serde_json::json!([]));
send_result(&mut websocket, &slots_subscribe, serde_json::json!(401)).await;
send_notification(
&mut websocket,
"slotsUpdatesNotification",
401,
serde_json::json!({"slot":76,"timestamp":1625081266243_i64,"type":"optimisticConfirmation"}),
)
.await;
let vote_subscribe = read_request(&mut websocket).await;
assert_eq!(vote_subscribe["method"], serde_json::json!("voteSubscribe"));
assert_eq!(vote_subscribe["params"], serde_json::json!([]));
send_result(&mut websocket, &vote_subscribe, serde_json::json!(402)).await;
send_notification(
&mut websocket,
"voteNotification",
402,
serde_json::json!({
"votePubkey":"11111111111111111111111111111111",
"slots":[75,76],
"hash":"fixture-hash",
"timestamp":null,
"signature":"fixture-signature"
}),
)
.await;
let slots_unsubscribe = read_request(&mut websocket).await;
assert_eq!(slots_unsubscribe["method"], serde_json::json!("slotsUpdatesUnsubscribe"));
assert_eq!(slots_unsubscribe["params"], serde_json::json!([401]));
send_result(&mut websocket, &slots_unsubscribe, serde_json::json!(true)).await;
let vote_unsubscribe = read_request(&mut websocket).await;
assert_eq!(vote_unsubscribe["method"], serde_json::json!("voteUnsubscribe"));
assert_eq!(vote_unsubscribe["params"], serde_json::json!([402]));
send_result(&mut websocket, &vote_unsubscribe, serde_json::json!(true)).await;
wait_for_close_frame(&mut websocket).await;
});
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
let mut slots_subscription = session.slots_updates_subscribe().await.expect("slotsUpdatesSubscribe must register");
let update = slots_subscription.recv().await.expect("slots update must arrive").expect("slots update must decode");
assert_eq!(update.update_type(), "optimisticConfirmation");
let mut vote_subscription = session.vote_subscribe().await.expect("voteSubscribe must register");
let vote = vote_subscription.recv().await.expect("vote notification must arrive").expect("vote notification must decode");
assert_eq!(vote.slots(), &[75, 76]);
assert!(vote.timestamp().is_none());
assert!(slots_subscription.unsubscribe().await.expect("slots update unsubscribe must complete"));
assert!(vote_subscription.unsubscribe().await.expect("vote unsubscribe must complete"));
session.close().await.expect("session close must complete");
server.await.expect("local server task must complete");
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs
// version: 3
// version: 4
fn non_zero(value: u64) -> std::num::NonZeroU64 {
return std::num::NonZeroU64::new(value).expect("test ID must be non-zero");
@@ -105,3 +105,21 @@ fn websocket_subscription_kinds_map_exact_standard_method_triplets() {
assert_eq!(kind.notification_method(), notification);
}
}
#[test]
fn websocket_unstable_subscription_partition_is_exact() {
let cases = [
(crate::WsSubscriptionKind::Account, false),
(crate::WsSubscriptionKind::Block, true),
(crate::WsSubscriptionKind::Logs, false),
(crate::WsSubscriptionKind::Program, false),
(crate::WsSubscriptionKind::Root, false),
(crate::WsSubscriptionKind::Signature, false),
(crate::WsSubscriptionKind::Slot, false),
(crate::WsSubscriptionKind::SlotsUpdates, true),
(crate::WsSubscriptionKind::Vote, true),
];
for (kind, unstable) in cases {
assert_eq!(kind.is_unstable(), unstable);
}
}