1use std::{collections::HashMap, fmt::Debug, sync::Arc};
17
18use alloy::primitives::Address;
19use nautilus_core::string::secret::{REDACTED, SecretString};
20use nautilus_live::SocketControl;
21#[cfg(feature = "hypersync")]
22use nautilus_model::defi::DexType;
23use nautilus_model::defi::{
24 Block, Chain,
25 rpc::{RpcLog, RpcNodeWssResponse},
26};
27use nautilus_network::{
28 RECONNECTED,
29 http::create_standard_nautilus_headers,
30 websocket::{TransportBackend, WebSocketClient, WebSocketConfig, channel_message_handler},
31};
32use tokio_tungstenite::tungstenite::Message;
33
34#[cfg(feature = "hypersync")]
35use crate::exchanges::get_dex_extended;
36use crate::rpc::{
37 error::BlockchainRpcClientError,
38 types::{BlockchainMessage, RpcEventType},
39 utils::{
40 extract_rpc_subscription_id, is_subscription_confirmation_response, is_subscription_event,
41 is_unsubscribe_confirmation_response,
42 },
43};
44
45pub struct CoreBlockchainRpcClient {
54 chain: Chain,
56 wss_rpc_url: String,
58 request_id: u64,
60 pending_subscription_request: HashMap<u64, RpcEventType>,
62 subscription_event_types: HashMap<String, RpcEventType>,
65 wss_client: Option<Arc<WebSocketClient>>,
67 wss_consumer_rx: Option<tokio::sync::mpsc::UnboundedReceiver<Message>>,
69 subscriptions: Arc<tokio::sync::RwLock<HashMap<RpcEventType, RpcSubscription>>>,
71 transport_backend: TransportBackend,
73 proxy_url: Option<SecretString>,
75 socket_control: Option<SocketControl>,
76}
77
78impl Debug for CoreBlockchainRpcClient {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 f.debug_struct(stringify!(CoreBlockchainRpcClient))
81 .field("chain", &self.chain)
82 .field("wss_rpc_url", &REDACTED)
83 .field("request_id", &self.request_id)
84 .field(
85 "pending_subscription_request",
86 &self.pending_subscription_request,
87 )
88 .field("subscription_event_types", &self.subscription_event_types)
89 .field(
90 "wss_client",
91 &self.wss_client.as_ref().map(|_| "<WebSocketClient>"),
92 )
93 .field(
94 "wss_consumer_rx",
95 &self.wss_consumer_rx.as_ref().map(|_| "<Receiver>"),
96 )
97 .field("confirmed_subscriptions", &"<RwLock<HashMap>>")
98 .finish()
99 }
100}
101
102#[derive(Debug, Clone)]
103struct RpcSubscription {
104 name: String,
105 filter: Option<serde_json::Value>,
106}
107
108impl RpcSubscription {
109 fn new(name: &str) -> Self {
110 Self {
111 name: name.to_string(),
112 filter: None,
113 }
114 }
115
116 fn pool_logs(addresses: &[Address], event_signature: String) -> Self {
117 let mut addresses: Vec<String> = addresses
118 .iter()
119 .map(|address| format!("{address:?}"))
120 .collect();
121 addresses.sort();
122
123 Self {
124 name: "logs".to_string(),
125 filter: Some(serde_json::json!({
126 "address": addresses,
127 "topics": [event_signature],
128 })),
129 }
130 }
131
132 fn params(&self) -> Vec<serde_json::Value> {
133 let mut params = vec![serde_json::json!(self.name)];
134 if let Some(filter) = &self.filter {
135 params.push(filter.clone());
136 }
137 params
138 }
139}
140
141impl CoreBlockchainRpcClient {
142 #[must_use]
143 pub fn new(chain: Chain, wss_rpc_url: String, proxy_url: Option<String>) -> Self {
144 Self {
145 chain,
146 wss_rpc_url,
147 request_id: 1,
148 wss_client: None,
149 pending_subscription_request: HashMap::new(),
150 subscription_event_types: HashMap::new(),
151 wss_consumer_rx: None,
152 subscriptions: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
153 transport_backend: TransportBackend::default(),
154 proxy_url: proxy_url.map(SecretString::from),
155 socket_control: None,
156 }
157 }
158
159 #[must_use]
161 pub fn with_transport_backend(mut self, backend: TransportBackend) -> Self {
162 self.transport_backend = backend;
163 self
164 }
165
166 pub fn set_transport_backend(&mut self, backend: TransportBackend) {
168 self.transport_backend = backend;
169 }
170
171 pub fn set_socket_control(&mut self, control: SocketControl) {
173 self.socket_control = Some(control);
174 }
175
176 pub async fn connect(&mut self) -> anyhow::Result<()> {
185 let (handler, rx) = channel_message_handler();
186
187 let heartbeat_interval = 30;
189 let headers = create_standard_nautilus_headers();
190
191 let config = WebSocketConfig {
192 url: self.wss_rpc_url.clone(),
193 headers,
194 heartbeat_interval_secs: Some(heartbeat_interval),
195 heartbeat_payload: None,
196 connect_timeout_ms: Some(10_000),
197 reconnect_delay_initial_ms: Some(1_000),
198 reconnect_delay_max_ms: Some(30_000),
199 reconnect_backoff_factor: Some(2.0),
200 reconnect_jitter_ms: Some(1_000),
201 reconnect_max_attempts: None,
202 heartbeat_timeout_secs: None,
203 idle_timeout_ms: None,
204 writer_capacity: None,
205 backend: self.transport_backend,
206 proxy_url: self
207 .proxy_url
208 .as_ref()
209 .map(|value| value.expose_secret().to_owned()),
210 max_message_size_bytes: None,
211 max_frame_size_bytes: None,
212 };
213
214 let client = WebSocketClient::builder()
215 .config(config)
216 .message_handler(handler)
217 .maybe_state_sink(self.socket_control.as_ref().map(SocketControl::sink))
218 .connect()
219 .await?;
220 let reconnect_handle = client.reconnect_handle();
221 if let Some(control) = &self.socket_control {
222 control.register(move || reconnect_handle.request_reconnect());
223 }
224
225 self.wss_client = Some(Arc::new(client));
226 self.wss_consumer_rx = Some(rx);
227
228 Ok(())
229 }
230
231 async fn subscribe_events(
233 &mut self,
234 event_type: RpcEventType,
235 subscription: RpcSubscription,
236 ) -> Result<(), BlockchainRpcClientError> {
237 if self.subscriptions.read().await.contains_key(&event_type) {
238 return Ok(());
239 }
240
241 self.send_subscription_request(event_type, subscription)
242 .await
243 }
244
245 async fn replace_subscription(
246 &mut self,
247 event_type: RpcEventType,
248 subscription: RpcSubscription,
249 ) -> Result<(), BlockchainRpcClientError> {
250 self.unsubscribe_event_type(event_type).await?;
251 self.send_subscription_request(event_type, subscription)
252 .await
253 }
254
255 async fn send_subscription_request(
256 &mut self,
257 event_type: RpcEventType,
258 subscription: RpcSubscription,
259 ) -> Result<(), BlockchainRpcClientError> {
260 if let Some(client) = &self.wss_client {
261 log::debug!(
262 "Subscribing to '{}' on chain '{}'",
263 subscription.name,
264 self.chain.name
265 );
266 let msg = serde_json::json!({
267 "method": "eth_subscribe",
268 "id": self.request_id,
269 "jsonrpc": "2.0",
270 "params": subscription.params()
271 });
272 self.pending_subscription_request
273 .insert(self.request_id, event_type);
274 self.request_id += 1;
275
276 if let Err(e) = client.send_text(msg.to_string(), None).await {
277 log::error!("Error sending subscribe message: {e:?}");
278 }
279
280 let mut confirmed = self.subscriptions.write().await;
282 confirmed.insert(event_type, subscription);
283
284 Ok(())
285 } else {
286 Err(BlockchainRpcClientError::ClientError(String::from(
287 "Client not connected",
288 )))
289 }
290 }
291
292 async fn resubscribe_all(&mut self) -> Result<(), BlockchainRpcClientError> {
294 let subscriptions = self.subscriptions.read().await;
295
296 if subscriptions.is_empty() {
297 log::debug!(
298 "No subscriptions to re-establish for chain '{}'",
299 self.chain.name
300 );
301 return Ok(());
302 }
303
304 log::info!(
305 "Re-establishing {} subscription(s) for chain '{}'",
306 subscriptions.len(),
307 self.chain.name
308 );
309
310 let subs_to_restore: Vec<(RpcEventType, RpcSubscription)> = subscriptions
311 .iter()
312 .map(|(event_type, subscription)| (*event_type, subscription.clone()))
313 .collect();
314
315 drop(subscriptions);
316
317 for (event_type, subscription) in subs_to_restore {
318 self.send_subscription_request(event_type, subscription)
319 .await?;
320 }
321
322 Ok(())
323 }
324
325 async fn unsubscribe_events(
327 &self,
328 subscription_id: String,
329 ) -> Result<(), BlockchainRpcClientError> {
330 if let Some(client) = &self.wss_client {
331 log::debug!(
332 "Unsubscribing from '{}' on chain {}",
333 subscription_id,
334 self.chain.name
335 );
336 let msg = serde_json::json!({
337 "method": "eth_unsubscribe",
338 "id": 1,
339 "jsonrpc": "2.0",
340 "params": [subscription_id]
341 });
342
343 if let Err(e) = client.send_text(msg.to_string(), None).await {
344 log::error!("Error sending unsubscribe message: {e:?}");
345 }
346 Ok(())
347 } else {
348 Err(BlockchainRpcClientError::ClientError(String::from(
349 "Client not connected",
350 )))
351 }
352 }
353
354 async fn unsubscribe_event_type(
355 &mut self,
356 event_type: RpcEventType,
357 ) -> Result<(), BlockchainRpcClientError> {
358 let subscription_ids_to_remove: Vec<String> = self
359 .subscription_event_types
360 .iter()
361 .filter(|(_, active_event_type)| **active_event_type == event_type)
362 .map(|(id, _)| id.clone())
363 .collect();
364
365 for id in subscription_ids_to_remove {
366 self.unsubscribe_events(id.clone()).await?;
367 self.subscription_event_types.remove(&id);
368 }
369
370 self.pending_subscription_request
371 .retain(|_, pending_event_type| *pending_event_type != event_type);
372 self.subscriptions.write().await.remove(&event_type);
373
374 Ok(())
375 }
376
377 pub async fn wait_on_rpc_channel(&mut self) -> Option<Message> {
379 self.wss_consumer_rx.as_mut()?.recv().await
380 }
381
382 pub async fn next_rpc_message(
390 &mut self,
391 ) -> Result<BlockchainMessage, BlockchainRpcClientError> {
392 while let Some(msg) = self.wait_on_rpc_channel().await {
393 match msg {
394 Message::Text(text) => {
395 if text == RECONNECTED {
396 log::info!("Detected reconnection for chain '{}'", self.chain.name);
397
398 if let Err(e) = self.resubscribe_all().await {
399 log::error!("Failed to re-establish subscriptions: {e:?}");
400 }
401 continue;
402 }
403
404 match serde_json::from_str::<serde_json::Value>(&text) {
405 Ok(json) => {
406 if is_unsubscribe_confirmation_response(&json) {
407 log::debug!(
408 "Received unsubscribe confirmation on chain '{}'",
409 self.chain.name
410 );
411 continue;
412 } else if is_subscription_confirmation_response(&json) {
413 let subscription_request_id = json
414 .get("id")
415 .and_then(serde_json::Value::as_u64)
416 .ok_or_else(|| {
417 BlockchainRpcClientError::InternalRpcClientError(
418 "Missing subscription request id".to_string(),
419 )
420 })?;
421 let result = json
422 .get("result")
423 .and_then(serde_json::Value::as_str)
424 .ok_or_else(|| {
425 BlockchainRpcClientError::InternalRpcClientError(
426 "Missing subscription id".to_string(),
427 )
428 })?;
429 let Some(event_type) = self
430 .pending_subscription_request
431 .remove(&subscription_request_id)
432 else {
433 log::debug!(
434 "Unsubscribing from stale subscription confirmation '{}' on chain '{}'",
435 result,
436 self.chain.name
437 );
438 self.unsubscribe_events(result.to_string()).await?;
439 continue;
440 };
441
442 if self.subscriptions.read().await.contains_key(&event_type) {
443 self.subscription_event_types
444 .insert(result.to_string(), event_type);
445 } else {
446 self.unsubscribe_events(result.to_string()).await?;
447 }
448 continue;
449 } else if is_subscription_event(&json) {
450 let subscription_id = match extract_rpc_subscription_id(&json) {
451 Some(id) => id,
452 None => {
453 return Err(BlockchainRpcClientError::InternalRpcClientError(
454 "Error parsing subscription id from valid rpc response"
455 .to_string(),
456 ));
457 }
458 };
459
460 if let Some(event_type) =
461 self.subscription_event_types.get(subscription_id).copied()
462 {
463 match event_type {
464 RpcEventType::NewBlock => {
465 return match serde_json::from_value::<
466 RpcNodeWssResponse<Block>,
467 >(
468 json
469 ) {
470 Ok(block_response) => {
471 let mut block = block_response.params.result;
472 block.set_chain(self.chain.name);
473 Ok(BlockchainMessage::Block(block))
474 }
475 Err(e) => Err(
476 BlockchainRpcClientError::MessageParsingError(
477 format!(
478 "Error parsing rpc response to block with error {e}"
479 ),
480 ),
481 ),
482 };
483 }
484 RpcEventType::PoolSwap(_)
485 | RpcEventType::PoolMint(_)
486 | RpcEventType::PoolBurn(_)
487 | RpcEventType::PoolCollect(_)
488 | RpcEventType::PoolFlash(_)
489 | RpcEventType::PoolFeeProtocolUpdate(_)
490 | RpcEventType::PoolFeeProtocolCollect(_) => {
491 let log = Self::parse_rpc_log_response(json)?;
492
493 if let Some(message) = self
494 .blockchain_message_from_pool_log(
495 event_type, &log,
496 )?
497 {
498 return Ok(message);
499 }
500 continue;
501 }
502 }
503 }
504 return Err(BlockchainRpcClientError::InternalRpcClientError(
505 format!(
506 "Event type not found for defined subscription id {subscription_id}"
507 ),
508 ));
509 }
510 return Err(BlockchainRpcClientError::UnsupportedRpcResponseType(
511 json.to_string(),
512 ));
513 }
514 Err(e) => {
515 return Err(BlockchainRpcClientError::MessageParsingError(
516 e.to_string(),
517 ));
518 }
519 }
520 }
521 Message::Pong(_) => {}
522 _ => {
523 return Err(BlockchainRpcClientError::UnsupportedRpcResponseType(
524 msg.to_string(),
525 ));
526 }
527 }
528 }
529
530 Err(BlockchainRpcClientError::NoMessageReceived)
531 }
532
533 fn parse_rpc_log_response(json: serde_json::Value) -> Result<RpcLog, BlockchainRpcClientError> {
534 serde_json::from_value::<RpcNodeWssResponse<RpcLog>>(json)
535 .map(|response| response.params.result)
536 .map_err(|e| {
537 BlockchainRpcClientError::MessageParsingError(format!(
538 "Error parsing rpc response to log with error {e}"
539 ))
540 })
541 }
542
543 #[cfg(feature = "hypersync")]
544 fn blockchain_message_from_pool_log(
545 &self,
546 event_type: RpcEventType,
547 log: &RpcLog,
548 ) -> Result<Option<BlockchainMessage>, BlockchainRpcClientError> {
549 if log.removed {
550 log::debug!(
551 "Skipping removed pool log on chain '{}' for event {:?}",
552 self.chain.name,
553 event_type
554 );
555 return Ok(None);
556 }
557
558 let dex = Self::pool_event_dex(event_type)?;
559 let dex_extended = get_dex_extended(self.chain.name, &dex).ok_or_else(|| {
560 BlockchainRpcClientError::InternalRpcClientError(format!(
561 "DEX {dex} is not registered for chain {}",
562 self.chain.name
563 ))
564 })?;
565
566 match event_type {
567 RpcEventType::PoolSwap(_) => dex_extended
568 .parse_swap_event_rpc(log)
569 .map(BlockchainMessage::SwapEvent),
570 RpcEventType::PoolMint(_) => dex_extended
571 .parse_mint_event_rpc(log)
572 .map(BlockchainMessage::MintEvent),
573 RpcEventType::PoolBurn(_) => dex_extended
574 .parse_burn_event_rpc(log)
575 .map(BlockchainMessage::BurnEvent),
576 RpcEventType::PoolCollect(_) => dex_extended
577 .parse_collect_event_rpc(log)
578 .map(BlockchainMessage::CollectEvent),
579 RpcEventType::PoolFlash(_) => dex_extended
580 .parse_flash_event_rpc(log)
581 .map(BlockchainMessage::FlashEvent),
582 RpcEventType::PoolFeeProtocolUpdate(_) => dex_extended
583 .parse_fee_protocol_update_event_rpc(log)
584 .map(BlockchainMessage::FeeProtocolUpdateEvent),
585 RpcEventType::PoolFeeProtocolCollect(_) => dex_extended
586 .parse_fee_protocol_collect_event_rpc(log)
587 .map(BlockchainMessage::FeeProtocolCollectEvent),
588 RpcEventType::NewBlock => Err(anyhow::anyhow!(
589 "NewBlock event type cannot parse pool logs"
590 )),
591 }
592 .map(Some)
593 .map_err(|e| BlockchainRpcClientError::MessageParsingError(e.to_string()))
594 }
595
596 #[cfg(not(feature = "hypersync"))]
597 fn blockchain_message_from_pool_log(
598 &self,
599 event_type: RpcEventType,
600 log: &RpcLog,
601 ) -> Result<Option<BlockchainMessage>, BlockchainRpcClientError> {
602 if log.removed {
603 log::debug!(
604 "Skipping removed pool log on chain '{}' for event {:?}",
605 self.chain.name,
606 event_type
607 );
608 return Ok(None);
609 }
610
611 Err(BlockchainRpcClientError::UnsupportedRpcResponseType(
612 format!("RPC pool log parsing for {event_type:?} requires the hypersync feature"),
613 ))
614 }
615
616 #[cfg(feature = "hypersync")]
617 fn pool_event_dex(event_type: RpcEventType) -> Result<DexType, BlockchainRpcClientError> {
618 match event_type {
619 RpcEventType::PoolSwap(dex)
620 | RpcEventType::PoolMint(dex)
621 | RpcEventType::PoolBurn(dex)
622 | RpcEventType::PoolCollect(dex)
623 | RpcEventType::PoolFlash(dex)
624 | RpcEventType::PoolFeeProtocolUpdate(dex)
625 | RpcEventType::PoolFeeProtocolCollect(dex) => Ok(dex),
626 RpcEventType::NewBlock => Err(BlockchainRpcClientError::InternalRpcClientError(
627 "NewBlock event type has no DEX".to_string(),
628 )),
629 }
630 }
631
632 pub async fn subscribe_blocks(&mut self) -> Result<(), BlockchainRpcClientError> {
638 self.subscribe_events(RpcEventType::NewBlock, RpcSubscription::new("newHeads"))
639 .await
640 }
641
642 pub async fn subscribe_pool_events(
648 &mut self,
649 event_type: RpcEventType,
650 addresses: &[Address],
651 event_signature: String,
652 ) -> Result<(), BlockchainRpcClientError> {
653 if matches!(event_type, RpcEventType::NewBlock) {
654 return Err(BlockchainRpcClientError::InvalidParameters(
655 "NewBlock is not a pool event subscription".to_string(),
656 ));
657 }
658
659 if addresses.is_empty() {
660 return self.unsubscribe_event_type(event_type).await;
661 }
662
663 self.replace_subscription(
664 event_type,
665 RpcSubscription::pool_logs(addresses, event_signature),
666 )
667 .await
668 }
669
670 pub async fn unsubscribe_blocks(&mut self) -> Result<(), BlockchainRpcClientError> {
676 self.unsubscribe_event_type(RpcEventType::NewBlock).await
677 }
678}
679
680#[cfg(test)]
681mod tests {
682 use alloy::primitives::address;
683 use nautilus_model::defi::{Blockchain, Chain, DexType};
684 use rstest::rstest;
685
686 use super::*;
687
688 #[rstest]
689 fn debug_redacts_websocket_rpc_url() {
690 const USERINFO_SECRET: &str = "core-wss-userinfo-secret";
691 const PATH_SECRET: &str = "core-wss-path-secret";
692 const QUERY_SECRET: &str = "core-wss-query-secret";
693 let wss_rpc_url = format!(
694 "wss://rpc-user:{USERINFO_SECRET}@rpc.example.com/{PATH_SECRET}?api_key={QUERY_SECRET}"
695 );
696 let client = CoreBlockchainRpcClient::new(
697 Chain::from_chain_id(1)
698 .expect("Ethereum chain should exist")
699 .clone(),
700 wss_rpc_url.clone(),
701 None,
702 );
703
704 let debug = format!("{client:?}");
705
706 assert!(debug.contains("wss_rpc_url: \"<redacted>\""));
707 assert!(!debug.contains(USERINFO_SECRET));
708 assert!(!debug.contains(PATH_SECRET));
709 assert!(!debug.contains(QUERY_SECRET));
710 assert!(!debug.contains(&wss_rpc_url));
711 }
712
713 #[rstest]
714 fn pool_logs_subscription_params_use_logs_filter_with_sorted_addresses() {
715 let event_signature =
716 "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67".to_string();
717 let subscription = RpcSubscription::pool_logs(
718 &[
719 address!("2222222222222222222222222222222222222222"),
720 address!("1111111111111111111111111111111111111111"),
721 ],
722 event_signature.clone(),
723 );
724
725 let params = subscription.params();
726 let filter = ¶ms[1];
727
728 assert_eq!(params[0], serde_json::json!("logs"));
729 assert_eq!(
730 filter["address"],
731 serde_json::json!([
732 "0x1111111111111111111111111111111111111111",
733 "0x2222222222222222222222222222222222222222",
734 ])
735 );
736 assert_eq!(filter["topics"], serde_json::json!([event_signature]));
737 }
738
739 #[cfg(feature = "hypersync")]
740 #[rstest]
741 fn pool_event_dex_rejects_block_event_type() {
742 assert!(CoreBlockchainRpcClient::pool_event_dex(RpcEventType::NewBlock).is_err());
743 assert_eq!(
744 CoreBlockchainRpcClient::pool_event_dex(RpcEventType::PoolSwap(DexType::UniswapV3))
745 .unwrap(),
746 DexType::UniswapV3
747 );
748 }
749
750 #[rstest]
751 fn removed_pool_log_returns_no_message() {
752 let client = CoreBlockchainRpcClient::new(
753 Chain::from_chain_id(1)
754 .expect("Ethereum chain should exist")
755 .clone(),
756 "ws://127.0.0.1:9".to_string(),
757 None,
758 );
759 let log = RpcLog {
760 removed: true,
761 log_index: Some("0x0".to_string()),
762 transaction_index: Some("0x0".to_string()),
763 transaction_hash: Some("0x1".to_string()),
764 block_hash: Some("0x1".to_string()),
765 block_number: Some("0x1".to_string()),
766 address: "0x1111111111111111111111111111111111111111".to_string(),
767 data: "0x".to_string(),
768 topics: vec![],
769 };
770
771 let message = client
772 .blockchain_message_from_pool_log(RpcEventType::PoolSwap(DexType::UniswapV3), &log)
773 .expect("removed logs should not fail conversion");
774
775 assert!(message.is_none());
776 }
777
778 #[tokio::test]
779 async fn next_rpc_message_skips_unsubscribe_confirmation() {
780 let mut client = CoreBlockchainRpcClient::new(
781 Chain::from_chain_id(1)
782 .expect("Ethereum chain should exist")
783 .clone(),
784 "ws://127.0.0.1:9".to_string(),
785 None,
786 );
787 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
788 client.wss_consumer_rx = Some(rx);
789 tx.send(Message::Text(
790 serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": true})
791 .to_string()
792 .into(),
793 ))
794 .expect("unsubscribe ack should enqueue");
795 drop(tx);
796
797 let error = client
798 .next_rpc_message()
799 .await
800 .expect_err("unsubscribe ack should be skipped");
801
802 assert!(matches!(error, BlockchainRpcClientError::NoMessageReceived));
803 }
804
805 #[tokio::test]
806 async fn next_rpc_message_sets_client_chain_on_new_block() {
807 let mut client = CoreBlockchainRpcClient::new(
808 Chain::from_chain_id(8453)
809 .expect("Base chain should exist")
810 .clone(),
811 "ws://127.0.0.1:9".to_string(),
812 None,
813 );
814 client
815 .subscription_event_types
816 .insert("0xblocks".to_string(), RpcEventType::NewBlock);
817 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
818 client.wss_consumer_rx = Some(rx);
819 tx.send(Message::Text(
820 serde_json::json!({
821 "jsonrpc": "2.0",
822 "method": "eth_subscription",
823 "params": {
824 "subscription": "0xblocks",
825 "result": {
826 "hash": "0xabc",
827 "number": "0x10",
828 "parentHash": "0xdef",
829 "miner": "0x0000000000000000000000000000000000000001",
830 "gasLimit": "0x1c9c380",
831 "gasUsed": "0x5208",
832 "timestamp": "0x6553f100"
833 }
834 }
835 })
836 .to_string()
837 .into(),
838 ))
839 .expect("block notification should enqueue");
840
841 let message = client
842 .next_rpc_message()
843 .await
844 .expect("block notification should parse");
845
846 let BlockchainMessage::Block(block) = message else {
847 panic!("expected block message");
848 };
849
850 assert_eq!(block.chain(), Blockchain::Base);
851 assert_eq!(block.number, 16);
852 assert_eq!(block.hash, "0xabc");
853 }
854}