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::sync::Arc;
17
18use ahash::AHashMap;
19use alloy::primitives::Address;
20use futures_util::Stream;
21use hypersync_client::{
22    StreamConfig,
23    net_types::{BlockField, BlockSelection, FieldSelection, Query},
24    simple_types::Log,
25};
26use nautilus_common::live::get_runtime;
27use nautilus_core::hex;
28use nautilus_model::{
29    defi::{Block, Blockchain, DexType, SharedChain},
30    identifiers::InstrumentId,
31};
32use nautilus_network::http::Url;
33
34use crate::{
35    exchanges::get_dex_extended, hypersync::transform::transform_hypersync_block,
36    rpc::types::BlockchainMessage,
37};
38
39/// An item yielded by the contract-events stream.
40///
41/// Blocks are surfaced ahead of the logs from the same response so callers can populate their
42/// block-timestamp cache before converting events from those blocks.
43#[derive(Debug)]
44pub enum PoolEventStreamItem {
45    /// A block referenced by subsequent logs.
46    Block(Block),
47    /// A contract event log.
48    Log(Log),
49}
50
51/// Maps one HyperSync response into stream items, surfacing blocks ahead of the logs from the
52/// same response so callers can cache them before converting events from those blocks.
53///
54/// Blocks that fail to transform are logged and skipped without dropping the response's logs.
55fn pool_events_from_response(
56    chain: Blockchain,
57    blocks: Vec<Vec<hypersync_client::simple_types::Block>>,
58    logs: Vec<Vec<Log>>,
59) -> Vec<PoolEventStreamItem> {
60    let mut items = Vec::new();
61
62    for batch in blocks {
63        for block in batch {
64            match transform_hypersync_block(chain, block) {
65                Ok(block) => items.push(PoolEventStreamItem::Block(block)),
66                Err(e) => log::error!("Failed to transform block for timestamp: {e}"),
67            }
68        }
69    }
70
71    for batch in logs {
72        for log in batch {
73            items.push(PoolEventStreamItem::Log(log));
74        }
75    }
76
77    items
78}
79
80/// The interval in milliseconds at which to check for new blocks when waiting
81/// for the hypersync to index the block.
82const BLOCK_POLLING_INTERVAL_MS: u64 = 50;
83
84/// Timeout in seconds for HyperSync HTTP requests.
85const HYPERSYNC_REQUEST_TIMEOUT_SECS: u64 = 30;
86
87/// Timeout in seconds for graceful task shutdown during disconnect.
88/// If the task doesn't finish within this time, it will be forcefully aborted.
89const DISCONNECT_TIMEOUT_SECS: u64 = 5;
90
91/// A client for interacting with a HyperSync API to retrieve blockchain data.
92#[derive(Debug)]
93pub struct HyperSyncClient {
94    /// The target blockchain identifier (e.g. Ethereum, Arbitrum).
95    chain: SharedChain,
96    /// The underlying HyperSync Rust client for making API requests.
97    client: Arc<hypersync_client::Client>,
98    /// Background task handle for the block subscription task.
99    blocks_task: Option<tokio::task::JoinHandle<()>>,
100    /// Cancellation token for the blocks subscription task.
101    blocks_cancellation_token: Option<tokio_util::sync::CancellationToken>,
102    /// Channel for sending blockchain messages to the adapter data client.
103    tx: Option<tokio::sync::mpsc::UnboundedSender<BlockchainMessage>>,
104    /// Index of pool addressed keyed by instrument ID.
105    pool_addresses: AHashMap<InstrumentId, Address>,
106    /// Cancellation token for graceful shutdown of background tasks.
107    cancellation_token: tokio_util::sync::CancellationToken,
108}
109
110impl HyperSyncClient {
111    /// Creates a new [`HyperSyncClient`] instance for the given chain and message sender.
112    ///
113    /// # Panics
114    ///
115    /// Panics if:
116    /// - The chain's `hypersync_url` is invalid.
117    /// - The `ENVIO_API_TOKEN` environment variable is not set or invalid.
118    /// - The underlying client cannot be initialized.
119    #[must_use]
120    pub fn new(
121        chain: SharedChain,
122        tx: Option<tokio::sync::mpsc::UnboundedSender<BlockchainMessage>>,
123        cancellation_token: tokio_util::sync::CancellationToken,
124    ) -> Self {
125        let mut config = hypersync_client::ClientConfig::default();
126        let hypersync_url =
127            Url::parse(chain.hypersync_url.as_str()).expect("Invalid HyperSync URL");
128        config.url = hypersync_url.to_string();
129        config.api_token = std::env::var("ENVIO_API_TOKEN")
130            .expect("ENVIO_API_TOKEN environment variable must be set");
131
132        let client = hypersync_client::Client::new(config)
133            .expect("Failed to create HyperSync client - check ENVIO_API_TOKEN is a valid UUID");
134
135        Self {
136            chain,
137            client: Arc::new(client),
138            blocks_task: None,
139            blocks_cancellation_token: None,
140            tx,
141            pool_addresses: AHashMap::new(),
142            cancellation_token,
143        }
144    }
145
146    #[must_use]
147    pub fn get_pool_address(&self, instrument_id: InstrumentId) -> Option<&Address> {
148        self.pool_addresses.get(&instrument_id)
149    }
150
151    /// Processes DEX contract events for a specific block.
152    ///
153    /// Spawns a short-lived task that streams the block's swap, mint, and burn events to the data
154    /// client. The query is bounded just past the requested block, so once its events are delivered
155    /// the stream reaches the end of its range (over-reaching the chain tip) and ends. An
156    /// end-of-stream error that arrives after a response is a clean drain (logged at debug); one
157    /// that arrives before any response means the events could not be fetched (logged at error).
158    ///
159    /// # Panics
160    ///
161    /// Panics if the DEX extended configuration cannot be retrieved or if stream creation fails.
162    pub fn process_block_dex_contract_events(
163        &mut self,
164        dex: &DexType,
165        block: u64,
166        contract_addresses: &[Address],
167        swap_event_encoded_signature: String,
168        mint_event_encoded_signature: String,
169        burn_event_encoded_signature: String,
170    ) {
171        let topics = vec![
172            swap_event_encoded_signature.as_str(),
173            &mint_event_encoded_signature.as_str(),
174            &burn_event_encoded_signature.as_str(),
175        ];
176
177        let query = Self::construct_contract_events_query(
178            block,
179            Some(block + 1),
180            contract_addresses,
181            &topics,
182        );
183
184        let tx = if let Some(tx) = &self.tx {
185            tx.clone()
186        } else {
187            log::error!("Hypersync client channel should have been initialized");
188            return;
189        };
190
191        let client = self.client.clone();
192        let dex_extended =
193            get_dex_extended(self.chain.name, dex).expect("Failed to get dex extended");
194        let cancellation_token = self.cancellation_token.clone();
195
196        let _task = get_runtime().spawn(async move {
197            let mut rx = match client.stream(query, StreamConfig::default()).await {
198                Ok(rx) => rx,
199                Err(e) => {
200                    log::error!("Failed to create DEX event stream: {e}");
201                    return;
202                }
203            };
204
205            let mut received_response = false;
206
207            loop {
208                tokio::select! {
209                    () = cancellation_token.cancelled() => {
210                        log::debug!("DEX event processing task received cancellation signal");
211                        break;
212                    }
213                    response = rx.recv() => {
214                        let Some(response) = response else {
215                            break;
216                        };
217
218                        let response = match response {
219                            Ok(resp) => resp,
220                            Err(e) => {
221                                if received_response {
222                                    log::debug!("DEX event stream drained for block {block}: {e}");
223                                } else {
224                                    log::error!("Failed to receive DEX event stream response: {e}");
225                                }
226                                break;
227                            }
228                        };
229
230                        received_response = true;
231
232                        for batch in response.data.logs {
233                            for log in batch {
234                                let event_signature = match log.topics.first().and_then(|t| t.as_ref()) {
235                                    Some(log_argument) => {
236                                        hex::encode_prefixed(log_argument.as_ref())
237                                    }
238                                    None => continue,
239                                };
240
241                                if event_signature == swap_event_encoded_signature {
242                                    match dex_extended.parse_swap_event_hypersync(&log) {
243                                        Ok(swap_event) => {
244                                            if let Err(e) =
245                                                tx.send(BlockchainMessage::SwapEvent(swap_event))
246                                            {
247                                                log::error!("Failed to send swap event: {e}");
248                                            }
249                                        }
250                                        Err(e) => {
251                                            log::error!(
252                                                "Failed to parse swap with error '{e:?}' for event: {log:?}",
253                                            );
254                                        }
255                                    }
256                                } else if event_signature == mint_event_encoded_signature {
257                                    match dex_extended.parse_mint_event_hypersync(&log) {
258                                        Ok(swap_event) => {
259                                            if let Err(e) =
260                                                tx.send(BlockchainMessage::MintEvent(swap_event))
261                                            {
262                                                log::error!("Failed to send mint event: {e}");
263                                            }
264                                        }
265                                        Err(e) => {
266                                            log::error!(
267                                                "Failed to parse mint with error '{e:?}' for event: {log:?}",
268                                            );
269                                        }
270                                    }
271                                } else if event_signature == burn_event_encoded_signature {
272                                    match dex_extended.parse_burn_event_hypersync(&log) {
273                                        Ok(swap_event) => {
274                                            if let Err(e) =
275                                                tx.send(BlockchainMessage::BurnEvent(swap_event))
276                                            {
277                                                log::error!("Failed to send burn event: {e}");
278                                            }
279                                        }
280                                        Err(e) => {
281                                            log::error!(
282                                                "Failed to parse burn with error '{e:?}' for event: {log:?}",
283                                            );
284                                        }
285                                    }
286                                } else {
287                                    log::error!("Unknown event signature: {event_signature}");
288                                }
289                            }
290                        }
291                    }
292                }
293            }
294        });
295    }
296
297    /// Creates a stream of contract event logs matching the specified criteria.
298    ///
299    /// # Panics
300    ///
301    /// Panics if the contract address cannot be parsed as a valid Ethereum address.
302    pub async fn request_contract_events_stream(
303        &self,
304        from_block: u64,
305        to_block: Option<u64>,
306        contract_address: &Address,
307        topics: Vec<&str>,
308    ) -> impl Stream<Item = PoolEventStreamItem> + use<> {
309        let query = Self::construct_contract_events_query(
310            from_block,
311            to_block,
312            &[*contract_address],
313            &topics,
314        );
315
316        let chain = self.chain.name;
317        let mut rx = self
318            .client
319            .clone()
320            .stream(query, StreamConfig::default())
321            .await
322            .expect("Failed to create stream");
323
324        async_stream::stream! {
325              while let Some(response) = rx.recv().await {
326                let response = response.unwrap();
327                for item in pool_events_from_response(chain, response.data.blocks, response.data.logs) {
328                    yield item;
329                }
330            }
331        }
332    }
333
334    /// Disconnects from the HyperSync service and stops all background tasks.
335    pub async fn disconnect(&mut self) {
336        log::debug!("Disconnecting HyperSync client");
337        self.cancellation_token.cancel();
338
339        // Await blocks task with timeout, abort if it takes too long
340        if let Some(mut task) = self.blocks_task.take() {
341            match tokio::time::timeout(
342                std::time::Duration::from_secs(DISCONNECT_TIMEOUT_SECS),
343                &mut task,
344            )
345            .await
346            {
347                Ok(Ok(())) => {
348                    log::debug!("Blocks task completed gracefully");
349                }
350                Ok(Err(e)) => {
351                    log::error!("Error awaiting blocks task: {e}");
352                }
353                Err(_) => {
354                    log::warn!(
355                        "Blocks task did not complete within {DISCONNECT_TIMEOUT_SECS}s timeout, \
356                         aborting task (this is expected if Hypersync long-poll was in progress)"
357                    );
358                    task.abort();
359                    let _ = task.await;
360                }
361            }
362        }
363
364        // DEX event tasks are short-lived and self-clean via cancellation_token
365
366        log::debug!("HyperSync client disconnected");
367    }
368
369    /// Returns the current block
370    ///
371    /// # Panics
372    ///
373    /// Panics if the client height request fails.
374    pub async fn current_block(&self) -> u64 {
375        self.client.get_height().await.unwrap()
376    }
377
378    /// Creates a stream that yields blockchain blocks within the specified range.
379    ///
380    /// # Panics
381    ///
382    /// Panics if the stream creation or block transformation fails.
383    pub async fn request_blocks_stream(
384        &self,
385        from_block: u64,
386        to_block: Option<u64>,
387    ) -> impl Stream<Item = Block> {
388        let query = Self::construct_block_query(from_block, to_block);
389        let mut rx = self
390            .client
391            .clone()
392            .stream(query, StreamConfig::default())
393            .await
394            .unwrap();
395
396        let chain = self.chain.name;
397
398        async_stream::stream! {
399            while let Some(response) = rx.recv().await {
400                let response = response.unwrap();
401                for batch in response.data.blocks {
402                        for received_block in batch {
403                            let block = transform_hypersync_block(chain, received_block).unwrap();
404                            yield block
405                        }
406                    }
407            }
408        }
409    }
410
411    /// Starts a background task that continuously polls for new blockchain blocks.
412    ///
413    /// # Panics
414    ///
415    /// Panics if client height requests or block transformations fail.
416    pub fn subscribe_blocks(&mut self) {
417        if self.blocks_task.is_some() {
418            return;
419        }
420
421        let chain = self.chain.name;
422        let client = self.client.clone();
423        let tx = if let Some(tx) = &self.tx {
424            tx.clone()
425        } else {
426            log::error!("Hypersync client channel should have been initialized");
427            return;
428        };
429
430        // Create a child token that can be cancelled independently
431        let blocks_token = self.cancellation_token.child_token();
432        let cancellation_token = blocks_token.clone();
433        self.blocks_cancellation_token = Some(blocks_token);
434
435        let task = get_runtime().spawn(async move {
436            log::debug!("Starting task 'blocks_feed");
437
438            let current_block_height = client.get_height().await.unwrap();
439            let mut query = Self::construct_block_query(current_block_height, None);
440
441            loop {
442                tokio::select! {
443                    () = cancellation_token.cancelled() => {
444                        log::debug!("Blocks subscription task received cancellation signal");
445                        break;
446                    }
447                    result = tokio::time::timeout(
448                        std::time::Duration::from_secs(HYPERSYNC_REQUEST_TIMEOUT_SECS),
449                        client.get(&query)
450                    ) => {
451                        let response = match result {
452                            Ok(Ok(resp)) => resp,
453                            Ok(Err(e)) => {
454                                log::error!("Hypersync request failed: {e}");
455                                break;
456                            }
457                            Err(_) => {
458                                log::warn!("Hypersync request timed out after {HYPERSYNC_REQUEST_TIMEOUT_SECS}s, retrying...");
459                                continue;
460                            }
461                        };
462
463                        for batch in response.data.blocks {
464                            for received_block in batch {
465                                let block = transform_hypersync_block(chain, received_block).unwrap();
466                                let msg = BlockchainMessage::Block(block);
467                                if let Err(e) = tx.send(msg) {
468                                    log::error!("Error sending message: {e}");
469                                }
470                            }
471                        }
472
473                        if let Some(archive_block_height) = response.archive_height
474                            && archive_block_height < response.next_block
475                        {
476                            while client.get_height().await.unwrap() < response.next_block {
477                                tokio::select! {
478                                    () = cancellation_token.cancelled() => {
479                                        log::debug!("Blocks subscription task received cancellation signal during polling");
480                                        return;
481                                    }
482                                    () = tokio::time::sleep(std::time::Duration::from_millis(
483                                        BLOCK_POLLING_INTERVAL_MS,
484                                    )) => {}
485                                }
486                            }
487                        }
488
489                        query.from_block = response.next_block;
490                    }
491                }
492            }
493        });
494
495        self.blocks_task = Some(task);
496    }
497
498    /// Constructs a HyperSync query for fetching blocks with all available fields within the specified range.
499    fn construct_block_query(from_block: u64, to_block: Option<u64>) -> Query {
500        Query {
501            from_block,
502            to_block: Self::to_hypersync_exclusive_bound(to_block),
503            blocks: vec![BlockSelection::default()],
504            field_selection: FieldSelection {
505                block: BlockField::all(),
506                ..Default::default()
507            },
508            ..Default::default()
509        }
510    }
511
512    fn construct_contract_events_query(
513        from_block: u64,
514        to_block: Option<u64>,
515        contract_addresses: &[Address],
516        topics: &[&str],
517    ) -> Query {
518        let mut query_value = serde_json::json!({
519            "from_block": from_block,
520            "logs": [{
521                "topics": [topics],
522                "address": contract_addresses
523            }],
524            "field_selection": {
525                "log": [
526                    "block_number",
527                    "transaction_hash",
528                    "transaction_index",
529                    "log_index",
530                    "address",
531                    "data",
532                    "topic0",
533                    "topic1",
534                    "topic2",
535                    "topic3",
536                ],
537                // Join block fields so callers can resolve each event's ts_event
538                "block": [
539                    "number",
540                    "hash",
541                    "parent_hash",
542                    "miner",
543                    "gas_limit",
544                    "gas_used",
545                    "timestamp",
546                ]
547            }
548        });
549
550        if let Some(to_block) = Self::to_hypersync_exclusive_bound(to_block)
551            && let Some(obj) = query_value.as_object_mut()
552        {
553            obj.insert("to_block".to_string(), serde_json::json!(to_block));
554        }
555
556        serde_json::from_value(query_value).unwrap()
557    }
558
559    fn to_hypersync_exclusive_bound(to_block: Option<u64>) -> Option<u64> {
560        to_block.map(|block| block.saturating_add(1))
561    }
562
563    /// Unsubscribes from new blocks by stopping the background watch task.
564    pub async fn unsubscribe_blocks(&mut self) {
565        if let Some(task) = self.blocks_task.take() {
566            // Cancel only the blocks child token, not the main cancellation token
567            if let Some(token) = self.blocks_cancellation_token.take() {
568                token.cancel();
569            }
570
571            if let Err(e) = task.await {
572                log::error!("Error awaiting blocks task during unsubscribe: {e}");
573            }
574            log::debug!("Unsubscribed from blocks");
575        }
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use std::str::FromStr;
582
583    use hypersync_client::{
584        format::{Address as HypersyncAddress, Hash, Quantity},
585        simple_types::{Block as HypersyncBlock, Log},
586    };
587    use nautilus_core::{UnixNanos, datetime::NANOSECONDS_IN_SECOND};
588    use rstest::rstest;
589
590    use super::*;
591
592    fn synthetic_block(number: u64, timestamp_secs: u64) -> HypersyncBlock {
593        HypersyncBlock {
594            number: Some(number),
595            hash: Some(
596                Hash::from_str(
597                    "0x0000000000000000000000000000000000000000000000000000000000000001",
598                )
599                .unwrap(),
600            ),
601            parent_hash: Some(
602                Hash::from_str(
603                    "0x0000000000000000000000000000000000000000000000000000000000000000",
604                )
605                .unwrap(),
606            ),
607            miner: Some(
608                HypersyncAddress::from_str("0x0000000000000000000000000000000000000001").unwrap(),
609            ),
610            gas_limit: Some(Quantity::from(21_000u64)),
611            gas_used: Some(Quantity::from(21_000u64)),
612            timestamp: Some(Quantity::from(timestamp_secs)),
613            ..Default::default()
614        }
615    }
616
617    #[rstest]
618    fn pool_events_yields_blocks_before_logs() {
619        let items = pool_events_from_response(
620            Blockchain::Ethereum,
621            vec![vec![synthetic_block(12, 100)]],
622            vec![vec![Log::default(), Log::default()]],
623        );
624
625        assert_eq!(items.len(), 3);
626        match &items[0] {
627            PoolEventStreamItem::Block(block) => {
628                assert_eq!(block.number, 12);
629                assert_eq!(block.timestamp, UnixNanos::new(100 * NANOSECONDS_IN_SECOND));
630            }
631            other => panic!("expected Block first, was {other:?}"),
632        }
633        assert!(matches!(items[1], PoolEventStreamItem::Log(_)));
634        assert!(matches!(items[2], PoolEventStreamItem::Log(_)));
635    }
636
637    #[rstest]
638    fn pool_events_skips_unparsable_block_but_keeps_logs() {
639        // A block missing required fields (gas, hash, ...) fails transform and is skipped, but
640        // the response's logs must still be yielded.
641        let bad_block = HypersyncBlock {
642            number: Some(7),
643            ..Default::default()
644        };
645        let items = pool_events_from_response(
646            Blockchain::Ethereum,
647            vec![vec![bad_block]],
648            vec![vec![Log::default()]],
649        );
650
651        assert_eq!(items.len(), 1);
652        assert!(matches!(items[0], PoolEventStreamItem::Log(_)));
653    }
654
655    #[rstest]
656    fn construct_block_query_converts_to_block_to_hypersync_exclusive_bound() {
657        let query = HyperSyncClient::construct_block_query(10, Some(12));
658
659        assert_eq!(query.from_block, 10);
660        assert_eq!(query.to_block, Some(13));
661    }
662
663    #[rstest]
664    fn construct_contract_events_query_converts_to_block_to_hypersync_exclusive_bound() {
665        let address = Address::from_str("0x0000000000000000000000000000000000000001").unwrap();
666        let query = HyperSyncClient::construct_contract_events_query(
667            10,
668            Some(12),
669            &[address],
670            &["0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67"],
671        );
672
673        assert_eq!(query.from_block, 10);
674        assert_eq!(query.to_block, Some(13));
675    }
676}