v0.2.7-pre.010

This commit is contained in:
2026-08-23 00:12:39 +02:00
parent 98bf88e431
commit 1391858972
14 changed files with 838 additions and 29 deletions

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-onchain-transport-lib/README.md -->
<!-- version: 15 -->
<!-- version: 16 -->
# `ksp-onchain-transport-lib`
@@ -164,6 +164,22 @@ logs_subscribe -> WsSubscription<SolanaRpcResponse<SolanaLogsNotification>>
L'unsubscribe de ces trois familles passe toujours par `WsSubscription::unsubscribe()`: le caller ne voit ni ne fournit l'ID serveur. Les paramètres initiaux restent conservés par l'actor pour le resubscribe déterministe acquis en `pre.007`, et toutes les règles de backpressure/terminal error acquises en `pre.008` s'appliquent sans branche spéciale aux nouveaux DTOs publics.
### Wrappers stables lot B `0.2.7-pre.010`
Le second lot stable complète les subscriptions standard non instables :
```text
signature_subscribe -> WsSubscription<SolanaRpcResponse<SolanaSignatureNotification>>
slot_subscribe -> WsSubscription<SolanaSlotNotification>
root_subscribe -> WsSubscription<u64>
```
`SolanaSignatureSubscribeConfig` conserve séparément `commitment` et `enableReceivedNotification`, y compris la différence entre option omise et booléen explicitement faux. `SolanaSignatureNotification` représente les deux formes wire : `ReceivedSignature` pour l'événement précoce optionnel et `Processed { err }` pour la notification terminale. Après livraison de `Processed`, l'actor ferme localement la subscription avec `terminal_error_code() == None`, retire son binding et ne la remet jamais dans le set de resubscribe, conformément au caractère one-shot du serveur Solana.
Une cancellation effectuée avant cette terminaison continue d'utiliser `WsSubscription::unsubscribe()` et émet `signatureUnsubscribe` avec le remote ID détenu uniquement par l'actor. Après la notification terminale, `unsubscribe()` devient local-only et retourne `false`, puisque la subscription est déjà fermée côté serveur et côté KSP.
`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`.
## 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: 15 -->
<!-- version: 16 -->
# Utilisation de `ksp-onchain-transport-lib`
@@ -126,6 +126,34 @@ La même session expose `program_subscribe()` avec `SolanaProgramSubscribeConfig
Les trois wrappers retournent le même handle `WsSubscription<T>` : reconnect, resubscribe, overflow, cause terminale et unsubscribe restent donc uniformes. Aucun wrapper public n'accepte un nom de méthode JSON-RPC arbitraire ni un remote subscription ID.
### Lot stable B : signature, slot et root
Depuis `0.2.7-pre.010`, la session expose également :
```rust
let signature_config = ksp_onchain_transport_lib::SolanaSignatureSubscribeConfig::new(
Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized),
Some(true),
);
let mut signature_subscription = match session.signature_subscribe("<base58-signature>", Some(&signature_config)).await {
Ok(value) => value,
Err(error) => return Err(error),
};
while let Some(notification) = signature_subscription.recv().await {
let notification = match notification {
Ok(value) => value,
Err(error) => return Err(error),
};
if notification.value().is_terminal() {
break;
}
}
```
Avec `enableReceivedNotification = true`, `ReceivedSignature` peut arriver avant la variante terminale `Processed { err }`. La variante terminale ferme automatiquement le handle KSP, sans `signatureUnsubscribe` supplémentaire et sans resubscribe lors d'une reconnexion ultérieure. Une cancellation explicite avant cette notification terminale reste possible via `unsubscribe().await`.
`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.
### 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`.
@@ -150,7 +178,7 @@ Le consumer doit traiter `overflow_count` et `continuity_gap_count` comme deux s
```rust
let session = ksp_onchain_transport_lib::WsSession::connect(endpoint).await?;
// ... account_subscribe/program_subscribe/logs_subscribe puis recv()/unsubscribe() ...
// ... wrappers standard puis recv()/unsubscribe() ...
session.close().await?;
```

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/lib.rs
// version: 26
// version: 27
#![warn(missing_docs)]
#![deny(unreachable_pub)]
@@ -44,6 +44,7 @@ mod rpc_tokens;
mod rpc_transactions;
mod settings;
mod ws_accounts;
mod ws_cluster;
mod ws_lifecycle;
mod ws_session;
mod ws_settings;
@@ -320,6 +321,8 @@ 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;
/// Slot relationship reported by the standard Solana `slotNotification` WebSocket method.
pub use self::ws_cluster::SolanaSlotNotification;
/// Stable local identity assigned to one physical WebSocket session.
pub use self::ws_lifecycle::WsSessionId;
/// Safe runtime snapshot for one physical WebSocket session.
@@ -360,6 +363,10 @@ pub use self::ws_subscription::WsSubscription;
pub use self::ws_transactions::SolanaLogsNotification;
/// Filter accepted by the standard Solana `logsSubscribe` WebSocket method.
pub use self::ws_transactions::SolanaLogsSubscribeFilter;
/// Typed value carried by standard Solana `signatureNotification` messages.
pub use self::ws_transactions::SolanaSignatureNotification;
/// Optional configuration accepted by standard Solana `signatureSubscribe`.
pub use self::ws_transactions::SolanaSignatureSubscribeConfig;
/// Owning tracing target for events emitted by the on-chain transport crate.
pub(crate) use self::constants::TRACING_TARGET;
@@ -387,3 +394,5 @@ pub(crate) use self::ws_subscription::WsSubscriptionRegistration;
pub(crate) use self::ws_subscription::WsSubscriptionRuntime;
/// Crate-internal constructor for bounded typed notification channels.
pub(crate) use self::ws_subscription::typed_notification_channel;
/// Crate-internal constructor for bounded typed notification channels with terminal-value classification.
pub(crate) use self::ws_subscription::typed_notification_channel_with_completion;

View File

@@ -0,0 +1,70 @@
// file: crates/ksp-onchain-transport-lib/src/ws_cluster.rs
// version: 1
/// Slot relationship reported by the standard Solana `slotNotification` WebSocket method.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SolanaSlotNotification {
slot: u64,
parent: u64,
root: u64,
}
impl SolanaSlotNotification {
/// Returns the newly processed slot.
#[must_use]
pub const fn slot(&self) -> u64 {
return self.slot;
}
/// Returns the parent slot reported by the validator.
#[must_use]
pub const fn parent(&self) -> u64 {
return self.parent;
}
/// Returns the current root slot reported alongside this slot update.
#[must_use]
pub const fn root(&self) -> u64 {
return self.root;
}
}
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>> {
return self
.subscribe_typed(crate::WsSubscriptionKind::Slot, std::vec::Vec::new(), |value| {
return decode_slot_notification("slotSubscribe", value);
})
.await;
}
/// Subscribes to standard Solana root-slot notifications through `rootSubscribe`.
pub async fn root_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<u64>> {
return self
.subscribe_typed(crate::WsSubscriptionKind::Root, std::vec::Vec::new(), |value| {
return crate::decode_wire_json::<u64>("rootSubscribe", value);
})
.await;
}
}
#[derive(serde::Deserialize)]
struct WireSlotNotification {
slot: u64,
parent: u64,
root: u64,
}
fn decode_slot_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaSlotNotification> {
let decoded = crate::decode_wire_json::<WireSlotNotification>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(crate::SolanaSlotNotification { slot: wire.slot, parent: wire.parent, root: wire.root });
}
#[cfg(test)]
#[path = "../unit_tests/ws_cluster.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/ws_session.rs
// version: 10
// version: 11
use futures_util::SinkExt; // rust-rules: trait-import
use futures_util::StreamExt; // rust-rules: trait-import
@@ -167,11 +167,27 @@ impl WsSession {
where
T: std::marker::Send + 'static,
F: Fn(serde_json::Value) -> ksp_core_lib::Result<T> + std::marker::Send + std::marker::Sync + 'static,
{
return self.subscribe_typed_with_completion(kind, params, decoder, |_| return false).await;
}
/// Creates one crate-internal typed subscription whose decoder can identify a delivered terminal notification.
pub(crate) async fn subscribe_typed_with_completion<T, F, C>(
&self,
kind: crate::WsSubscriptionKind,
params: std::vec::Vec<serde_json::Value>,
decoder: F,
is_terminal: C,
) -> ksp_core_lib::Result<crate::WsSubscription<T>>
where
T: std::marker::Send + 'static,
F: Fn(serde_json::Value) -> ksp_core_lib::Result<T> + std::marker::Send + std::marker::Sync + 'static,
C: Fn(&T) -> bool + std::marker::Send + std::marker::Sync + 'static,
{
if self.state() != crate::WsSessionState::Active {
return std::result::Result::Err(ws_session_closed_error(self.id, "WebSocket session is not active"));
}
let (dispatcher, notification_rx) = crate::typed_notification_channel(self.notification_queue_capacity, decoder);
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 };
let send_wait = tokio::time::timeout(self.command_timeout, self.command_tx.send(command)).await;
@@ -1901,6 +1917,17 @@ fn handle_subscription_notification(
let dispatch = (runtime.dispatcher)(result);
return match dispatch {
crate::WsNotificationDispatchOutcome::Delivered => WsActorIoOutcome::Continue,
crate::WsNotificationDispatchOutcome::DeliveredTerminal => {
close_local_subscription(subscriptions, remote_to_local, local_id, crate::WsSubscriptionState::Closed);
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
session_id = id.get(),
subscription_id = local_id.get(),
subscription_kind = subscription_kind.as_str(),
"logical WebSocket subscription observed its server-terminal notification"
);
WsActorIoOutcome::Continue
},
crate::WsNotificationDispatchOutcome::ReceiverClosed => {
close_local_subscription(subscriptions, remote_to_local, local_id, crate::WsSubscriptionState::Closed);
ksp_logging_lib::debug!(

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/ws_subscription.rs
// version: 3
// version: 4
/// Typed handle for one logical Solana WebSocket subscription.
///
@@ -124,6 +124,7 @@ impl<T> std::fmt::Debug for WsSubscription<T> {
/// Internal result of dispatching one decoded wire notification into a bounded typed channel.
pub(crate) enum WsNotificationDispatchOutcome {
Delivered,
DeliveredTerminal,
ReceiverClosed,
QueueFull,
DecodeFailed { code: ksp_core_lib::ErrorCode },
@@ -137,6 +138,20 @@ pub(crate) fn typed_notification_channel<T, F>(capacity: usize, decoder: F) -> (
where
T: std::marker::Send + 'static,
F: Fn(serde_json::Value) -> ksp_core_lib::Result<T> + std::marker::Send + std::marker::Sync + 'static,
{
return typed_notification_channel_with_completion(capacity, decoder, |_| return false);
}
/// Creates one bounded typed notification receiver whose dispatcher can mark a successfully delivered value as terminal.
pub(crate) fn typed_notification_channel_with_completion<T, F, C>(
capacity: usize,
decoder: F,
is_terminal: C,
) -> (WsNotificationDispatcher, tokio::sync::mpsc::Receiver<ksp_core_lib::Result<T>>)
where
T: std::marker::Send + 'static,
F: Fn(serde_json::Value) -> ksp_core_lib::Result<T> + std::marker::Send + std::marker::Sync + 'static,
C: Fn(&T) -> bool + std::marker::Send + std::marker::Sync + 'static,
{
let (notification_tx, notification_rx) = tokio::sync::mpsc::channel(capacity);
let dispatcher = move |value: serde_json::Value| -> WsNotificationDispatchOutcome {
@@ -151,7 +166,11 @@ where
};
return match decoder(value) {
std::result::Result::Ok(notification) => {
let terminal = is_terminal(&notification);
permit.send(std::result::Result::Ok(notification));
if terminal {
return WsNotificationDispatchOutcome::DeliveredTerminal;
}
WsNotificationDispatchOutcome::Delivered
},
std::result::Result::Err(error) => {

View File

@@ -1,5 +1,79 @@
// file: crates/ksp-onchain-transport-lib/src/ws_transactions.rs
// version: 2
// version: 3
/// Optional configuration accepted by standard Solana `signatureSubscribe`.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct SolanaSignatureSubscribeConfig {
commitment: std::option::Option<crate::SolanaCommitment>,
enable_received_notification: std::option::Option<bool>,
}
impl SolanaSignatureSubscribeConfig {
/// Creates an explicit signature-subscription configuration.
#[must_use]
pub const fn new(commitment: std::option::Option<crate::SolanaCommitment>, enable_received_notification: std::option::Option<bool>) -> Self {
return Self { commitment, enable_received_notification };
}
/// Returns the optional commitment level.
#[must_use]
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
return self.commitment;
}
/// Returns whether the server should emit the early `receivedSignature` notification when explicitly configured.
#[must_use]
pub const fn enable_received_notification(&self) -> std::option::Option<bool> {
return self.enable_received_notification;
}
fn is_empty(&self) -> bool {
return self.commitment.is_none() && self.enable_received_notification.is_none();
}
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(enable_received_notification) = self.enable_received_notification {
object.insert("enableReceivedNotification".to_owned(), serde_json::Value::Bool(enable_received_notification));
}
return serde_json::Value::Object(object);
}
}
/// Typed value carried by standard Solana `signatureNotification` messages.
#[derive(Clone, Debug, PartialEq)]
pub enum SolanaSignatureNotification {
/// Early notification emitted when the RPC node first receives the signature and `enableReceivedNotification` is enabled.
ReceivedSignature,
/// Terminal processing notification emitted when the configured commitment is reached.
Processed {
/// Nullable transaction-error wire value; `None` means the transaction succeeded at the requested commitment.
err: std::option::Option<serde_json::Value>,
},
}
impl SolanaSignatureNotification {
/// Returns whether this notification terminates the server-side one-shot subscription.
#[must_use]
pub const fn is_terminal(&self) -> bool {
return match self {
Self::ReceivedSignature => false,
Self::Processed { .. } => true,
};
}
/// Returns the transaction-error wire value for a terminal processing notification when present.
#[must_use]
pub const fn err(&self) -> std::option::Option<&serde_json::Value> {
return match self {
Self::ReceivedSignature | Self::Processed { err: std::option::Option::None } => std::option::Option::None,
Self::Processed { err: std::option::Option::Some(err) } => std::option::Option::Some(err),
};
}
}
/// Filter accepted by the standard Solana `logsSubscribe` WebSocket method.
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -51,6 +125,31 @@ impl SolanaLogsNotification {
}
impl crate::WsSession {
/// Subscribes to one Solana transaction signature through standard `signatureSubscribe`.
///
/// The server automatically terminates this subscription after the terminal processed notification. When
/// `enableReceivedNotification` is enabled, an earlier `ReceivedSignature` value may be delivered first without closing the logical handle.
pub async fn signature_subscribe(
&self,
signature: &str,
config: std::option::Option<&crate::SolanaSignatureSubscribeConfig>,
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaSignatureNotification>>> {
let mut params = std::vec![serde_json::Value::String(signature.to_owned())];
if let std::option::Option::Some(config) = config
&& !config.is_empty()
{
params.push((*config).to_json_value());
}
return self
.subscribe_typed_with_completion(
crate::WsSubscriptionKind::Signature,
params,
|value| return decode_signature_notification("signatureSubscribe", value),
|notification| return notification.value().is_terminal(),
)
.await;
}
/// Subscribes to Solana transaction logs through standard `logsSubscribe`.
pub async fn logs_subscribe(
&self,
@@ -67,6 +166,24 @@ impl crate::WsSession {
}
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum WireSignatureNotification {
Received(std::string::String),
Processed(WireSignatureProcessed),
}
#[derive(serde::Deserialize)]
struct WireSignatureProcessed {
err: serde_json::Value,
}
#[derive(serde::Deserialize)]
struct WireRpcResponseSignature {
context: serde_json::Value,
value: WireSignatureNotification,
}
#[derive(serde::Deserialize)]
struct WireRpcResponse {
context: serde_json::Value,
@@ -80,6 +197,36 @@ struct WireLogsNotification {
logs: std::vec::Vec<std::string::String>,
}
fn decode_signature_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaSignatureNotification>> {
let decoded = crate::decode_wire_json::<WireRpcResponseSignature>(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 notification = match wire.value {
WireSignatureNotification::Received(value) if value == "receivedSignature" => crate::SolanaSignatureNotification::ReceivedSignature,
WireSignatureNotification::Received(_) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "signatureSubscribe notification contains an unknown string variant")
.with_context("rpc_method", method),
);
},
WireSignatureNotification::Processed(processed) => {
let err = match processed.err {
serde_json::Value::Null => std::option::Option::None,
value => std::option::Option::Some(value),
};
crate::SolanaSignatureNotification::Processed { err }
},
};
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, notification));
}
fn decode_logs_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaLogsNotification>> {
let decoded = crate::decode_wire_json::<WireRpcResponse>(method, value);
let wire = match decoded {

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs
// version: 30
// version: 31
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
@@ -610,3 +610,20 @@ fn public_v0_2_7_pre_009_stable_websocket_lot_a_wrappers_and_dtos_are_available_
let _program_notification = std::any::type_name::<ksp_onchain_transport_lib::SolanaProgramNotification>();
let _logs_notification = std::any::type_name::<ksp_onchain_transport_lib::SolanaLogsNotification>();
}
#[test]
fn public_v0_2_7_pre_010_stable_websocket_lot_b_wrappers_and_dtos_are_available_from_crate_root() {
let _signature_subscribe = ksp_onchain_transport_lib::WsSession::signature_subscribe;
let _slot_subscribe = ksp_onchain_transport_lib::WsSession::slot_subscribe;
let _root_subscribe = ksp_onchain_transport_lib::WsSession::root_subscribe;
let signature_config = ksp_onchain_transport_lib::SolanaSignatureSubscribeConfig::new(
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized),
std::option::Option::Some(true),
);
assert_eq!(signature_config.commitment(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized));
assert_eq!(signature_config.enable_received_notification(), std::option::Option::Some(true));
let terminal = ksp_onchain_transport_lib::SolanaSignatureNotification::Processed { err: std::option::Option::None };
assert!(terminal.is_terminal());
assert!(terminal.err().is_none());
let _slot_notification = std::any::type_name::<ksp_onchain_transport_lib::SolanaSlotNotification>();
}

View File

@@ -0,0 +1,100 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_cluster.rs
// version: 1
use futures_util::SinkExt; // rust-rules: trait-import
use futures_util::StreamExt; // rust-rules: trait-import
fn local_endpoint(url: &str) -> crate::WsEndpointSettings {
return crate::WsEndpointSettings::new(
"local_ws_cluster",
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_notification(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, method: &str, remote_id: u64, result: serde_json::Value) {
let notification = serde_json::json!({"jsonrpc":"2.0","method":method,"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 slot_notification_decoder_preserves_slot_parent_and_root() {
let notification =
super::decode_slot_notification("slotSubscribe", serde_json::json!({"slot":76,"parent":75,"root":44})).expect("slot notification must decode");
assert_eq!(notification.slot(), 76);
assert_eq!(notification.parent(), 75);
assert_eq!(notification.root(), 44);
assert!(super::decode_slot_notification("slotSubscribe", serde_json::json!({"slot":76,"parent":75})).is_err());
}
#[tokio::test(flavor = "current_thread")]
async fn stable_slot_and_root_wrappers_use_no_params_decode_exact_notifications_and_unsubscribe_by_handle() {
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 slot_subscribe = read_request(&mut websocket).await;
assert_eq!(slot_subscribe["method"], serde_json::json!("slotSubscribe"));
assert_eq!(slot_subscribe["params"], serde_json::json!([]));
send_result(&mut websocket, &slot_subscribe, serde_json::json!(201)).await;
send_notification(&mut websocket, "slotNotification", 201, serde_json::json!({"slot":76,"parent":75,"root":44})).await;
let root_subscribe = read_request(&mut websocket).await;
assert_eq!(root_subscribe["method"], serde_json::json!("rootSubscribe"));
assert_eq!(root_subscribe["params"], serde_json::json!([]));
send_result(&mut websocket, &root_subscribe, serde_json::json!(202)).await;
send_notification(&mut websocket, "rootNotification", 202, serde_json::json!(42)).await;
let slot_unsubscribe = read_request(&mut websocket).await;
assert_eq!(slot_unsubscribe["method"], serde_json::json!("slotUnsubscribe"));
assert_eq!(slot_unsubscribe["params"], serde_json::json!([201]));
send_result(&mut websocket, &slot_unsubscribe, serde_json::json!(true)).await;
let root_unsubscribe = read_request(&mut websocket).await;
assert_eq!(root_unsubscribe["method"], serde_json::json!("rootUnsubscribe"));
assert_eq!(root_unsubscribe["params"], serde_json::json!([202]));
send_result(&mut websocket, &root_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 slot_subscription = session.slot_subscribe().await.expect("slotSubscribe must register");
let slot = slot_subscription.recv().await.expect("slot notification must arrive").expect("slot notification must decode");
assert_eq!((slot.slot(), slot.parent(), slot.root()), (76, 75, 44));
let mut root_subscription = session.root_subscribe().await.expect("rootSubscribe must register");
let root = root_subscription.recv().await.expect("root notification must arrive").expect("root notification must decode");
assert_eq!(root, 42);
assert!(slot_subscription.unsubscribe().await.expect("slot unsubscribe must complete"));
assert!(root_subscription.unsubscribe().await.expect("root 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_transactions.rs
// version: 1
// version: 2
use futures_util::SinkExt; // rust-rules: trait-import
use futures_util::StreamExt; // rust-rules: trait-import
@@ -120,3 +120,127 @@ async fn stable_logs_wrapper_uses_exact_filter_config_notification_and_handle_un
session.close().await.expect("session close must complete");
server.await.expect("local server task must complete");
}
#[test]
fn signature_subscribe_config_and_decoder_preserve_all_documented_wire_variants() {
let config = crate::SolanaSignatureSubscribeConfig::new(std::option::Option::Some(crate::SolanaCommitment::Confirmed), std::option::Option::Some(false));
assert_eq!(config.commitment(), std::option::Option::Some(crate::SolanaCommitment::Confirmed));
assert_eq!(config.enable_received_notification(), std::option::Option::Some(false));
assert_eq!(config.to_json_value(), serde_json::json!({"commitment":"confirmed","enableReceivedNotification":false}));
let received = super::decode_signature_notification("signatureSubscribe", serde_json::json!({"context":{"slot":90},"value":"receivedSignature"}))
.expect("receivedSignature notification must decode");
assert_eq!(received.context().slot(), 90);
assert_eq!(*received.value(), crate::SolanaSignatureNotification::ReceivedSignature);
assert!(!received.value().is_terminal());
let success = super::decode_signature_notification("signatureSubscribe", serde_json::json!({"context":{"slot":91},"value":{"err":null}}))
.expect("terminal successful signature notification must decode");
assert!(success.value().is_terminal());
assert!(success.value().err().is_none());
let failure = super::decode_signature_notification(
"signatureSubscribe",
serde_json::json!({"context":{"slot":92},"value":{"err":{"InstructionError":[0,"Custom"]}}}),
)
.expect("terminal failed signature notification must decode");
assert!(failure.value().is_terminal());
assert!(failure.value().err().is_some());
assert!(super::decode_signature_notification("signatureSubscribe", serde_json::json!({"context":{"slot":93},"value":"futureVariant"})).is_err());
assert!(super::decode_signature_notification("signatureSubscribe", serde_json::json!({"context":{"slot":94},"value":{}})).is_err());
}
#[tokio::test(flavor = "current_thread")]
async fn signature_unsubscribe_before_terminal_notification_uses_current_remote_id() {
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!("signatureSubscribe"));
assert_eq!(subscribe["params"], serde_json::json!(["fixture-signature"]));
send_result(&mut websocket, &subscribe, serde_json::json!(301)).await;
let unsubscribe = read_request(&mut websocket).await;
assert_eq!(unsubscribe["method"], serde_json::json!("signatureUnsubscribe"));
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 empty_config = crate::SolanaSignatureSubscribeConfig::default();
let mut subscription =
session.signature_subscribe("fixture-signature", std::option::Option::Some(&empty_config)).await.expect("signatureSubscribe must register");
assert!(subscription.unsubscribe().await.expect("signature unsubscribe must complete"));
assert_eq!(subscription.state(), crate::WsSubscriptionState::Closed);
session.close().await.expect("session close must complete");
server.await.expect("local server task must complete");
}
#[tokio::test(flavor = "current_thread")]
async fn signature_terminal_notification_closes_handle_and_is_not_resubscribed_after_reconnect() {
let (listener, url) = bind_local_listener().await;
let (send_terminal_tx, send_terminal_rx) = tokio::sync::oneshot::channel();
let (replacement_ready_tx, replacement_ready_rx) = tokio::sync::oneshot::channel();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("local server must accept initial client");
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("initial WebSocket handshake must succeed");
let subscribe = read_request(&mut websocket).await;
assert_eq!(subscribe["method"], serde_json::json!("signatureSubscribe"));
assert_eq!(subscribe["params"], serde_json::json!(["fixture-signature",{"commitment":"finalized","enableReceivedNotification":true}]));
send_result(&mut websocket, &subscribe, serde_json::json!(401)).await;
let received = serde_json::json!({
"jsonrpc":"2.0",
"method":"signatureNotification",
"params":{"result":{"context":{"slot":100},"value":"receivedSignature"},"subscription":401}
});
websocket
.send(tokio_tungstenite::tungstenite::Message::Text(received.to_string().into()))
.await
.expect("receivedSignature notification must send");
send_terminal_rx.await.expect("client must observe early signature notification before terminal send");
let terminal = serde_json::json!({
"jsonrpc":"2.0",
"method":"signatureNotification",
"params":{"result":{"context":{"slot":101},"value":{"err":null}},"subscription":401}
});
websocket
.send(tokio_tungstenite::tungstenite::Message::Text(terminal.to_string().into()))
.await
.expect("terminal signature notification must send");
let unexpected_cleanup = tokio::time::timeout(std::time::Duration::from_millis(100), websocket.next()).await;
assert!(unexpected_cleanup.is_err(), "server-terminal signature notification must not trigger signatureUnsubscribe");
drop(websocket);
let (replacement_stream, _) = listener.accept().await.expect("local server must accept replacement client");
let mut replacement = tokio_tungstenite::accept_async(replacement_stream).await.expect("replacement WebSocket handshake must succeed");
let unexpected = tokio::time::timeout(std::time::Duration::from_millis(100), replacement.next()).await;
assert!(unexpected.is_err(), "terminal signature subscription must not be replayed after reconnect");
replacement_ready_tx.send(()).expect("replacement-ready signal must send");
wait_for_close_frame(&mut replacement).await;
});
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
let config = crate::SolanaSignatureSubscribeConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized), std::option::Option::Some(true));
let mut subscription =
session.signature_subscribe("fixture-signature", std::option::Option::Some(&config)).await.expect("signatureSubscribe must register");
let received = subscription.recv().await.expect("receivedSignature must arrive").expect("receivedSignature must decode");
assert_eq!(*received.value(), crate::SolanaSignatureNotification::ReceivedSignature);
assert_eq!(subscription.state(), crate::WsSubscriptionState::Active);
send_terminal_tx.send(()).expect("terminal-send signal must reach fixture");
let terminal = subscription.recv().await.expect("terminal signature notification must arrive").expect("terminal signature notification must decode");
assert!(terminal.value().is_terminal());
assert!(terminal.value().err().is_none());
let closed = tokio::time::timeout(std::time::Duration::from_secs(1), async {
loop {
if subscription.state() == crate::WsSubscriptionState::Closed {
return;
}
tokio::task::yield_now().await;
}
})
.await;
assert!(closed.is_ok());
assert!(subscription.recv().await.is_none());
assert!(subscription.terminal_error_code().is_none());
assert!(!subscription.unsubscribe().await.expect("already terminal signature unsubscribe must be local-only"));
replacement_ready_rx.await.expect("replacement connection must be observed without signature replay");
assert_eq!(session.snapshot().subscription_count(), 0);
assert!(session.snapshot().continuity_gap_count() >= 1);
session.close().await.expect("session close must complete");
server.await.expect("local server task must complete");
}