Skip to main content

nautilus_blockchain/hypersync/
client.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::{
17    sync::{
18        Arc,
19        atomic::{AtomicU64, Ordering},
20    },
21    time::Duration,
22};
23
24use ahash::AHashMap;
25use alloy::primitives::Address;
26use futures_util::Stream;
27use hypersync_client::{
28    StreamConfig,
29    net_types::{BlockField, BlockSelection, FieldSelection, Query},
30    simple_types::Log,
31};
32use nautilus_core::{consts::NAUTILUS_USER_AGENT, hex};
33use nautilus_live::task::{TaskJoinOutcome, TaskSlot, finish_task};
34use nautilus_model::{
35    defi::{Block, Blockchain, DexType, SharedChain},
36    identifiers::InstrumentId,
37};
38
39use crate::{
40    exchanges::{extended::DexExtended, get_dex_extended},
41    hypersync::transform::transform_hypersync_block,
42    rpc::{http::validate_execution_endpoint, types::BlockchainMessage},
43};
44
45/// An item yielded by the contract-events stream.
46///
47/// Blocks are surfaced ahead of the logs from the same response so callers can populate their
48/// block-timestamp cache before converting events from those blocks.
49#[derive(Debug)]
50pub enum PoolEventStreamItem {
51    /// A block referenced by subsequent logs.
52    Block(Block),
53    /// A contract event log.
54    Log(Log),
55}
56
57/// The interval in milliseconds at which to check for new blocks when waiting
58/// for the hypersync to index the block.
59const BLOCK_POLLING_INTERVAL_MS: u64 = 50;
60
61/// Timeout in seconds for HyperSync HTTP requests.
62const HYPERSYNC_REQUEST_TIMEOUT_SECS: u64 = 30;
63
64/// Timeout in seconds for graceful task shutdown during disconnect.
65/// If the task doesn't finish within this time, it will be forcefully aborted.
66const DISCONNECT_TIMEOUT_SECS: u64 = 5;
67
68/// Delay before restarting a DEX event stream after it reaches the current indexed tip.
69const DEX_EVENT_STREAM_RETRY_DELAY_MS: u64 = 1_000;
70
71/// A client for interacting with a HyperSync API to retrieve blockchain data.
72#[derive(Debug)]
73pub struct HyperSyncClient {
74    /// The target blockchain identifier (e.g. Ethereum, Arbitrum).
75    chain: SharedChain,
76    /// The underlying HyperSync Rust client for making API requests.
77    client: Arc<hypersync_client::Client>,
78    /// Background task handle for the block subscription task.
79    blocks_task: TaskSlot<()>,
80    /// Cancellation token for the blocks subscription task.
81    blocks_cancellation_token: Option<tokio_util::sync::CancellationToken>,
82    /// Background DEX event stream tasks keyed by DEX type.
83    dex_event_tasks: AHashMap<DexType, DexEventStreamTask>,
84    /// Channel for sending blockchain messages to the adapter data client.
85    tx: Option<tokio::sync::mpsc::UnboundedSender<BlockchainMessage>>,
86    /// Index of pool addressed keyed by instrument ID.
87    pool_addresses: AHashMap<InstrumentId, Address>,
88    /// Cancellation token for graceful shutdown of background tasks.
89    cancellation_token: tokio_util::sync::CancellationToken,
90}
91
92impl HyperSyncClient {
93    /// Creates a new [`HyperSyncClient`] instance for the given chain and message sender.
94    ///
95    /// # Panics
96    ///
97    /// Panics if:
98    /// - The chain's `hypersync_url` is invalid.
99    /// - The `ENVIO_API_TOKEN` environment variable is not set or invalid.
100    /// - The underlying client cannot be initialized.
101    #[must_use]
102    pub fn new(
103        chain: SharedChain,
104        tx: Option<tokio::sync::mpsc::UnboundedSender<BlockchainMessage>>,
105        cancellation_token: tokio_util::sync::CancellationToken,
106    ) -> Self {
107        let mut config = hypersync_client::ClientConfig::default();
108        let hypersync_url = validate_execution_endpoint(chain.hypersync_url.as_str(), "HyperSync")
109            .expect("Invalid HyperSync URL");
110        config.url = hypersync_url.to_string();
111        config.api_token = std::env::var("ENVIO_API_TOKEN")
112            .expect("ENVIO_API_TOKEN environment variable must be set");
113
114        config
115            .validate()
116            .expect("Failed to create HyperSync client - check ENVIO_API_TOKEN is a valid UUID");
117        let client = hypersync_client::Client::new_with_agent(config, NAUTILUS_USER_AGENT)
118            .expect("Failed to create HyperSync client - check ENVIO_API_TOKEN is a valid UUID");
119
120        Self {
121            chain,
122            client: Arc::new(client),
123            blocks_task: TaskSlot::new(),
124            blocks_cancellation_token: None,
125            dex_event_tasks: AHashMap::new(),
126            tx,
127            pool_addresses: AHashMap::new(),
128            cancellation_token,
129        }
130    }
131
132    #[must_use]
133    pub fn get_pool_address(&self, instrument_id: InstrumentId) -> Option<&Address> {
134        self.pool_addresses.get(&instrument_id)
135    }
136
137    /// Starts, refreshes, or stops the live DEX event stream for one DEX.
138    ///
139    /// The stream query is open-ended (`to_block = None`) and the background task resumes from the
140    /// last HyperSync `next_block` whenever the SDK stream reaches the current indexed tip.
141    pub async fn update_dex_event_stream(
142        &mut self,
143        dex: DexType,
144        contract_addresses: Vec<Address>,
145        event_signatures: Vec<String>,
146    ) {
147        let filter = DexEventStreamFilter::new(contract_addresses, event_signatures);
148
149        if filter.is_empty() {
150            self.stop_dex_event_stream(dex).await;
151            return;
152        }
153
154        if self.dex_event_tasks.get(&dex).is_some_and(|task| {
155            task.filter == filter
156                && task
157                    .task
158                    .as_ref()
159                    .is_some_and(|handle| !handle.is_finished())
160        }) {
161            return;
162        }
163
164        let next_from_block = self.stop_dex_event_stream(dex).await;
165        if self.dex_event_tasks.contains_key(&dex) {
166            log::error!("Previous HyperSync DEX event stream for {dex} is still stopping");
167            return;
168        }
169
170        let from_block = match next_from_block {
171            Some(block) => block,
172            None => match self.client.get_height().await {
173                Ok(block) => block,
174                Err(e) => {
175                    log::error!("Failed to get HyperSync height for DEX event stream: {e}");
176                    return;
177                }
178            },
179        };
180
181        let tx = if let Some(tx) = &self.tx {
182            tx.clone()
183        } else {
184            log::error!("Hypersync client channel should have been initialized");
185            return;
186        };
187
188        let client = self.client.clone();
189        let chain = self.chain.name;
190        let Some(dex_extended) = get_dex_extended(chain, &dex) else {
191            log::error!("Failed to get DEX registration for {dex} on {chain}");
192            return;
193        };
194        let stream_token = self.cancellation_token.child_token();
195        let task_token = stream_token.clone();
196        let next_from_block = Arc::new(AtomicU64::new(from_block));
197        let task_next_from_block = next_from_block.clone();
198        let task_filter = filter.clone();
199
200        let mut task = TaskSlot::new();
201        if let Err(e) = task.spawn(async move {
202            Self::run_dex_event_stream(
203                dex,
204                client,
205                tx,
206                task_filter,
207                dex_extended,
208                task_next_from_block,
209                task_token,
210            )
211            .await;
212        }) {
213            stream_token.cancel();
214            log::error!("Failed to start HyperSync DEX event stream for {dex}: {e}");
215        }
216
217        self.dex_event_tasks.insert(
218            dex,
219            DexEventStreamTask {
220                filter,
221                next_from_block,
222                cancellation_token: stream_token,
223                task,
224            },
225        );
226    }
227
228    /// Creates a stream of contract event logs matching the specified criteria.
229    ///
230    /// # Panics
231    ///
232    /// Panics if the contract address cannot be parsed as a valid Ethereum address.
233    pub async fn request_contract_events_stream(
234        &self,
235        from_block: u64,
236        to_block: Option<u64>,
237        contract_address: &Address,
238        topics: Vec<&str>,
239    ) -> impl Stream<Item = PoolEventStreamItem> + use<> {
240        let query = Self::construct_contract_events_query(
241            from_block,
242            to_block,
243            &[*contract_address],
244            &topics,
245        );
246
247        let chain = self.chain.name;
248        let mut rx = self
249            .client
250            .clone()
251            .stream(query, StreamConfig::default())
252            .await
253            .expect("Failed to create stream");
254
255        async_stream::stream! {
256              while let Some(response) = rx.recv().await {
257                let response = response.unwrap();
258                for item in pool_events_from_response(chain, response.data.blocks, response.data.logs) {
259                    yield item;
260                }
261            }
262        }
263    }
264
265    /// Disconnects from the HyperSync service and stops all background tasks.
266    pub async fn disconnect(&mut self) {
267        log::debug!("Disconnecting HyperSync client");
268        self.cancellation_token.cancel();
269
270        if let Some(outcome) = finish_task(
271            &mut self.blocks_task,
272            Duration::from_secs(DISCONNECT_TIMEOUT_SECS),
273            Duration::from_secs(DISCONNECT_TIMEOUT_SECS),
274        )
275        .await
276        {
277            match outcome {
278                TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted => {}
279                TaskJoinOutcome::Failed(error) => {
280                    log::error!("HyperSync blocks task failed: {error}");
281                }
282                TaskJoinOutcome::Incomplete => {
283                    log::error!("HyperSync blocks task did not stop after abort");
284                }
285            }
286        }
287
288        let dexes = self.dex_event_tasks.keys().copied().collect::<Vec<_>>();
289        for dex in dexes {
290            self.stop_dex_event_stream(dex).await;
291        }
292
293        log::debug!("HyperSync client disconnected");
294    }
295
296    /// Returns the current block
297    ///
298    /// # Panics
299    ///
300    /// Panics if the client height request fails.
301    pub async fn current_block(&self) -> u64 {
302        self.client.get_height().await.unwrap()
303    }
304
305    /// Creates a stream that yields blockchain blocks within the specified range.
306    ///
307    /// # Panics
308    ///
309    /// Panics if the stream creation or block transformation fails.
310    pub async fn request_blocks_stream(
311        &self,
312        from_block: u64,
313        to_block: Option<u64>,
314    ) -> impl Stream<Item = Block> {
315        let query = Self::construct_block_query(from_block, to_block);
316        let mut rx = self
317            .client
318            .clone()
319            .stream(query, StreamConfig::default())
320            .await
321            .unwrap();
322
323        let chain = self.chain.name;
324
325        async_stream::stream! {
326            while let Some(response) = rx.recv().await {
327                let response = response.unwrap();
328                for received_block in response.data.blocks.into_iter().flatten() {
329                    let block = transform_hypersync_block(chain, received_block).unwrap();
330                    yield block
331                }
332            }
333        }
334    }
335
336    /// Starts a background task that continuously polls for new blockchain blocks.
337    ///
338    /// # Panics
339    ///
340    /// Panics if client height requests or block transformations fail.
341    pub fn subscribe_blocks(&mut self) {
342        if self.blocks_task.is_some() {
343            return;
344        }
345
346        let chain = self.chain.name;
347        let client = self.client.clone();
348        let tx = if let Some(tx) = &self.tx {
349            tx.clone()
350        } else {
351            log::error!("Hypersync client channel should have been initialized");
352            return;
353        };
354
355        // Create a child token that can be cancelled independently
356        let blocks_token = self.cancellation_token.child_token();
357        let cancellation_token = blocks_token.clone();
358        self.blocks_cancellation_token = Some(blocks_token);
359
360        if let Err(e) = self.blocks_task.spawn(async move {
361            log::debug!("Starting task 'blocks_feed");
362
363            let current_block_height = client.get_height().await.unwrap();
364            let mut query = Self::construct_block_query(current_block_height, None);
365
366            loop {
367                tokio::select! {
368                    () = cancellation_token.cancelled() => {
369                        log::debug!("Blocks subscription task received cancellation signal");
370                        break;
371                    }
372                    result = tokio::time::timeout(
373                        Duration::from_secs(HYPERSYNC_REQUEST_TIMEOUT_SECS),
374                        client.get(&query)
375                    ) => {
376                        let response = match result {
377                            Ok(Ok(resp)) => resp,
378                            Ok(Err(e)) => {
379                                log::error!("Hypersync request failed: {e}");
380                                break;
381                            }
382                            Err(_) => {
383                                log::warn!("Hypersync request timed out after {HYPERSYNC_REQUEST_TIMEOUT_SECS}s, retrying...");
384                                continue;
385                            }
386                        };
387
388                        for received_block in response.data.blocks.into_iter().flatten() {
389                            let block = transform_hypersync_block(chain, received_block).unwrap();
390                            let msg = BlockchainMessage::Block(block);
391                            if let Err(e) = tx.send(msg) {
392                                log::error!("Error sending message: {e}");
393                            }
394                        }
395
396                        if let Some(archive_block_height) = response.archive_height
397                            && archive_block_height < response.next_block
398                        {
399                            while client.get_height().await.unwrap() < response.next_block {
400                                tokio::select! {
401                                    () = cancellation_token.cancelled() => {
402                                        log::debug!("Blocks subscription task received cancellation signal during polling");
403                                        return;
404                                    }
405                                    () = tokio::time::sleep(Duration::from_millis(
406                                        BLOCK_POLLING_INTERVAL_MS,
407                                    )) => {}
408                                }
409                            }
410                        }
411
412                        query.from_block = response.next_block;
413                    }
414                }
415            }
416        }) {
417            if let Some(token) = self.blocks_cancellation_token.take() {
418                token.cancel();
419            }
420            log::error!("Failed to start HyperSync blocks subscription task: {e}");
421        }
422    }
423
424    /// Unsubscribes from new blocks by stopping the background watch task.
425    pub async fn unsubscribe_blocks(&mut self) {
426        if self.blocks_task.is_none() {
427            return;
428        }
429
430        // Cancel only the blocks child token, not the main cancellation token
431        if let Some(token) = self.blocks_cancellation_token.take() {
432            token.cancel();
433        }
434
435        if let Some(outcome) = finish_task(
436            &mut self.blocks_task,
437            Duration::from_secs(DISCONNECT_TIMEOUT_SECS),
438            Duration::from_secs(DISCONNECT_TIMEOUT_SECS),
439        )
440        .await
441        {
442            match outcome {
443                TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted => {}
444                TaskJoinOutcome::Failed(error) => {
445                    log::error!("HyperSync blocks task failed during unsubscribe: {error}");
446                }
447                TaskJoinOutcome::Incomplete => {
448                    log::error!("HyperSync blocks task did not stop after abort");
449                }
450            }
451        }
452        log::debug!("Unsubscribed from blocks");
453    }
454
455    /// Constructs a HyperSync query for fetching blocks with all available fields within the specified range.
456    fn construct_block_query(from_block: u64, to_block: Option<u64>) -> Query {
457        Query {
458            from_block,
459            to_block: Self::to_hypersync_exclusive_bound(to_block),
460            blocks: vec![BlockSelection::default()],
461            field_selection: FieldSelection {
462                block: BlockField::all(),
463                ..Default::default()
464            },
465            ..Default::default()
466        }
467    }
468
469    fn construct_contract_events_query(
470        from_block: u64,
471        to_block: Option<u64>,
472        contract_addresses: &[Address],
473        topics: &[&str],
474    ) -> Query {
475        let mut query_value = serde_json::json!({
476            "from_block": from_block,
477            "logs": [{
478                "topics": [topics],
479                "address": contract_addresses
480            }],
481            "field_selection": {
482                "log": [
483                    "block_number",
484                    "transaction_hash",
485                    "transaction_index",
486                    "log_index",
487                    "address",
488                    "data",
489                    "topic0",
490                    "topic1",
491                    "topic2",
492                    "topic3",
493                ],
494                // Join block fields so callers can resolve each event's ts_event
495                "block": [
496                    "number",
497                    "hash",
498                    "parent_hash",
499                    "miner",
500                    "gas_limit",
501                    "gas_used",
502                    "timestamp",
503                ]
504            }
505        });
506
507        if let Some(to_block) = Self::to_hypersync_exclusive_bound(to_block)
508            && let Some(obj) = query_value.as_object_mut()
509        {
510            obj.insert("to_block".to_string(), serde_json::json!(to_block));
511        }
512
513        serde_json::from_value(query_value).unwrap()
514    }
515
516    fn to_hypersync_exclusive_bound(to_block: Option<u64>) -> Option<u64> {
517        to_block.map(|block| block.saturating_add(1))
518    }
519
520    async fn run_dex_event_stream(
521        dex: DexType,
522        client: Arc<hypersync_client::Client>,
523        tx: tokio::sync::mpsc::UnboundedSender<BlockchainMessage>,
524        filter: DexEventStreamFilter,
525        dex_extended: &'static DexExtended,
526        next_from_block: Arc<AtomicU64>,
527        cancellation_token: tokio_util::sync::CancellationToken,
528    ) {
529        log::debug!("Starting task 'dex_event_stream' for {dex}");
530
531        loop {
532            let from_block = next_from_block.load(Ordering::Relaxed);
533            Self::wait_for_stream_start_block(&client, from_block, &cancellation_token).await;
534            if cancellation_token.is_cancelled() {
535                break;
536            }
537
538            let topics = filter
539                .event_signatures
540                .iter()
541                .map(String::as_str)
542                .collect::<Vec<_>>();
543            let query = Self::construct_contract_events_query(
544                from_block,
545                None,
546                &filter.contract_addresses,
547                &topics,
548            );
549            let mut rx = match client.stream(query, StreamConfig::default()).await {
550                Ok(rx) => rx,
551                Err(e) => {
552                    log::error!("Failed to create DEX event stream for {dex}: {e}");
553
554                    if !Self::sleep_or_cancel(
555                        Duration::from_millis(DEX_EVENT_STREAM_RETRY_DELAY_MS),
556                        &cancellation_token,
557                    )
558                    .await
559                    {
560                        break;
561                    }
562                    continue;
563                }
564            };
565
566            let mut received_response = false;
567
568            loop {
569                tokio::select! {
570                    () = cancellation_token.cancelled() => {
571                        log::debug!("DEX event stream task for {dex} received cancellation signal");
572                        break;
573                    }
574                    response = rx.recv() => {
575                        let Some(response) = response else {
576                            break;
577                        };
578
579                        let response = match response {
580                            Ok(resp) => resp,
581                            Err(e) => {
582                                if received_response {
583                                    log::debug!("DEX event stream drained for {dex}: {e}");
584                                } else {
585                                    log::error!("Failed to receive DEX event stream response for {dex}: {e}");
586                                }
587                                break;
588                            }
589                        };
590
591                        received_response = true;
592                        next_from_block.fetch_max(response.next_block, Ordering::Relaxed);
593
594                        for log in response.data.logs.into_iter().flatten() {
595                            Self::send_dex_event_log(&tx, dex_extended, &log);
596                        }
597                    }
598                }
599            }
600
601            if !Self::sleep_or_cancel(
602                Duration::from_millis(DEX_EVENT_STREAM_RETRY_DELAY_MS),
603                &cancellation_token,
604            )
605            .await
606            {
607                break;
608            }
609        }
610
611        log::debug!("Stopped task 'dex_event_stream' for {dex}");
612    }
613
614    async fn wait_for_stream_start_block(
615        client: &hypersync_client::Client,
616        from_block: u64,
617        cancellation_token: &tokio_util::sync::CancellationToken,
618    ) {
619        loop {
620            match client.get_height().await {
621                Ok(height) if height >= from_block => return,
622                Ok(_) => {}
623                Err(e) => log::error!("Failed to get HyperSync height for DEX event stream: {e}"),
624            }
625
626            if !Self::sleep_or_cancel(
627                Duration::from_millis(BLOCK_POLLING_INTERVAL_MS),
628                cancellation_token,
629            )
630            .await
631            {
632                return;
633            }
634        }
635    }
636
637    async fn sleep_or_cancel(
638        duration: Duration,
639        cancellation_token: &tokio_util::sync::CancellationToken,
640    ) -> bool {
641        tokio::select! {
642            () = cancellation_token.cancelled() => false,
643            () = tokio::time::sleep(duration) => true,
644        }
645    }
646
647    fn send_dex_event_log(
648        tx: &tokio::sync::mpsc::UnboundedSender<BlockchainMessage>,
649        dex_extended: &DexExtended,
650        log: &Log,
651    ) {
652        let event_signature = match log.topics.first().and_then(|t| t.as_ref()) {
653            Some(log_argument) => hex::encode_prefixed(log_argument.as_ref()),
654            None => return,
655        };
656
657        let (event_name, event) = if event_signature == dex_extended.swap_created_event.as_ref() {
658            (
659                "swap",
660                dex_extended
661                    .parse_swap_event_hypersync(log)
662                    .map(BlockchainMessage::SwapEvent),
663            )
664        } else if event_signature == dex_extended.mint_created_event.as_ref() {
665            (
666                "mint",
667                dex_extended
668                    .parse_mint_event_hypersync(log)
669                    .map(BlockchainMessage::MintEvent),
670            )
671        } else if event_signature == dex_extended.burn_created_event.as_ref() {
672            (
673                "burn",
674                dex_extended
675                    .parse_burn_event_hypersync(log)
676                    .map(BlockchainMessage::BurnEvent),
677            )
678        } else if event_signature == dex_extended.collect_created_event.as_ref() {
679            (
680                "collect",
681                dex_extended
682                    .parse_collect_event_hypersync(log)
683                    .map(BlockchainMessage::CollectEvent),
684            )
685        } else if dex_extended.flash_created_event.as_deref() == Some(event_signature.as_str()) {
686            (
687                "flash",
688                dex_extended
689                    .parse_flash_event_hypersync(log)
690                    .map(BlockchainMessage::FlashEvent),
691            )
692        } else if dex_extended.fee_protocol_update_event.as_deref()
693            == Some(event_signature.as_str())
694        {
695            (
696                "fee-protocol update",
697                dex_extended
698                    .parse_fee_protocol_update_event_hypersync(log)
699                    .map(BlockchainMessage::FeeProtocolUpdateEvent),
700            )
701        } else if dex_extended.fee_protocol_collect_event.as_deref()
702            == Some(event_signature.as_str())
703        {
704            (
705                "fee-protocol collect",
706                dex_extended
707                    .parse_fee_protocol_collect_event_hypersync(log)
708                    .map(BlockchainMessage::FeeProtocolCollectEvent),
709            )
710        } else {
711            log::error!("Unknown event signature: {event_signature}");
712            return;
713        };
714
715        match event {
716            Ok(event) => {
717                if let Err(e) = tx.send(event) {
718                    log::error!("Failed to send {event_name} event: {e}");
719                }
720            }
721            Err(e) => {
722                log::error!("Failed to parse {event_name} with error '{e:?}' for event: {log:?}",);
723            }
724        }
725    }
726
727    async fn stop_dex_event_stream(&mut self, dex: DexType) -> Option<u64> {
728        let task = self.dex_event_tasks.get_mut(&dex)?;
729        let terminated = Self::stop_dex_event_task(dex, task).await;
730        let next_from_block = task.next_from_block.load(Ordering::Relaxed);
731
732        if terminated {
733            self.dex_event_tasks.remove(&dex);
734        }
735
736        Some(next_from_block)
737    }
738
739    async fn stop_dex_event_task(dex: DexType, task: &mut DexEventStreamTask) -> bool {
740        task.cancellation_token.cancel();
741
742        let outcome = finish_task(
743            &mut task.task,
744            Duration::from_secs(DISCONNECT_TIMEOUT_SECS),
745            Duration::from_secs(DISCONNECT_TIMEOUT_SECS),
746        )
747        .await;
748
749        match outcome {
750            None | Some(TaskJoinOutcome::Completed(())) | Some(TaskJoinOutcome::Aborted) => true,
751            Some(TaskJoinOutcome::Failed(error)) => {
752                log::error!("HyperSync DEX event stream task for {dex} failed: {error}");
753                true
754            }
755            Some(TaskJoinOutcome::Incomplete) => {
756                log::error!("HyperSync DEX event stream task for {dex} did not stop after abort");
757                false
758            }
759        }
760    }
761}
762
763impl Drop for HyperSyncClient {
764    fn drop(&mut self) {
765        self.cancellation_token.cancel();
766        if let Some(token) = self.blocks_cancellation_token.as_ref() {
767            token.cancel();
768        }
769        self.blocks_task.abort();
770
771        for task in self.dex_event_tasks.values_mut() {
772            task.cancellation_token.cancel();
773            task.task.abort();
774        }
775    }
776}
777
778#[derive(Debug, Clone, PartialEq, Eq)]
779struct DexEventStreamFilter {
780    contract_addresses: Vec<Address>,
781    event_signatures: Vec<String>,
782}
783
784impl DexEventStreamFilter {
785    fn new(mut contract_addresses: Vec<Address>, mut event_signatures: Vec<String>) -> Self {
786        contract_addresses.sort_unstable();
787        contract_addresses.dedup();
788        event_signatures.sort_unstable();
789        event_signatures.dedup();
790        Self {
791            contract_addresses,
792            event_signatures,
793        }
794    }
795
796    fn is_empty(&self) -> bool {
797        self.contract_addresses.is_empty() || self.event_signatures.is_empty()
798    }
799}
800
801#[derive(Debug)]
802struct DexEventStreamTask {
803    filter: DexEventStreamFilter,
804    next_from_block: Arc<AtomicU64>,
805    cancellation_token: tokio_util::sync::CancellationToken,
806    task: TaskSlot<()>,
807}
808
809/// Maps one HyperSync response into stream items, surfacing blocks ahead of the logs from the
810/// same response so callers can cache them before converting events from those blocks.
811///
812/// Blocks that fail to transform are logged and skipped without dropping the response's logs.
813fn pool_events_from_response(
814    chain: Blockchain,
815    blocks: Vec<Vec<hypersync_client::simple_types::Block>>,
816    logs: Vec<Vec<Log>>,
817) -> Vec<PoolEventStreamItem> {
818    let mut items = Vec::new();
819
820    for block in blocks.into_iter().flatten() {
821        match transform_hypersync_block(chain, block) {
822            Ok(block) => items.push(PoolEventStreamItem::Block(block)),
823            Err(e) => log::error!("Failed to transform block for timestamp: {e}"),
824        }
825    }
826
827    items.extend(logs.into_iter().flatten().map(PoolEventStreamItem::Log));
828    items
829}
830
831#[cfg(test)]
832mod tests {
833    use std::{
834        str::FromStr,
835        sync::{
836            Arc,
837            atomic::{AtomicU64, Ordering},
838        },
839    };
840
841    use hypersync_client::{
842        format::{Address as HypersyncAddress, Hash, Quantity},
843        simple_types::{Block as HypersyncBlock, Log},
844    };
845    use nautilus_core::{UnixNanos, datetime::NANOSECONDS_IN_SECOND};
846    use nautilus_model::defi::Chain;
847    use rstest::rstest;
848
849    use super::*;
850
851    fn synthetic_block(number: u64, timestamp_secs: u64) -> HypersyncBlock {
852        HypersyncBlock {
853            number: Some(number),
854            hash: Some(
855                Hash::from_str(
856                    "0x0000000000000000000000000000000000000000000000000000000000000001",
857                )
858                .unwrap(),
859            ),
860            parent_hash: Some(
861                Hash::from_str(
862                    "0x0000000000000000000000000000000000000000000000000000000000000000",
863                )
864                .unwrap(),
865            ),
866            miner: Some(
867                HypersyncAddress::from_str("0x0000000000000000000000000000000000000001").unwrap(),
868            ),
869            gas_limit: Some(Quantity::from(21_000u64)),
870            gas_used: Some(Quantity::from(21_000u64)),
871            timestamp: Some(Quantity::from(timestamp_secs)),
872            ..Default::default()
873        }
874    }
875
876    #[rstest]
877    #[should_panic(expected = "Invalid HyperSync URL")]
878    fn hypersync_rejects_remote_cleartext_before_loading_token() {
879        let mut chain = Chain::new(Blockchain::Arbitrum, 42_161);
880        chain.hypersync_url = "http://localhost:8080".to_string();
881
882        let _ = HyperSyncClient::new(
883            Arc::new(chain),
884            None,
885            tokio_util::sync::CancellationToken::new(),
886        );
887    }
888
889    #[rstest]
890    fn pool_events_yields_blocks_before_logs() {
891        let items = pool_events_from_response(
892            Blockchain::Ethereum,
893            vec![vec![synthetic_block(12, 100)]],
894            vec![vec![Log::default(), Log::default()]],
895        );
896
897        assert_eq!(items.len(), 3);
898        match &items[0] {
899            PoolEventStreamItem::Block(block) => {
900                assert_eq!(block.number, 12);
901                assert_eq!(block.timestamp, UnixNanos::new(100 * NANOSECONDS_IN_SECOND));
902            }
903            other => panic!("expected Block first, was {other:?}"),
904        }
905        assert!(matches!(items[1], PoolEventStreamItem::Log(_)));
906        assert!(matches!(items[2], PoolEventStreamItem::Log(_)));
907    }
908
909    #[rstest]
910    fn pool_events_skips_unparsable_block_but_keeps_logs() {
911        // A block missing required fields (gas, hash, ...) fails transform and is skipped, but
912        // the response's logs must still be yielded.
913        let bad_block = HypersyncBlock {
914            number: Some(7),
915            ..Default::default()
916        };
917        let items = pool_events_from_response(
918            Blockchain::Ethereum,
919            vec![vec![bad_block]],
920            vec![vec![Log::default()]],
921        );
922
923        assert_eq!(items.len(), 1);
924        assert!(matches!(items[0], PoolEventStreamItem::Log(_)));
925    }
926
927    #[rstest]
928    fn construct_block_query_converts_to_block_to_hypersync_exclusive_bound() {
929        let query = HyperSyncClient::construct_block_query(10, Some(12));
930
931        assert_eq!(query.from_block, 10);
932        assert_eq!(query.to_block, Some(13));
933    }
934
935    #[rstest]
936    fn construct_contract_events_query_converts_to_block_to_hypersync_exclusive_bound() {
937        let address = Address::from_str("0x0000000000000000000000000000000000000001").unwrap();
938        let query = HyperSyncClient::construct_contract_events_query(
939            10,
940            Some(12),
941            &[address],
942            &["0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67"],
943        );
944
945        assert_eq!(query.from_block, 10);
946        assert_eq!(query.to_block, Some(13));
947    }
948
949    #[rstest]
950    fn construct_contract_events_query_single_block_uses_next_block_as_exclusive_bound() {
951        let address = Address::from_str("0x0000000000000000000000000000000000000001").unwrap();
952        let query = HyperSyncClient::construct_contract_events_query(
953            10,
954            Some(10),
955            &[address],
956            &["0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67"],
957        );
958
959        assert_eq!(query.from_block, 10);
960        assert_eq!(query.to_block, Some(11));
961    }
962
963    #[rstest]
964    fn construct_contract_events_query_open_upper_bound_stays_open() {
965        let address = Address::from_str("0x0000000000000000000000000000000000000001").unwrap();
966        let query = HyperSyncClient::construct_contract_events_query(
967            10,
968            None,
969            &[address],
970            &["0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67"],
971        );
972
973        assert_eq!(query.from_block, 10);
974        assert_eq!(query.to_block, None);
975    }
976
977    #[rstest]
978    fn dex_event_stream_filter_sorts_and_deduplicates_inputs() {
979        let address1 = Address::from_str("0x0000000000000000000000000000000000000001").unwrap();
980        let address2 = Address::from_str("0x0000000000000000000000000000000000000002").unwrap();
981        let filter = DexEventStreamFilter::new(
982            vec![address2, address1, address2],
983            vec![
984                "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
985                "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
986                "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
987            ],
988        );
989
990        assert_eq!(filter.contract_addresses, vec![address1, address2]);
991        assert_eq!(
992            filter.event_signatures,
993            vec![
994                "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
995                "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
996            ]
997        );
998    }
999
1000    #[rstest]
1001    fn dex_event_stream_filter_requires_addresses_and_signatures() {
1002        let address = Address::from_str("0x0000000000000000000000000000000000000001").unwrap();
1003
1004        assert!(DexEventStreamFilter::new(vec![], vec!["0x01".to_string()]).is_empty());
1005        assert!(DexEventStreamFilter::new(vec![address], vec![]).is_empty());
1006        assert!(!DexEventStreamFilter::new(vec![address], vec!["0x01".to_string()]).is_empty());
1007    }
1008
1009    #[tokio::test]
1010    async fn stop_dex_event_task_cancels_and_awaits_task() {
1011        let cancellation_token = tokio_util::sync::CancellationToken::new();
1012        let task_token = cancellation_token.clone();
1013        let next_from_block = Arc::new(AtomicU64::new(42));
1014        let task_next_from_block = next_from_block.clone();
1015
1016        let task = tokio::spawn(async move {
1017            task_token.cancelled().await;
1018            task_next_from_block.store(99, Ordering::Relaxed);
1019        });
1020        let stream_task = DexEventStreamTask {
1021            filter: DexEventStreamFilter::new(vec![], vec![]),
1022            next_from_block,
1023            cancellation_token,
1024            task: TaskSlot::from_handle(task),
1025        };
1026        let mut stream_task = stream_task;
1027
1028        let terminated =
1029            HyperSyncClient::stop_dex_event_task(DexType::UniswapV3, &mut stream_task).await;
1030
1031        assert!(terminated);
1032        assert_eq!(stream_task.next_from_block.load(Ordering::Relaxed), 99);
1033        assert!(stream_task.task.is_none());
1034    }
1035}