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