1use 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#[derive(Debug)]
44pub enum PoolEventStreamItem {
45 Block(Block),
47 Log(Log),
49}
50
51fn 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
80const BLOCK_POLLING_INTERVAL_MS: u64 = 50;
83
84const HYPERSYNC_REQUEST_TIMEOUT_SECS: u64 = 30;
86
87const DISCONNECT_TIMEOUT_SECS: u64 = 5;
90
91#[derive(Debug)]
93pub struct HyperSyncClient {
94 chain: SharedChain,
96 client: Arc<hypersync_client::Client>,
98 blocks_task: Option<tokio::task::JoinHandle<()>>,
100 blocks_cancellation_token: Option<tokio_util::sync::CancellationToken>,
102 tx: Option<tokio::sync::mpsc::UnboundedSender<BlockchainMessage>>,
104 pool_addresses: AHashMap<InstrumentId, Address>,
106 cancellation_token: tokio_util::sync::CancellationToken,
108}
109
110impl HyperSyncClient {
111 #[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 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 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 pub async fn disconnect(&mut self) {
336 log::debug!("Disconnecting HyperSync client");
337 self.cancellation_token.cancel();
338
339 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 log::debug!("HyperSync client disconnected");
367 }
368
369 pub async fn current_block(&self) -> u64 {
375 self.client.get_height().await.unwrap()
376 }
377
378 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 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 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 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 "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 pub async fn unsubscribe_blocks(&mut self) {
565 if let Some(task) = self.blocks_task.take() {
566 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 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}