// 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::().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) -> serde_json::Value { let message = websocket.next().await.expect("request message must exist").expect("request message must decode"); let text = message.to_text().expect("request must be text"); return serde_json::from_str(text).expect("request must contain JSON"); } async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream, request: &serde_json::Value, result: serde_json::Value) { let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric"); let response = serde_json::json!({"jsonrpc":"2.0","id":id,"result":result}); websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send"); } async fn send_error(websocket: &mut tokio_tungstenite::WebSocketStream, 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, 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) { 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"); }