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 = Arc::clone(&self.client);
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 = Arc::clone(&next_from_block);
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            .stream(query, StreamConfig::default())
251            .await
252            .expect("Failed to create stream");
253
254        async_stream::stream! {
255              while let Some(response) = rx.recv().await {
256                let response = response.unwrap();
257                for item in pool_events_from_response(chain, response.data.blocks, response.data.logs) {
258                    yield item;
259                }
260            }
261        }
262    }
263
264    /// Disconnects from the HyperSync service and stops all background tasks.
265    pub async fn disconnect(&mut self) {
266        log::debug!("Disconnecting HyperSync client");
267        self.cancellation_token.cancel();
268
269        if let Some(outcome) = finish_task(
270            &mut self.blocks_task,
271            Duration::from_secs(DISCONNECT_TIMEOUT_SECS),
272            Duration::from_secs(DISCONNECT_TIMEOUT_SECS),
273        )
274        .await
275        {
276            match outcome {
277                TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted => {}
278                TaskJoinOutcome::Failed(error) => {
279                    log::error!("HyperSync blocks task failed: {error}");
280                }
281                TaskJoinOutcome::Incomplete => {
282                    log::error!("HyperSync blocks task did not stop after abort");
283                }
284            }
285        }
286
287        let dexes = self.dex_event_tasks.keys().copied().collect::<Vec<_>>();
288        for dex in dexes {
289            self.stop_dex_event_stream(dex).await;
290        }
291
292        log::debug!("HyperSync client disconnected");
293    }
294
295    /// Returns the current block
296    ///
297    /// # Panics
298    ///
299    /// Panics if the client height request fails.
300    pub async fn current_block(&self) -> u64 {
301        self.client.get_height().await.unwrap()
302    }
303
304    /// Creates a stream that yields blockchain blocks within the specified range.
305    ///
306    /// # Panics
307    ///
308    /// Panics if the stream creation or block transformation fails.
309    pub async fn request_blocks_stream(
310        &self,
311        from_block: u64,
312        to_block: Option<u64>,
313    ) -> impl Stream<Item = Block> {
314        let query = Self::construct_block_query(from_block, to_block);
315        let mut rx = self
316            .client
317            .stream(query, StreamConfig::default())
318            .await
319            .unwrap();
320
321        let chain = self.chain.name;
322
323        async_stream::stream! {
324            while let Some(response) = rx.recv().await {
325                let response = response.unwrap();
326                for received_block in response.data.blocks.into_iter().flatten() {
327                    let block = transform_hypersync_block(chain, received_block).unwrap();
328                    yield block
329                }
330            }
331        }
332    }
333
334    /// Starts a background task that continuously polls for new blockchain blocks.
335    ///
336    /// # Panics
337    ///
338    /// Panics if client height requests or block transformations fail.
339    pub fn subscribe_blocks(&mut self) {
340        if self.blocks_task.is_some() {
341            return;
342        }
343
344        let chain = self.chain.name;
345        let client = Arc::clone(&self.client);
346        let tx = if let Some(tx) = &self.tx {
347            tx.clone()
348        } else {
349            log::error!("Hypersync client channel should have been initialized");
350            return;
351        };
352
353        // Create a child token that can be cancelled independently
354        let blocks_token = self.cancellation_token.child_token();
355        let cancellation_token = blocks_token.clone();
356        self.blocks_cancellation_token = Some(blocks_token);
357
358        if let Err(e) = self.blocks_task.spawn(async move {
359            log::debug!("Starting task 'blocks_feed");
360
361            let current_block_height = client.get_height().await.unwrap();
362            let mut query = Self::construct_block_query(current_block_height, None);
363
364            loop {
365                tokio::select! {
366                    () = cancellation_token.cancelled() => {
367                        log::debug!("Blocks subscription task received cancellation signal");
368                        break;
369                    }
370                    result = tokio::time::timeout(
371                        Duration::from_secs(HYPERSYNC_REQUEST_TIMEOUT_SECS),
372                        client.get(&query)
373                    ) => {
374                        let response = match result {
375                            Ok(Ok(resp)) => resp,
376                            Ok(Err(e)) => {
377                                log::error!("Hypersync request failed: {e}");
378                                break;
379                            }
380                            Err(_) => {
381                                log::warn!("Hypersync request timed out after {HYPERSYNC_REQUEST_TIMEOUT_SECS}s, retrying...");
382                                continue;
383                            }
384                        };
385
386                        for received_block in response.data.blocks.into_iter().flatten() {
387                            let block = transform_hypersync_block(chain, received_block).unwrap();
388                            let msg = BlockchainMessage::Block(block);
389                            if let Err(e) = tx.send(msg) {
390                                log::error!("Error sending message: {e}");
391                            }
392                        }
393
394                        if let Some(archive_block_height) = response.archive_height
395                            && archive_block_height < response.next_block
396                        {
397                            while client.get_height().await.unwrap() < response.next_block {
398                                tokio::select! {
399                                    () = cancellation_token.cancelled() => {
400                                        log::debug!("Blocks subscription task received cancellation signal during polling");
401                                        return;
402                                    }
403                                    () = tokio::time::sleep(Duration::from_millis(
404                                        BLOCK_POLLING_INTERVAL_MS,
405                                    )) => {}
406                                }
407                            }
408                        }
409
410                        query.from_block = response.next_block;
411                    }
412                }
413            }
414        }) {
415            if let Some(token) = self.blocks_cancellation_token.take() {
416                token.cancel();
417            }
418            log::error!("Failed to start HyperSync blocks subscription task: {e}");
419        }
420    }
421
422    /// Unsubscribes from new blocks by stopping the background watch task.
423    pub async fn unsubscribe_blocks(&mut self) {
424        if self.blocks_task.is_none() {
425            return;
426        }
427
428        // Cancel only the blocks child token, not the main cancellation token
429        if let Some(token) = self.blocks_cancellation_token.take() {
430            token.cancel();
431        }
432
433        if let Some(outcome) = finish_task(
434            &mut self.blocks_task,
435            Duration::from_secs(DISCONNECT_TIMEOUT_SECS),
436            Duration::from_secs(DISCONNECT_TIMEOUT_SECS),
437        )
438        .await
439        {
440            match outcome {
441                TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted => {}
442                TaskJoinOutcome::Failed(error) => {
443                    log::error!("HyperSync blocks task failed during unsubscribe: {error}");
444                }
445                TaskJoinOutcome::Incomplete => {
446                    log::error!("HyperSync blocks task did not stop after abort");
447                }
448            }
449        }
450        log::debug!("Unsubscribed from blocks");
451    }
452
453    /// Constructs a HyperSync query for fetching blocks with all available fields within the specified range.
454    fn construct_block_query(from_block: u64, to_block: Option<u64>) -> Query {
455        Query {
456            from_block,
457            to_block: Self::to_hypersync_exclusive_bound(to_block),
458            blocks: vec![BlockSelection::default()],
459            field_selection: FieldSelection {
460                block: BlockField::all(),
461                ..Default::default()
462            },
463            ..Default::default()
464        }
465    }
466
467    fn construct_contract_events_query(
468        from_block: u64,
469        to_block: Option<u64>,
470        contract_addresses: &[Address],
471        topics: &[&str],
472    ) -> Query {
473        let mut query_value = serde_json::json!({
474            "from_block": from_block,
475            "logs": [{
476                "topics": [topics],
477                "address": contract_addresses
478            }],
479            "field_selection": {
480                "log": [
481                    "block_number",
482                    "transaction_hash",
483                    "transaction_index",
484                    "log_index",
485                    "address",
486                    "data",
487                    "topic0",
488                    "topic1",
489                    "topic2",
490                    "topic3",
491                ],
492                // Join block fields so callers can resolve each event's ts_event
493                "block": [
494                    "number",
495                    "hash",
496                    "parent_hash",
497                    "miner",
498                    "gas_limit",
499                    "gas_used",
500                    "timestamp",
501                ]
502            }
503        });
504
505        if let Some(to_block) = Self::to_hypersync_exclusive_bound(to_block)
506            && let Some(obj) = query_value.as_object_mut()
507        {
508            obj.insert("to_block".to_string(), serde_json::json!(to_block));
509        }
510
511        serde_json::from_value(query_value).unwrap()
512    }
513
514    fn to_hypersync_exclusive_bound(to_block: Option<u64>) -> Option<u64> {
515        to_block.map(|block| block.saturating_add(1))
516    }
517
518    async fn run_dex_event_stream(
519        dex: DexType,
520        client: Arc<hypersync_client::Client>,
521        tx: tokio::sync::mpsc::UnboundedSender<BlockchainMessage>,
522        filter: DexEventStreamFilter,
523        dex_extended: &'static DexExtended,
524        next_from_block: Arc<AtomicU64>,
525        cancellation_token: tokio_util::sync::CancellationToken,
526    ) {
527        log::debug!("Starting task 'dex_event_stream' for {dex}");
528
529        loop {
530            let from_block = next_from_block.load(Ordering::Relaxed);
531            Self::wait_for_stream_start_block(&client, from_block, &cancellation_token).await;
532            if cancellation_token.is_cancelled() {
533                break;
534            }
535
536            let topics = filter
537                .event_signatures
538                .iter()
539                .map(String::as_str)
540                .collect::<Vec<_>>();
541            let query = Self::construct_contract_events_query(
542                from_block,
543                None,
544                &filter.contract_addresses,
545                &topics,
546            );
547            let mut rx = match client.stream(query, StreamConfig::default()).await {
548                Ok(rx) => rx,
549                Err(e) => {
550                    log::warn!("Failed to create DEX event stream for {dex}: {e}");
551
552                    if !Self::sleep_or_cancel(
553                        Duration::from_millis(DEX_EVENT_STREAM_RETRY_DELAY_MS),
554                        &cancellation_token,
555                    )
556                    .await
557                    {
558                        break;
559                    }
560                    continue;
561                }
562            };
563
564            let mut received_response = false;
565
566            loop {
567                tokio::select! {
568                    () = cancellation_token.cancelled() => {
569                        log::debug!("DEX event stream task for {dex} received cancellation signal");
570                        break;
571                    }
572                    response = rx.recv() => {
573                        let Some(response) = response else {
574                            break;
575                        };
576
577                        let response = match response {
578                            Ok(resp) => resp,
579                            Err(e) => {
580                                if received_response {
581                                    log::debug!("DEX event stream drained for {dex}: {e}");
582                                } else {
583                                    log::warn!("Failed to receive DEX event stream response for {dex}: {e}");
584                                }
585                                break;
586                            }
587                        };
588
589                        received_response = true;
590                        next_from_block.fetch_max(response.next_block, Ordering::Relaxed);
591
592                        for log in response.data.logs.into_iter().flatten() {
593                            Self::send_dex_event_log(&tx, dex_extended, &log);
594                        }
595                    }
596                }
597            }
598
599            if !Self::sleep_or_cancel(
600                Duration::from_millis(DEX_EVENT_STREAM_RETRY_DELAY_MS),
601                &cancellation_token,
602            )
603            .await
604            {
605                break;
606            }
607        }
608
609        log::debug!("Stopped task 'dex_event_stream' for {dex}");
610    }
611
612    async fn wait_for_stream_start_block(
613        client: &hypersync_client::Client,
614        from_block: u64,
615        cancellation_token: &tokio_util::sync::CancellationToken,
616    ) {
617        loop {
618            match client.get_height().await {
619                Ok(height) if height >= from_block => return,
620                Ok(_) => {}
621                Err(e) => log::warn!("Failed to get HyperSync height for DEX event stream: {e}"),
622            }
623
624            if !Self::sleep_or_cancel(
625                Duration::from_millis(BLOCK_POLLING_INTERVAL_MS),
626                cancellation_token,
627            )
628            .await
629            {
630                return;
631            }
632        }
633    }
634
635    async fn sleep_or_cancel(
636        duration: Duration,
637        cancellation_token: &tokio_util::sync::CancellationToken,
638    ) -> bool {
639        tokio::select! {
640            () = cancellation_token.cancelled() => false,
641            () = tokio::time::sleep(duration) => true,
642        }
643    }
644
645    fn send_dex_event_log(
646        tx: &tokio::sync::mpsc::UnboundedSender<BlockchainMessage>,
647        dex_extended: &DexExtended,
648        log: &Log,
649    ) {
650        let event_signature = match log.topics.first().and_then(|t| t.as_ref()) {
651            Some(log_argument) => hex::encode_prefixed(log_argument.as_ref()),
652            None => return,
653        };
654
655        let (event_name, event) = if event_signature == dex_extended.swap_created_event.as_ref() {
656            (
657                "swap",
658                dex_extended
659                    .parse_swap_event_hypersync(log)
660                    .map(BlockchainMessage::SwapEvent),
661            )
662        } else if event_signature == dex_extended.mint_created_event.as_ref() {
663            (
664                "mint",
665                dex_extended
666                    .parse_mint_event_hypersync(log)
667                    .map(BlockchainMessage::MintEvent),
668            )
669        } else if event_signature == dex_extended.burn_created_event.as_ref() {
670            (
671                "burn",
672                dex_extended
673                    .parse_burn_event_hypersync(log)
674                    .map(BlockchainMessage::BurnEvent),
675            )
676        } else if event_signature == dex_extended.collect_created_event.as_ref() {
677            (
678                "collect",
679                dex_extended
680                    .parse_collect_event_hypersync(log)
681                    .map(BlockchainMessage::CollectEvent),
682            )
683        } else if dex_extended.flash_created_event.as_deref() == Some(event_signature.as_str()) {
684            (
685                "flash",
686                dex_extended
687                    .parse_flash_event_hypersync(log)
688                    .map(BlockchainMessage::FlashEvent),
689            )
690        } else if dex_extended.fee_protocol_update_event.as_deref()
691            == Some(event_signature.as_str())
692        {
693            (
694                "fee-protocol update",
695                dex_extended
696                    .parse_fee_protocol_update_event_hypersync(log)
697                    .map(BlockchainMessage::FeeProtocolUpdateEvent),
698            )
699        } else if dex_extended.fee_protocol_collect_event.as_deref()
700            == Some(event_signature.as_str())
701        {
702            (
703                "fee-protocol collect",
704                dex_extended
705                    .parse_fee_protocol_collect_event_hypersync(log)
706                    .map(BlockchainMessage::FeeProtocolCollectEvent),
707            )
708        } else {
709            log::error!("Unknown event signature: {event_signature}");
710            return;
711        };
712
713        match event {
714            Ok(event) => {
715                if let Err(e) = tx.send(event) {
716                    log::error!("Failed to send {event_name} event: {e}");
717                }
718            }
719            Err(e) => {
720                log::error!("Failed to parse {event_name} with error '{e:?}' for event: {log:?}",);
721            }
722        }
723    }
724
725    async fn stop_dex_event_stream(&mut self, dex: DexType) -> Option<u64> {
726        let task = self.dex_event_tasks.get_mut(&dex)?;
727        let terminated = Self::stop_dex_event_task(dex, task).await;
728        let next_from_block = task.next_from_block.load(Ordering::Relaxed);
729
730        if terminated {
731            self.dex_event_tasks.remove(&dex);
732        }
733
734        Some(next_from_block)
735    }
736
737    async fn stop_dex_event_task(dex: DexType, task: &mut DexEventStreamTask) -> bool {
738        task.cancellation_token.cancel();
739
740        let outcome = finish_task(
741            &mut task.task,
742            Duration::from_secs(DISCONNECT_TIMEOUT_SECS),
743            Duration::from_secs(DISCONNECT_TIMEOUT_SECS),
744        )
745        .await;
746
747        match outcome {
748            None | Some(TaskJoinOutcome::Completed(())) | Some(TaskJoinOutcome::Aborted) => true,
749            Some(TaskJoinOutcome::Failed(error)) => {
750                log::error!("HyperSync DEX event stream task for {dex} failed: {error}");
751                true
752            }
753            Some(TaskJoinOutcome::Incomplete) => {
754                log::error!("HyperSync DEX event stream task for {dex} did not stop after abort");
755                false
756            }
757        }
758    }
759}
760
761impl Drop for HyperSyncClient {
762    fn drop(&mut self) {
763        self.cancellation_token.cancel();
764        if let Some(token) = self.blocks_cancellation_token.as_ref() {
765            token.cancel();
766        }
767        self.blocks_task.abort();
768
769        for task in self.dex_event_tasks.values_mut() {
770            task.cancellation_token.cancel();
771            task.task.abort();
772        }
773    }
774}
775
776#[derive(Debug, Clone, PartialEq, Eq)]
777struct DexEventStreamFilter {
778    contract_addresses: Vec<Address>,
779    event_signatures: Vec<String>,
780}
781
782impl DexEventStreamFilter {
783    fn new(mut contract_addresses: Vec<Address>, mut event_signatures: Vec<String>) -> Self {
784        contract_addresses.sort_unstable();
785        contract_addresses.dedup();
786        event_signatures.sort_unstable();
787        event_signatures.dedup();
788        Self {
789            contract_addresses,
790            event_signatures,
791        }
792    }
793
794    fn is_empty(&self) -> bool {
795        self.contract_addresses.is_empty() || self.event_signatures.is_empty()
796    }
797}
798
799#[derive(Debug)]
800struct DexEventStreamTask {
801    filter: DexEventStreamFilter,
802    next_from_block: Arc<AtomicU64>,
803    cancellation_token: tokio_util::sync::CancellationToken,
804    task: TaskSlot<()>,
805}
806
807/// Maps one HyperSync response into stream items, surfacing blocks ahead of the logs from the
808/// same response so callers can cache them before converting events from those blocks.
809///
810/// Blocks that fail to transform are logged and skipped without dropping the response's logs.
811fn pool_events_from_response(
812    chain: Blockchain,
813    blocks: Vec<Vec<hypersync_client::simple_types::Block>>,
814    logs: Vec<Vec<Log>>,
815) -> Vec<PoolEventStreamItem> {
816    let mut items = Vec::new();
817
818    for block in blocks.into_iter().flatten() {
819        match transform_hypersync_block(chain, block) {
820            Ok(block) => items.push(PoolEventStreamItem::Block(block)),
821            Err(e) => log::error!("Failed to transform block for timestamp: {e}"),
822        }
823    }
824
825    items.extend(logs.into_iter().flatten().map(PoolEventStreamItem::Log));
826    items
827}
828
829#[cfg(test)]
830mod tests {
831    use std::{
832        str::FromStr,
833        sync::{
834            Arc,
835            atomic::{AtomicU64, Ordering},
836        },
837    };
838
839    use hypersync_client::{
840        format::{Address as HypersyncAddress, Hash, Quantity},
841        simple_types::{Block as HypersyncBlock, Log},
842    };
843    use nautilus_core::{UnixNanos, datetime::NANOSECONDS_IN_SECOND};
844    use nautilus_model::defi::Chain;
845    use rstest::rstest;
846
847    use super::*;
848
849    fn synthetic_block(number: u64, timestamp_secs: u64) -> HypersyncBlock {
850        HypersyncBlock {
851            number: Some(number),
852            hash: Some(
853                Hash::from_str(
854                    "0x0000000000000000000000000000000000000000000000000000000000000001",
855                )
856                .unwrap(),
857            ),
858            parent_hash: Some(
859                Hash::from_str(
860                    "0x0000000000000000000000000000000000000000000000000000000000000000",
861                )
862                .unwrap(),
863            ),
864            miner: Some(
865                HypersyncAddress::from_str("0x0000000000000000000000000000000000000001").unwrap(),
866            ),
867            gas_limit: Some(Quantity::from(21_000u64)),
868            gas_used: Some(Quantity::from(21_000u64)),
869            timestamp: Some(Quantity::from(timestamp_secs)),
870            ..Default::default()
871        }
872    }
873
874    #[rstest]
875    #[should_panic(expected = "Invalid HyperSync URL")]
876    fn hypersync_rejects_remote_cleartext_before_loading_token() {
877        let mut chain = Chain::new(Blockchain::Arbitrum, 42_161);
878        chain.hypersync_url = "http://localhost:8080".to_string();
879
880        let _ = HyperSyncClient::new(
881            Arc::new(chain),
882            None,
883            tokio_util::sync::CancellationToken::new(),
884        );
885    }
886
887    #[rstest]
888    fn pool_events_yields_blocks_before_logs() {
889        let items = pool_events_from_response(
890            Blockchain::Ethereum,
891            vec![vec![synthetic_block(12, 100)]],
892            vec![vec![Log::default(), Log::default()]],
893        );
894
895        assert_eq!(items.len(), 3);
896        match &items[0] {
897            PoolEventStreamItem::Block(block) => {
898                assert_eq!(block.number, 12);
899                assert_eq!(block.timestamp, UnixNanos::new(100 * NANOSECONDS_IN_SECOND));
900            }
901            other => panic!("expected Block first, was {other:?}"),
902        }
903        assert!(matches!(items[1], PoolEventStreamItem::Log(_)));
904        assert!(matches!(items[2], PoolEventStreamItem::Log(_)));
905    }
906
907    #[rstest]
908    fn pool_events_skips_unparsable_block_but_keeps_logs() {
909        // A block missing required fields (gas, hash, ...) fails transform and is skipped, but
910        // the response's logs must still be yielded.
911        let bad_block = HypersyncBlock {
912            number: Some(7),
913            ..Default::default()
914        };
915        let items = pool_events_from_response(
916            Blockchain::Ethereum,
917            vec![vec![bad_block]],
918            vec![vec![Log::default()]],
919        );
920
921        assert_eq!(items.len(), 1);
922        assert!(matches!(items[0], PoolEventStreamItem::Log(_)));
923    }
924
925    #[rstest]
926    fn construct_block_query_converts_to_block_to_hypersync_exclusive_bound() {
927        let query = HyperSyncClient::construct_block_query(10, Some(12));
928
929        assert_eq!(query.from_block, 10);
930        assert_eq!(query.to_block, Some(13));
931    }
932
933    #[rstest]
934    fn construct_contract_events_query_converts_to_block_to_hypersync_exclusive_bound() {
935        let address = Address::from_str("0x0000000000000000000000000000000000000001").unwrap();
936        let query = HyperSyncClient::construct_contract_events_query(
937            10,
938            Some(12),
939            &[address],
940            &["0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67"],
941        );
942
943        assert_eq!(query.from_block, 10);
944        assert_eq!(query.to_block, Some(13));
945    }
946
947    #[rstest]
948    fn construct_contract_events_query_single_block_uses_next_block_as_exclusive_bound() {
949        let address = Address::from_str("0x0000000000000000000000000000000000000001").unwrap();
950        let query = HyperSyncClient::construct_contract_events_query(
951            10,
952            Some(10),
953            &[address],
954            &["0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67"],
955        );
956
957        assert_eq!(query.from_block, 10);
958        assert_eq!(query.to_block, Some(11));
959    }
960
961    #[rstest]
962    fn construct_contract_events_query_open_upper_bound_stays_open() {
963        let address = Address::from_str("0x0000000000000000000000000000000000000001").unwrap();
964        let query = HyperSyncClient::construct_contract_events_query(
965            10,
966            None,
967            &[address],
968            &["0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67"],
969        );
970
971        assert_eq!(query.from_block, 10);
972        assert_eq!(query.to_block, None);
973    }
974
975    #[rstest]
976    fn dex_event_stream_filter_sorts_and_deduplicates_inputs() {
977        let address1 = Address::from_str("0x0000000000000000000000000000000000000001").unwrap();
978        let address2 = Address::from_str("0x0000000000000000000000000000000000000002").unwrap();
979        let filter = DexEventStreamFilter::new(
980            vec![address2, address1, address2],
981            vec![
982                "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
983                "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
984                "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
985            ],
986        );
987
988        assert_eq!(filter.contract_addresses, vec![address1, address2]);
989        assert_eq!(
990            filter.event_signatures,
991            vec![
992                "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
993                "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
994            ]
995        );
996    }
997
998    #[rstest]
999    fn dex_event_stream_filter_requires_addresses_and_signatures() {
1000        let address = Address::from_str("0x0000000000000000000000000000000000000001").unwrap();
1001
1002        assert!(DexEventStreamFilter::new(vec![], vec!["0x01".to_string()]).is_empty());
1003        assert!(DexEventStreamFilter::new(vec![address], vec![]).is_empty());
1004        assert!(!DexEventStreamFilter::new(vec![address], vec!["0x01".to_string()]).is_empty());
1005    }
1006
1007    #[tokio::test]
1008    async fn stop_dex_event_task_cancels_and_awaits_task() {
1009        let cancellation_token = tokio_util::sync::CancellationToken::new();
1010        let task_token = cancellation_token.clone();
1011        let next_from_block = Arc::new(AtomicU64::new(42));
1012        let task_next_from_block = Arc::clone(&next_from_block);
1013
1014        let task = tokio::spawn(async move {
1015            task_token.cancelled().await;
1016            task_next_from_block.store(99, Ordering::Relaxed);
1017        });
1018        let stream_task = DexEventStreamTask {
1019            filter: DexEventStreamFilter::new(vec![], vec![]),
1020            next_from_block,
1021            cancellation_token,
1022            task: TaskSlot::from_handle(task),
1023        };
1024        let mut stream_task = stream_task;
1025
1026        let terminated =
1027            HyperSyncClient::stop_dex_event_task(DexType::UniswapV3, &mut stream_task).await;
1028
1029        assert!(terminated);
1030        assert_eq!(stream_task.next_from_block.load(Ordering::Relaxed), 99);
1031        assert!(stream_task.task.is_none());
1032    }
1033}