Skip to main content

nautilus_blockchain/rpc/
core.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use 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
45/// Core implementation of a blockchain RPC client that serves as the base for all chain-specific clients.
46///
47/// It provides a shared implementation of common blockchain RPC functionality, handling:
48/// - WebSocket connection management with blockchain RPC node.
49/// - Subscription lifecycle (creation, tracking, and termination).
50/// - Message serialization and deserialization of RPC messages.
51/// - Event type mapping and dispatching.
52/// - Automatic subscription re-establishment on reconnection.
53pub struct CoreBlockchainRpcClient {
54    /// The blockchain network type this client connects to.
55    chain: Chain,
56    /// WebSocket secure URL for the blockchain node's RPC endpoint.
57    wss_rpc_url: String,
58    /// Auto-incrementing counter for generating unique RPC request IDs.
59    request_id: u64,
60    /// Tracks in-flight subscription requests by mapping request IDs to their event types.
61    pending_subscription_request: HashMap<u64, RpcEventType>,
62    /// Maps active subscription IDs to their corresponding event types for message
63    /// deserialization.
64    subscription_event_types: HashMap<String, RpcEventType>,
65    /// The active WebSocket client connection.
66    wss_client: Option<Arc<WebSocketClient>>,
67    /// Channel receiver for consuming WebSocket messages.
68    wss_consumer_rx: Option<tokio::sync::mpsc::UnboundedReceiver<Message>>,
69    /// Tracks desired subscriptions that need to be re-established on reconnection.
70    subscriptions: Arc<tokio::sync::RwLock<HashMap<RpcEventType, RpcSubscription>>>,
71    /// WebSocket transport backend (defaults to `Sockudo`).
72    transport_backend: TransportBackend,
73    /// Optional proxy URL for the WebSocket connection.
74    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    /// Sets the transport backend for the next [`Self::connect`].
160    #[must_use]
161    pub fn with_transport_backend(mut self, backend: TransportBackend) -> Self {
162        self.transport_backend = backend;
163        self
164    }
165
166    /// Updates the transport backend in place.
167    pub fn set_transport_backend(&mut self, backend: TransportBackend) {
168        self.transport_backend = backend;
169    }
170
171    /// Configures socket state reporting and reconnect control.
172    pub fn set_socket_control(&mut self, control: SocketControl) {
173        self.socket_control = Some(control);
174    }
175
176    /// Establishes a WebSocket connection to the blockchain node and sets up the message channel.
177    ///
178    /// Configures automatic reconnection with exponential backoff and subscription re-establishment.
179    /// Reconnection is handled via the `RECONNECTED` message in the message stream.
180    ///
181    /// # Errors
182    ///
183    /// Returns an error if the WebSocket connection fails.
184    pub async fn connect(&mut self) -> anyhow::Result<()> {
185        let (handler, rx) = channel_message_handler();
186
187        // Most blockchain RPC nodes require a heartbeat to keep the connection alive
188        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    /// Registers a subscription for the specified event type.
232    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            // Track subscription for re-establishment on reconnect
281            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    /// Re-establishes all confirmed subscriptions after reconnection.
293    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    /// Terminates a subscription with the blockchain node using the provided subscription ID.
326    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    /// Waits for and returns the next available message from the WebSocket channel.
378    pub async fn wait_on_rpc_channel(&mut self) -> Option<Message> {
379        self.wss_consumer_rx.as_mut()?.recv().await
380    }
381
382    /// Retrieves, parses, and returns the next blockchain RPC message as a structured `BlockchainRpcMessage` type.
383    ///
384    /// Handles subscription confirmations, events, and reconnection signals automatically.
385    ///
386    /// # Errors
387    ///
388    /// Returns an error if the RPC channel encounters an error or if deserialization of the message fails.
389    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    /// Subscribes to real-time block updates from the blockchain node.
633    ///
634    /// # Errors
635    ///
636    /// Returns an error if the subscription request fails or if the client is not connected.
637    pub async fn subscribe_blocks(&mut self) -> Result<(), BlockchainRpcClientError> {
638        self.subscribe_events(RpcEventType::NewBlock, RpcSubscription::new("newHeads"))
639            .await
640    }
641
642    /// Subscribes to real-time pool logs for one event type.
643    ///
644    /// # Errors
645    ///
646    /// Returns an error if the subscription request fails or if the client is not connected.
647    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    /// Cancels the subscription to real-time block updates.
671    ///
672    /// # Errors
673    ///
674    /// Returns an error if the unsubscription request fails or if the client is not connected.
675    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 = &params[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}