1use 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#[derive(Debug)]
50pub enum PoolEventStreamItem {
51 Block(Block),
53 Log(Log),
55}
56
57const BLOCK_POLLING_INTERVAL_MS: u64 = 50;
60
61const HYPERSYNC_REQUEST_TIMEOUT_SECS: u64 = 30;
63
64const DISCONNECT_TIMEOUT_SECS: u64 = 5;
67
68const DEX_EVENT_STREAM_RETRY_DELAY_MS: u64 = 1_000;
70
71#[derive(Debug)]
73pub struct HyperSyncClient {
74 chain: SharedChain,
76 client: Arc<hypersync_client::Client>,
78 blocks_task: TaskSlot<()>,
80 blocks_cancellation_token: Option<tokio_util::sync::CancellationToken>,
82 dex_event_tasks: AHashMap<DexType, DexEventStreamTask>,
84 tx: Option<tokio::sync::mpsc::UnboundedSender<BlockchainMessage>>,
86 pool_addresses: AHashMap<InstrumentId, Address>,
88 cancellation_token: tokio_util::sync::CancellationToken,
90}
91
92impl HyperSyncClient {
93 #[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 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 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 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 pub async fn current_block(&self) -> u64 {
299 self.client.get_height().await.unwrap()
300 }
301
302 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 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 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 pub async fn unsubscribe_blocks(&mut self) {
423 if self.blocks_task.is_none() {
424 return;
425 }
426
427 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 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 "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
806fn 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 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}