1use std::{cmp::max, collections::BTreeMap, sync::Arc};
17
18use ahash::AHashMap;
19use anyhow::Context;
20use futures_util::StreamExt;
21use nautilus_common::messages::DataEvent;
22use nautilus_core::{
23 UnixNanos, hex,
24 string::{formatting::Separable, secret::REDACTED},
25};
26use nautilus_live::SocketControl;
27use nautilus_model::defi::{
28 Block, Blockchain, DexType, Pool, PoolIdentifier, PoolLiquidityUpdate, PoolProfiler, PoolSwap,
29 SharedChain, SharedDex, SharedPool,
30 data::{
31 DefiData, DexPoolData, PoolFeeCollect, PoolFeeProtocolCollect, PoolFeeProtocolUpdate,
32 PoolFlash,
33 block::{BLOCK_SCOPED_SNAPSHOT_INDEX, BlockPosition},
34 },
35 pool_analysis::{compare::compare_pool_profiler_detailed, snapshot::PoolSnapshot},
36 reporting::{BlockchainSyncReportItems, BlockchainSyncReporter},
37 tick_map::tick::PoolTick,
38};
39use nautilus_network::websocket::TransportBackend;
40
41use crate::{
42 cache::{BlockchainCache, PoolEventSyncState},
43 config::BlockchainDataClientConfig,
44 contracts::{
45 erc20::Erc20Contract,
46 uniswap_v3_pool::{FeeProtocolEncoding, UniswapV3PoolContract},
47 },
48 data::subscription::DefiDataSubscriptionManager,
49 events::{
50 burn::BurnEvent, collect::CollectEvent, fee_protocol_collect::FeeProtocolCollectEvent,
51 fee_protocol_update::FeeProtocolUpdateEvent, flash::FlashEvent, mint::MintEvent,
52 swap::SwapEvent,
53 },
54 exchanges::{extended::DexExtended, get_dex_extended},
55 hypersync::{
56 client::{HyperSyncClient, PoolEventStreamItem},
57 helpers::{extract_block_number, extract_event_signature_bytes},
58 },
59 rpc::{
60 BlockchainRpcClient, BlockchainRpcClientAny,
61 chains::{
62 arbitrum::ArbitrumRpcClient, base::BaseRpcClient, bsc::BscRpcClient,
63 ethereum::EthereumRpcClient, polygon::PolygonRpcClient,
64 },
65 http::BlockchainHttpRpcClient,
66 types::BlockchainMessage,
67 },
68 services::PoolDiscoveryService,
69};
70
71const BLOCKS_PROCESS_IN_SYNC_REPORT: u64 = 50_000;
72const POOL_EVENT_BLOCK_BATCH_SIZE: usize = 20_000;
73const POOL_EVENT_SYNC_VERSION_LEGACY: u32 = 0;
74const POOL_EVENT_SYNC_VERSION_PROTOCOL_FEE: u32 = 1;
75const POOL_EVENT_SYNC_VERSION: u32 = POOL_EVENT_SYNC_VERSION_PROTOCOL_FEE;
77#[derive(Debug, Clone, PartialEq, Eq)]
80struct PoolEventFamily {
81 name: &'static str,
82 signature: String,
83 introduced_version: u32,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87struct PoolEventSyncRange {
88 from_block: u64,
89 families: Vec<PoolEventFamily>,
90}
91
92#[derive(Debug)]
97pub struct BlockchainDataClientCore {
98 pub chain: SharedChain,
100 pub config: BlockchainDataClientConfig,
102 pub cache: BlockchainCache,
104 tokens: Erc20Contract,
106 univ3_pool: UniswapV3PoolContract,
108 pub hypersync_client: HyperSyncClient,
110 pub rpc_client: Option<BlockchainRpcClientAny>,
112 pub subscription_manager: DefiDataSubscriptionManager,
114 data_tx: Option<tokio::sync::mpsc::UnboundedSender<DataEvent>>,
116 cancellation_token: tokio_util::sync::CancellationToken,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum SnapshotValidation {
123 OnChain,
125 Replay,
128 Invalid,
130}
131
132impl SnapshotValidation {
133 #[must_use]
135 pub const fn is_usable(self) -> bool {
136 !matches!(self, Self::Invalid)
137 }
138
139 #[must_use]
141 pub const fn as_str(self) -> &'static str {
142 match self {
143 Self::OnChain => "on_chain",
144 Self::Replay => "replay",
145 Self::Invalid => "invalid",
146 }
147 }
148
149 #[must_use]
153 pub fn from_db_token(token: &str) -> Option<Self> {
154 match token {
155 "on_chain" => Some(Self::OnChain),
156 "replay" => Some(Self::Replay),
157 "invalid" => Some(Self::Invalid),
158 _ => None,
159 }
160 }
161}
162
163impl BlockchainDataClientCore {
164 #[must_use]
170 pub fn new(
171 config: BlockchainDataClientConfig,
172 hypersync_tx: Option<tokio::sync::mpsc::UnboundedSender<BlockchainMessage>>,
173 data_tx: Option<tokio::sync::mpsc::UnboundedSender<DataEvent>>,
174 cancellation_token: tokio_util::sync::CancellationToken,
175 ) -> Self {
176 let chain = config.chain.clone();
177 let cache = BlockchainCache::new(chain.clone());
178
179 log::debug!(
181 "Initializing blockchain data client for '{}' with HTTP RPC: {}",
182 chain.name,
183 REDACTED
184 );
185
186 let rpc_client = if !config.use_hypersync_for_live_data && config.wss_rpc_url.is_some() {
187 let wss_rpc_url = config.wss_rpc_url.clone().expect("wss_rpc_url is required");
188 log::debug!("WebSocket RPC URL: {REDACTED}");
189 Some(Self::initialize_rpc_client(
190 chain.name,
191 wss_rpc_url,
192 config.transport_backend,
193 config.proxy_url.clone(),
194 ))
195 } else {
196 log::debug!("Using HyperSync for live data (no WebSocket RPC)");
197 None
198 };
199 let http_rpc_client = Arc::new(BlockchainHttpRpcClient::new(
200 config.http_rpc_url.clone(),
201 config.rpc_requests_per_second,
202 config.proxy_url.clone(),
203 ));
204 let multicall_calls_per_rpc_request = config.multicall_calls_per_rpc_request;
205 let erc20_contract = Erc20Contract::new(
206 http_rpc_client.clone(),
207 config.pool_filters.remove_pools_with_empty_erc20fields,
208 );
209
210 let hypersync_client =
211 HyperSyncClient::new(chain.clone(), hypersync_tx, cancellation_token.clone());
212 Self {
213 chain,
214 config,
215 rpc_client,
216 tokens: erc20_contract,
217 univ3_pool: UniswapV3PoolContract::new(
218 http_rpc_client,
219 multicall_calls_per_rpc_request,
220 ),
221 cache,
222 hypersync_client,
223 subscription_manager: DefiDataSubscriptionManager::new(),
224 data_tx,
225 cancellation_token,
226 }
227 }
228
229 pub fn set_socket_control(&mut self, control: SocketControl) {
231 if let Some(rpc_client) = &mut self.rpc_client {
232 rpc_client.set_socket_control(control);
233 }
234 }
235
236 pub async fn initialize_cache_database(&mut self) {
238 if let Some(pg_connect_options) = &self.config.postgres_cache_database_config {
239 log::debug!(
240 "Initializing blockchain cache on database '{}'",
241 pg_connect_options.database
242 );
243 self.cache
244 .initialize_database(pg_connect_options.clone().into())
245 .await;
246 }
247 }
248
249 fn initialize_rpc_client(
251 blockchain: Blockchain,
252 wss_rpc_url: String,
253 transport_backend: TransportBackend,
254 proxy_url: Option<String>,
255 ) -> BlockchainRpcClientAny {
256 let mut client = match blockchain {
257 Blockchain::Ethereum => {
258 BlockchainRpcClientAny::Ethereum(EthereumRpcClient::new(wss_rpc_url, proxy_url))
259 }
260 Blockchain::Polygon => {
261 BlockchainRpcClientAny::Polygon(PolygonRpcClient::new(wss_rpc_url, proxy_url))
262 }
263 Blockchain::Base => {
264 BlockchainRpcClientAny::Base(BaseRpcClient::new(wss_rpc_url, proxy_url))
265 }
266 Blockchain::Arbitrum => {
267 BlockchainRpcClientAny::Arbitrum(ArbitrumRpcClient::new(wss_rpc_url, proxy_url))
268 }
269 Blockchain::Bsc => {
270 BlockchainRpcClientAny::Bsc(BscRpcClient::new(wss_rpc_url, proxy_url))
271 }
272 _ => panic!("Unsupported blockchain {blockchain} for RPC connection"),
273 };
274 client.set_transport_backend(transport_backend);
275 client
276 }
277
278 pub async fn connect(&mut self) -> anyhow::Result<()> {
284 log::debug!(
285 "Connecting blockchain data client for '{}'",
286 self.chain.name
287 );
288 self.initialize_cache_database().await;
289
290 if let Some(ref mut rpc_client) = self.rpc_client {
291 rpc_client.connect().await?;
292 }
293
294 let from_block = self.determine_from_block();
295
296 log::debug!(
297 "Connecting to blockchain data source for '{}' from block {}",
298 self.chain.name,
299 from_block.separate_with_commas()
300 );
301
302 self.cache.initialize_chain().await;
304 self.cache.connect(from_block).await?;
306 for dex in self.config.dex_ids.clone() {
310 self.register_dex_exchange(dex).await?;
311 self.sync_exchange_pools(&dex, from_block, None, false)
312 .await?;
313 }
314
315 Ok(())
316 }
317
318 pub async fn sync_blocks_checked(
324 &mut self,
325 from_block: u64,
326 to_block: Option<u64>,
327 ) -> anyhow::Result<()> {
328 if let Some(blocks_status) = self.cache.get_cache_block_consistency_status().await {
329 if blocks_status.is_consistent() {
331 log::debug!(
332 "Cache is consistent: no gaps detected (last continuous block: {})",
333 blocks_status.last_continuous_block
334 );
335 let target_block = max(blocks_status.max_block + 1, from_block);
336 log::debug!(
337 "Starting fast sync with COPY from block {}",
338 target_block.separate_with_commas()
339 );
340 self.sync_blocks(target_block, to_block, true).await?;
341 } else {
342 let gap_size = blocks_status.max_block - blocks_status.last_continuous_block;
343 log::debug!(
344 "Cache inconsistency detected: {} blocks missing between {} and {}",
345 gap_size,
346 blocks_status.last_continuous_block + 1,
347 blocks_status.max_block
348 );
349
350 log::debug!(
351 "Block syncing Phase 1: Filling gaps with INSERT (blocks {} to {})",
352 blocks_status.last_continuous_block + 1,
353 blocks_status.max_block
354 );
355 self.sync_blocks(
356 blocks_status.last_continuous_block + 1,
357 Some(blocks_status.max_block),
358 false,
359 )
360 .await?;
361
362 log::debug!(
363 "Block syncing Phase 2: Continuing with fast COPY from block {}",
364 (blocks_status.max_block + 1).separate_with_commas()
365 );
366 self.sync_blocks(blocks_status.max_block + 1, to_block, true)
367 .await?;
368 }
369 } else {
370 self.sync_blocks(from_block, to_block, true).await?;
371 }
372
373 Ok(())
374 }
375
376 pub async fn sync_blocks(
382 &mut self,
383 from_block: u64,
384 to_block: Option<u64>,
385 use_copy_command: bool,
386 ) -> anyhow::Result<()> {
387 const BATCH_SIZE: usize = 1000;
388
389 let to_block = if let Some(block) = to_block {
390 block
391 } else {
392 self.hypersync_client.current_block().await
393 };
394 let total_blocks = to_block.saturating_sub(from_block) + 1;
395 log::debug!(
396 "Syncing blocks from {} to {} (total: {} blocks)",
397 from_block.separate_with_commas(),
398 to_block.separate_with_commas(),
399 total_blocks.separate_with_commas()
400 );
401
402 if let Err(e) = self.cache.toggle_performance_settings(true).await {
404 log::warn!("Failed to enable performance settings: {e}");
405 }
406
407 let blocks_stream = self
408 .hypersync_client
409 .request_blocks_stream(from_block, Some(to_block))
410 .await;
411
412 tokio::pin!(blocks_stream);
413
414 let mut metrics = BlockchainSyncReporter::new(
415 BlockchainSyncReportItems::Blocks,
416 from_block,
417 total_blocks,
418 BLOCKS_PROCESS_IN_SYNC_REPORT,
419 );
420
421 let mut batch: Vec<Block> = Vec::with_capacity(BATCH_SIZE);
422
423 let cancellation_token = self.cancellation_token.clone();
424 let sync_result = tokio::select! {
425 () = cancellation_token.cancelled() => {
426 log::debug!("Block sync cancelled");
427 Err(anyhow::anyhow!("Sync cancelled"))
428 }
429 result = async {
430 while let Some(block) = blocks_stream.next().await {
431 let block_number = block.number;
432 if self.cache.get_block_timestamp(block_number).is_some() {
433 continue;
434 }
435 batch.push(block);
436
437 if batch.len() >= BATCH_SIZE || block_number >= to_block {
439 let batch_size = batch.len();
440
441 self.cache.add_blocks_batch(batch, use_copy_command).await?;
442 metrics.update(batch_size);
443
444 batch = Vec::with_capacity(BATCH_SIZE);
446 }
447
448 if metrics.should_log_progress(block_number, to_block) {
450 metrics.log_progress(block_number);
451 }
452 }
453
454 if !batch.is_empty() {
456 let batch_size = batch.len();
457 self.cache.add_blocks_batch(batch, use_copy_command).await?;
458 metrics.update(batch_size);
459 }
460
461 metrics.log_final_stats();
462 Ok(())
463 } => result
464 };
465
466 sync_result?;
467
468 if let Err(e) = self.cache.toggle_performance_settings(false).await {
470 log::warn!("Failed to restore default settings: {e}");
471 }
472
473 Ok(())
474 }
475
476 pub async fn sync_pool_events(
482 &mut self,
483 dex: &DexType,
484 pool_identifier: PoolIdentifier,
485 from_block: Option<u64>,
486 to_block: Option<u64>,
487 reset: bool,
488 ) -> anyhow::Result<()> {
489 const EVENT_BATCH_SIZE: usize = 20000;
490
491 let pool: SharedPool = self.get_pool(&pool_identifier)?.clone();
492 let pool_display = pool.to_full_spec_string();
493 let from_block = from_block.unwrap_or(pool.creation_block);
494 let pool_address = &pool.address;
496
497 let dex_extended = self.get_dex_extended(dex)?.clone();
498 let event_families = Self::pool_event_families(&dex_extended);
499 let sync_state = if reset {
500 PoolEventSyncState::default()
501 } else {
502 self.cache
503 .get_pool_event_sync_state(dex, &pool_identifier)
504 .await?
505 };
506
507 let to_block = match to_block {
508 Some(block) => block,
509 None => self.hypersync_client.current_block().await,
510 };
511
512 if from_block > to_block {
513 log::debug!(
514 "D {} Pool '{}' requested event range {} to {} is empty, skipping sync",
515 dex,
516 pool_display,
517 from_block.separate_with_commas(),
518 to_block.separate_with_commas(),
519 );
520 return Ok(());
521 }
522
523 let sync_ranges =
524 Self::pool_event_sync_ranges(&event_families, &sync_state, from_block, to_block);
525 let inherited_event_family_names = event_families
526 .iter()
527 .filter(|family| {
528 sync_state.version == POOL_EVENT_SYNC_VERSION_LEGACY
529 && family.introduced_version == POOL_EVENT_SYNC_VERSION_LEGACY
530 && !sync_state
531 .family_blocks
532 .iter()
533 .any(|(name, _)| name == family.name)
534 })
535 .map(|family| family.name)
536 .collect::<Vec<_>>();
537
538 let Some(sync_range) = sync_ranges.first().cloned() else {
539 if sync_state.version < POOL_EVENT_SYNC_VERSION {
540 if let Some(last_full_sync_block) = sync_state.last_full_sync_block {
541 self.cache
542 .update_pool_event_sync(
543 dex,
544 &pool_identifier,
545 &inherited_event_family_names,
546 last_full_sync_block,
547 None,
548 )
549 .await?;
550 }
551 self.cache
552 .update_pool_event_sync(
553 dex,
554 &pool_identifier,
555 &[],
556 to_block,
557 Some(POOL_EVENT_SYNC_VERSION),
558 )
559 .await?;
560 }
561
562 log::debug!(
563 "D {} Pool '{}' event families already synced to block {}, skipping sync",
564 dex,
565 pool_display,
566 to_block.separate_with_commas()
567 );
568 return Ok(());
569 };
570 let has_more_ranges = sync_ranges.len() > 1;
571
572 let last_block_across_pool_events_table = self
574 .cache
575 .get_pool_event_tables_last_block(&pool_identifier)
576 .await?;
577
578 let effective_from_block = sync_range.from_block;
579 let event_family_names = sync_range
580 .families
581 .iter()
582 .map(|family| family.name)
583 .collect::<Vec<_>>();
584 let total_blocks = to_block.saturating_sub(effective_from_block) + 1;
585 log::debug!(
586 "Syncing Pool: '{}' event families {:?} from {} to {} (total: {} blocks)",
587 pool_display,
588 event_family_names,
589 effective_from_block.separate_with_commas(),
590 to_block.separate_with_commas(),
591 total_blocks.separate_with_commas(),
592 );
593
594 let mut metrics = BlockchainSyncReporter::new(
595 BlockchainSyncReportItems::PoolEvents,
596 effective_from_block,
597 total_blocks,
598 BLOCKS_PROCESS_IN_SYNC_REPORT,
599 );
600 let swap_event_signature = dex_extended.swap_created_event.as_ref();
601 let mint_event_signature = dex_extended.mint_created_event.as_ref();
602 let burn_event_signature = dex_extended.burn_created_event.as_ref();
603 let collect_event_signature = dex_extended.collect_created_event.as_ref();
604 let flash_event_signature = dex_extended.flash_created_event.as_ref();
605 let protocol_update_event_signature = dex_extended.fee_protocol_update_event.as_ref();
606 let protocol_collect_event_signature = dex_extended.fee_protocol_collect_event.as_ref();
607 let initialize_event_signature: Option<&str> =
608 dex_extended.initialize_event.as_ref().map(|s| s.as_ref());
609
610 let swap_sig_bytes = hex::decode(
612 swap_event_signature
613 .strip_prefix("0x")
614 .unwrap_or(swap_event_signature),
615 )?;
616 let mint_sig_bytes = hex::decode(
617 mint_event_signature
618 .strip_prefix("0x")
619 .unwrap_or(mint_event_signature),
620 )?;
621 let burn_sig_bytes = hex::decode(
622 burn_event_signature
623 .strip_prefix("0x")
624 .unwrap_or(burn_event_signature),
625 )?;
626 let collect_sig_bytes = hex::decode(
627 collect_event_signature
628 .strip_prefix("0x")
629 .unwrap_or(collect_event_signature),
630 )?;
631 let flash_sig_bytes = flash_event_signature
632 .map(|s| hex::decode(s.strip_prefix("0x").unwrap_or(s)).unwrap_or_default());
633 let protocol_update_sig_bytes = protocol_update_event_signature
634 .map(|s| hex::decode(s.strip_prefix("0x").unwrap_or(s)).unwrap_or_default());
635 let protocol_collect_sig_bytes = protocol_collect_event_signature
636 .map(|s| hex::decode(s.strip_prefix("0x").unwrap_or(s)).unwrap_or_default());
637 let initialize_sig_bytes = initialize_event_signature
638 .map(|s| hex::decode(s.strip_prefix("0x").unwrap_or(s)).unwrap_or_default());
639
640 let event_signatures = sync_range
641 .families
642 .iter()
643 .map(|family| family.signature.as_str())
644 .collect();
645
646 let pool_events_stream = self
647 .hypersync_client
648 .request_contract_events_stream(
649 effective_from_block,
650 Some(to_block),
651 pool_address,
652 event_signatures,
653 )
654 .await;
655 tokio::pin!(pool_events_stream);
656
657 let mut last_block_saved = effective_from_block;
658 let mut blocks_processed = 0;
659
660 let mut block_batch: Vec<Block> = Vec::with_capacity(POOL_EVENT_BLOCK_BATCH_SIZE);
661 let mut swap_batch: Vec<PoolSwap> = Vec::with_capacity(EVENT_BATCH_SIZE);
662 let mut liquidity_batch: Vec<PoolLiquidityUpdate> = Vec::with_capacity(EVENT_BATCH_SIZE);
663 let mut collect_batch: Vec<PoolFeeCollect> = Vec::with_capacity(EVENT_BATCH_SIZE);
664 let mut protocol_update_batch: Vec<PoolFeeProtocolUpdate> =
665 Vec::with_capacity(EVENT_BATCH_SIZE);
666 let mut protocol_collect_batch: Vec<PoolFeeProtocolCollect> =
667 Vec::with_capacity(EVENT_BATCH_SIZE);
668 let mut flash_batch: Vec<PoolFlash> = Vec::with_capacity(EVENT_BATCH_SIZE);
669
670 let mut beyond_stale_data = last_block_across_pool_events_table
672 .is_none_or(|tables_max| effective_from_block > tables_max);
673
674 let cancellation_token = self.cancellation_token.clone();
675 let sync_result = tokio::select! {
676 () = cancellation_token.cancelled() => {
677 log::debug!("Pool event sync cancelled");
678 Err(anyhow::anyhow!("Sync cancelled"))
679 }
680 result = async {
681 while let Some(item) = pool_events_stream.next().await {
682 let log = match item {
683 PoolEventStreamItem::Block(block) => {
684 self.record_pool_event_block(block, &mut block_batch).await?;
685 continue;
686 }
687 PoolEventStreamItem::Log(log) => log,
688 };
689 let block_number = extract_block_number(&log)?;
690 blocks_processed += block_number - last_block_saved;
691 last_block_saved = block_number;
692
693 let event_sig_bytes = extract_event_signature_bytes(&log)?;
694 if event_sig_bytes == swap_sig_bytes.as_slice() {
695 let swap_event = dex_extended.parse_swap_event_hypersync(&log)?;
696 let swap = self
697 .process_pool_swap_event(&swap_event, &pool)
698 .with_context(|| {
699 format!("failed to process swap event at block {}", swap_event.block_number)
700 })?;
701 swap_batch.push(swap);
702 } else if event_sig_bytes == mint_sig_bytes.as_slice() {
703 let mint_event = dex_extended.parse_mint_event_hypersync(&log)?;
704 let liquidity_update = self
705 .process_pool_mint_event(&mint_event, &pool, &dex_extended)
706 .with_context(|| {
707 format!("failed to process mint event at block {}", mint_event.block_number)
708 })?;
709 liquidity_batch.push(liquidity_update);
710 } else if event_sig_bytes == burn_sig_bytes.as_slice() {
711 let burn_event = dex_extended.parse_burn_event_hypersync(&log)?;
712 let liquidity_update = self
713 .process_pool_burn_event(&burn_event, &pool, &dex_extended)
714 .with_context(|| {
715 format!("failed to process burn event at block {}", burn_event.block_number)
716 })?;
717 liquidity_batch.push(liquidity_update);
718 } else if event_sig_bytes == collect_sig_bytes.as_slice() {
719 let collect_event = dex_extended.parse_collect_event_hypersync(&log)?;
720 let fee_collect = self
721 .process_pool_collect_event(&collect_event, &pool, &dex_extended)
722 .with_context(|| {
723 format!(
724 "failed to process collect event at block {}",
725 collect_event.block_number
726 )
727 })?;
728 collect_batch.push(fee_collect);
729 } else if initialize_sig_bytes.as_ref().is_some_and(|sig| sig.as_slice() == event_sig_bytes) {
730 let initialize_event = dex_extended.parse_initialize_event_hypersync(&log)?;
731 self.cache
732 .update_pool_initialize_price_tick(&initialize_event)
733 .await?;
734 } else if protocol_update_sig_bytes.as_ref().is_some_and(|sig| sig.as_slice() == event_sig_bytes) {
735 let fee_protocol_update_event = dex_extended.parse_fee_protocol_update_event_hypersync(&log)?;
736 let update = self
737 .process_pool_fee_protocol_update_event(&fee_protocol_update_event, &pool)
738 .with_context(|| {
739 format!(
740 "failed to process SetFeeProtocol event at block {}",
741 fee_protocol_update_event.block_number
742 )
743 })?;
744 protocol_update_batch.push(update);
745 } else if protocol_collect_sig_bytes.as_ref().is_some_and(|sig| sig.as_slice() == event_sig_bytes) {
746 let fee_protocol_collect_event = dex_extended.parse_fee_protocol_collect_event_hypersync(&log)?;
747 let collect = self
748 .process_pool_fee_protocol_collect_event(&fee_protocol_collect_event, &pool)
749 .with_context(|| {
750 format!(
751 "failed to process CollectProtocol event at block {}",
752 fee_protocol_collect_event.block_number
753 )
754 })?;
755 protocol_collect_batch.push(collect);
756 } else if flash_sig_bytes.as_ref().is_some_and(|sig| sig.as_slice() == event_sig_bytes) {
757 let parse_fn = dex_extended
758 .parse_flash_event_hypersync_fn
759 .context("missing flash event parser")?;
760 let flash_event = parse_fn(dex_extended.dex.clone(), &log)
761 .context("failed to parse flash event")?;
762 let flash = self
763 .process_pool_flash_event(&flash_event, &pool)
764 .with_context(|| {
765 format!("failed to process flash event at block {}", flash_event.block_number)
766 })?;
767 flash_batch.push(flash);
768 } else {
769 let event_signature = hex::encode(event_sig_bytes);
770 anyhow::bail!("unexpected event signature {event_signature} for log {log:?}");
771 }
772
773 if !beyond_stale_data
775 && last_block_across_pool_events_table
776 .is_some_and(|table_max| block_number > table_max)
777 {
778 log::debug!(
779 "Crossed beyond stale data at block {block_number} - flushing current batches with ON CONFLICT, then switching to COPY"
780 );
781
782 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 beyond_stale_data = true;
798 log::debug!("Switched to COPY mode - future batches will use COPY command");
799 } else {
800 self.flush_event_batches(
802 EVENT_BATCH_SIZE,
803 &mut block_batch,
804 &mut swap_batch,
805 &mut liquidity_batch,
806 &mut collect_batch,
807 &mut protocol_update_batch,
808 &mut protocol_collect_batch,
809 &mut flash_batch,
810 false, false,
812 )
813 .await?;
814 }
815
816 metrics.update(blocks_processed as usize);
817 blocks_processed = 0;
818
819 if metrics.should_log_progress(block_number, to_block) {
821 metrics.log_progress(block_number);
822 self.flush_event_batches(
823 EVENT_BATCH_SIZE,
824 &mut block_batch,
825 &mut swap_batch,
826 &mut liquidity_batch,
827 &mut collect_batch,
828 &mut protocol_update_batch,
829 &mut protocol_collect_batch,
830 &mut flash_batch,
831 false,
832 true,
833 )
834 .await?;
835
836 if let Some(checkpoint_block) =
837 Self::completed_pool_event_checkpoint(block_number, effective_from_block)
838 {
839 self.cache
840 .update_pool_event_sync(
841 dex,
842 &pool_identifier,
843 &event_family_names,
844 checkpoint_block,
845 None,
846 )
847 .await?;
848 }
849 }
850 }
851
852 self.flush_event_batches(
853 EVENT_BATCH_SIZE,
854 &mut block_batch,
855 &mut swap_batch,
856 &mut liquidity_batch,
857 &mut collect_batch,
858 &mut protocol_update_batch,
859 &mut protocol_collect_batch,
860 &mut flash_batch,
861 false,
862 true,
863 )
864 .await?;
865
866 metrics.log_final_stats();
867 self.cache
868 .update_pool_event_sync(
869 dex,
870 &pool_identifier,
871 &event_family_names,
872 to_block,
873 None,
874 )
875 .await?;
876
877 log::debug!(
878 "Successfully synced Dex '{}' Pool '{}' event families {:?} up to block {}",
879 dex,
880 pool_display,
881 event_family_names,
882 to_block.separate_with_commas()
883 );
884 Ok(())
885 } => result
886 };
887
888 sync_result?;
889
890 if has_more_ranges {
891 return Box::pin(self.sync_pool_events(
892 dex,
893 pool_identifier,
894 Some(from_block),
895 Some(to_block),
896 false,
897 ))
898 .await;
899 }
900
901 if let Some(last_full_sync_block) = sync_state.last_full_sync_block {
902 self.cache
903 .update_pool_event_sync(
904 dex,
905 &pool_identifier,
906 &inherited_event_family_names,
907 last_full_sync_block,
908 None,
909 )
910 .await?;
911 }
912 self.cache
913 .update_pool_event_sync(
914 dex,
915 &pool_identifier,
916 &[],
917 to_block,
918 Some(POOL_EVENT_SYNC_VERSION),
919 )
920 .await?;
921
922 Ok(())
923 }
924
925 fn pool_event_families(dex: &DexExtended) -> Vec<PoolEventFamily> {
926 let mut families = vec![
927 PoolEventFamily {
928 name: "swap",
929 signature: dex.swap_created_event.to_string(),
930 introduced_version: POOL_EVENT_SYNC_VERSION_LEGACY,
931 },
932 PoolEventFamily {
933 name: "mint",
934 signature: dex.mint_created_event.to_string(),
935 introduced_version: POOL_EVENT_SYNC_VERSION_LEGACY,
936 },
937 PoolEventFamily {
938 name: "burn",
939 signature: dex.burn_created_event.to_string(),
940 introduced_version: POOL_EVENT_SYNC_VERSION_LEGACY,
941 },
942 PoolEventFamily {
943 name: "collect",
944 signature: dex.collect_created_event.to_string(),
945 introduced_version: POOL_EVENT_SYNC_VERSION_LEGACY,
946 },
947 ];
948
949 if let Some(signature) = &dex.initialize_event {
950 families.push(PoolEventFamily {
951 name: "initialize",
952 signature: signature.to_string(),
953 introduced_version: POOL_EVENT_SYNC_VERSION_LEGACY,
954 });
955 }
956
957 if let Some(signature) = &dex.flash_created_event {
958 families.push(PoolEventFamily {
959 name: "flash",
960 signature: signature.to_string(),
961 introduced_version: POOL_EVENT_SYNC_VERSION_LEGACY,
962 });
963 }
964
965 if let Some(signature) = &dex.fee_protocol_update_event {
966 families.push(PoolEventFamily {
967 name: "fee_protocol_update",
968 signature: signature.to_string(),
969 introduced_version: POOL_EVENT_SYNC_VERSION_PROTOCOL_FEE,
970 });
971 }
972
973 if let Some(signature) = &dex.fee_protocol_collect_event {
974 families.push(PoolEventFamily {
975 name: "fee_protocol_collect",
976 signature: signature.to_string(),
977 introduced_version: POOL_EVENT_SYNC_VERSION_PROTOCOL_FEE,
978 });
979 }
980
981 families
982 }
983
984 fn pool_event_sync_ranges(
985 families: &[PoolEventFamily],
986 state: &PoolEventSyncState,
987 from_block: u64,
988 to_block: u64,
989 ) -> Vec<PoolEventSyncRange> {
990 let mut grouped = BTreeMap::<u64, Vec<PoolEventFamily>>::new();
991
992 for family in families {
993 let explicit_checkpoint = state
994 .family_blocks
995 .iter()
996 .find_map(|(name, block)| (name == family.name).then_some(*block));
997 let legacy_checkpoint = (state.version == POOL_EVENT_SYNC_VERSION_LEGACY
998 && family.introduced_version == POOL_EVENT_SYNC_VERSION_LEGACY)
999 .then_some(state.last_full_sync_block)
1000 .flatten();
1001 let family_from_block = explicit_checkpoint
1002 .or(legacy_checkpoint)
1003 .map_or(from_block, |block| max(from_block, block.saturating_add(1)));
1004
1005 if family_from_block <= to_block {
1006 grouped
1007 .entry(family_from_block)
1008 .or_default()
1009 .push(family.clone());
1010 }
1011 }
1012
1013 grouped
1014 .into_iter()
1015 .map(|(from_block, families)| PoolEventSyncRange {
1016 from_block,
1017 families,
1018 })
1019 .collect()
1020 }
1021
1022 #[expect(clippy::too_many_arguments)]
1023 async fn flush_event_batches(
1024 &mut self,
1025 event_batch_size: usize,
1026 block_batch: &mut Vec<Block>,
1027 swap_batch: &mut Vec<PoolSwap>,
1028 liquidity_batch: &mut Vec<PoolLiquidityUpdate>,
1029 collect_batch: &mut Vec<PoolFeeCollect>,
1030 protocol_update_batch: &mut Vec<PoolFeeProtocolUpdate>,
1031 protocol_collect_batch: &mut Vec<PoolFeeProtocolCollect>,
1032 flash_batch: &mut Vec<PoolFlash>,
1033 use_copy_command: bool,
1034 force_flush_all: bool,
1035 ) -> anyhow::Result<()> {
1036 let should_flush_swaps =
1037 (force_flush_all || swap_batch.len() >= event_batch_size) && !swap_batch.is_empty();
1038 let should_flush_liquidity = (force_flush_all || liquidity_batch.len() >= event_batch_size)
1039 && !liquidity_batch.is_empty();
1040 let should_flush_collects = (force_flush_all || collect_batch.len() >= event_batch_size)
1041 && !collect_batch.is_empty();
1042 let should_flush_protocol_update = (force_flush_all
1043 || protocol_update_batch.len() >= event_batch_size)
1044 && !protocol_update_batch.is_empty();
1045 let should_flush_protocol_collect = (force_flush_all
1046 || protocol_collect_batch.len() >= event_batch_size)
1047 && !protocol_collect_batch.is_empty();
1048 let should_flush_flash =
1049 (force_flush_all || flash_batch.len() >= event_batch_size) && !flash_batch.is_empty();
1050
1051 if force_flush_all
1052 || should_flush_swaps
1053 || should_flush_liquidity
1054 || should_flush_collects
1055 || should_flush_protocol_update
1056 || should_flush_protocol_collect
1057 || should_flush_flash
1058 {
1059 self.flush_pool_event_blocks(block_batch).await?;
1060 }
1061
1062 if should_flush_swaps {
1063 self.cache
1064 .add_pool_swaps_batch(swap_batch, use_copy_command)
1065 .await?;
1066 swap_batch.clear();
1067 }
1068
1069 if should_flush_liquidity {
1070 self.cache
1071 .add_pool_liquidity_updates_batch(liquidity_batch, use_copy_command)
1072 .await?;
1073 liquidity_batch.clear();
1074 }
1075
1076 if should_flush_collects {
1077 self.cache
1078 .add_pool_fee_collects_batch(collect_batch, use_copy_command)
1079 .await?;
1080 collect_batch.clear();
1081 }
1082
1083 if should_flush_protocol_update {
1084 self.cache
1085 .add_pool_fee_protocol_updates_batch(protocol_update_batch)
1086 .await?;
1087 protocol_update_batch.clear();
1088 }
1089
1090 if should_flush_protocol_collect {
1091 self.cache
1092 .add_pool_fee_protocol_collect_batch(protocol_collect_batch)
1093 .await?;
1094 protocol_collect_batch.clear();
1095 }
1096
1097 if should_flush_flash {
1098 self.cache.add_pool_flash_batch(flash_batch).await?;
1099 flash_batch.clear();
1100 }
1101 Ok(())
1102 }
1103
1104 async fn record_pool_event_block(
1105 &mut self,
1106 block: Block,
1107 block_batch: &mut Vec<Block>,
1108 ) -> anyhow::Result<()> {
1109 self.cache.cache_block_metadata(&block);
1110 block_batch.push(block);
1111 if block_batch.len() >= POOL_EVENT_BLOCK_BATCH_SIZE {
1112 self.flush_pool_event_blocks(block_batch).await?;
1113 }
1114 Ok(())
1115 }
1116
1117 async fn flush_pool_event_blocks(
1118 &mut self,
1119 block_batch: &mut Vec<Block>,
1120 ) -> anyhow::Result<()> {
1121 if block_batch.is_empty() {
1122 return Ok(());
1123 }
1124
1125 self.cache
1126 .add_pool_event_blocks_batch(std::mem::take(block_batch))
1127 .await
1128 }
1129
1130 fn completed_pool_event_checkpoint(
1131 block_number: u64,
1132 effective_from_block: u64,
1133 ) -> Option<u64> {
1134 let checkpoint_block = block_number.checked_sub(1)?;
1135 (checkpoint_block >= effective_from_block).then_some(checkpoint_block)
1136 }
1137
1138 pub fn process_pool_swap_event(
1148 &self,
1149 swap_event: &SwapEvent,
1150 pool: &SharedPool,
1151 ) -> anyhow::Result<PoolSwap> {
1152 let timestamp = self
1153 .cache
1154 .get_block_timestamp(swap_event.block_number)
1155 .copied()
1156 .context("missing block timestamp for swap event")?;
1157 let mut swap = swap_event.to_pool_swap(
1158 self.chain.clone(),
1159 pool.instrument_id,
1160 pool.pool_identifier,
1161 timestamp,
1162 );
1163 swap.block_hash = Some(self.observed_block_hash(swap_event.block_number, "swap")?);
1164 if let Err(e) = swap.calculate_trade_info(&pool.token0, &pool.token1, None) {
1166 log::warn!(
1167 "Skipping trade info for swap at block {} on pool {}: {e}",
1168 swap_event.block_number,
1169 pool.instrument_id,
1170 );
1171 }
1172
1173 Ok(swap)
1174 }
1175
1176 pub fn process_pool_mint_event(
1182 &self,
1183 mint_event: &MintEvent,
1184 pool: &SharedPool,
1185 dex_extended: &DexExtended,
1186 ) -> anyhow::Result<PoolLiquidityUpdate> {
1187 let timestamp = self
1188 .cache
1189 .get_block_timestamp(mint_event.block_number)
1190 .copied()
1191 .context("missing block timestamp for mint event")?;
1192
1193 let mut liquidity_update = mint_event.to_pool_liquidity_update(
1194 self.chain.clone(),
1195 dex_extended.dex.clone(),
1196 pool.instrument_id,
1197 timestamp,
1198 );
1199 liquidity_update.block_hash =
1200 Some(self.observed_block_hash(mint_event.block_number, "mint")?);
1201
1202 Ok(liquidity_update)
1205 }
1206
1207 pub fn process_pool_burn_event(
1214 &self,
1215 burn_event: &BurnEvent,
1216 pool: &SharedPool,
1217 dex_extended: &DexExtended,
1218 ) -> anyhow::Result<PoolLiquidityUpdate> {
1219 let timestamp = self
1220 .cache
1221 .get_block_timestamp(burn_event.block_number)
1222 .copied()
1223 .context("missing block timestamp for burn event")?;
1224
1225 let mut liquidity_update = burn_event.to_pool_liquidity_update(
1226 self.chain.clone(),
1227 dex_extended.dex.clone(),
1228 pool.instrument_id,
1229 pool.pool_identifier,
1230 timestamp,
1231 );
1232 liquidity_update.block_hash =
1233 Some(self.observed_block_hash(burn_event.block_number, "burn")?);
1234
1235 Ok(liquidity_update)
1238 }
1239
1240 pub fn process_pool_collect_event(
1246 &self,
1247 collect_event: &CollectEvent,
1248 pool: &SharedPool,
1249 dex_extended: &DexExtended,
1250 ) -> anyhow::Result<PoolFeeCollect> {
1251 let timestamp = self
1252 .cache
1253 .get_block_timestamp(collect_event.block_number)
1254 .copied()
1255 .context("missing block timestamp for collect event")?;
1256
1257 let mut fee_collect = collect_event.to_pool_fee_collect(
1258 self.chain.clone(),
1259 dex_extended.dex.clone(),
1260 pool.instrument_id,
1261 timestamp,
1262 );
1263 fee_collect.block_hash =
1264 Some(self.observed_block_hash(collect_event.block_number, "collect")?);
1265
1266 Ok(fee_collect)
1267 }
1268
1269 pub fn process_pool_flash_event(
1275 &self,
1276 flash_event: &FlashEvent,
1277 pool: &SharedPool,
1278 ) -> anyhow::Result<PoolFlash> {
1279 let timestamp = self
1280 .cache
1281 .get_block_timestamp(flash_event.block_number)
1282 .copied()
1283 .context("missing block timestamp for flash event")?;
1284
1285 let mut flash =
1286 flash_event.to_pool_flash(self.chain.clone(), pool.instrument_id, timestamp);
1287 flash.block_hash = Some(self.observed_block_hash(flash_event.block_number, "flash")?);
1288
1289 Ok(flash)
1290 }
1291
1292 pub fn process_pool_fee_protocol_update_event(
1298 &self,
1299 fee_protocol_update_event: &FeeProtocolUpdateEvent,
1300 pool: &SharedPool,
1301 ) -> anyhow::Result<PoolFeeProtocolUpdate> {
1302 let timestamp = self
1303 .cache
1304 .get_block_timestamp(fee_protocol_update_event.block_number)
1305 .copied()
1306 .context("missing block timestamp for SetFeeProtocol event")?;
1307
1308 let mut update = fee_protocol_update_event.to_pool_fee_protocol_update(
1309 self.chain.clone(),
1310 pool.instrument_id,
1311 timestamp,
1312 );
1313 update.block_hash = Some(
1314 self.observed_block_hash(fee_protocol_update_event.block_number, "SetFeeProtocol")?,
1315 );
1316
1317 Ok(update)
1318 }
1319
1320 pub fn process_pool_fee_protocol_collect_event(
1326 &self,
1327 fee_protocol_collect_event: &FeeProtocolCollectEvent,
1328 pool: &SharedPool,
1329 ) -> anyhow::Result<PoolFeeProtocolCollect> {
1330 let timestamp = self
1331 .cache
1332 .get_block_timestamp(fee_protocol_collect_event.block_number)
1333 .copied()
1334 .context("missing block timestamp for CollectProtocol event")?;
1335
1336 let mut collect = fee_protocol_collect_event.to_pool_fee_protocol_collect(
1337 self.chain.clone(),
1338 pool.instrument_id,
1339 timestamp,
1340 );
1341 collect.block_hash = Some(
1342 self.observed_block_hash(fee_protocol_collect_event.block_number, "CollectProtocol")?,
1343 );
1344
1345 Ok(collect)
1346 }
1347
1348 fn observed_block_hash(&self, block_number: u64, event: &str) -> anyhow::Result<String> {
1349 self.cache
1350 .get_block_hash(block_number)
1351 .map(str::to_owned)
1352 .with_context(|| {
1353 format!("missing block hash for {event} event at block {block_number}")
1354 })
1355 }
1356
1357 pub async fn sync_exchange_pools(
1368 &mut self,
1369 dex: &DexType,
1370 from_block: u64,
1371 to_block: Option<u64>,
1372 reset: bool,
1373 ) -> anyhow::Result<()> {
1374 let dex_extended = self.get_dex_extended(dex)?.clone();
1375
1376 let mut service = PoolDiscoveryService::new(
1377 self.chain.clone(),
1378 &mut self.cache,
1379 &self.tokens,
1380 &self.hypersync_client,
1381 self.cancellation_token.clone(),
1382 self.config.clone(),
1383 );
1384
1385 service
1386 .sync_pools(&dex_extended, from_block, to_block, reset)
1387 .await?;
1388
1389 Ok(())
1390 }
1391
1392 pub async fn register_dex_exchange(&mut self, dex_id: DexType) -> anyhow::Result<()> {
1403 self.register_dex(dex_id).await?;
1404 let _ = self.cache.load_pools(&dex_id).await?;
1405 Ok(())
1406 }
1407
1408 pub async fn register_dex_exchange_for_pool(
1418 &mut self,
1419 dex_id: DexType,
1420 pool_identifier: &PoolIdentifier,
1421 ) -> anyhow::Result<()> {
1422 self.register_dex(dex_id).await?;
1423 let _ = self.cache.load_pool(&dex_id, pool_identifier).await?;
1424 Ok(())
1425 }
1426
1427 async fn register_dex(&mut self, dex_id: DexType) -> anyhow::Result<()> {
1429 let Some(dex_extended) = get_dex_extended(self.chain.name, &dex_id) else {
1430 anyhow::bail!("Unknown DEX {dex_id} on chain {}", self.chain.name);
1431 };
1432
1433 log::debug!("Registering DEX {dex_id} on chain {}", self.chain.name);
1434 self.cache.add_dex(dex_extended.dex.clone()).await?;
1435 self.subscription_manager.register_dex_for_subscriptions(
1436 dex_id,
1437 dex_extended.swap_created_event.as_ref(),
1438 dex_extended.mint_created_event.as_ref(),
1439 dex_extended.burn_created_event.as_ref(),
1440 dex_extended.collect_created_event.as_ref(),
1441 dex_extended.flash_created_event.as_deref(),
1442 );
1443 self.subscription_manager.register_dex_fee_protocol_events(
1444 dex_id,
1445 dex_extended.fee_protocol_update_event.as_deref(),
1446 dex_extended.fee_protocol_collect_event.as_deref(),
1447 );
1448 Ok(())
1449 }
1450
1451 pub async fn bootstrap_latest_pool_profiler(
1472 &mut self,
1473 pool: &SharedPool,
1474 to_block: Option<u64>,
1475 ) -> anyhow::Result<(PoolProfiler, bool)> {
1476 log::debug!(
1477 "Bootstrapping latest pool profiler for pool {}",
1478 pool.address
1479 );
1480
1481 if self.cache.database.is_none() {
1482 anyhow::bail!(
1483 "Database is not initialized, so we cannot properly bootstrap the latest pool profiler"
1484 );
1485 }
1486
1487 let to_block = match to_block {
1488 Some(block) => block,
1489 None => self.hypersync_client.current_block().await,
1490 };
1491 let (mut profiler, from_position) = self
1492 .seed_pool_profiler_from_latest_snapshot(pool, to_block)
1493 .await?;
1494
1495 if self
1499 .cache
1500 .database
1501 .as_ref()
1502 .unwrap()
1503 .get_pool_last_synced_block(self.chain.chain_id, &pool.dex.name, &pool.pool_identifier)
1504 .await?
1505 .is_none()
1506 {
1507 return self
1508 .construct_pool_profiler_from_hypersync_rpc(profiler, from_position, to_block)
1509 .await;
1510 }
1511
1512 self.sync_pool_events(
1514 &pool.dex.name,
1515 pool.pool_identifier,
1516 None,
1517 Some(to_block),
1518 false,
1519 )
1520 .await
1521 .context("failed to sync pool events for snapshot request")?;
1522
1523 if !profiler.is_initialized {
1524 if let Some(initial_sqrt_price_x96) = pool.initial_sqrt_price_x96 {
1525 profiler.initialize(initial_sqrt_price_x96)?;
1526 } else {
1527 anyhow::bail!(
1528 "Pool is not initialized and it doesn't contain initial price, cannot bootstrap profiler"
1529 );
1530 }
1531 }
1532
1533 let from_block = from_position
1534 .as_ref()
1535 .map_or(profiler.pool.creation_block, |block_position| {
1536 block_position.number
1537 });
1538 let total_blocks = to_block.saturating_sub(from_block) + 1;
1539
1540 profiler.enable_reporting(from_block, total_blocks, BLOCKS_PROCESS_IN_SYNC_REPORT);
1542
1543 let mut stream = self.cache.database.as_ref().unwrap().stream_pool_events(
1544 pool.chain.clone(),
1545 pool.dex.clone(),
1546 pool.instrument_id,
1547 pool.pool_identifier,
1548 from_position.clone(),
1549 Some(to_block),
1550 );
1551
1552 while let Some(result) = stream.next().await {
1553 match result {
1554 Ok(event) => {
1555 profiler.process(&event)?;
1556 }
1557 Err(e) => return Err(e).context("failed to stream pool event from database"),
1558 }
1559 }
1560
1561 profiler.finalize_reporting();
1562
1563 Ok((profiler, false))
1564 }
1565
1566 pub async fn bootstrap_pool_profiler_from_rpc_snapshot(
1575 &mut self,
1576 pool: &SharedPool,
1577 to_block: u64,
1578 ) -> anyhow::Result<(PoolProfiler, bool)> {
1579 if self.cache.database.is_none() {
1580 anyhow::bail!(
1581 "Database is not initialized, so we cannot bootstrap the pool profiler from an RPC snapshot"
1582 );
1583 }
1584
1585 self.construct_pool_profiler_from_hypersync_rpc(
1586 PoolProfiler::new(pool.clone()),
1587 None,
1588 to_block,
1589 )
1590 .await
1591 }
1592
1593 pub async fn advance_pool_profiler_from_rpc_snapshot(
1604 &mut self,
1605 profiler: PoolProfiler,
1606 to_block: u64,
1607 ) -> anyhow::Result<(PoolProfiler, bool)> {
1608 let from_position = profiler.last_processed_event.clone().ok_or_else(|| {
1609 anyhow::anyhow!("cannot advance an RPC profiler without a snapshot watermark")
1610 })?;
1611
1612 if to_block < from_position.number {
1613 anyhow::bail!(
1614 "cannot advance RPC profiler from block {} to earlier block {to_block}",
1615 from_position.number
1616 );
1617 }
1618
1619 self.construct_pool_profiler_from_hypersync_rpc(profiler, Some(from_position), to_block)
1620 .await
1621 }
1622
1623 async fn seed_pool_profiler_from_latest_snapshot(
1624 &self,
1625 pool: &SharedPool,
1626 to_block: u64,
1627 ) -> anyhow::Result<(PoolProfiler, Option<BlockPosition>)> {
1628 let mut profiler = PoolProfiler::new(pool.clone());
1629
1630 let from_position = match self
1631 .cache
1632 .database
1633 .as_ref()
1634 .expect("database presence is checked by caller")
1635 .load_latest_pool_snapshot(
1636 pool.chain.chain_id,
1637 &pool.pool_identifier,
1638 Some(to_block),
1639 true,
1640 )
1641 .await
1642 {
1643 Ok(Some(snapshot)) => {
1644 if snapshot.positions.is_empty()
1650 && snapshot.ticks.is_empty()
1651 && snapshot.block_position.number == pool.creation_block
1652 {
1653 log::warn!(
1654 "Ignoring empty stub snapshot at pool creation block {} for {}; rebuilding from events",
1655 snapshot.block_position.number.separate_with_commas(),
1656 pool.instrument_id,
1657 );
1658 None
1659 } else {
1660 log::debug!(
1661 "Loaded valid snapshot from block {} which contains {} positions and {} ticks",
1662 snapshot.block_position.number.separate_with_commas(),
1663 snapshot.positions.len(),
1664 snapshot.ticks.len()
1665 );
1666 let block_position = snapshot.block_position.clone();
1667 profiler.restore_from_snapshot(snapshot)?;
1668 log::debug!("Restored profiler from snapshot");
1669 Some(block_position)
1670 }
1671 }
1672 _ => {
1673 log::debug!("No valid snapshot found, processing from beginning");
1674 None
1675 }
1676 };
1677
1678 Ok((profiler, from_position))
1679 }
1680
1681 async fn construct_pool_profiler_from_hypersync_rpc(
1702 &mut self,
1703 mut profiler: PoolProfiler,
1704 from_position: Option<BlockPosition>,
1705 to_block: u64,
1706 ) -> anyhow::Result<(PoolProfiler, bool)> {
1707 log::debug!("Constructing pool profiler from hypersync stream and RPC target block state");
1708 let dex_extended = self.get_dex_extended(&profiler.pool.dex.name)?.clone();
1709 let mint_event_signature = dex_extended.mint_created_event.as_ref();
1710 let burn_event_signature = dex_extended.burn_created_event.as_ref();
1711 let initialize_event_signature =
1712 if let Some(initialize_event) = &dex_extended.initialize_event {
1713 initialize_event.as_ref()
1714 } else {
1715 anyhow::bail!(
1716 "DEX {} does not have initialize event set.",
1717 profiler.pool.dex.name
1718 );
1719 };
1720 let mint_sig_bytes = hex::decode(
1721 mint_event_signature
1722 .strip_prefix("0x")
1723 .unwrap_or(mint_event_signature),
1724 )?;
1725 let burn_sig_bytes = hex::decode(
1726 burn_event_signature
1727 .strip_prefix("0x")
1728 .unwrap_or(burn_event_signature),
1729 )?;
1730 let initialize_sig_bytes = hex::decode(
1731 initialize_event_signature
1732 .strip_prefix("0x")
1733 .unwrap_or(initialize_event_signature),
1734 )?;
1735 let protocol_update_event_signature = dex_extended.fee_protocol_update_event.as_deref();
1736 let protocol_update_sig_bytes = protocol_update_event_signature
1737 .map(|s| hex::decode(s.strip_prefix("0x").unwrap_or(s)).unwrap_or_default());
1738 let protocol_collect_event_signature = dex_extended.fee_protocol_collect_event.as_deref();
1739 let protocol_collect_sig_bytes = protocol_collect_event_signature
1740 .map(|s| hex::decode(s.strip_prefix("0x").unwrap_or(s)).unwrap_or_default());
1741
1742 let from_block = from_position.map_or(profiler.pool.creation_block, |block_position| {
1743 block_position.number
1744 });
1745 let total_blocks = to_block.saturating_sub(from_block) + 1;
1746
1747 log::debug!(
1748 "Bootstrapping pool profiler for pool {} from block {} to {} (total: {} blocks)",
1749 profiler.pool.address,
1750 from_block.separate_with_commas(),
1751 to_block.separate_with_commas(),
1752 total_blocks.separate_with_commas()
1753 );
1754
1755 profiler.enable_reporting(from_block, total_blocks, BLOCKS_PROCESS_IN_SYNC_REPORT);
1757
1758 let mut event_signatures = vec![
1759 mint_event_signature,
1760 burn_event_signature,
1761 initialize_event_signature,
1762 ];
1763
1764 if let Some(event) = protocol_update_event_signature {
1765 event_signatures.push(event);
1766 }
1767
1768 if let Some(event) = protocol_collect_event_signature {
1769 event_signatures.push(event);
1770 }
1771
1772 let pool_events_stream = self
1773 .hypersync_client
1774 .request_contract_events_stream(
1775 from_block,
1776 Some(to_block),
1777 &profiler.pool.address,
1778 event_signatures,
1779 )
1780 .await;
1781 tokio::pin!(pool_events_stream);
1782 let mut block_batch: Vec<Block> = Vec::with_capacity(POOL_EVENT_BLOCK_BATCH_SIZE);
1783
1784 while let Some(item) = pool_events_stream.next().await {
1785 let log = match item {
1786 PoolEventStreamItem::Block(block) => {
1787 self.record_pool_event_block(block, &mut block_batch)
1788 .await?;
1789 continue;
1790 }
1791 PoolEventStreamItem::Log(log) => log,
1792 };
1793 let event_sig_bytes = extract_event_signature_bytes(&log)?;
1794
1795 if event_sig_bytes == initialize_sig_bytes {
1796 if profiler.is_initialized {
1797 log::debug!(
1801 "Profiler already initialized; skipping Initialize event at block {}",
1802 extract_block_number(&log)?.separate_with_commas(),
1803 );
1804 } else {
1805 let initialize_event = dex_extended.parse_initialize_event_hypersync(&log)?;
1806 profiler.initialize(initialize_event.sqrt_price_x96)?;
1807 self.cache
1808 .database
1809 .as_ref()
1810 .unwrap()
1811 .update_pool_initial_price_tick(self.chain.chain_id, &initialize_event)
1812 .await?;
1813 }
1814 } else if event_sig_bytes == mint_sig_bytes {
1815 let mint_event = dex_extended.parse_mint_event_hypersync(&log)?;
1816 let liquidity_update = self
1817 .process_pool_mint_event(&mint_event, &profiler.pool, &dex_extended)
1818 .with_context(|| {
1819 format!(
1820 "failed to process mint event at block {}",
1821 mint_event.block_number
1822 )
1823 })?;
1824 profiler.process(&DexPoolData::LiquidityUpdate(liquidity_update))?;
1825 } else if event_sig_bytes == burn_sig_bytes {
1826 let burn_event = dex_extended.parse_burn_event_hypersync(&log)?;
1827 let liquidity_update = self
1828 .process_pool_burn_event(&burn_event, &profiler.pool, &dex_extended)
1829 .with_context(|| {
1830 format!(
1831 "failed to process burn event at block {}",
1832 burn_event.block_number
1833 )
1834 })?;
1835 profiler.process(&DexPoolData::LiquidityUpdate(liquidity_update))?;
1836 } else if protocol_update_sig_bytes
1837 .as_ref()
1838 .is_some_and(|sig| sig.as_slice() == event_sig_bytes)
1839 {
1840 let fee_protocol_update_event =
1841 dex_extended.parse_fee_protocol_update_event_hypersync(&log)?;
1842 let update = self
1843 .process_pool_fee_protocol_update_event(
1844 &fee_protocol_update_event,
1845 &profiler.pool,
1846 )
1847 .with_context(|| {
1848 format!(
1849 "failed to process SetFeeProtocol event at block {}",
1850 fee_protocol_update_event.block_number
1851 )
1852 })?;
1853 profiler.process(&DexPoolData::FeeProtocolUpdate(update))?;
1854 } else if protocol_collect_sig_bytes
1855 .as_ref()
1856 .is_some_and(|sig| sig.as_slice() == event_sig_bytes)
1857 {
1858 let fee_protocol_collect_event =
1859 dex_extended.parse_fee_protocol_collect_event_hypersync(&log)?;
1860 let collect = self
1861 .process_pool_fee_protocol_collect_event(
1862 &fee_protocol_collect_event,
1863 &profiler.pool,
1864 )
1865 .with_context(|| {
1866 format!(
1867 "failed to process CollectProtocol event at block {}",
1868 fee_protocol_collect_event.block_number
1869 )
1870 })?;
1871 profiler.process(&DexPoolData::FeeProtocolCollect(collect))?;
1872 } else {
1873 let event_signature = hex::encode(event_sig_bytes);
1874 anyhow::bail!(
1875 "unexpected event signature in bootstrap_latest_pool_profiler: {event_signature} for log {log:?}"
1876 );
1877 }
1878 }
1879
1880 self.flush_pool_event_blocks(&mut block_batch).await?;
1881 profiler.finalize_reporting();
1882
1883 let snapshot_block_position = self.block_scoped_snapshot_position(to_block).await?;
1884 let on_chain_snapshot = self
1885 .get_on_chain_snapshot_at_position(&profiler, snapshot_block_position)
1886 .await
1887 .with_context(|| {
1888 format!(
1889 "failed to restore pool {} from RPC snapshot at target block {} with {} ticks and {} positions",
1890 profiler.pool.address,
1891 to_block.separate_with_commas(),
1892 profiler.get_active_tick_values().len().separate_with_commas(),
1893 profiler.get_all_position_keys().len().separate_with_commas()
1894 )
1895 })?;
1896 Self::validate_rpc_snapshot_topology(&profiler, &on_chain_snapshot).with_context(|| {
1897 format!(
1898 "RPC snapshot topology validation failed for pool {} at block {}",
1899 profiler.pool.address,
1900 to_block.separate_with_commas()
1901 )
1902 })?;
1903 profiler.restore_from_snapshot(on_chain_snapshot)?;
1904
1905 Ok((profiler, true))
1906 }
1907
1908 pub async fn check_snapshot_validity(
1928 &self,
1929 profiler: &PoolProfiler,
1930 already_validated: bool,
1931 ) -> anyhow::Result<SnapshotValidation> {
1932 let (validation, block_position) = if already_validated {
1933 log::debug!("Snapshot already validated from RPC, skipping on-chain comparison");
1935 let last_event = profiler
1936 .last_processed_event
1937 .clone()
1938 .expect("Profiler should have last_processed_event");
1939 (SnapshotValidation::OnChain, Some(last_event))
1940 } else {
1941 match self.get_on_chain_snapshot(profiler).await {
1943 Ok(on_chain_snapshot) => {
1944 log::debug!("Comparing profiler state with on-chain state...");
1945 let comparison = compare_pool_profiler_detailed(profiler, &on_chain_snapshot);
1946 let validation = if comparison.is_valid_for_snapshot() {
1947 if !comparison.is_exact_match() {
1948 log::warn!(
1949 "Pool profiler snapshot has a non-structural mismatch (sqrt ratio, fee protocol, or protocol fees); accepting snapshot"
1950 );
1951 }
1952 SnapshotValidation::OnChain
1953 } else {
1954 log::error!(
1955 "Pool profiler state does NOT match on-chain smart contract state"
1956 );
1957 SnapshotValidation::Invalid
1958 };
1959 (validation, Some(on_chain_snapshot.block_position))
1960 }
1961 Err(e) => {
1962 log::warn!(
1963 "Could not validate snapshot against on-chain state, keeping replay-derived snapshot: {e}"
1964 );
1965 let reported = self
1969 .stored_snapshot_validation(profiler)
1970 .await?
1971 .unwrap_or(SnapshotValidation::Replay);
1972 (reported, None)
1973 }
1974 }
1975 };
1976
1977 if let (Some(block_position), Some(cache_database)) = (block_position, &self.cache.database)
1978 {
1979 cache_database
1980 .set_pool_snapshot_validation_state(
1981 profiler.pool.chain.chain_id,
1982 &profiler.pool.pool_identifier,
1983 block_position.number,
1984 block_position.transaction_index,
1985 block_position.log_index,
1986 validation.as_str(),
1987 )
1988 .await?;
1989 log::debug!(
1990 "Set pool snapshot validation state to {}",
1991 validation.as_str()
1992 );
1993 }
1994
1995 Ok(validation)
1996 }
1997
1998 async fn stored_snapshot_validation(
2003 &self,
2004 profiler: &PoolProfiler,
2005 ) -> anyhow::Result<Option<SnapshotValidation>> {
2006 let (Some(block_position), Some(cache_database)) =
2007 (profiler.last_processed_event.as_ref(), &self.cache.database)
2008 else {
2009 return Ok(None);
2010 };
2011
2012 let stored = cache_database
2013 .get_pool_snapshot_validation_state(
2014 profiler.pool.chain.chain_id,
2015 &profiler.pool.pool_identifier,
2016 block_position.number,
2017 block_position.transaction_index,
2018 block_position.log_index,
2019 )
2020 .await?;
2021
2022 Ok(stored.and_then(|token| SnapshotValidation::from_db_token(&token)))
2023 }
2024
2025 async fn get_on_chain_snapshot(&self, profiler: &PoolProfiler) -> anyhow::Result<PoolSnapshot> {
2031 let last_processed_event = Self::last_processed_event_for_on_chain_snapshot(profiler)?;
2032 self.get_on_chain_snapshot_at_position(profiler, last_processed_event)
2033 .await
2034 }
2035
2036 async fn get_on_chain_snapshot_at_position(
2037 &self,
2038 profiler: &PoolProfiler,
2039 block_position: BlockPosition,
2040 ) -> anyhow::Result<PoolSnapshot> {
2041 if matches!(
2043 profiler.pool.dex.name,
2044 DexType::UniswapV3 | DexType::PancakeSwapV3
2045 ) {
2046 let fee_protocol_encoding = match profiler.pool.dex.name {
2047 DexType::PancakeSwapV3 => FeeProtocolEncoding::PancakeSwapV3BasisPoints,
2048 _ => FeeProtocolEncoding::UniswapV3Packed,
2049 };
2050 let timestamp = Self::timestamp_for_on_chain_snapshot(
2051 profiler,
2052 self.cache
2053 .get_block_timestamp(block_position.number)
2054 .copied(),
2055 )?;
2056 let on_chain_snapshot = self
2057 .univ3_pool
2058 .fetch_snapshot(
2059 &profiler.pool.address,
2060 profiler.pool.instrument_id,
2061 profiler.get_active_tick_values().as_slice(),
2062 &profiler.get_all_position_keys(),
2063 block_position,
2064 timestamp, timestamp, fee_protocol_encoding,
2067 )
2068 .await?;
2069
2070 Ok(on_chain_snapshot)
2071 } else {
2072 anyhow::bail!(
2073 "Fetching on-chain snapshot for Dex protocol {} is not supported yet.",
2074 profiler.pool.dex.name
2075 )
2076 }
2077 }
2078
2079 fn validate_rpc_snapshot_topology(
2080 profiler: &PoolProfiler,
2081 snapshot: &PoolSnapshot,
2082 ) -> anyhow::Result<()> {
2083 let tick_spacing = i32::try_from(
2084 profiler
2085 .pool
2086 .tick_spacing
2087 .context("pool tick spacing is not set")?,
2088 )?;
2089 let expected_positions: AHashMap<_, _> = profiler
2090 .get_all_positions()
2091 .into_iter()
2092 .map(|position| {
2093 (
2094 (position.owner, position.tick_lower, position.tick_upper),
2095 position.liquidity,
2096 )
2097 })
2098 .collect();
2099 let actual_positions: AHashMap<_, _> = snapshot
2100 .positions
2101 .iter()
2102 .map(|position| {
2103 (
2104 (position.owner, position.tick_lower, position.tick_upper),
2105 position.liquidity,
2106 )
2107 })
2108 .collect();
2109
2110 if actual_positions.len() != snapshot.positions.len()
2111 || actual_positions != expected_positions
2112 {
2113 anyhow::bail!(
2114 "RPC positions do not match the complete HyperSync topology: expected {} positions, received {}",
2115 expected_positions.len(),
2116 actual_positions.len()
2117 );
2118 }
2119
2120 let actual_ticks: AHashMap<_, _> = snapshot
2121 .ticks
2122 .iter()
2123 .map(|tick| (tick.value, tick))
2124 .collect();
2125
2126 if actual_ticks.len() != snapshot.ticks.len() {
2127 anyhow::bail!("RPC snapshot contains duplicate ticks");
2128 }
2129
2130 let expected_tick_values = profiler.get_active_tick_values();
2131 if actual_ticks.len() != expected_tick_values.len() {
2132 anyhow::bail!(
2133 "RPC ticks do not match the complete HyperSync topology: expected {} ticks, received {}",
2134 expected_tick_values.len(),
2135 actual_ticks.len()
2136 );
2137 }
2138
2139 for tick_value in expected_tick_values {
2140 let expected_tick = profiler
2141 .get_tick(tick_value)
2142 .with_context(|| format!("missing replay tick {tick_value}"))?;
2143 let actual_tick = actual_ticks
2144 .get(&tick_value)
2145 .with_context(|| format!("RPC snapshot omitted tick {tick_value}"))?;
2146
2147 if !actual_tick.initialized
2148 || actual_tick.liquidity_gross == 0
2149 || actual_tick.liquidity_gross != expected_tick.liquidity_gross
2150 || actual_tick.liquidity_net != expected_tick.liquidity_net
2151 {
2152 anyhow::bail!(
2153 "RPC tick {tick_value} topology mismatch: expected gross={} net={}, received gross={} net={} initialized={}",
2154 expected_tick.liquidity_gross,
2155 expected_tick.liquidity_net,
2156 actual_tick.liquidity_gross,
2157 actual_tick.liquidity_net,
2158 actual_tick.initialized
2159 );
2160 }
2161 }
2162
2163 let mut derived_ticks: AHashMap<i32, (u128, i128)> = AHashMap::new();
2164 let mut active_liquidity = 0_u128;
2165
2166 for position in &snapshot.positions {
2167 if position.tick_lower >= position.tick_upper
2168 || position.tick_lower < PoolTick::MIN_TICK
2169 || position.tick_upper > PoolTick::MAX_TICK
2170 || position.tick_lower % tick_spacing != 0
2171 || position.tick_upper % tick_spacing != 0
2172 {
2173 anyhow::bail!(
2174 "RPC position {} has invalid tick range [{}, {}) for spacing {tick_spacing}",
2175 position.owner,
2176 position.tick_lower,
2177 position.tick_upper
2178 );
2179 }
2180
2181 if position.liquidity == 0 {
2182 continue;
2183 }
2184
2185 let liquidity_net = i128::try_from(position.liquidity).with_context(|| {
2186 format!(
2187 "RPC position {} liquidity exceeds i128::MAX",
2188 position.owner
2189 )
2190 })?;
2191 let lower = derived_ticks.entry(position.tick_lower).or_default();
2192 lower.0 = lower
2193 .0
2194 .checked_add(position.liquidity)
2195 .context("lower tick liquidity gross overflow")?;
2196 lower.1 = lower
2197 .1
2198 .checked_add(liquidity_net)
2199 .context("lower tick liquidity net overflow")?;
2200 let upper = derived_ticks.entry(position.tick_upper).or_default();
2201 upper.0 = upper
2202 .0
2203 .checked_add(position.liquidity)
2204 .context("upper tick liquidity gross overflow")?;
2205 upper.1 = upper
2206 .1
2207 .checked_sub(liquidity_net)
2208 .context("upper tick liquidity net underflow")?;
2209
2210 if position.tick_lower <= snapshot.state.current_tick
2211 && snapshot.state.current_tick < position.tick_upper
2212 {
2213 active_liquidity = active_liquidity
2214 .checked_add(position.liquidity)
2215 .context("active position liquidity overflow")?;
2216 }
2217 }
2218
2219 if active_liquidity != snapshot.state.liquidity {
2220 anyhow::bail!(
2221 "RPC active liquidity mismatch: positions sum to {active_liquidity}, global state reports {}",
2222 snapshot.state.liquidity
2223 );
2224 }
2225
2226 if derived_ticks.len() != actual_ticks.len() {
2227 anyhow::bail!(
2228 "RPC tick topology does not match positions: derived {} ticks, received {}",
2229 derived_ticks.len(),
2230 actual_ticks.len()
2231 );
2232 }
2233
2234 for (tick_value, (liquidity_gross, liquidity_net)) in derived_ticks {
2235 let actual_tick = actual_ticks
2236 .get(&tick_value)
2237 .with_context(|| format!("RPC snapshot omitted position boundary {tick_value}"))?;
2238
2239 if actual_tick.liquidity_gross != liquidity_gross
2240 || actual_tick.liquidity_net != liquidity_net
2241 {
2242 anyhow::bail!(
2243 "RPC tick {tick_value} does not match positions: derived gross={liquidity_gross} net={liquidity_net}, received gross={} net={}",
2244 actual_tick.liquidity_gross,
2245 actual_tick.liquidity_net
2246 );
2247 }
2248 }
2249
2250 Ok(())
2251 }
2252
2253 fn timestamp_for_on_chain_snapshot(
2254 profiler: &PoolProfiler,
2255 cached_timestamp: Option<UnixNanos>,
2256 ) -> anyhow::Result<UnixNanos> {
2257 cached_timestamp
2258 .or(profiler.last_processed_ts)
2259 .context("missing block timestamp for on-chain snapshot")
2260 }
2261
2262 fn last_processed_event_for_on_chain_snapshot(
2263 profiler: &PoolProfiler,
2264 ) -> anyhow::Result<BlockPosition> {
2265 profiler.last_processed_event.clone().with_context(|| {
2266 format!(
2267 "cannot fetch on-chain snapshot for pool {} without a processed event",
2268 profiler.pool.address
2269 )
2270 })
2271 }
2272
2273 async fn block_scoped_snapshot_position(
2274 &mut self,
2275 block_number: u64,
2276 ) -> anyhow::Result<BlockPosition> {
2277 let blocks_stream = self
2278 .hypersync_client
2279 .request_blocks_stream(block_number, Some(block_number))
2280 .await;
2281 tokio::pin!(blocks_stream);
2282 let block = blocks_stream
2283 .next()
2284 .await
2285 .with_context(|| format!("failed to fetch block {block_number} for RPC snapshot"))?;
2286
2287 let block_position =
2288 Self::block_scoped_snapshot_position_from_block(&mut self.cache, &block, block_number)?;
2289 self.cache.add_pool_event_blocks_batch(vec![block]).await?;
2290
2291 Ok(block_position)
2292 }
2293
2294 fn block_scoped_snapshot_position_from_block(
2295 cache: &mut BlockchainCache,
2296 block: &Block,
2297 block_number: u64,
2298 ) -> anyhow::Result<BlockPosition> {
2299 if block.number != block_number {
2300 anyhow::bail!(
2301 "Fetched block {} while requesting RPC snapshot block {}",
2302 block.number,
2303 block_number
2304 );
2305 }
2306
2307 cache.cache_block_metadata(block);
2308
2309 Ok(BlockPosition::new(
2310 block.number,
2311 block.hash.clone(),
2312 BLOCK_SCOPED_SNAPSHOT_INDEX,
2313 BLOCK_SCOPED_SNAPSHOT_INDEX,
2314 )
2315 .with_block_hash(Some(block.hash.clone())))
2316 }
2317
2318 pub async fn replay_pool_events(&self, pool: &Pool, dex: &SharedDex) -> anyhow::Result<()> {
2327 if let Some(database) = &self.cache.database {
2328 log::debug!(
2329 "Replaying historical events for pool {} to hydrate profiler",
2330 pool.instrument_id
2331 );
2332
2333 let mut event_stream = database.stream_pool_events(
2334 self.chain.clone(),
2335 dex.clone(),
2336 pool.instrument_id,
2337 pool.pool_identifier,
2338 None,
2339 None,
2340 );
2341 let mut event_count = 0;
2342
2343 while let Some(event_result) = event_stream.next().await {
2344 match event_result {
2345 Ok(event) => {
2346 let data_event = match event {
2347 DexPoolData::Swap(swap) => DataEvent::DeFi(DefiData::PoolSwap(swap)),
2348 DexPoolData::LiquidityUpdate(update) => {
2349 DataEvent::DeFi(DefiData::PoolLiquidityUpdate(update))
2350 }
2351 DexPoolData::FeeCollect(collect) => {
2352 DataEvent::DeFi(DefiData::PoolFeeCollect(collect))
2353 }
2354 DexPoolData::FeeProtocolUpdate(update) => {
2355 DataEvent::DeFi(DefiData::PoolFeeProtocolUpdate(update))
2356 }
2357 DexPoolData::FeeProtocolCollect(collect) => {
2358 DataEvent::DeFi(DefiData::PoolFeeProtocolCollect(collect))
2359 }
2360 DexPoolData::Flash(flash) => {
2361 DataEvent::DeFi(DefiData::PoolFlash(flash))
2362 }
2363 };
2364 self.send_data(data_event);
2365 event_count += 1;
2366 }
2367 Err(e) => {
2368 log::error!("Error streaming event for pool {}: {e}", pool.instrument_id);
2369 }
2370 }
2371 }
2372
2373 log::debug!(
2374 "Replayed {event_count} historical events for pool {}",
2375 pool.instrument_id
2376 );
2377 } else {
2378 log::debug!(
2379 "No database available, skipping event replay for pool {}",
2380 pool.instrument_id
2381 );
2382 }
2383
2384 Ok(())
2385 }
2386
2387 fn determine_from_block(&self) -> u64 {
2389 self.config
2390 .from_block
2391 .unwrap_or_else(|| self.cache.min_dex_creation_block().unwrap_or(0))
2392 }
2393
2394 fn get_dex_extended(&self, dex_id: &DexType) -> anyhow::Result<&DexExtended> {
2396 if !self.cache.get_registered_dexes().contains(dex_id) {
2397 anyhow::bail!("DEX {dex_id} is not registered in the data client");
2398 }
2399
2400 match get_dex_extended(self.chain.name, dex_id) {
2401 Some(dex) => Ok(dex),
2402 None => anyhow::bail!("Dex {dex_id} doesn't exist for chain {}", self.chain.name),
2403 }
2404 }
2405
2406 pub fn get_pool(&self, pool_identifier: &PoolIdentifier) -> anyhow::Result<&SharedPool> {
2412 match self.cache.get_pool(pool_identifier) {
2413 Some(pool) => Ok(pool),
2414 None => anyhow::bail!("Pool {pool_identifier} is not registered"),
2415 }
2416 }
2417
2418 pub fn send_data(&self, data: DataEvent) {
2420 if let Some(data_tx) = &self.data_tx {
2421 log::debug!("Sending {data}");
2422
2423 if let Err(e) = data_tx.send(data) {
2424 log::error!("Failed to send data: {e}");
2425 }
2426 } else {
2427 log::error!("No data event channel for sending data");
2428 }
2429 }
2430
2431 pub async fn disconnect(&mut self) {
2436 self.subscription_manager.clear_block_demand();
2437 self.hypersync_client.disconnect().await;
2438 }
2439}
2440
2441#[cfg(test)]
2442mod tests {
2443 use alloy::primitives::{Address, U160, U256, address};
2444 use nautilus_core::UnixNanos;
2445 use nautilus_model::defi::{
2446 Chain, Token,
2447 pool_analysis::{
2448 position::PoolPosition,
2449 snapshot::{PoolAnalytics, PoolState},
2450 },
2451 };
2452 use rstest::rstest;
2453 use tokio_util::sync::CancellationToken;
2454 use ustr::Ustr;
2455
2456 use super::*;
2457
2458 const WETH_USDT_POOL: &str = "0x4e68ccd3e89f51c3074ca5072bbac773960dfa36";
2459 const WETH_USDT_CREATION_BLOCK: u64 = 12_375_326;
2460
2461 #[rstest]
2462 #[case(SnapshotValidation::OnChain, "on_chain", true)]
2463 #[case(SnapshotValidation::Replay, "replay", true)]
2464 #[case(SnapshotValidation::Invalid, "invalid", false)]
2465 fn snapshot_validation_db_token_and_usability(
2466 #[case] validation: SnapshotValidation,
2467 #[case] expected_str: &str,
2468 #[case] expected_usable: bool,
2469 ) {
2470 assert_eq!(validation.as_str(), expected_str);
2473 assert_eq!(validation.is_usable(), expected_usable);
2474 assert_eq!(
2477 SnapshotValidation::from_db_token(expected_str),
2478 Some(validation)
2479 );
2480 }
2481
2482 #[rstest]
2483 fn snapshot_validation_from_db_token_rejects_unknown() {
2484 assert_eq!(SnapshotValidation::from_db_token("bogus"), None);
2485 }
2486
2487 #[rstest]
2488 fn last_processed_event_for_on_chain_snapshot_rejects_unprocessed_profiler() {
2489 let mut profiler = PoolProfiler::new(weth_usdt_pool());
2490 profiler
2491 .initialize(U160::from_str_radix("3cb0adde486484998be0b", 16).unwrap())
2492 .expect("Known WETH/USDT initial sqrt price should initialize");
2493
2494 let error = BlockchainDataClientCore::last_processed_event_for_on_chain_snapshot(&profiler)
2495 .expect_err("unprocessed profiler should not fetch on-chain state");
2496
2497 assert_eq!(
2498 error.to_string(),
2499 format!(
2500 "cannot fetch on-chain snapshot for pool {} without a processed event",
2501 profiler.pool.address
2502 )
2503 );
2504 }
2505
2506 #[rstest]
2507 fn validate_rpc_snapshot_topology_accepts_consistent_snapshot() {
2508 let (profiler, snapshot) = rpc_topology_fixture();
2509
2510 let result = BlockchainDataClientCore::validate_rpc_snapshot_topology(&profiler, &snapshot);
2511
2512 assert!(result.is_ok());
2513 }
2514
2515 #[rstest]
2516 fn validate_rpc_snapshot_topology_rejects_missing_tick() {
2517 let (profiler, mut snapshot) = rpc_topology_fixture();
2518 snapshot.ticks.pop();
2519
2520 let error = BlockchainDataClientCore::validate_rpc_snapshot_topology(&profiler, &snapshot)
2521 .unwrap_err();
2522
2523 assert!(error.to_string().contains("expected 2 ticks, received 1"));
2524 }
2525
2526 #[rstest]
2527 fn validate_rpc_snapshot_topology_rejects_zeroed_position() {
2528 let (profiler, mut snapshot) = rpc_topology_fixture();
2529 snapshot.positions[0].liquidity = 0;
2530
2531 let error = BlockchainDataClientCore::validate_rpc_snapshot_topology(&profiler, &snapshot)
2532 .unwrap_err();
2533
2534 assert!(
2535 error
2536 .to_string()
2537 .contains("RPC positions do not match the complete HyperSync topology")
2538 );
2539 }
2540
2541 #[rstest]
2542 fn validate_rpc_snapshot_topology_rejects_active_liquidity_mismatch() {
2543 let (profiler, mut snapshot) = rpc_topology_fixture();
2544 snapshot.state.liquidity -= 1;
2545
2546 let error = BlockchainDataClientCore::validate_rpc_snapshot_topology(&profiler, &snapshot)
2547 .unwrap_err();
2548
2549 assert!(error.to_string().contains("RPC active liquidity mismatch"));
2550 }
2551
2552 #[rstest]
2553 fn validate_rpc_snapshot_topology_rejects_tick_liquidity_mismatch() {
2554 let (profiler, mut snapshot) = rpc_topology_fixture();
2555 snapshot.ticks[0].liquidity_gross -= 1;
2556
2557 let error = BlockchainDataClientCore::validate_rpc_snapshot_topology(&profiler, &snapshot)
2558 .unwrap_err();
2559
2560 assert!(error.to_string().contains("RPC tick -60 topology mismatch"));
2561 }
2562
2563 #[rstest]
2564 fn timestamp_for_on_chain_snapshot_prefers_cached_block_timestamp() {
2565 let pool = weth_usdt_pool();
2566 let mut profiler = PoolProfiler::new(pool);
2567 let cached_ts = UnixNanos::from(1_700_000_001_000_000_000);
2568 let profiler_ts = UnixNanos::from(1_700_000_000_000_000_000);
2569 profiler.last_processed_ts = Some(profiler_ts);
2570
2571 let timestamp =
2572 BlockchainDataClientCore::timestamp_for_on_chain_snapshot(&profiler, Some(cached_ts))
2573 .unwrap();
2574
2575 assert_eq!(timestamp, cached_ts);
2576 }
2577
2578 #[rstest]
2579 fn timestamp_for_on_chain_snapshot_falls_back_to_profiler_timestamp() {
2580 let pool = weth_usdt_pool();
2581 let mut profiler = PoolProfiler::new(pool);
2582 let profiler_ts = UnixNanos::from(1_700_000_000_000_000_000);
2583 profiler.last_processed_ts = Some(profiler_ts);
2584
2585 let timestamp =
2586 BlockchainDataClientCore::timestamp_for_on_chain_snapshot(&profiler, None).unwrap();
2587
2588 assert_eq!(timestamp, profiler_ts);
2589 }
2590
2591 #[rstest]
2592 fn timestamp_for_on_chain_snapshot_rejects_missing_timestamp() {
2593 let pool = weth_usdt_pool();
2594 let profiler = PoolProfiler::new(pool);
2595
2596 let error = BlockchainDataClientCore::timestamp_for_on_chain_snapshot(&profiler, None)
2597 .expect_err("missing timestamps should fail");
2598
2599 assert_eq!(
2600 error.to_string(),
2601 "missing block timestamp for on-chain snapshot"
2602 );
2603 }
2604
2605 #[rstest]
2606 fn block_scoped_snapshot_position_from_block_caches_timestamp_and_uses_sentinel_indexes() {
2607 let chain = Arc::new(
2608 Chain::from_chain_id(42161)
2609 .expect("Arbitrum chain should exist")
2610 .clone(),
2611 );
2612 let mut cache = BlockchainCache::new(chain);
2613 let timestamp = UnixNanos::from(1_700_000_002_000_000_000);
2614 let block = test_block(123, timestamp);
2615
2616 let position = BlockchainDataClientCore::block_scoped_snapshot_position_from_block(
2617 &mut cache, &block, 123,
2618 )
2619 .unwrap();
2620
2621 assert_eq!(position.number, 123);
2622 assert_eq!(position.transaction_hash, block.hash);
2623 assert_eq!(position.transaction_index, BLOCK_SCOPED_SNAPSHOT_INDEX);
2624 assert_eq!(position.log_index, BLOCK_SCOPED_SNAPSHOT_INDEX);
2625 assert_eq!(cache.get_block_timestamp(123), Some(×tamp));
2626 }
2627
2628 #[rstest]
2629 fn block_scoped_snapshot_position_from_block_rejects_mismatched_block() {
2630 let chain = Arc::new(
2631 Chain::from_chain_id(42161)
2632 .expect("Arbitrum chain should exist")
2633 .clone(),
2634 );
2635 let mut cache = BlockchainCache::new(chain);
2636 let block = test_block(122, UnixNanos::from(1_700_000_002_000_000_000));
2637
2638 let error = BlockchainDataClientCore::block_scoped_snapshot_position_from_block(
2639 &mut cache, &block, 123,
2640 )
2641 .expect_err("mismatched block should fail");
2642
2643 assert_eq!(
2644 error.to_string(),
2645 "Fetched block 122 while requesting RPC snapshot block 123"
2646 );
2647 assert_eq!(cache.get_block_timestamp(122), None);
2648 }
2649
2650 #[rstest]
2651 #[case(100, 50, Some(99))]
2652 #[case(50, 50, None)]
2653 #[case(0, 0, None)]
2654 fn completed_pool_event_checkpoint_excludes_in_flight_block(
2655 #[case] block_number: u64,
2656 #[case] effective_from_block: u64,
2657 #[case] expected: Option<u64>,
2658 ) {
2659 let checkpoint = BlockchainDataClientCore::completed_pool_event_checkpoint(
2660 block_number,
2661 effective_from_block,
2662 );
2663
2664 assert_eq!(checkpoint, expected);
2665 }
2666
2667 #[rstest]
2668 fn pool_event_sync_ranges_preserve_legacy_history_during_new_family_backfill() {
2669 let families = vec![
2670 PoolEventFamily {
2671 name: "swap",
2672 signature: "swap".to_string(),
2673 introduced_version: 0,
2674 },
2675 PoolEventFamily {
2676 name: "fee_protocol_update",
2677 signature: "fee_protocol_update".to_string(),
2678 introduced_version: 1,
2679 },
2680 PoolEventFamily {
2681 name: "fee_protocol_collect",
2682 signature: "fee_protocol_collect".to_string(),
2683 introduced_version: 1,
2684 },
2685 ];
2686 let state = PoolEventSyncState {
2687 version: 0,
2688 last_full_sync_block: Some(100),
2689 family_blocks: Vec::new(),
2690 };
2691
2692 let ranges = BlockchainDataClientCore::pool_event_sync_ranges(&families, &state, 10, 110);
2693
2694 assert_eq!(
2695 ranges,
2696 vec![
2697 PoolEventSyncRange {
2698 from_block: 10,
2699 families: vec![families[1].clone(), families[2].clone()],
2700 },
2701 PoolEventSyncRange {
2702 from_block: 101,
2703 families: vec![families[0].clone()],
2704 },
2705 ]
2706 );
2707 }
2708
2709 #[rstest]
2710 fn pool_event_sync_ranges_group_families_by_checkpoint() {
2711 let families = vec![
2712 PoolEventFamily {
2713 name: "swap",
2714 signature: "swap".to_string(),
2715 introduced_version: 0,
2716 },
2717 PoolEventFamily {
2718 name: "fee_protocol_update",
2719 signature: "fee_protocol_update".to_string(),
2720 introduced_version: 1,
2721 },
2722 PoolEventFamily {
2723 name: "fee_protocol_collect",
2724 signature: "fee_protocol_collect".to_string(),
2725 introduced_version: 1,
2726 },
2727 ];
2728 let state = PoolEventSyncState {
2729 version: 1,
2730 last_full_sync_block: Some(100),
2731 family_blocks: vec![
2732 ("fee_protocol_collect".to_string(), 75),
2733 ("fee_protocol_update".to_string(), 50),
2734 ("swap".to_string(), 100),
2735 ],
2736 };
2737
2738 let ranges = BlockchainDataClientCore::pool_event_sync_ranges(&families, &state, 10, 100);
2739
2740 assert_eq!(
2741 ranges,
2742 vec![
2743 PoolEventSyncRange {
2744 from_block: 51,
2745 families: vec![families[1].clone()],
2746 },
2747 PoolEventSyncRange {
2748 from_block: 76,
2749 families: vec![families[2].clone()],
2750 },
2751 ]
2752 );
2753 }
2754
2755 #[rstest]
2756 fn pool_event_sync_ranges_backfill_future_family_without_schema_migration() {
2757 let families = vec![
2758 PoolEventFamily {
2759 name: "swap",
2760 signature: "swap".to_string(),
2761 introduced_version: 0,
2762 },
2763 PoolEventFamily {
2764 name: "future",
2765 signature: "future".to_string(),
2766 introduced_version: 2,
2767 },
2768 ];
2769 let state = PoolEventSyncState {
2770 version: 1,
2771 last_full_sync_block: Some(100),
2772 family_blocks: vec![("swap".to_string(), 100)],
2773 };
2774
2775 let ranges = BlockchainDataClientCore::pool_event_sync_ranges(&families, &state, 10, 100);
2776
2777 assert_eq!(
2778 ranges,
2779 vec![PoolEventSyncRange {
2780 from_block: 10,
2781 families: vec![families[1].clone()],
2782 }]
2783 );
2784 }
2785
2786 #[tokio::test(flavor = "multi_thread")]
2787 #[ignore = "requires ENVIO_API_TOKEN and live HyperSync access"]
2788 async fn live_hypersync_bootstrap_fails_closed_when_rpc_hydration_fails() {
2789 std::env::var("ENVIO_API_TOKEN").expect("ENVIO_API_TOKEN must be set");
2790
2791 let pool = weth_usdt_pool();
2792 let chain = Arc::new(
2793 Chain::from_chain_id(1)
2794 .expect("Ethereum chain should exist")
2795 .clone(),
2796 );
2797 let dex = get_dex_extended(chain.name, &DexType::UniswapV3)
2798 .expect("Ethereum UniswapV3 should be registered")
2799 .dex
2800 .clone();
2801 let (hypersync_tx, _hypersync_rx) = tokio::sync::mpsc::unbounded_channel();
2802 let config = BlockchainDataClientConfig::builder()
2803 .chain(chain)
2804 .dex_ids(vec![DexType::UniswapV3])
2805 .http_rpc_url("http://127.0.0.1:9".to_string())
2806 .use_hypersync_for_live_data(true)
2807 .maybe_from_block(Some(WETH_USDT_CREATION_BLOCK))
2808 .build();
2809 let mut core = BlockchainDataClientCore::new(
2810 config,
2811 Some(hypersync_tx),
2812 None,
2813 CancellationToken::new(),
2814 );
2815 core.cache
2816 .add_dex(dex)
2817 .await
2818 .expect("DEX should be added to in-memory cache");
2819
2820 let block_position = BlockPosition::new(
2821 WETH_USDT_CREATION_BLOCK,
2822 "0x2e07c690f149223e4f290986277304ea6a05c6ee47ba303732166bc1b15cbafb".to_string(),
2823 11,
2824 27,
2825 );
2826 let mut profiler = PoolProfiler::new(pool);
2827 profiler
2828 .initialize(U160::from_str_radix("3cb0adde486484998be0b", 16).unwrap())
2829 .expect("Known WETH/USDT initial sqrt price should initialize");
2830 profiler.last_processed_event = Some(block_position.clone());
2831
2832 let result = core
2833 .construct_pool_profiler_from_hypersync_rpc(
2834 profiler,
2835 Some(block_position),
2836 WETH_USDT_CREATION_BLOCK,
2837 )
2838 .await;
2839
2840 let error = result.expect_err("RPC hydration failure should fail closed");
2841 let error_message = format!("{error:?}");
2842 assert!(
2843 error_message.contains("failed to restore pool"),
2844 "hydration error should include pool context, was {error_message}"
2845 );
2846 assert!(
2847 error_message.to_lowercase().contains(WETH_USDT_POOL),
2848 "hydration error should include pool address, was {error_message}"
2849 );
2850 }
2851
2852 #[tokio::test(flavor = "multi_thread")]
2853 #[ignore = "requires ENVIO_API_TOKEN and live HyperSync access"]
2854 async fn live_hypersync_parses_real_set_fee_protocol_update_event() {
2855 std::env::var("ENVIO_API_TOKEN").expect("ENVIO_API_TOKEN must be set");
2856
2857 let chain = Arc::new(
2860 Chain::from_chain_id(42161)
2861 .expect("Arbitrum chain should exist")
2862 .clone(),
2863 );
2864 let dex_extended = get_dex_extended(chain.name, &DexType::UniswapV3)
2865 .expect("Arbitrum UniswapV3 should be registered");
2866 let pool_address = address!("c31e54c7a869b9fcbecc14363cf510d1c41fa443");
2867 let signature = dex_extended
2868 .dex
2869 .fee_protocol_update_event
2870 .as_deref()
2871 .expect("UniswapV3 should advertise the SetFeeProtocol signature");
2872
2873 let client = HyperSyncClient::new(chain, None, CancellationToken::new());
2874 let stream = client
2875 .request_contract_events_stream(
2876 438_989_951,
2877 Some(438_989_951),
2878 &pool_address,
2879 vec![signature],
2880 )
2881 .await;
2882 tokio::pin!(stream);
2883
2884 let mut events = Vec::new();
2885
2886 while let Some(item) = stream.next().await {
2887 if let PoolEventStreamItem::Log(log) = item {
2888 events.push(
2889 dex_extended
2890 .parse_fee_protocol_update_event_hypersync(&log)
2891 .expect("real SetFeeProtocol log should parse"),
2892 );
2893 }
2894 }
2895
2896 assert_eq!(events.len(), 1, "expected exactly one SetFeeProtocol event");
2897 assert_eq!(events[0].block_number, 438_989_951);
2898 assert_eq!(events[0].fee_protocol0_new, 4);
2899 assert_eq!(events[0].fee_protocol1_new, 4);
2900 }
2901
2902 fn weth_usdt_pool() -> SharedPool {
2903 let chain = Arc::new(
2904 Chain::from_chain_id(1)
2905 .expect("Ethereum chain should exist")
2906 .clone(),
2907 );
2908 let dex = get_dex_extended(chain.name, &DexType::UniswapV3)
2909 .expect("Ethereum UniswapV3 should be registered")
2910 .dex
2911 .clone();
2912 let pool_address = address!("4e68ccd3e89f51c3074ca5072bbac773960dfa36");
2913 let token0 = Token::new(
2914 chain.clone(),
2915 address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
2916 "Wrapped Ether".to_string(),
2917 "WETH".to_string(),
2918 18,
2919 );
2920 let token1 = Token::new(
2921 chain.clone(),
2922 address!("dAC17F958D2ee523a2206206994597C13D831ec7"),
2923 "Tether USD".to_string(),
2924 "USDT".to_string(),
2925 6,
2926 );
2927
2928 Arc::new(Pool::new(
2929 chain,
2930 dex,
2931 pool_address,
2932 PoolIdentifier::from_address(pool_address),
2933 WETH_USDT_CREATION_BLOCK,
2934 token0,
2935 token1,
2936 Some(3_000),
2937 Some(60),
2938 UnixNanos::default(),
2939 ))
2940 }
2941
2942 fn rpc_topology_fixture() -> (PoolProfiler, PoolSnapshot) {
2943 let pool = weth_usdt_pool();
2944 let owner = Address::ZERO;
2945 let liquidity = 1_000_u128;
2946 let state = PoolState {
2947 current_tick: 0,
2948 liquidity,
2949 ..Default::default()
2950 };
2951 let position = PoolPosition::new(owner, -60, 60, liquidity as i128);
2952 let ticks = vec![
2953 PoolTick::new(
2954 -60,
2955 liquidity,
2956 liquidity as i128,
2957 U256::ZERO,
2958 U256::ZERO,
2959 true,
2960 0,
2961 ),
2962 PoolTick::new(
2963 60,
2964 liquidity,
2965 -(liquidity as i128),
2966 U256::ZERO,
2967 U256::ZERO,
2968 true,
2969 0,
2970 ),
2971 ];
2972 let timestamp = UnixNanos::from(1_700_000_000_000_000_000);
2973 let snapshot = PoolSnapshot::new(
2974 pool.instrument_id,
2975 state,
2976 vec![position],
2977 ticks,
2978 PoolAnalytics::default(),
2979 BlockPosition::new(100, "0xabc".to_string(), 0, 0),
2980 timestamp,
2981 timestamp,
2982 );
2983 let mut profiler = PoolProfiler::new(pool);
2984 profiler.restore_from_snapshot(snapshot.clone()).unwrap();
2985
2986 (profiler, snapshot)
2987 }
2988
2989 fn test_block(number: u64, timestamp: UnixNanos) -> Block {
2990 Block::new(
2991 format!("0x{number:064x}"),
2992 String::from("0x0"),
2993 number,
2994 Ustr::from("0x0000000000000000000000000000000000000000"),
2995 30_000_000,
2996 21_000,
2997 timestamp,
2998 Some(Blockchain::Arbitrum),
2999 )
3000 }
3001}