1use std::{cmp::max, sync::Arc};
17
18use anyhow::Context;
19use futures_util::StreamExt;
20use nautilus_common::messages::DataEvent;
21use nautilus_core::{UnixNanos, hex, string::formatting::Separable};
22use nautilus_model::defi::{
23 Block, Blockchain, DexType, Pool, PoolIdentifier, PoolLiquidityUpdate, PoolProfiler, PoolSwap,
24 SharedChain, SharedDex, SharedPool,
25 data::{
26 DefiData, DexPoolData, PoolFeeCollect, PoolFeeProtocolCollect, PoolFeeProtocolUpdate,
27 PoolFlash, block::BlockPosition,
28 },
29 pool_analysis::{compare::compare_pool_profiler_detailed, snapshot::PoolSnapshot},
30 reporting::{BlockchainSyncReportItems, BlockchainSyncReporter},
31};
32use nautilus_network::websocket::TransportBackend;
33
34use crate::{
35 cache::BlockchainCache,
36 config::BlockchainDataClientConfig,
37 contracts::{erc20::Erc20Contract, uniswap_v3_pool::UniswapV3PoolContract},
38 data::subscription::DefiDataSubscriptionManager,
39 events::{
40 burn::BurnEvent, collect::CollectEvent, fee_protocol_collect::FeeProtocolCollectEvent,
41 fee_protocol_update::FeeProtocolUpdateEvent, flash::FlashEvent, mint::MintEvent,
42 swap::SwapEvent,
43 },
44 exchanges::{extended::DexExtended, get_dex_extended},
45 hypersync::{
46 client::{HyperSyncClient, PoolEventStreamItem},
47 helpers::{extract_block_number, extract_event_signature_bytes},
48 },
49 rpc::{
50 BlockchainRpcClient, BlockchainRpcClientAny,
51 chains::{
52 arbitrum::ArbitrumRpcClient, base::BaseRpcClient, bsc::BscRpcClient,
53 ethereum::EthereumRpcClient, polygon::PolygonRpcClient,
54 },
55 http::BlockchainHttpRpcClient,
56 types::BlockchainMessage,
57 },
58 services::PoolDiscoveryService,
59};
60
61const BLOCKS_PROCESS_IN_SYNC_REPORT: u64 = 50_000;
62const POOL_EVENT_BLOCK_BATCH_SIZE: usize = 20_000;
63
64#[derive(Debug)]
69pub struct BlockchainDataClientCore {
70 pub chain: SharedChain,
72 pub config: BlockchainDataClientConfig,
74 pub cache: BlockchainCache,
76 tokens: Erc20Contract,
78 univ3_pool: UniswapV3PoolContract,
80 pub hypersync_client: HyperSyncClient,
82 pub rpc_client: Option<BlockchainRpcClientAny>,
84 pub subscription_manager: DefiDataSubscriptionManager,
86 data_tx: Option<tokio::sync::mpsc::UnboundedSender<DataEvent>>,
88 cancellation_token: tokio_util::sync::CancellationToken,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum SnapshotValidation {
95 OnChain,
97 Replay,
100 Invalid,
102}
103
104impl SnapshotValidation {
105 #[must_use]
107 pub const fn is_usable(self) -> bool {
108 !matches!(self, Self::Invalid)
109 }
110
111 #[must_use]
113 pub const fn as_str(self) -> &'static str {
114 match self {
115 Self::OnChain => "on_chain",
116 Self::Replay => "replay",
117 Self::Invalid => "invalid",
118 }
119 }
120
121 #[must_use]
125 pub fn from_db_token(token: &str) -> Option<Self> {
126 match token {
127 "on_chain" => Some(Self::OnChain),
128 "replay" => Some(Self::Replay),
129 "invalid" => Some(Self::Invalid),
130 _ => None,
131 }
132 }
133}
134
135impl BlockchainDataClientCore {
136 #[must_use]
142 pub fn new(
143 config: BlockchainDataClientConfig,
144 hypersync_tx: Option<tokio::sync::mpsc::UnboundedSender<BlockchainMessage>>,
145 data_tx: Option<tokio::sync::mpsc::UnboundedSender<DataEvent>>,
146 cancellation_token: tokio_util::sync::CancellationToken,
147 ) -> Self {
148 let chain = config.chain.clone();
149 let cache = BlockchainCache::new(chain.clone());
150
151 log::debug!(
153 "Initializing blockchain data client for '{}' with HTTP RPC: {}",
154 chain.name,
155 config.http_rpc_url
156 );
157
158 let rpc_client = if !config.use_hypersync_for_live_data && config.wss_rpc_url.is_some() {
159 let wss_rpc_url = config.wss_rpc_url.clone().expect("wss_rpc_url is required");
160 log::debug!("WebSocket RPC URL: {wss_rpc_url}");
161 Some(Self::initialize_rpc_client(
162 chain.name,
163 wss_rpc_url,
164 config.transport_backend,
165 config.proxy_url.clone(),
166 ))
167 } else {
168 log::debug!("Using HyperSync for live data (no WebSocket RPC)");
169 None
170 };
171 let http_rpc_client = Arc::new(BlockchainHttpRpcClient::new(
172 config.http_rpc_url.clone(),
173 config.rpc_requests_per_second,
174 config.proxy_url.clone(),
175 ));
176 let multicall_calls_per_rpc_request = config.multicall_calls_per_rpc_request;
177 let erc20_contract = Erc20Contract::new(
178 http_rpc_client.clone(),
179 config.pool_filters.remove_pools_with_empty_erc20fields,
180 );
181
182 let hypersync_client =
183 HyperSyncClient::new(chain.clone(), hypersync_tx, cancellation_token.clone());
184 Self {
185 chain,
186 config,
187 rpc_client,
188 tokens: erc20_contract,
189 univ3_pool: UniswapV3PoolContract::new(
190 http_rpc_client,
191 multicall_calls_per_rpc_request,
192 ),
193 cache,
194 hypersync_client,
195 subscription_manager: DefiDataSubscriptionManager::new(),
196 data_tx,
197 cancellation_token,
198 }
199 }
200
201 pub async fn initialize_cache_database(&mut self) {
203 if let Some(pg_connect_options) = &self.config.postgres_cache_database_config {
204 log::debug!(
205 "Initializing blockchain cache on database '{}'",
206 pg_connect_options.database
207 );
208 self.cache
209 .initialize_database(pg_connect_options.clone().into())
210 .await;
211 }
212 }
213
214 fn initialize_rpc_client(
216 blockchain: Blockchain,
217 wss_rpc_url: String,
218 transport_backend: TransportBackend,
219 proxy_url: Option<String>,
220 ) -> BlockchainRpcClientAny {
221 let mut client = match blockchain {
222 Blockchain::Ethereum => {
223 BlockchainRpcClientAny::Ethereum(EthereumRpcClient::new(wss_rpc_url, proxy_url))
224 }
225 Blockchain::Polygon => {
226 BlockchainRpcClientAny::Polygon(PolygonRpcClient::new(wss_rpc_url, proxy_url))
227 }
228 Blockchain::Base => {
229 BlockchainRpcClientAny::Base(BaseRpcClient::new(wss_rpc_url, proxy_url))
230 }
231 Blockchain::Arbitrum => {
232 BlockchainRpcClientAny::Arbitrum(ArbitrumRpcClient::new(wss_rpc_url, proxy_url))
233 }
234 Blockchain::Bsc => {
235 BlockchainRpcClientAny::Bsc(BscRpcClient::new(wss_rpc_url, proxy_url))
236 }
237 _ => panic!("Unsupported blockchain {blockchain} for RPC connection"),
238 };
239 client.set_transport_backend(transport_backend);
240 client
241 }
242
243 pub async fn connect(&mut self) -> anyhow::Result<()> {
249 log::debug!(
250 "Connecting blockchain data client for '{}'",
251 self.chain.name
252 );
253 self.initialize_cache_database().await;
254
255 if let Some(ref mut rpc_client) = self.rpc_client {
256 rpc_client.connect().await?;
257 }
258
259 let from_block = self.determine_from_block();
260
261 log::debug!(
262 "Connecting to blockchain data source for '{}' from block {}",
263 self.chain.name,
264 from_block.separate_with_commas()
265 );
266
267 self.cache.initialize_chain().await;
269 self.cache.connect(from_block).await?;
271 for dex in self.config.dex_ids.clone() {
275 self.register_dex_exchange(dex).await?;
276 self.sync_exchange_pools(&dex, from_block, None, false)
277 .await?;
278 }
279
280 Ok(())
281 }
282
283 pub async fn sync_blocks_checked(
289 &mut self,
290 from_block: u64,
291 to_block: Option<u64>,
292 ) -> anyhow::Result<()> {
293 if let Some(blocks_status) = self.cache.get_cache_block_consistency_status().await {
294 if blocks_status.is_consistent() {
296 log::debug!(
297 "Cache is consistent: no gaps detected (last continuous block: {})",
298 blocks_status.last_continuous_block
299 );
300 let target_block = max(blocks_status.max_block + 1, from_block);
301 log::debug!(
302 "Starting fast sync with COPY from block {}",
303 target_block.separate_with_commas()
304 );
305 self.sync_blocks(target_block, to_block, true).await?;
306 } else {
307 let gap_size = blocks_status.max_block - blocks_status.last_continuous_block;
308 log::debug!(
309 "Cache inconsistency detected: {} blocks missing between {} and {}",
310 gap_size,
311 blocks_status.last_continuous_block + 1,
312 blocks_status.max_block
313 );
314
315 log::debug!(
316 "Block syncing Phase 1: Filling gaps with INSERT (blocks {} to {})",
317 blocks_status.last_continuous_block + 1,
318 blocks_status.max_block
319 );
320 self.sync_blocks(
321 blocks_status.last_continuous_block + 1,
322 Some(blocks_status.max_block),
323 false,
324 )
325 .await?;
326
327 log::debug!(
328 "Block syncing Phase 2: Continuing with fast COPY from block {}",
329 (blocks_status.max_block + 1).separate_with_commas()
330 );
331 self.sync_blocks(blocks_status.max_block + 1, to_block, true)
332 .await?;
333 }
334 } else {
335 self.sync_blocks(from_block, to_block, true).await?;
336 }
337
338 Ok(())
339 }
340
341 pub async fn sync_blocks(
347 &mut self,
348 from_block: u64,
349 to_block: Option<u64>,
350 use_copy_command: bool,
351 ) -> anyhow::Result<()> {
352 const BATCH_SIZE: usize = 1000;
353
354 let to_block = if let Some(block) = to_block {
355 block
356 } else {
357 self.hypersync_client.current_block().await
358 };
359 let total_blocks = to_block.saturating_sub(from_block) + 1;
360 log::debug!(
361 "Syncing blocks from {} to {} (total: {} blocks)",
362 from_block.separate_with_commas(),
363 to_block.separate_with_commas(),
364 total_blocks.separate_with_commas()
365 );
366
367 if let Err(e) = self.cache.toggle_performance_settings(true).await {
369 log::warn!("Failed to enable performance settings: {e}");
370 }
371
372 let blocks_stream = self
373 .hypersync_client
374 .request_blocks_stream(from_block, Some(to_block))
375 .await;
376
377 tokio::pin!(blocks_stream);
378
379 let mut metrics = BlockchainSyncReporter::new(
380 BlockchainSyncReportItems::Blocks,
381 from_block,
382 total_blocks,
383 BLOCKS_PROCESS_IN_SYNC_REPORT,
384 );
385
386 let mut batch: Vec<Block> = Vec::with_capacity(BATCH_SIZE);
387
388 let cancellation_token = self.cancellation_token.clone();
389 let sync_result = tokio::select! {
390 () = cancellation_token.cancelled() => {
391 log::debug!("Block sync cancelled");
392 Err(anyhow::anyhow!("Sync cancelled"))
393 }
394 result = async {
395 while let Some(block) = blocks_stream.next().await {
396 let block_number = block.number;
397 if self.cache.get_block_timestamp(block_number).is_some() {
398 continue;
399 }
400 batch.push(block);
401
402 if batch.len() >= BATCH_SIZE || block_number >= to_block {
404 let batch_size = batch.len();
405
406 self.cache.add_blocks_batch(batch, use_copy_command).await?;
407 metrics.update(batch_size);
408
409 batch = Vec::with_capacity(BATCH_SIZE);
411 }
412
413 if metrics.should_log_progress(block_number, to_block) {
415 metrics.log_progress(block_number);
416 }
417 }
418
419 if !batch.is_empty() {
421 let batch_size = batch.len();
422 self.cache.add_blocks_batch(batch, use_copy_command).await?;
423 metrics.update(batch_size);
424 }
425
426 metrics.log_final_stats();
427 Ok(())
428 } => result
429 };
430
431 sync_result?;
432
433 if let Err(e) = self.cache.toggle_performance_settings(false).await {
435 log::warn!("Failed to restore default settings: {e}");
436 }
437
438 Ok(())
439 }
440
441 pub async fn sync_pool_events(
447 &mut self,
448 dex: &DexType,
449 pool_identifier: PoolIdentifier,
450 from_block: Option<u64>,
451 to_block: Option<u64>,
452 reset: bool,
453 ) -> anyhow::Result<()> {
454 const EVENT_BATCH_SIZE: usize = 20000;
455
456 let pool: SharedPool = self.get_pool(&pool_identifier)?.clone();
457 let pool_display = pool.to_full_spec_string();
458 let from_block = from_block.unwrap_or(pool.creation_block);
459 let pool_address = &pool.address;
461
462 let (last_synced_block, effective_from_block) = if reset {
463 (None, from_block)
464 } else {
465 let last_synced_block = self
466 .cache
467 .get_pool_last_synced_block(dex, &pool_identifier)
468 .await?;
469 let effective_from_block = last_synced_block
470 .map_or(from_block, |last_synced| max(from_block, last_synced + 1));
471 (last_synced_block, effective_from_block)
472 };
473
474 let to_block = match to_block {
475 Some(block) => block,
476 None => self.hypersync_client.current_block().await,
477 };
478
479 if effective_from_block > to_block {
481 log::debug!(
482 "D {} already synced to block {} (current: {}), skipping sync",
483 dex,
484 last_synced_block.unwrap_or(0).separate_with_commas(),
485 to_block.separate_with_commas()
486 );
487 return Ok(());
488 }
489
490 let last_block_across_pool_events_table = self
492 .cache
493 .get_pool_event_tables_last_block(&pool_identifier)
494 .await?;
495
496 let total_blocks = to_block.saturating_sub(effective_from_block) + 1;
497 log::debug!(
498 "Syncing Pool: '{}' events from {} to {} (total: {} blocks){}",
499 pool_display,
500 effective_from_block.separate_with_commas(),
501 to_block.separate_with_commas(),
502 total_blocks.separate_with_commas(),
503 if let Some(last_synced) = last_synced_block {
504 format!(
505 " - resuming from last synced block {}",
506 last_synced.separate_with_commas()
507 )
508 } else {
509 String::new()
510 }
511 );
512
513 let mut metrics = BlockchainSyncReporter::new(
514 BlockchainSyncReportItems::PoolEvents,
515 effective_from_block,
516 total_blocks,
517 BLOCKS_PROCESS_IN_SYNC_REPORT,
518 );
519 let dex_extended = self.get_dex_extended(dex)?.clone();
520 let swap_event_signature = dex_extended.swap_created_event.as_ref();
521 let mint_event_signature = dex_extended.mint_created_event.as_ref();
522 let burn_event_signature = dex_extended.burn_created_event.as_ref();
523 let collect_event_signature = dex_extended.collect_created_event.as_ref();
524 let flash_event_signature = dex_extended.flash_created_event.as_ref();
525 let protocol_update_event_signature = dex_extended.fee_protocol_update_event.as_ref();
526 let protocol_collect_event_signature = dex_extended.fee_protocol_collect_event.as_ref();
527 let initialize_event_signature: Option<&str> =
528 dex_extended.initialize_event.as_ref().map(|s| s.as_ref());
529
530 let swap_sig_bytes = hex::decode(
532 swap_event_signature
533 .strip_prefix("0x")
534 .unwrap_or(swap_event_signature),
535 )?;
536 let mint_sig_bytes = hex::decode(
537 mint_event_signature
538 .strip_prefix("0x")
539 .unwrap_or(mint_event_signature),
540 )?;
541 let burn_sig_bytes = hex::decode(
542 burn_event_signature
543 .strip_prefix("0x")
544 .unwrap_or(burn_event_signature),
545 )?;
546 let collect_sig_bytes = hex::decode(
547 collect_event_signature
548 .strip_prefix("0x")
549 .unwrap_or(collect_event_signature),
550 )?;
551 let flash_sig_bytes = flash_event_signature
552 .map(|s| hex::decode(s.strip_prefix("0x").unwrap_or(s)).unwrap_or_default());
553 let protocol_update_sig_bytes = protocol_update_event_signature
554 .map(|s| hex::decode(s.strip_prefix("0x").unwrap_or(s)).unwrap_or_default());
555 let protocol_collect_sig_bytes = protocol_collect_event_signature
556 .map(|s| hex::decode(s.strip_prefix("0x").unwrap_or(s)).unwrap_or_default());
557 let initialize_sig_bytes = initialize_event_signature
558 .map(|s| hex::decode(s.strip_prefix("0x").unwrap_or(s)).unwrap_or_default());
559
560 let mut event_signatures = vec![
561 swap_event_signature,
562 mint_event_signature,
563 burn_event_signature,
564 collect_event_signature,
565 ];
566
567 if let Some(event) = dex_extended.initialize_event.as_ref() {
568 event_signatures.push(event);
569 }
570
571 if let Some(event) = dex_extended.fee_protocol_update_event.as_ref() {
572 event_signatures.push(event);
573 }
574
575 if let Some(event) = dex_extended.fee_protocol_collect_event.as_ref() {
576 event_signatures.push(event);
577 }
578
579 if let Some(event) = dex_extended.flash_created_event.as_ref() {
580 event_signatures.push(event);
581 }
582
583 let pool_events_stream = self
584 .hypersync_client
585 .request_contract_events_stream(
586 effective_from_block,
587 Some(to_block),
588 pool_address,
589 event_signatures,
590 )
591 .await;
592 tokio::pin!(pool_events_stream);
593
594 let mut last_block_saved = effective_from_block;
595 let mut blocks_processed = 0;
596
597 let mut block_batch: Vec<Block> = Vec::with_capacity(POOL_EVENT_BLOCK_BATCH_SIZE);
598 let mut swap_batch: Vec<PoolSwap> = Vec::with_capacity(EVENT_BATCH_SIZE);
599 let mut liquidity_batch: Vec<PoolLiquidityUpdate> = Vec::with_capacity(EVENT_BATCH_SIZE);
600 let mut collect_batch: Vec<PoolFeeCollect> = Vec::with_capacity(EVENT_BATCH_SIZE);
601 let mut protocol_update_batch: Vec<PoolFeeProtocolUpdate> =
602 Vec::with_capacity(EVENT_BATCH_SIZE);
603 let mut protocol_collect_batch: Vec<PoolFeeProtocolCollect> =
604 Vec::with_capacity(EVENT_BATCH_SIZE);
605 let mut flash_batch: Vec<PoolFlash> = Vec::with_capacity(EVENT_BATCH_SIZE);
606
607 let mut beyond_stale_data = last_block_across_pool_events_table
609 .is_none_or(|tables_max| effective_from_block > tables_max);
610
611 let cancellation_token = self.cancellation_token.clone();
612 let sync_result = tokio::select! {
613 () = cancellation_token.cancelled() => {
614 log::debug!("Pool event sync cancelled");
615 Err(anyhow::anyhow!("Sync cancelled"))
616 }
617 result = async {
618 while let Some(item) = pool_events_stream.next().await {
619 let log = match item {
620 PoolEventStreamItem::Block(block) => {
621 self.record_pool_event_block(block, &mut block_batch).await?;
622 continue;
623 }
624 PoolEventStreamItem::Log(log) => log,
625 };
626 let block_number = extract_block_number(&log)?;
627 blocks_processed += block_number - last_block_saved;
628 last_block_saved = block_number;
629
630 let event_sig_bytes = extract_event_signature_bytes(&log)?;
631 if event_sig_bytes == swap_sig_bytes.as_slice() {
632 let swap_event = dex_extended.parse_swap_event_hypersync(&log)?;
633 let swap = self
634 .process_pool_swap_event(&swap_event, &pool)
635 .with_context(|| {
636 format!("failed to process swap event at block {}", swap_event.block_number)
637 })?;
638 swap_batch.push(swap);
639 } else if event_sig_bytes == mint_sig_bytes.as_slice() {
640 let mint_event = dex_extended.parse_mint_event_hypersync(&log)?;
641 let liquidity_update = self
642 .process_pool_mint_event(&mint_event, &pool, &dex_extended)
643 .with_context(|| {
644 format!("failed to process mint event at block {}", mint_event.block_number)
645 })?;
646 liquidity_batch.push(liquidity_update);
647 } else if event_sig_bytes == burn_sig_bytes.as_slice() {
648 let burn_event = dex_extended.parse_burn_event_hypersync(&log)?;
649 let liquidity_update = self
650 .process_pool_burn_event(&burn_event, &pool, &dex_extended)
651 .with_context(|| {
652 format!("failed to process burn event at block {}", burn_event.block_number)
653 })?;
654 liquidity_batch.push(liquidity_update);
655 } else if event_sig_bytes == collect_sig_bytes.as_slice() {
656 let collect_event = dex_extended.parse_collect_event_hypersync(&log)?;
657 let fee_collect = self
658 .process_pool_collect_event(&collect_event, &pool, &dex_extended)
659 .with_context(|| {
660 format!(
661 "failed to process collect event at block {}",
662 collect_event.block_number
663 )
664 })?;
665 collect_batch.push(fee_collect);
666 } else if initialize_sig_bytes.as_ref().is_some_and(|sig| sig.as_slice() == event_sig_bytes) {
667 let initialize_event = dex_extended.parse_initialize_event_hypersync(&log)?;
668 self.cache
669 .update_pool_initialize_price_tick(&initialize_event)
670 .await?;
671 } else if protocol_update_sig_bytes.as_ref().is_some_and(|sig| sig.as_slice() == event_sig_bytes) {
672 let fee_protocol_update_event = dex_extended.parse_fee_protocol_update_event_hypersync(&log)?;
673 let update = self
674 .process_pool_fee_protocol_update_event(&fee_protocol_update_event, &pool)
675 .with_context(|| {
676 format!(
677 "failed to process SetFeeProtocol event at block {}",
678 fee_protocol_update_event.block_number
679 )
680 })?;
681 protocol_update_batch.push(update);
682 } else if protocol_collect_sig_bytes.as_ref().is_some_and(|sig| sig.as_slice() == event_sig_bytes) {
683 let fee_protocol_collect_event = dex_extended.parse_fee_protocol_collect_event_hypersync(&log)?;
684 let collect = self
685 .process_pool_fee_protocol_collect_event(&fee_protocol_collect_event, &pool)
686 .with_context(|| {
687 format!(
688 "failed to process CollectProtocol event at block {}",
689 fee_protocol_collect_event.block_number
690 )
691 })?;
692 protocol_collect_batch.push(collect);
693 } else if flash_sig_bytes.as_ref().is_some_and(|sig| sig.as_slice() == event_sig_bytes) {
694 let parse_fn = dex_extended
695 .parse_flash_event_hypersync_fn
696 .context("missing flash event parser")?;
697 let flash_event = parse_fn(dex_extended.dex.clone(), &log)
698 .context("failed to parse flash event")?;
699 let flash = self
700 .process_pool_flash_event(&flash_event, &pool)
701 .with_context(|| {
702 format!("failed to process flash event at block {}", flash_event.block_number)
703 })?;
704 flash_batch.push(flash);
705 } else {
706 let event_signature = hex::encode(event_sig_bytes);
707 anyhow::bail!("unexpected event signature {event_signature} for log {log:?}");
708 }
709
710 if !beyond_stale_data
712 && last_block_across_pool_events_table
713 .is_some_and(|table_max| block_number > table_max)
714 {
715 log::debug!(
716 "Crossed beyond stale data at block {block_number} - flushing current batches with ON CONFLICT, then switching to COPY"
717 );
718
719 self.flush_event_batches(
721 EVENT_BATCH_SIZE,
722 &mut block_batch,
723 &mut swap_batch,
724 &mut liquidity_batch,
725 &mut collect_batch,
726 &mut protocol_update_batch,
727 &mut protocol_collect_batch,
728 &mut flash_batch,
729 false,
730 true,
731 )
732 .await?;
733
734 beyond_stale_data = true;
735 log::debug!("Switched to COPY mode - future batches will use COPY command");
736 } else {
737 self.flush_event_batches(
739 EVENT_BATCH_SIZE,
740 &mut block_batch,
741 &mut swap_batch,
742 &mut liquidity_batch,
743 &mut collect_batch,
744 &mut protocol_update_batch,
745 &mut protocol_collect_batch,
746 &mut flash_batch,
747 false, false,
749 )
750 .await?;
751 }
752
753 metrics.update(blocks_processed as usize);
754 blocks_processed = 0;
755
756 if metrics.should_log_progress(block_number, to_block) {
758 metrics.log_progress(block_number);
759 self.flush_event_batches(
760 EVENT_BATCH_SIZE,
761 &mut block_batch,
762 &mut swap_batch,
763 &mut liquidity_batch,
764 &mut collect_batch,
765 &mut protocol_update_batch,
766 &mut protocol_collect_batch,
767 &mut flash_batch,
768 false,
769 true,
770 )
771 .await?;
772
773 if let Some(checkpoint_block) =
774 Self::completed_pool_event_checkpoint(block_number, effective_from_block)
775 {
776 self.cache
777 .update_pool_last_synced_block(dex, &pool_identifier, checkpoint_block)
778 .await?;
779 }
780 }
781 }
782
783 self.flush_event_batches(
784 EVENT_BATCH_SIZE,
785 &mut block_batch,
786 &mut swap_batch,
787 &mut liquidity_batch,
788 &mut collect_batch,
789 &mut protocol_update_batch,
790 &mut protocol_collect_batch,
791 &mut flash_batch,
792 false,
793 true,
794 )
795 .await?;
796
797 metrics.log_final_stats();
798 self.cache
799 .update_pool_last_synced_block(dex, &pool_identifier, to_block)
800 .await?;
801
802 log::debug!(
803 "Successfully synced Dex '{}' Pool '{}' up to block {}",
804 dex,
805 pool_display,
806 to_block.separate_with_commas()
807 );
808 Ok(())
809 } => result
810 };
811
812 sync_result
813 }
814
815 #[expect(clippy::too_many_arguments)]
816 async fn flush_event_batches(
817 &mut self,
818 event_batch_size: usize,
819 block_batch: &mut Vec<Block>,
820 swap_batch: &mut Vec<PoolSwap>,
821 liquidity_batch: &mut Vec<PoolLiquidityUpdate>,
822 collect_batch: &mut Vec<PoolFeeCollect>,
823 protocol_update_batch: &mut Vec<PoolFeeProtocolUpdate>,
824 protocol_collect_batch: &mut Vec<PoolFeeProtocolCollect>,
825 flash_batch: &mut Vec<PoolFlash>,
826 use_copy_command: bool,
827 force_flush_all: bool,
828 ) -> anyhow::Result<()> {
829 let should_flush_swaps =
830 (force_flush_all || swap_batch.len() >= event_batch_size) && !swap_batch.is_empty();
831 let should_flush_liquidity = (force_flush_all || liquidity_batch.len() >= event_batch_size)
832 && !liquidity_batch.is_empty();
833 let should_flush_collects = (force_flush_all || collect_batch.len() >= event_batch_size)
834 && !collect_batch.is_empty();
835 let should_flush_protocol_update = (force_flush_all
836 || protocol_update_batch.len() >= event_batch_size)
837 && !protocol_update_batch.is_empty();
838 let should_flush_protocol_collect = (force_flush_all
839 || protocol_collect_batch.len() >= event_batch_size)
840 && !protocol_collect_batch.is_empty();
841 let should_flush_flash =
842 (force_flush_all || flash_batch.len() >= event_batch_size) && !flash_batch.is_empty();
843
844 if force_flush_all
845 || should_flush_swaps
846 || should_flush_liquidity
847 || should_flush_collects
848 || should_flush_protocol_update
849 || should_flush_protocol_collect
850 || should_flush_flash
851 {
852 self.flush_pool_event_blocks(block_batch).await?;
853 }
854
855 if should_flush_swaps {
856 self.cache
857 .add_pool_swaps_batch(swap_batch, use_copy_command)
858 .await?;
859 swap_batch.clear();
860 }
861
862 if should_flush_liquidity {
863 self.cache
864 .add_pool_liquidity_updates_batch(liquidity_batch, use_copy_command)
865 .await?;
866 liquidity_batch.clear();
867 }
868
869 if should_flush_collects {
870 self.cache
871 .add_pool_fee_collects_batch(collect_batch, use_copy_command)
872 .await?;
873 collect_batch.clear();
874 }
875
876 if should_flush_protocol_update {
877 self.cache
878 .add_pool_fee_protocol_updates_batch(protocol_update_batch)
879 .await?;
880 protocol_update_batch.clear();
881 }
882
883 if should_flush_protocol_collect {
884 self.cache
885 .add_pool_fee_protocol_collect_batch(protocol_collect_batch)
886 .await?;
887 protocol_collect_batch.clear();
888 }
889
890 if should_flush_flash {
891 self.cache.add_pool_flash_batch(flash_batch).await?;
892 flash_batch.clear();
893 }
894 Ok(())
895 }
896
897 async fn record_pool_event_block(
898 &mut self,
899 block: Block,
900 block_batch: &mut Vec<Block>,
901 ) -> anyhow::Result<()> {
902 self.cache
903 .cache_block_timestamp(block.number, block.timestamp);
904 block_batch.push(block);
905 if block_batch.len() >= POOL_EVENT_BLOCK_BATCH_SIZE {
906 self.flush_pool_event_blocks(block_batch).await?;
907 }
908 Ok(())
909 }
910
911 async fn flush_pool_event_blocks(
912 &mut self,
913 block_batch: &mut Vec<Block>,
914 ) -> anyhow::Result<()> {
915 if block_batch.is_empty() {
916 return Ok(());
917 }
918
919 self.cache
920 .add_pool_event_blocks_batch(std::mem::take(block_batch))
921 .await
922 }
923
924 fn completed_pool_event_checkpoint(
925 block_number: u64,
926 effective_from_block: u64,
927 ) -> Option<u64> {
928 let checkpoint_block = block_number.checked_sub(1)?;
929 (checkpoint_block >= effective_from_block).then_some(checkpoint_block)
930 }
931
932 pub fn process_pool_swap_event(
942 &self,
943 swap_event: &SwapEvent,
944 pool: &SharedPool,
945 ) -> anyhow::Result<PoolSwap> {
946 let timestamp = self
947 .cache
948 .get_block_timestamp(swap_event.block_number)
949 .copied()
950 .context("missing block timestamp for swap event")?;
951 let mut swap = swap_event.to_pool_swap(
952 self.chain.clone(),
953 pool.instrument_id,
954 pool.pool_identifier,
955 timestamp,
956 );
957 if let Err(e) = swap.calculate_trade_info(&pool.token0, &pool.token1, None) {
959 log::warn!(
960 "Skipping trade info for swap at block {} on pool {}: {e}",
961 swap_event.block_number,
962 pool.instrument_id,
963 );
964 }
965
966 Ok(swap)
967 }
968
969 pub fn process_pool_mint_event(
975 &self,
976 mint_event: &MintEvent,
977 pool: &SharedPool,
978 dex_extended: &DexExtended,
979 ) -> anyhow::Result<PoolLiquidityUpdate> {
980 let timestamp = self
981 .cache
982 .get_block_timestamp(mint_event.block_number)
983 .copied()
984 .context("missing block timestamp for mint event")?;
985
986 let liquidity_update = mint_event.to_pool_liquidity_update(
987 self.chain.clone(),
988 dex_extended.dex.clone(),
989 pool.instrument_id,
990 timestamp,
991 );
992
993 Ok(liquidity_update)
996 }
997
998 pub fn process_pool_burn_event(
1005 &self,
1006 burn_event: &BurnEvent,
1007 pool: &SharedPool,
1008 dex_extended: &DexExtended,
1009 ) -> anyhow::Result<PoolLiquidityUpdate> {
1010 let timestamp = self
1011 .cache
1012 .get_block_timestamp(burn_event.block_number)
1013 .copied()
1014 .context("missing block timestamp for burn event")?;
1015
1016 let liquidity_update = burn_event.to_pool_liquidity_update(
1017 self.chain.clone(),
1018 dex_extended.dex.clone(),
1019 pool.instrument_id,
1020 pool.pool_identifier,
1021 timestamp,
1022 );
1023
1024 Ok(liquidity_update)
1027 }
1028
1029 pub fn process_pool_collect_event(
1035 &self,
1036 collect_event: &CollectEvent,
1037 pool: &SharedPool,
1038 dex_extended: &DexExtended,
1039 ) -> anyhow::Result<PoolFeeCollect> {
1040 let timestamp = self
1041 .cache
1042 .get_block_timestamp(collect_event.block_number)
1043 .copied()
1044 .context("missing block timestamp for collect event")?;
1045
1046 let fee_collect = collect_event.to_pool_fee_collect(
1047 self.chain.clone(),
1048 dex_extended.dex.clone(),
1049 pool.instrument_id,
1050 timestamp,
1051 );
1052
1053 Ok(fee_collect)
1054 }
1055
1056 pub fn process_pool_flash_event(
1062 &self,
1063 flash_event: &FlashEvent,
1064 pool: &SharedPool,
1065 ) -> anyhow::Result<PoolFlash> {
1066 let timestamp = self
1067 .cache
1068 .get_block_timestamp(flash_event.block_number)
1069 .copied()
1070 .context("missing block timestamp for flash event")?;
1071
1072 let flash = flash_event.to_pool_flash(self.chain.clone(), pool.instrument_id, timestamp);
1073
1074 Ok(flash)
1075 }
1076
1077 pub fn process_pool_fee_protocol_update_event(
1083 &self,
1084 fee_protocol_update_event: &FeeProtocolUpdateEvent,
1085 pool: &SharedPool,
1086 ) -> anyhow::Result<PoolFeeProtocolUpdate> {
1087 let timestamp = self
1088 .cache
1089 .get_block_timestamp(fee_protocol_update_event.block_number)
1090 .copied()
1091 .context("missing block timestamp for SetFeeProtocol event")?;
1092
1093 let update = fee_protocol_update_event.to_pool_fee_protocol_update(
1094 self.chain.clone(),
1095 pool.instrument_id,
1096 timestamp,
1097 );
1098
1099 Ok(update)
1100 }
1101
1102 pub fn process_pool_fee_protocol_collect_event(
1108 &self,
1109 fee_protocol_collect_event: &FeeProtocolCollectEvent,
1110 pool: &SharedPool,
1111 ) -> anyhow::Result<PoolFeeProtocolCollect> {
1112 let timestamp = self
1113 .cache
1114 .get_block_timestamp(fee_protocol_collect_event.block_number)
1115 .copied()
1116 .context("missing block timestamp for CollectProtocol event")?;
1117
1118 let collect = fee_protocol_collect_event.to_pool_fee_protocol_collect(
1119 self.chain.clone(),
1120 pool.instrument_id,
1121 timestamp,
1122 );
1123
1124 Ok(collect)
1125 }
1126
1127 pub async fn sync_exchange_pools(
1138 &mut self,
1139 dex: &DexType,
1140 from_block: u64,
1141 to_block: Option<u64>,
1142 reset: bool,
1143 ) -> anyhow::Result<()> {
1144 let dex_extended = self.get_dex_extended(dex)?.clone();
1145
1146 let mut service = PoolDiscoveryService::new(
1147 self.chain.clone(),
1148 &mut self.cache,
1149 &self.tokens,
1150 &self.hypersync_client,
1151 self.cancellation_token.clone(),
1152 self.config.clone(),
1153 );
1154
1155 service
1156 .sync_pools(&dex_extended, from_block, to_block, reset)
1157 .await?;
1158
1159 Ok(())
1160 }
1161
1162 pub async fn register_dex_exchange(&mut self, dex_id: DexType) -> anyhow::Result<()> {
1173 self.register_dex(dex_id).await?;
1174 let _ = self.cache.load_pools(&dex_id).await?;
1175 Ok(())
1176 }
1177
1178 pub async fn register_dex_exchange_for_pool(
1188 &mut self,
1189 dex_id: DexType,
1190 pool_identifier: &PoolIdentifier,
1191 ) -> anyhow::Result<()> {
1192 self.register_dex(dex_id).await?;
1193 let _ = self.cache.load_pool(&dex_id, pool_identifier).await?;
1194 Ok(())
1195 }
1196
1197 async fn register_dex(&mut self, dex_id: DexType) -> anyhow::Result<()> {
1199 let Some(dex_extended) = get_dex_extended(self.chain.name, &dex_id) else {
1200 anyhow::bail!("Unknown DEX {dex_id} on chain {}", self.chain.name);
1201 };
1202
1203 log::debug!("Registering DEX {dex_id} on chain {}", self.chain.name);
1204 self.cache.add_dex(dex_extended.dex.clone()).await?;
1205 self.subscription_manager.register_dex_for_subscriptions(
1206 dex_id,
1207 dex_extended.swap_created_event.as_ref(),
1208 dex_extended.mint_created_event.as_ref(),
1209 dex_extended.burn_created_event.as_ref(),
1210 dex_extended.collect_created_event.as_ref(),
1211 dex_extended.flash_created_event.as_deref(),
1212 );
1213 Ok(())
1214 }
1215
1216 pub async fn bootstrap_latest_pool_profiler(
1237 &mut self,
1238 pool: &SharedPool,
1239 to_block: Option<u64>,
1240 ) -> anyhow::Result<(PoolProfiler, bool)> {
1241 log::debug!(
1242 "Bootstrapping latest pool profiler for pool {}",
1243 pool.address
1244 );
1245
1246 if self.cache.database.is_none() {
1247 anyhow::bail!(
1248 "Database is not initialized, so we cannot properly bootstrap the latest pool profiler"
1249 );
1250 }
1251
1252 let to_block = match to_block {
1253 Some(block) => block,
1254 None => self.hypersync_client.current_block().await,
1255 };
1256 let mut profiler = PoolProfiler::new(pool.clone());
1257
1258 let from_position = match self
1260 .cache
1261 .database
1262 .as_ref()
1263 .unwrap()
1264 .load_latest_pool_snapshot(
1265 pool.chain.chain_id,
1266 &pool.pool_identifier,
1267 Some(to_block),
1268 true,
1269 )
1270 .await
1271 {
1272 Ok(Some(snapshot)) => {
1273 if snapshot.positions.is_empty()
1279 && snapshot.ticks.is_empty()
1280 && snapshot.block_position.number == pool.creation_block
1281 {
1282 log::warn!(
1283 "Ignoring empty stub snapshot at pool creation block {} for {}; rebuilding from events",
1284 snapshot.block_position.number.separate_with_commas(),
1285 pool.instrument_id,
1286 );
1287 None
1288 } else {
1289 log::debug!(
1290 "Loaded valid snapshot from block {} which contains {} positions and {} ticks",
1291 snapshot.block_position.number.separate_with_commas(),
1292 snapshot.positions.len(),
1293 snapshot.ticks.len()
1294 );
1295 let block_position = snapshot.block_position.clone();
1296 profiler.restore_from_snapshot(snapshot)?;
1297 log::debug!("Restored profiler from snapshot");
1298 Some(block_position)
1299 }
1300 }
1301 _ => {
1302 log::debug!("No valid snapshot found, processing from beginning");
1303 None
1304 }
1305 };
1306
1307 if self
1311 .cache
1312 .database
1313 .as_ref()
1314 .unwrap()
1315 .get_pool_last_synced_block(self.chain.chain_id, &pool.dex.name, &pool.pool_identifier)
1316 .await?
1317 .is_none()
1318 {
1319 return self
1320 .construct_pool_profiler_from_hypersync_rpc(profiler, from_position, to_block)
1321 .await;
1322 }
1323
1324 self.sync_pool_events(
1326 &pool.dex.name,
1327 pool.pool_identifier,
1328 None,
1329 Some(to_block),
1330 false,
1331 )
1332 .await
1333 .context("failed to sync pool events for snapshot request")?;
1334
1335 if !profiler.is_initialized {
1336 if let Some(initial_sqrt_price_x96) = pool.initial_sqrt_price_x96 {
1337 profiler.initialize(initial_sqrt_price_x96)?;
1338 } else {
1339 anyhow::bail!(
1340 "Pool is not initialized and it doesn't contain initial price, cannot bootstrap profiler"
1341 );
1342 }
1343 }
1344
1345 let from_block = from_position
1346 .as_ref()
1347 .map_or(profiler.pool.creation_block, |block_position| {
1348 block_position.number
1349 });
1350 let total_blocks = to_block.saturating_sub(from_block) + 1;
1351
1352 profiler.enable_reporting(from_block, total_blocks, BLOCKS_PROCESS_IN_SYNC_REPORT);
1354
1355 let mut stream = self.cache.database.as_ref().unwrap().stream_pool_events(
1356 pool.chain.clone(),
1357 pool.dex.clone(),
1358 pool.instrument_id,
1359 pool.pool_identifier,
1360 from_position.clone(),
1361 Some(to_block),
1362 );
1363
1364 while let Some(result) = stream.next().await {
1365 match result {
1366 Ok(event) => {
1367 profiler.process(&event)?;
1368 }
1369 Err(e) => return Err(e).context("failed to stream pool event from database"),
1370 }
1371 }
1372
1373 profiler.finalize_reporting();
1374
1375 Ok((profiler, false))
1376 }
1377
1378 async fn construct_pool_profiler_from_hypersync_rpc(
1398 &mut self,
1399 mut profiler: PoolProfiler,
1400 from_position: Option<BlockPosition>,
1401 to_block: u64,
1402 ) -> anyhow::Result<(PoolProfiler, bool)> {
1403 log::debug!(
1404 "Constructing pool profiler from hypersync stream and RPC final state querying"
1405 );
1406 let dex_extended = self.get_dex_extended(&profiler.pool.dex.name)?.clone();
1407 let mint_event_signature = dex_extended.mint_created_event.as_ref();
1408 let burn_event_signature = dex_extended.burn_created_event.as_ref();
1409 let initialize_event_signature =
1410 if let Some(initialize_event) = &dex_extended.initialize_event {
1411 initialize_event.as_ref()
1412 } else {
1413 anyhow::bail!(
1414 "DEX {} does not have initialize event set.",
1415 profiler.pool.dex.name
1416 );
1417 };
1418 let mint_sig_bytes = hex::decode(
1419 mint_event_signature
1420 .strip_prefix("0x")
1421 .unwrap_or(mint_event_signature),
1422 )?;
1423 let burn_sig_bytes = hex::decode(
1424 burn_event_signature
1425 .strip_prefix("0x")
1426 .unwrap_or(burn_event_signature),
1427 )?;
1428 let initialize_sig_bytes = hex::decode(
1429 initialize_event_signature
1430 .strip_prefix("0x")
1431 .unwrap_or(initialize_event_signature),
1432 )?;
1433 let protocol_update_event_signature = dex_extended.fee_protocol_update_event.as_deref();
1434 let protocol_update_sig_bytes = protocol_update_event_signature
1435 .map(|s| hex::decode(s.strip_prefix("0x").unwrap_or(s)).unwrap_or_default());
1436
1437 let from_block = from_position.map_or(profiler.pool.creation_block, |block_position| {
1438 block_position.number
1439 });
1440 let total_blocks = to_block.saturating_sub(from_block) + 1;
1441
1442 log::debug!(
1443 "Bootstrapping pool profiler for pool {} from block {} to {} (total: {} blocks)",
1444 profiler.pool.address,
1445 from_block.separate_with_commas(),
1446 to_block.separate_with_commas(),
1447 total_blocks.separate_with_commas()
1448 );
1449
1450 profiler.enable_reporting(from_block, total_blocks, BLOCKS_PROCESS_IN_SYNC_REPORT);
1452
1453 let mut event_signatures = vec![
1454 mint_event_signature,
1455 burn_event_signature,
1456 initialize_event_signature,
1457 ];
1458
1459 if let Some(event) = protocol_update_event_signature {
1460 event_signatures.push(event);
1461 }
1462
1463 let pool_events_stream = self
1464 .hypersync_client
1465 .request_contract_events_stream(
1466 from_block,
1467 Some(to_block),
1468 &profiler.pool.address,
1469 event_signatures,
1470 )
1471 .await;
1472 tokio::pin!(pool_events_stream);
1473 let mut block_batch: Vec<Block> = Vec::with_capacity(POOL_EVENT_BLOCK_BATCH_SIZE);
1474
1475 while let Some(item) = pool_events_stream.next().await {
1476 let log = match item {
1477 PoolEventStreamItem::Block(block) => {
1478 self.record_pool_event_block(block, &mut block_batch)
1479 .await?;
1480 continue;
1481 }
1482 PoolEventStreamItem::Log(log) => log,
1483 };
1484 let event_sig_bytes = extract_event_signature_bytes(&log)?;
1485
1486 if event_sig_bytes == initialize_sig_bytes {
1487 if profiler.is_initialized {
1488 log::debug!(
1492 "Profiler already initialized; skipping Initialize event at block {}",
1493 extract_block_number(&log)?.separate_with_commas(),
1494 );
1495 } else {
1496 let initialize_event = dex_extended.parse_initialize_event_hypersync(&log)?;
1497 profiler.initialize(initialize_event.sqrt_price_x96)?;
1498 self.cache
1499 .database
1500 .as_ref()
1501 .unwrap()
1502 .update_pool_initial_price_tick(self.chain.chain_id, &initialize_event)
1503 .await?;
1504 }
1505 } else if event_sig_bytes == mint_sig_bytes {
1506 let mint_event = dex_extended.parse_mint_event_hypersync(&log)?;
1507 let liquidity_update = self
1508 .process_pool_mint_event(&mint_event, &profiler.pool, &dex_extended)
1509 .with_context(|| {
1510 format!(
1511 "failed to process mint event at block {}",
1512 mint_event.block_number
1513 )
1514 })?;
1515 profiler.process(&DexPoolData::LiquidityUpdate(liquidity_update))?;
1516 } else if event_sig_bytes == burn_sig_bytes {
1517 let burn_event = dex_extended.parse_burn_event_hypersync(&log)?;
1518 let liquidity_update = self
1519 .process_pool_burn_event(&burn_event, &profiler.pool, &dex_extended)
1520 .with_context(|| {
1521 format!(
1522 "failed to process burn event at block {}",
1523 burn_event.block_number
1524 )
1525 })?;
1526 profiler.process(&DexPoolData::LiquidityUpdate(liquidity_update))?;
1527 } else if protocol_update_sig_bytes
1528 .as_ref()
1529 .is_some_and(|sig| sig.as_slice() == event_sig_bytes)
1530 {
1531 let fee_protocol_update_event =
1532 dex_extended.parse_fee_protocol_update_event_hypersync(&log)?;
1533 let update = self
1534 .process_pool_fee_protocol_update_event(
1535 &fee_protocol_update_event,
1536 &profiler.pool,
1537 )
1538 .with_context(|| {
1539 format!(
1540 "failed to process SetFeeProtocol event at block {}",
1541 fee_protocol_update_event.block_number
1542 )
1543 })?;
1544 profiler.process(&DexPoolData::FeeProtocolUpdate(update))?;
1545 } else {
1546 let event_signature = hex::encode(event_sig_bytes);
1547 anyhow::bail!(
1548 "unexpected event signature in bootstrap_latest_pool_profiler: {event_signature} for log {log:?}"
1549 );
1550 }
1551 }
1552
1553 self.flush_pool_event_blocks(&mut block_batch).await?;
1554 profiler.finalize_reporting();
1555
1556 let on_chain_snapshot = self
1557 .get_on_chain_snapshot(&profiler)
1558 .await
1559 .with_context(|| {
1560 let snapshot_block = profiler
1561 .last_processed_event
1562 .as_ref()
1563 .map_or(profiler.pool.creation_block, |event| event.number);
1564
1565 format!(
1566 "failed to restore pool {} from on-chain snapshot at block {} with {} ticks and {} positions",
1567 profiler.pool.address,
1568 snapshot_block.separate_with_commas(),
1569 profiler.get_active_tick_values().len().separate_with_commas(),
1570 profiler.get_all_position_keys().len().separate_with_commas()
1571 )
1572 })?;
1573 profiler.restore_from_snapshot(on_chain_snapshot)?;
1574
1575 Ok((profiler, true))
1576 }
1577
1578 pub async fn check_snapshot_validity(
1598 &self,
1599 profiler: &PoolProfiler,
1600 already_validated: bool,
1601 ) -> anyhow::Result<SnapshotValidation> {
1602 let (validation, block_position) = if already_validated {
1603 log::debug!("Snapshot already validated from RPC, skipping on-chain comparison");
1605 let last_event = profiler
1606 .last_processed_event
1607 .clone()
1608 .expect("Profiler should have last_processed_event");
1609 (SnapshotValidation::OnChain, Some(last_event))
1610 } else {
1611 match self.get_on_chain_snapshot(profiler).await {
1613 Ok(on_chain_snapshot) => {
1614 log::debug!("Comparing profiler state with on-chain state...");
1615 let comparison = compare_pool_profiler_detailed(profiler, &on_chain_snapshot);
1616 let validation = if comparison.is_valid_for_snapshot() {
1617 if !comparison.is_exact_match() {
1618 log::warn!(
1619 "Pool profiler snapshot has a non-structural mismatch (sqrt ratio, fee protocol, or protocol fees); accepting snapshot"
1620 );
1621 }
1622 SnapshotValidation::OnChain
1623 } else {
1624 log::error!(
1625 "Pool profiler state does NOT match on-chain smart contract state"
1626 );
1627 SnapshotValidation::Invalid
1628 };
1629 (validation, Some(on_chain_snapshot.block_position))
1630 }
1631 Err(e) => {
1632 log::warn!(
1633 "Could not validate snapshot against on-chain state, keeping replay-derived snapshot: {e}"
1634 );
1635 let reported = self
1639 .stored_snapshot_validation(profiler)
1640 .await?
1641 .unwrap_or(SnapshotValidation::Replay);
1642 (reported, None)
1643 }
1644 }
1645 };
1646
1647 if let (Some(block_position), Some(cache_database)) = (block_position, &self.cache.database)
1648 {
1649 cache_database
1650 .set_pool_snapshot_validation_state(
1651 profiler.pool.chain.chain_id,
1652 &profiler.pool.pool_identifier,
1653 block_position.number,
1654 block_position.transaction_index,
1655 block_position.log_index,
1656 validation.as_str(),
1657 )
1658 .await?;
1659 log::debug!(
1660 "Set pool snapshot validation state to {}",
1661 validation.as_str()
1662 );
1663 }
1664
1665 Ok(validation)
1666 }
1667
1668 async fn stored_snapshot_validation(
1673 &self,
1674 profiler: &PoolProfiler,
1675 ) -> anyhow::Result<Option<SnapshotValidation>> {
1676 let (Some(block_position), Some(cache_database)) =
1677 (profiler.last_processed_event.as_ref(), &self.cache.database)
1678 else {
1679 return Ok(None);
1680 };
1681
1682 let stored = cache_database
1683 .get_pool_snapshot_validation_state(
1684 profiler.pool.chain.chain_id,
1685 &profiler.pool.pool_identifier,
1686 block_position.number,
1687 block_position.transaction_index,
1688 block_position.log_index,
1689 )
1690 .await?;
1691
1692 Ok(stored.and_then(|token| SnapshotValidation::from_db_token(&token)))
1693 }
1694
1695 async fn get_on_chain_snapshot(&self, profiler: &PoolProfiler) -> anyhow::Result<PoolSnapshot> {
1701 if matches!(
1703 profiler.pool.dex.name,
1704 DexType::UniswapV3 | DexType::PancakeSwapV3
1705 ) {
1706 let last_processed_event = Self::last_processed_event_for_on_chain_snapshot(profiler)?;
1707 let timestamp = Self::timestamp_for_on_chain_snapshot(
1708 profiler,
1709 self.cache
1710 .get_block_timestamp(last_processed_event.number)
1711 .copied(),
1712 )?;
1713 let on_chain_snapshot = self
1714 .univ3_pool
1715 .fetch_snapshot(
1716 &profiler.pool.address,
1717 profiler.pool.instrument_id,
1718 profiler.get_active_tick_values().as_slice(),
1719 &profiler.get_all_position_keys(),
1720 last_processed_event,
1721 timestamp, timestamp, )
1724 .await?;
1725
1726 Ok(on_chain_snapshot)
1727 } else {
1728 anyhow::bail!(
1729 "Fetching on-chain snapshot for Dex protocol {} is not supported yet.",
1730 profiler.pool.dex.name
1731 )
1732 }
1733 }
1734
1735 fn timestamp_for_on_chain_snapshot(
1736 profiler: &PoolProfiler,
1737 cached_timestamp: Option<UnixNanos>,
1738 ) -> anyhow::Result<UnixNanos> {
1739 if let Some(timestamp) = cached_timestamp {
1740 return Ok(timestamp);
1741 }
1742
1743 profiler
1744 .last_processed_ts
1745 .context("missing block timestamp for on-chain snapshot")
1746 }
1747
1748 fn last_processed_event_for_on_chain_snapshot(
1749 profiler: &PoolProfiler,
1750 ) -> anyhow::Result<BlockPosition> {
1751 let Some(last_processed_event) = profiler.last_processed_event.clone() else {
1752 anyhow::bail!(
1753 "cannot fetch on-chain snapshot for pool {} without a processed event",
1754 profiler.pool.address
1755 );
1756 };
1757 Ok(last_processed_event)
1758 }
1759
1760 pub async fn replay_pool_events(&self, pool: &Pool, dex: &SharedDex) -> anyhow::Result<()> {
1769 if let Some(database) = &self.cache.database {
1770 log::debug!(
1771 "Replaying historical events for pool {} to hydrate profiler",
1772 pool.instrument_id
1773 );
1774
1775 let mut event_stream = database.stream_pool_events(
1776 self.chain.clone(),
1777 dex.clone(),
1778 pool.instrument_id,
1779 pool.pool_identifier,
1780 None,
1781 None,
1782 );
1783 let mut event_count = 0;
1784
1785 while let Some(event_result) = event_stream.next().await {
1786 match event_result {
1787 Ok(event) => {
1788 let data_event = match event {
1789 DexPoolData::Swap(swap) => DataEvent::DeFi(DefiData::PoolSwap(swap)),
1790 DexPoolData::LiquidityUpdate(update) => {
1791 DataEvent::DeFi(DefiData::PoolLiquidityUpdate(update))
1792 }
1793 DexPoolData::FeeCollect(collect) => {
1794 DataEvent::DeFi(DefiData::PoolFeeCollect(collect))
1795 }
1796 DexPoolData::FeeProtocolUpdate(update) => {
1797 DataEvent::DeFi(DefiData::PoolFeeProtocolUpdate(update))
1798 }
1799 DexPoolData::FeeProtocolCollect(collect) => {
1800 DataEvent::DeFi(DefiData::PoolFeeProtocolCollect(collect))
1801 }
1802 DexPoolData::Flash(flash) => {
1803 DataEvent::DeFi(DefiData::PoolFlash(flash))
1804 }
1805 };
1806 self.send_data(data_event);
1807 event_count += 1;
1808 }
1809 Err(e) => {
1810 log::error!("Error streaming event for pool {}: {e}", pool.instrument_id);
1811 }
1812 }
1813 }
1814
1815 log::debug!(
1816 "Replayed {event_count} historical events for pool {}",
1817 pool.instrument_id
1818 );
1819 } else {
1820 log::debug!(
1821 "No database available, skipping event replay for pool {}",
1822 pool.instrument_id
1823 );
1824 }
1825
1826 Ok(())
1827 }
1828
1829 fn determine_from_block(&self) -> u64 {
1831 self.config
1832 .from_block
1833 .unwrap_or_else(|| self.cache.min_dex_creation_block().unwrap_or(0))
1834 }
1835
1836 fn get_dex_extended(&self, dex_id: &DexType) -> anyhow::Result<&DexExtended> {
1838 if !self.cache.get_registered_dexes().contains(dex_id) {
1839 anyhow::bail!("DEX {dex_id} is not registered in the data client");
1840 }
1841
1842 match get_dex_extended(self.chain.name, dex_id) {
1843 Some(dex) => Ok(dex),
1844 None => anyhow::bail!("Dex {dex_id} doesn't exist for chain {}", self.chain.name),
1845 }
1846 }
1847
1848 pub fn get_pool(&self, pool_identifier: &PoolIdentifier) -> anyhow::Result<&SharedPool> {
1854 match self.cache.get_pool(pool_identifier) {
1855 Some(pool) => Ok(pool),
1856 None => anyhow::bail!("Pool {pool_identifier} is not registered"),
1857 }
1858 }
1859
1860 pub fn send_data(&self, data: DataEvent) {
1862 if let Some(data_tx) = &self.data_tx {
1863 log::debug!("Sending {data}");
1864
1865 if let Err(e) = data_tx.send(data) {
1866 log::error!("Failed to send data: {e}");
1867 }
1868 } else {
1869 log::error!("No data event channel for sending data");
1870 }
1871 }
1872
1873 pub async fn disconnect(&mut self) {
1878 self.hypersync_client.disconnect().await;
1879 }
1880}
1881
1882#[cfg(test)]
1883mod tests {
1884 use alloy::primitives::{U160, address};
1885 use nautilus_core::UnixNanos;
1886 use nautilus_model::defi::{Chain, Token};
1887 use rstest::rstest;
1888 use tokio_util::sync::CancellationToken;
1889
1890 use super::*;
1891
1892 const WETH_USDT_POOL: &str = "0x4e68ccd3e89f51c3074ca5072bbac773960dfa36";
1893 const WETH_USDT_CREATION_BLOCK: u64 = 12_375_326;
1894
1895 #[rstest]
1896 #[case(SnapshotValidation::OnChain, "on_chain", true)]
1897 #[case(SnapshotValidation::Replay, "replay", true)]
1898 #[case(SnapshotValidation::Invalid, "invalid", false)]
1899 fn snapshot_validation_db_token_and_usability(
1900 #[case] validation: SnapshotValidation,
1901 #[case] expected_str: &str,
1902 #[case] expected_usable: bool,
1903 ) {
1904 assert_eq!(validation.as_str(), expected_str);
1907 assert_eq!(validation.is_usable(), expected_usable);
1908 assert_eq!(
1911 SnapshotValidation::from_db_token(expected_str),
1912 Some(validation)
1913 );
1914 }
1915
1916 #[rstest]
1917 fn snapshot_validation_from_db_token_rejects_unknown() {
1918 assert_eq!(SnapshotValidation::from_db_token("bogus"), None);
1919 }
1920
1921 #[rstest]
1922 fn last_processed_event_for_on_chain_snapshot_rejects_unprocessed_profiler() {
1923 let mut profiler = PoolProfiler::new(weth_usdt_pool());
1924 profiler
1925 .initialize(U160::from_str_radix("3cb0adde486484998be0b", 16).unwrap())
1926 .expect("Known WETH/USDT initial sqrt price should initialize");
1927
1928 let error = BlockchainDataClientCore::last_processed_event_for_on_chain_snapshot(&profiler)
1929 .expect_err("unprocessed profiler should not fetch on-chain state");
1930
1931 assert_eq!(
1932 error.to_string(),
1933 format!(
1934 "cannot fetch on-chain snapshot for pool {} without a processed event",
1935 profiler.pool.address
1936 )
1937 );
1938 }
1939
1940 #[rstest]
1941 fn timestamp_for_on_chain_snapshot_prefers_cached_block_timestamp() {
1942 let pool = weth_usdt_pool();
1943 let mut profiler = PoolProfiler::new(pool);
1944 let cached_ts = UnixNanos::from(1_700_000_001_000_000_000);
1945 let profiler_ts = UnixNanos::from(1_700_000_000_000_000_000);
1946 profiler.last_processed_ts = Some(profiler_ts);
1947
1948 let timestamp =
1949 BlockchainDataClientCore::timestamp_for_on_chain_snapshot(&profiler, Some(cached_ts))
1950 .unwrap();
1951
1952 assert_eq!(timestamp, cached_ts);
1953 }
1954
1955 #[rstest]
1956 fn timestamp_for_on_chain_snapshot_falls_back_to_profiler_timestamp() {
1957 let pool = weth_usdt_pool();
1958 let mut profiler = PoolProfiler::new(pool);
1959 let profiler_ts = UnixNanos::from(1_700_000_000_000_000_000);
1960 profiler.last_processed_ts = Some(profiler_ts);
1961
1962 let timestamp =
1963 BlockchainDataClientCore::timestamp_for_on_chain_snapshot(&profiler, None).unwrap();
1964
1965 assert_eq!(timestamp, profiler_ts);
1966 }
1967
1968 #[rstest]
1969 fn timestamp_for_on_chain_snapshot_rejects_missing_timestamp() {
1970 let pool = weth_usdt_pool();
1971 let profiler = PoolProfiler::new(pool);
1972
1973 let error = BlockchainDataClientCore::timestamp_for_on_chain_snapshot(&profiler, None)
1974 .expect_err("missing timestamps should fail");
1975
1976 assert_eq!(
1977 error.to_string(),
1978 "missing block timestamp for on-chain snapshot"
1979 );
1980 }
1981
1982 #[rstest]
1983 #[case(100, 50, Some(99))]
1984 #[case(50, 50, None)]
1985 #[case(0, 0, None)]
1986 fn completed_pool_event_checkpoint_excludes_in_flight_block(
1987 #[case] block_number: u64,
1988 #[case] effective_from_block: u64,
1989 #[case] expected: Option<u64>,
1990 ) {
1991 let checkpoint = BlockchainDataClientCore::completed_pool_event_checkpoint(
1992 block_number,
1993 effective_from_block,
1994 );
1995
1996 assert_eq!(checkpoint, expected);
1997 }
1998
1999 #[tokio::test(flavor = "multi_thread")]
2000 #[ignore = "requires ENVIO_API_TOKEN and live HyperSync access"]
2001 async fn live_hypersync_bootstrap_fails_closed_when_rpc_hydration_fails() {
2002 std::env::var("ENVIO_API_TOKEN").expect("ENVIO_API_TOKEN must be set");
2003
2004 let pool = weth_usdt_pool();
2005 let chain = Arc::new(
2006 Chain::from_chain_id(1)
2007 .expect("Ethereum chain should exist")
2008 .clone(),
2009 );
2010 let dex = get_dex_extended(chain.name, &DexType::UniswapV3)
2011 .expect("Ethereum UniswapV3 should be registered")
2012 .dex
2013 .clone();
2014 let (hypersync_tx, _hypersync_rx) = tokio::sync::mpsc::unbounded_channel();
2015 let config = BlockchainDataClientConfig::builder()
2016 .chain(chain)
2017 .dex_ids(vec![DexType::UniswapV3])
2018 .http_rpc_url("http://127.0.0.1:9".to_string())
2019 .use_hypersync_for_live_data(true)
2020 .maybe_from_block(Some(WETH_USDT_CREATION_BLOCK))
2021 .build();
2022 let mut core = BlockchainDataClientCore::new(
2023 config,
2024 Some(hypersync_tx),
2025 None,
2026 CancellationToken::new(),
2027 );
2028 core.cache
2029 .add_dex(dex)
2030 .await
2031 .expect("DEX should be added to in-memory cache");
2032
2033 let block_position = BlockPosition::new(
2034 WETH_USDT_CREATION_BLOCK,
2035 "0x2e07c690f149223e4f290986277304ea6a05c6ee47ba303732166bc1b15cbafb".to_string(),
2036 11,
2037 27,
2038 );
2039 let mut profiler = PoolProfiler::new(pool);
2040 profiler
2041 .initialize(U160::from_str_radix("3cb0adde486484998be0b", 16).unwrap())
2042 .expect("Known WETH/USDT initial sqrt price should initialize");
2043 profiler.last_processed_event = Some(block_position.clone());
2044
2045 let result = core
2046 .construct_pool_profiler_from_hypersync_rpc(
2047 profiler,
2048 Some(block_position),
2049 WETH_USDT_CREATION_BLOCK,
2050 )
2051 .await;
2052
2053 let error = result.expect_err("RPC hydration failure should fail closed");
2054 let error_message = format!("{error:?}");
2055 assert!(
2056 error_message.contains("failed to restore pool"),
2057 "hydration error should include pool context, was {error_message}"
2058 );
2059 assert!(
2060 error_message.to_lowercase().contains(WETH_USDT_POOL),
2061 "hydration error should include pool address, was {error_message}"
2062 );
2063 }
2064
2065 #[tokio::test(flavor = "multi_thread")]
2066 #[ignore = "requires ENVIO_API_TOKEN and live HyperSync access"]
2067 async fn live_hypersync_parses_real_set_fee_protocol_update_event() {
2068 std::env::var("ENVIO_API_TOKEN").expect("ENVIO_API_TOKEN must be set");
2069
2070 let chain = Arc::new(
2073 Chain::from_chain_id(42161)
2074 .expect("Arbitrum chain should exist")
2075 .clone(),
2076 );
2077 let dex_extended = get_dex_extended(chain.name, &DexType::UniswapV3)
2078 .expect("Arbitrum UniswapV3 should be registered");
2079 let pool_address = address!("c31e54c7a869b9fcbecc14363cf510d1c41fa443");
2080 let signature = dex_extended
2081 .dex
2082 .fee_protocol_update_event
2083 .as_deref()
2084 .expect("UniswapV3 should advertise the SetFeeProtocol signature");
2085
2086 let client = HyperSyncClient::new(chain, None, CancellationToken::new());
2087 let stream = client
2088 .request_contract_events_stream(
2089 438_989_951,
2090 Some(438_989_951),
2091 &pool_address,
2092 vec![signature],
2093 )
2094 .await;
2095 tokio::pin!(stream);
2096
2097 let mut events = Vec::new();
2098
2099 while let Some(item) = stream.next().await {
2100 if let PoolEventStreamItem::Log(log) = item {
2101 events.push(
2102 dex_extended
2103 .parse_fee_protocol_update_event_hypersync(&log)
2104 .expect("real SetFeeProtocol log should parse"),
2105 );
2106 }
2107 }
2108
2109 assert_eq!(events.len(), 1, "expected exactly one SetFeeProtocol event");
2110 assert_eq!(events[0].block_number, 438_989_951);
2111 assert_eq!(events[0].fee_protocol0_new, 4);
2112 assert_eq!(events[0].fee_protocol1_new, 4);
2113 }
2114
2115 fn weth_usdt_pool() -> SharedPool {
2116 let chain = Arc::new(
2117 Chain::from_chain_id(1)
2118 .expect("Ethereum chain should exist")
2119 .clone(),
2120 );
2121 let dex = get_dex_extended(chain.name, &DexType::UniswapV3)
2122 .expect("Ethereum UniswapV3 should be registered")
2123 .dex
2124 .clone();
2125 let pool_address = address!("4e68ccd3e89f51c3074ca5072bbac773960dfa36");
2126 let token0 = Token::new(
2127 chain.clone(),
2128 address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
2129 "Wrapped Ether".to_string(),
2130 "WETH".to_string(),
2131 18,
2132 );
2133 let token1 = Token::new(
2134 chain.clone(),
2135 address!("dAC17F958D2ee523a2206206994597C13D831ec7"),
2136 "Tether USD".to_string(),
2137 "USDT".to_string(),
2138 6,
2139 );
2140
2141 Arc::new(Pool::new(
2142 chain,
2143 dex,
2144 pool_address,
2145 PoolIdentifier::from_address(pool_address),
2146 WETH_USDT_CREATION_BLOCK,
2147 token0,
2148 token1,
2149 Some(3_000),
2150 Some(60),
2151 UnixNanos::default(),
2152 ))
2153 }
2154}