Skip to main content

nautilus_blockchain/data/
core.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::{cmp::max, collections::BTreeMap, sync::Arc};
17
18use ahash::AHashMap;
19use anyhow::Context;
20use futures_util::StreamExt;
21use nautilus_common::{live::sender::EventSender, 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        log::{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;
75// Give each added family a new introduction version, then advance this current version
76const POOL_EVENT_SYNC_VERSION: u32 = POOL_EVENT_SYNC_VERSION_PROTOCOL_FEE;
77// Block-scoped RPC snapshots include the whole block, so same-block replay must skip every log.
78
79#[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/// Core blockchain data client responsible for fetching, processing, and caching blockchain data.
93///
94/// This struct encapsulates the core functionality for interacting with blockchain networks,
95/// including syncing historical data, processing real-time events, and managing cached entities.
96#[derive(Debug)]
97pub struct BlockchainDataClientCore {
98    /// The blockchain being targeted by this client instance.
99    pub chain: SharedChain,
100    /// The configuration for the data client.
101    pub config: BlockchainDataClientConfig,
102    /// Local cache for blockchain entities.
103    pub cache: BlockchainCache,
104    /// Interface for interacting with ERC20 token contracts.
105    tokens: Erc20Contract,
106    /// Interface for interacting with UniswapV3 pool contracts.
107    univ3_pool: UniswapV3PoolContract,
108    /// Client for the HyperSync data indexing service.
109    pub hypersync_client: HyperSyncClient,
110    /// Optional WebSocket RPC client for direct blockchain node communication.
111    pub rpc_client: Option<BlockchainRpcClientAny>,
112    /// Manages subscriptions for various DEX events (swaps, mints, burns).
113    pub subscription_manager: DefiDataSubscriptionManager,
114    /// Channel sender for data events.
115    data_tx: Option<EventSender<DataEvent>>,
116    /// Cancellation token for graceful shutdown of long-running operations.
117    cancellation_token: tokio_util::sync::CancellationToken,
118}
119
120/// Outcome of validating a pool snapshot against on-chain state.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum SnapshotValidation {
123    /// Hydrated from chain and matched the profiler state.
124    OnChain,
125    /// Emitted from deterministic replay and not checked against chain (the RPC could not serve the
126    /// block, or validation was skipped). Usable as a replay start point.
127    Replay,
128    /// Hydrated from chain and did not match the profiler state. Not usable as a replay start point.
129    Invalid,
130}
131
132impl SnapshotValidation {
133    /// Returns `true` if the snapshot is usable as a replay start point.
134    #[must_use]
135    pub const fn is_usable(self) -> bool {
136        !matches!(self, Self::Invalid)
137    }
138
139    /// Returns the database/JSON token for this state.
140    #[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    /// Parses the database/JSON token into a [`SnapshotValidation`].
150    ///
151    /// Returns `None` for an unrecognized token.
152    #[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    /// Creates a new instance of [`BlockchainDataClientCore`].
165    ///
166    /// # Errors
167    ///
168    /// Returns an error if DEX selections contain duplicates or RPC or HyperSync initialization fails.
169    pub fn new(
170        config: BlockchainDataClientConfig,
171        hypersync_tx: Option<tokio::sync::mpsc::UnboundedSender<BlockchainMessage>>,
172        data_tx: Option<EventSender<DataEvent>>,
173        cancellation_token: tokio_util::sync::CancellationToken,
174    ) -> anyhow::Result<Self> {
175        config.validate()?;
176
177        let chain = Arc::clone(&config.chain);
178        let cache = BlockchainCache::new(Arc::clone(&chain));
179
180        // Log RPC endpoints being used
181        log::debug!(
182            "Initializing blockchain data client for '{}' with HTTP RPC: {}",
183            chain.name,
184            REDACTED
185        );
186
187        let rpc_client = if let (false, Some(wss_rpc_url)) = (
188            config.use_hypersync_for_live_data,
189            config.wss_rpc_url.as_ref(),
190        ) {
191            log::debug!("WebSocket RPC URL: {REDACTED}");
192            Some(Self::initialize_rpc_client(
193                chain.name,
194                wss_rpc_url.clone().into_inner(),
195                config.transport_backend,
196                config.proxy_url.clone().map(|value| value.into_inner()),
197            ))
198        } else {
199            log::debug!("Using HyperSync for live data (no WebSocket RPC)");
200            None
201        };
202        let http_rpc_client = Arc::new(BlockchainHttpRpcClient::new(
203            config.http_rpc_url.clone().into_inner(),
204            config.rpc_requests_per_second,
205            config.proxy_url.clone().map(|value| value.into_inner()),
206        )?);
207        let multicall_calls_per_rpc_request = config.multicall_calls_per_rpc_request;
208        let erc20_contract = Erc20Contract::new(
209            Arc::clone(&http_rpc_client),
210            config.pool_filters.remove_pools_with_empty_erc20fields,
211        );
212
213        let hypersync_client =
214            HyperSyncClient::new(Arc::clone(&chain), hypersync_tx, cancellation_token.clone())?;
215        Ok(Self {
216            chain,
217            config,
218            rpc_client,
219            tokens: erc20_contract,
220            univ3_pool: UniswapV3PoolContract::new(
221                http_rpc_client,
222                multicall_calls_per_rpc_request,
223            ),
224            cache,
225            hypersync_client,
226            subscription_manager: DefiDataSubscriptionManager::new(),
227            data_tx,
228            cancellation_token,
229        })
230    }
231
232    /// Configures socket state reporting and reconnect control for direct RPC data.
233    pub fn set_socket_control(&mut self, control: SocketControl) {
234        if let Some(rpc_client) = &mut self.rpc_client {
235            rpc_client.set_socket_control(control);
236        }
237    }
238
239    /// Initializes the database connection for the blockchain cache.
240    pub async fn initialize_cache_database(&mut self) {
241        if let Some(pg_connect_options) = &self.config.postgres_cache_database_config {
242            log::debug!(
243                "Initializing blockchain cache on database '{}'",
244                pg_connect_options.database
245            );
246            self.cache
247                .initialize_database(pg_connect_options.clone().into())
248                .await;
249        }
250    }
251
252    /// Creates an appropriate blockchain RPC client for the specified blockchain.
253    fn initialize_rpc_client(
254        blockchain: Blockchain,
255        wss_rpc_url: String,
256        transport_backend: TransportBackend,
257        proxy_url: Option<String>,
258    ) -> BlockchainRpcClientAny {
259        let mut client = match blockchain {
260            Blockchain::Ethereum => {
261                BlockchainRpcClientAny::Ethereum(EthereumRpcClient::new(wss_rpc_url, proxy_url))
262            }
263            Blockchain::Polygon => {
264                BlockchainRpcClientAny::Polygon(PolygonRpcClient::new(wss_rpc_url, proxy_url))
265            }
266            Blockchain::Base => {
267                BlockchainRpcClientAny::Base(BaseRpcClient::new(wss_rpc_url, proxy_url))
268            }
269            Blockchain::Arbitrum => {
270                BlockchainRpcClientAny::Arbitrum(ArbitrumRpcClient::new(wss_rpc_url, proxy_url))
271            }
272            Blockchain::Bsc => {
273                BlockchainRpcClientAny::Bsc(BscRpcClient::new(wss_rpc_url, proxy_url))
274            }
275            _ => panic!("Unsupported blockchain {blockchain} for RPC connection"),
276        };
277        client.set_transport_backend(transport_backend);
278        client
279    }
280
281    /// Establishes connections to all configured data sources and initializes the cache.
282    ///
283    /// # Errors
284    ///
285    /// Returns an error if DEX selections contain duplicates, cache initialization fails, or
286    /// connection setup fails.
287    pub async fn connect(&mut self) -> anyhow::Result<()> {
288        self.config.validate()?;
289
290        log::debug!(
291            "Connecting blockchain data client for '{}'",
292            self.chain.name
293        );
294        self.initialize_cache_database().await;
295
296        if let Some(ref mut rpc_client) = self.rpc_client {
297            rpc_client.connect().await?;
298        }
299
300        let from_block = self.determine_from_block();
301
302        log::debug!(
303            "Connecting to blockchain data source for '{}' from block {}",
304            self.chain.name,
305            from_block.separate_with_commas()
306        );
307
308        // Initialize the chain and register the Dex exchanges in the cache.
309        self.cache.initialize_chain().await;
310        // Import the cached blockchain data.
311        self.cache.connect(from_block).await?;
312        // TODO disable block syncing for now as we don't have timestamps yet configured
313        // Sync the remaining blocks which are missing.
314        // self.sync_blocks(Some(from_block), None).await?;
315        for dex in self.config.dex_ids.clone() {
316            self.register_dex_exchange(dex).await?;
317            self.sync_exchange_pools(&dex, from_block, None, false)
318                .await?;
319        }
320
321        Ok(())
322    }
323
324    /// Syncs blocks with consistency checks to ensure data integrity.
325    ///
326    /// # Errors
327    ///
328    /// Returns an error if block syncing fails or if consistency checks fail.
329    pub async fn sync_blocks_checked(
330        &mut self,
331        from_block: u64,
332        to_block: Option<u64>,
333    ) -> anyhow::Result<()> {
334        if let Some(blocks_status) = self.cache.get_cache_block_consistency_status().await {
335            // If blocks are consistent proceed with copy command.
336            if blocks_status.is_consistent() {
337                log::debug!(
338                    "Cache is consistent: no gaps detected (last continuous block: {})",
339                    blocks_status.last_continuous_block
340                );
341                let target_block = max(blocks_status.max_block + 1, from_block);
342                log::debug!(
343                    "Starting fast sync with COPY from block {}",
344                    target_block.separate_with_commas()
345                );
346                self.sync_blocks(target_block, to_block, true).await?;
347            } else {
348                let gap_size = blocks_status.max_block - blocks_status.last_continuous_block;
349                log::debug!(
350                    "Cache inconsistency detected: {} blocks missing between {} and {}",
351                    gap_size,
352                    blocks_status.last_continuous_block + 1,
353                    blocks_status.max_block
354                );
355
356                log::debug!(
357                    "Block syncing Phase 1: Filling gaps with INSERT (blocks {} to {})",
358                    blocks_status.last_continuous_block + 1,
359                    blocks_status.max_block
360                );
361                self.sync_blocks(
362                    blocks_status.last_continuous_block + 1,
363                    Some(blocks_status.max_block),
364                    false,
365                )
366                .await?;
367
368                log::debug!(
369                    "Block syncing Phase 2: Continuing with fast COPY from block {}",
370                    (blocks_status.max_block + 1).separate_with_commas()
371                );
372                self.sync_blocks(blocks_status.max_block + 1, to_block, true)
373                    .await?;
374            }
375        } else {
376            self.sync_blocks(from_block, to_block, true).await?;
377        }
378
379        Ok(())
380    }
381
382    /// Synchronizes blockchain data by fetching and caching all blocks from the starting block to the current chain head.
383    ///
384    /// # Errors
385    ///
386    /// Returns an error if block fetching, caching, or database operations fail.
387    pub async fn sync_blocks(
388        &mut self,
389        from_block: u64,
390        to_block: Option<u64>,
391        use_copy_command: bool,
392    ) -> anyhow::Result<()> {
393        const BATCH_SIZE: usize = 1000;
394
395        let to_block = if let Some(block) = to_block {
396            block
397        } else {
398            self.hypersync_client.current_block().await
399        };
400        let total_blocks = to_block.saturating_sub(from_block) + 1;
401        log::debug!(
402            "Syncing blocks from {} to {} (total: {} blocks)",
403            from_block.separate_with_commas(),
404            to_block.separate_with_commas(),
405            total_blocks.separate_with_commas()
406        );
407
408        // Enable performance settings for sync operations
409        if let Err(e) = self.cache.toggle_performance_settings(true).await {
410            log::warn!("Failed to enable performance settings: {e}");
411        }
412
413        let blocks_stream = self
414            .hypersync_client
415            .request_blocks_stream(from_block, Some(to_block))
416            .await;
417
418        tokio::pin!(blocks_stream);
419
420        let mut metrics = BlockchainSyncReporter::new(
421            BlockchainSyncReportItems::Blocks,
422            from_block,
423            total_blocks,
424            BLOCKS_PROCESS_IN_SYNC_REPORT,
425        );
426
427        let mut batch: Vec<Block> = Vec::with_capacity(BATCH_SIZE);
428
429        let cancellation_token = self.cancellation_token.clone();
430        let sync_result = tokio::select! {
431            () = cancellation_token.cancelled() => {
432                log::debug!("Block sync cancelled");
433                Err(anyhow::anyhow!("Sync cancelled"))
434            }
435            result = async {
436                while let Some(block) = blocks_stream.next().await {
437                    let block_number = block.number;
438                    if self.cache.get_block_timestamp(block_number).is_some() {
439                        continue;
440                    }
441                    batch.push(block);
442
443                    // Process batch when full or last block
444                    if batch.len() >= BATCH_SIZE || block_number >= to_block {
445                        let batch_size = batch.len();
446
447                        self.cache.add_blocks_batch(batch, use_copy_command).await?;
448                        metrics.update(batch_size);
449
450                        // Re-initialize batch vector
451                        batch = Vec::with_capacity(BATCH_SIZE);
452                    }
453
454                    // Log progress if needed
455                    if metrics.should_log_progress(block_number, to_block) {
456                        metrics.log_progress(block_number);
457                    }
458                }
459
460                // Process any remaining blocks
461                if !batch.is_empty() {
462                    let batch_size = batch.len();
463                    self.cache.add_blocks_batch(batch, use_copy_command).await?;
464                    metrics.update(batch_size);
465                }
466
467                metrics.log_final_stats();
468                Ok(())
469            } => result
470        };
471
472        sync_result?;
473
474        // Restore default safe settings after sync completion
475        if let Err(e) = self.cache.toggle_performance_settings(false).await {
476            log::warn!("Failed to restore default settings: {e}");
477        }
478
479        Ok(())
480    }
481
482    /// Synchronizes all events for a specific pool within the given block range.
483    ///
484    /// # Errors
485    ///
486    /// Returns an error if event syncing, parsing, or database operations fail.
487    pub async fn sync_pool_events(
488        &mut self,
489        dex: &DexType,
490        pool_identifier: PoolIdentifier,
491        from_block: Option<u64>,
492        to_block: Option<u64>,
493        reset: bool,
494    ) -> anyhow::Result<()> {
495        const EVENT_BATCH_SIZE: usize = 20000;
496
497        let pool: SharedPool = Arc::clone(self.get_pool(&pool_identifier)?);
498        let pool_display = pool.to_full_spec_string();
499        let from_block = from_block.unwrap_or(pool.creation_block);
500        // Extract address for blockchain queries
501        let pool_address = &pool.address;
502
503        let dex_extended = self.get_dex_extended(dex)?.clone();
504        let event_families = Self::pool_event_families(&dex_extended);
505        let sync_state = if reset {
506            PoolEventSyncState::default()
507        } else {
508            self.cache
509                .get_pool_event_sync_state(dex, &pool_identifier)
510                .await?
511        };
512
513        let to_block = match to_block {
514            Some(block) => block,
515            None => self.hypersync_client.current_block().await,
516        };
517
518        if from_block > to_block {
519            log::debug!(
520                "D {} Pool '{}' requested event range {} to {} is empty, skipping sync",
521                dex,
522                pool_display,
523                from_block.separate_with_commas(),
524                to_block.separate_with_commas(),
525            );
526            return Ok(());
527        }
528
529        let sync_ranges =
530            Self::pool_event_sync_ranges(&event_families, &sync_state, from_block, to_block);
531        let inherited_event_family_names = event_families
532            .iter()
533            .filter(|family| {
534                sync_state.version == POOL_EVENT_SYNC_VERSION_LEGACY
535                    && family.introduced_version == POOL_EVENT_SYNC_VERSION_LEGACY
536                    && !sync_state
537                        .family_blocks
538                        .iter()
539                        .any(|(name, _)| name == family.name)
540            })
541            .map(|family| family.name)
542            .collect::<Vec<_>>();
543
544        let Some(sync_range) = sync_ranges.first().cloned() else {
545            if sync_state.version < POOL_EVENT_SYNC_VERSION {
546                if let Some(last_full_sync_block) = sync_state.last_full_sync_block {
547                    self.cache
548                        .update_pool_event_sync(
549                            dex,
550                            &pool_identifier,
551                            &inherited_event_family_names,
552                            last_full_sync_block,
553                            None,
554                        )
555                        .await?;
556                }
557                self.cache
558                    .update_pool_event_sync(
559                        dex,
560                        &pool_identifier,
561                        &[],
562                        to_block,
563                        Some(POOL_EVENT_SYNC_VERSION),
564                    )
565                    .await?;
566            }
567
568            log::debug!(
569                "D {} Pool '{}' event families already synced to block {}, skipping sync",
570                dex,
571                pool_display,
572                to_block.separate_with_commas()
573            );
574            return Ok(());
575        };
576        let has_more_ranges = sync_ranges.len() > 1;
577
578        // Query table max blocks to detect last blocks to use batch insert before that, then COPY command.
579        let last_block_across_pool_events_table = self
580            .cache
581            .get_pool_event_tables_last_block(&pool_identifier)
582            .await?;
583
584        let effective_from_block = sync_range.from_block;
585        let event_family_names = sync_range
586            .families
587            .iter()
588            .map(|family| family.name)
589            .collect::<Vec<_>>();
590        let total_blocks = to_block.saturating_sub(effective_from_block) + 1;
591        log::debug!(
592            "Syncing Pool: '{}' event families {:?} from {} to {} (total: {} blocks)",
593            pool_display,
594            event_family_names,
595            effective_from_block.separate_with_commas(),
596            to_block.separate_with_commas(),
597            total_blocks.separate_with_commas(),
598        );
599
600        let mut metrics = BlockchainSyncReporter::new(
601            BlockchainSyncReportItems::PoolEvents,
602            effective_from_block,
603            total_blocks,
604            BLOCKS_PROCESS_IN_SYNC_REPORT,
605        );
606        let swap_event_signature = dex_extended.swap_created_event.as_ref();
607        let mint_event_signature = dex_extended.mint_created_event.as_ref();
608        let burn_event_signature = dex_extended.burn_created_event.as_ref();
609        let collect_event_signature = dex_extended.collect_created_event.as_ref();
610        let flash_event_signature = dex_extended.flash_created_event.as_ref();
611        let protocol_update_event_signature = dex_extended.fee_protocol_update_event.as_ref();
612        let protocol_collect_event_signature = dex_extended.fee_protocol_collect_event.as_ref();
613        let initialize_event_signature: Option<&str> =
614            dex_extended.initialize_event.as_ref().map(|s| s.as_ref());
615
616        // Pre-decode event signatures to bytes for efficient comparison
617        let swap_sig_bytes = hex::decode(
618            swap_event_signature
619                .strip_prefix("0x")
620                .unwrap_or(swap_event_signature),
621        )?;
622        let mint_sig_bytes = hex::decode(
623            mint_event_signature
624                .strip_prefix("0x")
625                .unwrap_or(mint_event_signature),
626        )?;
627        let burn_sig_bytes = hex::decode(
628            burn_event_signature
629                .strip_prefix("0x")
630                .unwrap_or(burn_event_signature),
631        )?;
632        let collect_sig_bytes = hex::decode(
633            collect_event_signature
634                .strip_prefix("0x")
635                .unwrap_or(collect_event_signature),
636        )?;
637        let flash_sig_bytes = flash_event_signature
638            .map(|s| hex::decode(s.strip_prefix("0x").unwrap_or(s)).unwrap_or_default());
639        let protocol_update_sig_bytes = protocol_update_event_signature
640            .map(|s| hex::decode(s.strip_prefix("0x").unwrap_or(s)).unwrap_or_default());
641        let protocol_collect_sig_bytes = protocol_collect_event_signature
642            .map(|s| hex::decode(s.strip_prefix("0x").unwrap_or(s)).unwrap_or_default());
643        let initialize_sig_bytes = initialize_event_signature
644            .map(|s| hex::decode(s.strip_prefix("0x").unwrap_or(s)).unwrap_or_default());
645
646        let event_signatures = sync_range
647            .families
648            .iter()
649            .map(|family| family.signature.as_str())
650            .collect();
651
652        let pool_events_stream = self
653            .hypersync_client
654            .request_contract_events_stream(
655                effective_from_block,
656                Some(to_block),
657                pool_address,
658                event_signatures,
659            )
660            .await;
661        tokio::pin!(pool_events_stream);
662
663        let mut last_block_saved = effective_from_block;
664        let mut blocks_processed = 0;
665
666        let mut block_batch: Vec<Block> = Vec::with_capacity(POOL_EVENT_BLOCK_BATCH_SIZE);
667        let mut swap_batch: Vec<PoolSwap> = Vec::with_capacity(EVENT_BATCH_SIZE);
668        let mut liquidity_batch: Vec<PoolLiquidityUpdate> = Vec::with_capacity(EVENT_BATCH_SIZE);
669        let mut collect_batch: Vec<PoolFeeCollect> = Vec::with_capacity(EVENT_BATCH_SIZE);
670        let mut protocol_update_batch: Vec<PoolFeeProtocolUpdate> =
671            Vec::with_capacity(EVENT_BATCH_SIZE);
672        let mut protocol_collect_batch: Vec<PoolFeeProtocolCollect> =
673            Vec::with_capacity(EVENT_BATCH_SIZE);
674        let mut flash_batch: Vec<PoolFlash> = Vec::with_capacity(EVENT_BATCH_SIZE);
675
676        // Track when we've moved beyond stale data and can use COPY
677        let mut beyond_stale_data = last_block_across_pool_events_table
678            .is_none_or(|tables_max| effective_from_block > tables_max);
679
680        let cancellation_token = self.cancellation_token.clone();
681        let sync_result = tokio::select! {
682            () = cancellation_token.cancelled() => {
683                log::debug!("Pool event sync cancelled");
684                Err(anyhow::anyhow!("Sync cancelled"))
685            }
686            result = async {
687                while let Some(item) = pool_events_stream.next().await {
688                    let log = match item {
689                        PoolEventStreamItem::Block(block) => {
690                            self.record_pool_event_block(block, &mut block_batch).await?;
691                            continue;
692                        }
693                        PoolEventStreamItem::Log(log) => log,
694                    };
695                    let block_number = extract_block_number(&log)?;
696                    blocks_processed += block_number - last_block_saved;
697                    last_block_saved = block_number;
698
699                    let event_sig_bytes = extract_event_signature_bytes(&log)?;
700            if event_sig_bytes == swap_sig_bytes.as_slice() {
701                let swap_event = dex_extended.parse_swap_event_hypersync(&log)?;
702                let swap = self
703                    .process_pool_swap_event(&swap_event, &pool)
704                    .with_context(|| {
705                        format!("failed to process swap event at block {}", swap_event.block_number)
706                    })?;
707                swap_batch.push(swap);
708            } else if event_sig_bytes == mint_sig_bytes.as_slice() {
709                let mint_event = dex_extended.parse_mint_event_hypersync(&log)?;
710                let liquidity_update = self
711                    .process_pool_mint_event(&mint_event, &pool, &dex_extended)
712                    .with_context(|| {
713                        format!("failed to process mint event at block {}", mint_event.block_number)
714                    })?;
715                liquidity_batch.push(liquidity_update);
716            } else if event_sig_bytes == burn_sig_bytes.as_slice() {
717                let burn_event = dex_extended.parse_burn_event_hypersync(&log)?;
718                let liquidity_update = self
719                    .process_pool_burn_event(&burn_event, &pool, &dex_extended)
720                    .with_context(|| {
721                        format!("failed to process burn event at block {}", burn_event.block_number)
722                    })?;
723                liquidity_batch.push(liquidity_update);
724            } else if event_sig_bytes == collect_sig_bytes.as_slice() {
725                let collect_event = dex_extended.parse_collect_event_hypersync(&log)?;
726                let fee_collect = self
727                    .process_pool_collect_event(&collect_event, &pool, &dex_extended)
728                    .with_context(|| {
729                        format!(
730                            "failed to process collect event at block {}",
731                            collect_event.block_number
732                        )
733                    })?;
734                collect_batch.push(fee_collect);
735            } else if initialize_sig_bytes.as_ref().is_some_and(|sig| sig.as_slice() == event_sig_bytes) {
736                let initialize_event = dex_extended.parse_initialize_event_hypersync(&log)?;
737                self.cache
738                    .update_pool_initialize_price_tick(&initialize_event)
739                    .await?;
740            } else if protocol_update_sig_bytes.as_ref().is_some_and(|sig| sig.as_slice() == event_sig_bytes) {
741                let fee_protocol_update_event = dex_extended.parse_fee_protocol_update_event_hypersync(&log)?;
742                let update = self
743                    .process_pool_fee_protocol_update_event(&fee_protocol_update_event, &pool)
744                    .with_context(|| {
745                        format!(
746                            "failed to process SetFeeProtocol event at block {}",
747                            fee_protocol_update_event.block_number
748                        )
749                    })?;
750                protocol_update_batch.push(update);
751            } else if protocol_collect_sig_bytes.as_ref().is_some_and(|sig| sig.as_slice() == event_sig_bytes) {
752                let fee_protocol_collect_event = dex_extended.parse_fee_protocol_collect_event_hypersync(&log)?;
753                let collect = self
754                    .process_pool_fee_protocol_collect_event(&fee_protocol_collect_event, &pool)
755                    .with_context(|| {
756                        format!(
757                            "failed to process CollectProtocol event at block {}",
758                            fee_protocol_collect_event.block_number
759                        )
760                    })?;
761                protocol_collect_batch.push(collect);
762            } else if flash_sig_bytes.as_ref().is_some_and(|sig| sig.as_slice() == event_sig_bytes) {
763                let parse_fn = dex_extended
764                    .parse_flash_event_hypersync_fn
765                    .context("missing flash event parser")?;
766                let flash_event = parse_fn(Arc::clone(&dex_extended.dex), &log)
767                    .context("failed to parse flash event")?;
768                let flash = self
769                    .process_pool_flash_event(&flash_event, &pool)
770                    .with_context(|| {
771                        format!("failed to process flash event at block {}", flash_event.block_number)
772                    })?;
773                flash_batch.push(flash);
774            } else {
775                let event_signature = hex::encode(event_sig_bytes);
776                anyhow::bail!("unexpected event signature {event_signature} for log {log:?}");
777            }
778
779            // Check if we've moved beyond stale data (transition point for strategy change)
780            if !beyond_stale_data
781                && last_block_across_pool_events_table
782                    .is_some_and(|table_max| block_number > table_max)
783            {
784                log::debug!(
785                    "Crossed beyond stale data at block {block_number} - flushing current batches with ON CONFLICT, then switching to COPY"
786                );
787
788                // Flush all batches with ON CONFLICT to handle any remaining duplicates
789                self.flush_event_batches(
790                    EVENT_BATCH_SIZE,
791                    &mut block_batch,
792                    &mut swap_batch,
793                    &mut liquidity_batch,
794                    &mut collect_batch,
795                    &mut protocol_update_batch,
796                    &mut protocol_collect_batch,
797                    &mut flash_batch,
798                    false,
799                    true,
800                )
801                .await?;
802
803                beyond_stale_data = true;
804                log::debug!("Switched to COPY mode - future batches will use COPY command");
805            } else {
806                // Process batches when they reach batch size
807                self.flush_event_batches(
808                    EVENT_BATCH_SIZE,
809                    &mut block_batch,
810                    &mut swap_batch,
811                    &mut liquidity_batch,
812                    &mut collect_batch,
813                    &mut protocol_update_batch,
814                    &mut protocol_collect_batch,
815                    &mut flash_batch,
816                    false, // TODO temporary dont use copy command
817                    false,
818                )
819                .await?;
820            }
821
822            metrics.update(blocks_processed as usize);
823            blocks_processed = 0;
824
825            // Log progress if needed
826            if metrics.should_log_progress(block_number, to_block) {
827                metrics.log_progress(block_number);
828                self.flush_event_batches(
829                    EVENT_BATCH_SIZE,
830                    &mut block_batch,
831                    &mut swap_batch,
832                    &mut liquidity_batch,
833                    &mut collect_batch,
834                    &mut protocol_update_batch,
835                    &mut protocol_collect_batch,
836                    &mut flash_batch,
837                    false,
838                    true,
839                )
840                .await?;
841
842                if let Some(checkpoint_block) =
843                    Self::completed_pool_event_checkpoint(block_number, effective_from_block)
844                {
845                    self.cache
846                        .update_pool_event_sync(
847                            dex,
848                            &pool_identifier,
849                            &event_family_names,
850                            checkpoint_block,
851                            None,
852                        )
853                        .await?;
854                }
855            }
856        }
857
858        self.flush_event_batches(
859            EVENT_BATCH_SIZE,
860            &mut block_batch,
861            &mut swap_batch,
862            &mut liquidity_batch,
863            &mut collect_batch,
864            &mut protocol_update_batch,
865            &mut protocol_collect_batch,
866            &mut flash_batch,
867            false,
868            true,
869        )
870        .await?;
871
872        metrics.log_final_stats();
873        self.cache
874            .update_pool_event_sync(
875                dex,
876                &pool_identifier,
877                &event_family_names,
878                to_block,
879                None,
880            )
881            .await?;
882
883        log::debug!(
884            "Successfully synced Dex '{}' Pool '{}' event families {:?} up to block {}",
885            dex,
886            pool_display,
887            event_family_names,
888            to_block.separate_with_commas()
889        );
890                Ok(())
891            } => result
892        };
893
894        sync_result?;
895
896        if has_more_ranges {
897            return Box::pin(self.sync_pool_events(
898                dex,
899                pool_identifier,
900                Some(from_block),
901                Some(to_block),
902                false,
903            ))
904            .await;
905        }
906
907        if let Some(last_full_sync_block) = sync_state.last_full_sync_block {
908            self.cache
909                .update_pool_event_sync(
910                    dex,
911                    &pool_identifier,
912                    &inherited_event_family_names,
913                    last_full_sync_block,
914                    None,
915                )
916                .await?;
917        }
918        self.cache
919            .update_pool_event_sync(
920                dex,
921                &pool_identifier,
922                &[],
923                to_block,
924                Some(POOL_EVENT_SYNC_VERSION),
925            )
926            .await?;
927
928        Ok(())
929    }
930
931    fn pool_event_families(dex: &DexExtended) -> Vec<PoolEventFamily> {
932        let mut families = vec![
933            PoolEventFamily {
934                name: "swap",
935                signature: dex.swap_created_event.to_string(),
936                introduced_version: POOL_EVENT_SYNC_VERSION_LEGACY,
937            },
938            PoolEventFamily {
939                name: "mint",
940                signature: dex.mint_created_event.to_string(),
941                introduced_version: POOL_EVENT_SYNC_VERSION_LEGACY,
942            },
943            PoolEventFamily {
944                name: "burn",
945                signature: dex.burn_created_event.to_string(),
946                introduced_version: POOL_EVENT_SYNC_VERSION_LEGACY,
947            },
948            PoolEventFamily {
949                name: "collect",
950                signature: dex.collect_created_event.to_string(),
951                introduced_version: POOL_EVENT_SYNC_VERSION_LEGACY,
952            },
953        ];
954
955        if let Some(signature) = &dex.initialize_event {
956            families.push(PoolEventFamily {
957                name: "initialize",
958                signature: signature.to_string(),
959                introduced_version: POOL_EVENT_SYNC_VERSION_LEGACY,
960            });
961        }
962
963        if let Some(signature) = &dex.flash_created_event {
964            families.push(PoolEventFamily {
965                name: "flash",
966                signature: signature.to_string(),
967                introduced_version: POOL_EVENT_SYNC_VERSION_LEGACY,
968            });
969        }
970
971        if let Some(signature) = &dex.fee_protocol_update_event {
972            families.push(PoolEventFamily {
973                name: "fee_protocol_update",
974                signature: signature.to_string(),
975                introduced_version: POOL_EVENT_SYNC_VERSION_PROTOCOL_FEE,
976            });
977        }
978
979        if let Some(signature) = &dex.fee_protocol_collect_event {
980            families.push(PoolEventFamily {
981                name: "fee_protocol_collect",
982                signature: signature.to_string(),
983                introduced_version: POOL_EVENT_SYNC_VERSION_PROTOCOL_FEE,
984            });
985        }
986
987        families
988    }
989
990    fn pool_event_sync_ranges(
991        families: &[PoolEventFamily],
992        state: &PoolEventSyncState,
993        from_block: u64,
994        to_block: u64,
995    ) -> Vec<PoolEventSyncRange> {
996        let mut grouped = BTreeMap::<u64, Vec<PoolEventFamily>>::new();
997
998        for family in families {
999            let explicit_checkpoint = state
1000                .family_blocks
1001                .iter()
1002                .find_map(|(name, block)| (name == family.name).then_some(*block));
1003            let legacy_checkpoint = (state.version == POOL_EVENT_SYNC_VERSION_LEGACY
1004                && family.introduced_version == POOL_EVENT_SYNC_VERSION_LEGACY)
1005                .then_some(state.last_full_sync_block)
1006                .flatten();
1007            let family_from_block = explicit_checkpoint
1008                .or(legacy_checkpoint)
1009                .map_or(from_block, |block| max(from_block, block.saturating_add(1)));
1010
1011            if family_from_block <= to_block {
1012                grouped
1013                    .entry(family_from_block)
1014                    .or_default()
1015                    .push(family.clone());
1016            }
1017        }
1018
1019        grouped
1020            .into_iter()
1021            .map(|(from_block, families)| PoolEventSyncRange {
1022                from_block,
1023                families,
1024            })
1025            .collect()
1026    }
1027
1028    #[expect(clippy::too_many_arguments)]
1029    async fn flush_event_batches(
1030        &mut self,
1031        event_batch_size: usize,
1032        block_batch: &mut Vec<Block>,
1033        swap_batch: &mut Vec<PoolSwap>,
1034        liquidity_batch: &mut Vec<PoolLiquidityUpdate>,
1035        collect_batch: &mut Vec<PoolFeeCollect>,
1036        protocol_update_batch: &mut Vec<PoolFeeProtocolUpdate>,
1037        protocol_collect_batch: &mut Vec<PoolFeeProtocolCollect>,
1038        flash_batch: &mut Vec<PoolFlash>,
1039        use_copy_command: bool,
1040        force_flush_all: bool,
1041    ) -> anyhow::Result<()> {
1042        let should_flush_swaps =
1043            (force_flush_all || swap_batch.len() >= event_batch_size) && !swap_batch.is_empty();
1044        let should_flush_liquidity = (force_flush_all || liquidity_batch.len() >= event_batch_size)
1045            && !liquidity_batch.is_empty();
1046        let should_flush_collects = (force_flush_all || collect_batch.len() >= event_batch_size)
1047            && !collect_batch.is_empty();
1048        let should_flush_protocol_update = (force_flush_all
1049            || protocol_update_batch.len() >= event_batch_size)
1050            && !protocol_update_batch.is_empty();
1051        let should_flush_protocol_collect = (force_flush_all
1052            || protocol_collect_batch.len() >= event_batch_size)
1053            && !protocol_collect_batch.is_empty();
1054        let should_flush_flash =
1055            (force_flush_all || flash_batch.len() >= event_batch_size) && !flash_batch.is_empty();
1056
1057        if force_flush_all
1058            || should_flush_swaps
1059            || should_flush_liquidity
1060            || should_flush_collects
1061            || should_flush_protocol_update
1062            || should_flush_protocol_collect
1063            || should_flush_flash
1064        {
1065            self.flush_pool_event_blocks(block_batch).await?;
1066        }
1067
1068        if should_flush_swaps {
1069            self.cache
1070                .add_pool_swaps_batch(swap_batch, use_copy_command)
1071                .await?;
1072            swap_batch.clear();
1073        }
1074
1075        if should_flush_liquidity {
1076            self.cache
1077                .add_pool_liquidity_updates_batch(liquidity_batch, use_copy_command)
1078                .await?;
1079            liquidity_batch.clear();
1080        }
1081
1082        if should_flush_collects {
1083            self.cache
1084                .add_pool_fee_collects_batch(collect_batch, use_copy_command)
1085                .await?;
1086            collect_batch.clear();
1087        }
1088
1089        if should_flush_protocol_update {
1090            self.cache
1091                .add_pool_fee_protocol_updates_batch(protocol_update_batch)
1092                .await?;
1093            protocol_update_batch.clear();
1094        }
1095
1096        if should_flush_protocol_collect {
1097            self.cache
1098                .add_pool_fee_protocol_collect_batch(protocol_collect_batch)
1099                .await?;
1100            protocol_collect_batch.clear();
1101        }
1102
1103        if should_flush_flash {
1104            self.cache.add_pool_flash_batch(flash_batch).await?;
1105            flash_batch.clear();
1106        }
1107        Ok(())
1108    }
1109
1110    async fn record_pool_event_block(
1111        &mut self,
1112        block: Block,
1113        block_batch: &mut Vec<Block>,
1114    ) -> anyhow::Result<()> {
1115        self.cache.cache_block_metadata(&block);
1116        block_batch.push(block);
1117        if block_batch.len() >= POOL_EVENT_BLOCK_BATCH_SIZE {
1118            self.flush_pool_event_blocks(block_batch).await?;
1119        }
1120        Ok(())
1121    }
1122
1123    async fn flush_pool_event_blocks(
1124        &mut self,
1125        block_batch: &mut Vec<Block>,
1126    ) -> anyhow::Result<()> {
1127        if block_batch.is_empty() {
1128            return Ok(());
1129        }
1130
1131        self.cache
1132            .add_pool_event_blocks_batch(std::mem::take(block_batch))
1133            .await
1134    }
1135
1136    fn completed_pool_event_checkpoint(
1137        block_number: u64,
1138        effective_from_block: u64,
1139    ) -> Option<u64> {
1140        let checkpoint_block = block_number.checked_sub(1)?;
1141        (checkpoint_block >= effective_from_block).then_some(checkpoint_block)
1142    }
1143
1144    /// Processes a swap event and converts it to a pool swap.
1145    ///
1146    /// Trade-info computation can fail for degenerate MIN/MAX-tick swaps on near-zero-liquidity
1147    /// pools, whose spot price overflows the price representation. Such failures are non-fatal: the
1148    /// swap is kept with empty trade-info so an otherwise-valid event does not abort the pool sync.
1149    ///
1150    /// # Errors
1151    ///
1152    /// Returns an error if the swap event's block timestamp is missing from the cache.
1153    pub fn process_pool_swap_event(
1154        &self,
1155        swap_event: &SwapEvent,
1156        pool: &SharedPool,
1157    ) -> anyhow::Result<PoolSwap> {
1158        let timestamp = self
1159            .cache
1160            .get_block_timestamp(swap_event.block_number)
1161            .copied()
1162            .context("missing block timestamp for swap event")?;
1163        let mut swap = swap_event.to_pool_swap(
1164            Arc::clone(&self.chain),
1165            pool.instrument_id,
1166            pool.pool_identifier,
1167            timestamp,
1168        );
1169        swap.block_hash = Some(self.observed_block_hash(swap_event.block_number, "swap")?);
1170        // Keep the swap and leave price metadata empty rather than aborting the pool sync
1171        if let Err(e) = swap.calculate_trade_info(&pool.token0, &pool.token1, None) {
1172            log::debug!(
1173                "Skipping trade info for swap at block {} on pool {}: {e}",
1174                swap_event.block_number,
1175                pool.instrument_id,
1176            );
1177        }
1178
1179        Ok(swap)
1180    }
1181
1182    /// Processes a mint event (liquidity addition) and converts it to a `PoolLiquidityUpdate`.
1183    ///
1184    /// # Errors
1185    ///
1186    /// Returns an error if mint event processing fails or if the liquidity update creation fails.
1187    pub fn process_pool_mint_event(
1188        &self,
1189        mint_event: &MintEvent,
1190        pool: &SharedPool,
1191        dex_extended: &DexExtended,
1192    ) -> anyhow::Result<PoolLiquidityUpdate> {
1193        let timestamp = self
1194            .cache
1195            .get_block_timestamp(mint_event.block_number)
1196            .copied()
1197            .context("missing block timestamp for mint event")?;
1198
1199        let mut liquidity_update = mint_event.to_pool_liquidity_update(
1200            Arc::clone(&self.chain),
1201            Arc::clone(&dex_extended.dex),
1202            pool.instrument_id,
1203            timestamp,
1204        );
1205        liquidity_update.block_hash =
1206            Some(self.observed_block_hash(mint_event.block_number, "mint")?);
1207
1208        // self.cache.add_liquidity_update(&liquidity_update).await?;
1209
1210        Ok(liquidity_update)
1211    }
1212
1213    /// Processes a burn event (liquidity removal) and converts it to a `PoolLiquidityUpdate`.
1214    /// Processes a pool burn event and converts it to a liquidity update.
1215    ///
1216    /// # Errors
1217    ///
1218    /// Returns an error if the burn event processing fails or if the liquidity update creation fails.
1219    pub fn process_pool_burn_event(
1220        &self,
1221        burn_event: &BurnEvent,
1222        pool: &SharedPool,
1223        dex_extended: &DexExtended,
1224    ) -> anyhow::Result<PoolLiquidityUpdate> {
1225        let timestamp = self
1226            .cache
1227            .get_block_timestamp(burn_event.block_number)
1228            .copied()
1229            .context("missing block timestamp for burn event")?;
1230
1231        let mut liquidity_update = burn_event.to_pool_liquidity_update(
1232            Arc::clone(&self.chain),
1233            Arc::clone(&dex_extended.dex),
1234            pool.instrument_id,
1235            pool.pool_identifier,
1236            timestamp,
1237        );
1238        liquidity_update.block_hash =
1239            Some(self.observed_block_hash(burn_event.block_number, "burn")?);
1240
1241        // self.cache.add_liquidity_update(&liquidity_update).await?;
1242
1243        Ok(liquidity_update)
1244    }
1245
1246    /// Processes a pool collect event and converts it to a fee collection.
1247    ///
1248    /// # Errors
1249    ///
1250    /// Returns an error if the collect event processing fails or if the fee collection creation fails.
1251    pub fn process_pool_collect_event(
1252        &self,
1253        collect_event: &CollectEvent,
1254        pool: &SharedPool,
1255        dex_extended: &DexExtended,
1256    ) -> anyhow::Result<PoolFeeCollect> {
1257        let timestamp = self
1258            .cache
1259            .get_block_timestamp(collect_event.block_number)
1260            .copied()
1261            .context("missing block timestamp for collect event")?;
1262
1263        let mut fee_collect = collect_event.to_pool_fee_collect(
1264            Arc::clone(&self.chain),
1265            Arc::clone(&dex_extended.dex),
1266            pool.instrument_id,
1267            timestamp,
1268        );
1269        fee_collect.block_hash =
1270            Some(self.observed_block_hash(collect_event.block_number, "collect")?);
1271
1272        Ok(fee_collect)
1273    }
1274
1275    /// Processes a pool flash event and converts it to a flash loan.
1276    ///
1277    /// # Errors
1278    ///
1279    /// Returns an error if the flash event processing fails or if the flash loan creation fails.
1280    pub fn process_pool_flash_event(
1281        &self,
1282        flash_event: &FlashEvent,
1283        pool: &SharedPool,
1284    ) -> anyhow::Result<PoolFlash> {
1285        let timestamp = self
1286            .cache
1287            .get_block_timestamp(flash_event.block_number)
1288            .copied()
1289            .context("missing block timestamp for flash event")?;
1290
1291        let mut flash =
1292            flash_event.to_pool_flash(Arc::clone(&self.chain), pool.instrument_id, timestamp);
1293        flash.block_hash = Some(self.observed_block_hash(flash_event.block_number, "flash")?);
1294
1295        Ok(flash)
1296    }
1297
1298    /// Processes a `SetFeeProtocol` event and converts it to a pool fee-protocol update.
1299    ///
1300    /// # Errors
1301    ///
1302    /// Returns an error if the event's block timestamp is missing from the cache.
1303    pub fn process_pool_fee_protocol_update_event(
1304        &self,
1305        fee_protocol_update_event: &FeeProtocolUpdateEvent,
1306        pool: &SharedPool,
1307    ) -> anyhow::Result<PoolFeeProtocolUpdate> {
1308        let timestamp = self
1309            .cache
1310            .get_block_timestamp(fee_protocol_update_event.block_number)
1311            .copied()
1312            .context("missing block timestamp for SetFeeProtocol event")?;
1313
1314        let mut update = fee_protocol_update_event.to_pool_fee_protocol_update(
1315            Arc::clone(&self.chain),
1316            pool.instrument_id,
1317            timestamp,
1318        );
1319        update.block_hash = Some(
1320            self.observed_block_hash(fee_protocol_update_event.block_number, "SetFeeProtocol")?,
1321        );
1322
1323        Ok(update)
1324    }
1325
1326    /// Processes a `CollectProtocol` event and converts it to a pool protocol-fee withdrawal.
1327    ///
1328    /// # Errors
1329    ///
1330    /// Returns an error if the event's block timestamp is missing from the cache.
1331    pub fn process_pool_fee_protocol_collect_event(
1332        &self,
1333        fee_protocol_collect_event: &FeeProtocolCollectEvent,
1334        pool: &SharedPool,
1335    ) -> anyhow::Result<PoolFeeProtocolCollect> {
1336        let timestamp = self
1337            .cache
1338            .get_block_timestamp(fee_protocol_collect_event.block_number)
1339            .copied()
1340            .context("missing block timestamp for CollectProtocol event")?;
1341
1342        let mut collect = fee_protocol_collect_event.to_pool_fee_protocol_collect(
1343            Arc::clone(&self.chain),
1344            pool.instrument_id,
1345            timestamp,
1346        );
1347        collect.block_hash = Some(
1348            self.observed_block_hash(fee_protocol_collect_event.block_number, "CollectProtocol")?,
1349        );
1350
1351        Ok(collect)
1352    }
1353
1354    fn observed_block_hash(&self, block_number: u64, event: &str) -> anyhow::Result<String> {
1355        self.cache
1356            .get_block_hash(block_number)
1357            .map(str::to_owned)
1358            .with_context(|| {
1359                format!("missing block hash for {event} event at block {block_number}")
1360            })
1361    }
1362
1363    /// Synchronizes all pools and their tokens for a specific DEX within the given block range.
1364    ///
1365    /// This method performs a full sync of:
1366    /// 1. Pool creation events from the DEX factory
1367    /// 2. Token metadata for all tokens in discovered pools
1368    /// 3. Pool entities with proper token associations
1369    ///
1370    /// # Errors
1371    ///
1372    /// Returns an error if syncing pools, tokens, or DEX operations fail.
1373    pub async fn sync_exchange_pools(
1374        &mut self,
1375        dex: &DexType,
1376        from_block: u64,
1377        to_block: Option<u64>,
1378        reset: bool,
1379    ) -> anyhow::Result<()> {
1380        let dex_extended = self.get_dex_extended(dex)?.clone();
1381
1382        let mut service = PoolDiscoveryService::new(
1383            Arc::clone(&self.chain),
1384            &mut self.cache,
1385            &self.tokens,
1386            &self.hypersync_client,
1387            self.cancellation_token.clone(),
1388            self.config.clone(),
1389        );
1390
1391        service
1392            .sync_pools(&dex_extended, from_block, to_block, reset)
1393            .await?;
1394
1395        Ok(())
1396    }
1397
1398    /// Registers a decentralized exchange for data collection and event monitoring.
1399    ///
1400    /// Registration involves:
1401    /// 1. Adding the DEX to the cache
1402    /// 2. Loading existing pools for the DEX
1403    /// 3. Configuring event signatures for subscriptions
1404    ///
1405    /// # Errors
1406    ///
1407    /// Returns an error if DEX registration, cache operations, or pool loading fails.
1408    pub async fn register_dex_exchange(&mut self, dex_id: DexType) -> anyhow::Result<()> {
1409        self.register_dex(dex_id).await?;
1410        let _ = self.cache.load_pools(&dex_id).await?;
1411        Ok(())
1412    }
1413
1414    /// Registers a decentralized exchange but loads only a single pool into the cache.
1415    ///
1416    /// Like [`Self::register_dex_exchange`], but loads just `pool_identifier` instead of the whole
1417    /// DEX pool set, so per-pool tools (e.g. `analyze-pool`) avoid the full pool-set load. A pool
1418    /// absent from the cache database is left for the caller's later lookup to report.
1419    ///
1420    /// # Errors
1421    ///
1422    /// Returns an error if DEX registration or the pool load fails.
1423    pub async fn register_dex_exchange_for_pool(
1424        &mut self,
1425        dex_id: DexType,
1426        pool_identifier: &PoolIdentifier,
1427    ) -> anyhow::Result<()> {
1428        self.register_dex(dex_id).await?;
1429        let _ = self.cache.load_pool(&dex_id, pool_identifier).await?;
1430        Ok(())
1431    }
1432
1433    /// Registers a DEX in the cache and its event signatures for subscriptions, without loading pools.
1434    async fn register_dex(&mut self, dex_id: DexType) -> anyhow::Result<()> {
1435        let Some(dex_extended) = get_dex_extended(self.chain.name, &dex_id) else {
1436            anyhow::bail!("Unknown DEX {dex_id} on chain {}", self.chain.name);
1437        };
1438
1439        log::debug!("Registering DEX {dex_id} on chain {}", self.chain.name);
1440        self.cache.add_dex(Arc::clone(&dex_extended.dex)).await?;
1441        self.subscription_manager.register_dex_for_subscriptions(
1442            dex_id,
1443            dex_extended.swap_created_event.as_ref(),
1444            dex_extended.mint_created_event.as_ref(),
1445            dex_extended.burn_created_event.as_ref(),
1446            dex_extended.collect_created_event.as_ref(),
1447            dex_extended.flash_created_event.as_deref(),
1448        );
1449        self.subscription_manager.register_dex_fee_protocol_events(
1450            dex_id,
1451            dex_extended.fee_protocol_update_event.as_deref(),
1452            dex_extended.fee_protocol_collect_event.as_deref(),
1453        );
1454        Ok(())
1455    }
1456
1457    /// Bootstraps a [`PoolProfiler`] with the latest state for a given pool.
1458    ///
1459    /// Uses two paths depending on whether the pool has been synced to the database:
1460    /// - **Never synced**: Streams events from HyperSync, restores from on-chain RPC, returns `(profiler, true)`.
1461    /// - **Previously synced**: Syncs new events to DB, streams from DB, returns `(profiler, false)`.
1462    ///
1463    /// Both paths restore from the latest valid snapshot first (if available), otherwise initialize with pool's initial price.
1464    ///
1465    /// # Returns
1466    ///
1467    /// - `PoolProfiler`: Hydrated profiler with current pool state
1468    /// - `bool`: `true` if constructed from RPC (already valid), `false` if from DB (needs validation)
1469    ///
1470    /// # Errors
1471    ///
1472    /// Returns an error if database is not initialized or event processing fails.
1473    ///
1474    /// # Panics
1475    ///
1476    /// Panics if the database reference is unavailable.
1477    pub async fn bootstrap_latest_pool_profiler(
1478        &mut self,
1479        pool: &SharedPool,
1480        to_block: Option<u64>,
1481    ) -> anyhow::Result<(PoolProfiler, bool)> {
1482        log::debug!(
1483            "Bootstrapping latest pool profiler for pool {}",
1484            pool.address
1485        );
1486
1487        if self.cache.database.is_none() {
1488            anyhow::bail!(
1489                "Database is not initialized, so we cannot properly bootstrap the latest pool profiler"
1490            );
1491        }
1492
1493        let to_block = match to_block {
1494            Some(block) => block,
1495            None => self.hypersync_client.current_block().await,
1496        };
1497        let (mut profiler, from_position) = self
1498            .seed_pool_profiler_from_latest_snapshot(pool, to_block)
1499            .await?;
1500
1501        // If we don't have never synced pool events, proceed with faster
1502        // construction of pool profiler from hypersync and RPC, where we
1503        // dont need syncing of pool events and fetching it from database
1504        if self
1505            .cache
1506            .database
1507            .as_ref()
1508            .unwrap()
1509            .get_pool_last_synced_block(self.chain.chain_id, &pool.dex.name, &pool.pool_identifier)
1510            .await?
1511            .is_none()
1512        {
1513            return self
1514                .construct_pool_profiler_from_hypersync_rpc(profiler, from_position, to_block)
1515                .await;
1516        }
1517
1518        // Sync the pool events before bootstrapping of pool profiler
1519        self.sync_pool_events(
1520            &pool.dex.name,
1521            pool.pool_identifier,
1522            None,
1523            Some(to_block),
1524            false,
1525        )
1526        .await
1527        .context("failed to sync pool events for snapshot request")?;
1528
1529        if !profiler.is_initialized {
1530            if let Some(initial_sqrt_price_x96) = pool.initial_sqrt_price_x96 {
1531                profiler.initialize(initial_sqrt_price_x96)?;
1532            } else {
1533                anyhow::bail!(
1534                    "Pool is not initialized and it doesn't contain initial price, cannot bootstrap profiler"
1535                );
1536            }
1537        }
1538
1539        let from_block = from_position
1540            .as_ref()
1541            .map_or(profiler.pool.creation_block, |block_position| {
1542                block_position.number
1543            });
1544        let total_blocks = to_block.saturating_sub(from_block) + 1;
1545
1546        // Enable embedded profiler reporting
1547        profiler.enable_reporting(from_block, total_blocks, BLOCKS_PROCESS_IN_SYNC_REPORT);
1548
1549        let mut stream = self.cache.database.as_ref().unwrap().stream_pool_events(
1550            Arc::clone(&pool.chain),
1551            Arc::clone(&pool.dex),
1552            pool.instrument_id,
1553            pool.pool_identifier,
1554            from_position.clone(),
1555            Some(to_block),
1556        );
1557
1558        while let Some(result) = stream.next().await {
1559            match result {
1560                Ok(event) => {
1561                    profiler.process(&event)?;
1562                }
1563                Err(e) => return Err(e).context("failed to stream pool event from database"),
1564            }
1565        }
1566
1567        profiler.finalize_reporting();
1568
1569        Ok((profiler, false))
1570    }
1571
1572    /// Bootstraps a pool profiler by reading liquidity topology from HyperSync and state from RPC.
1573    ///
1574    /// This mode avoids storing the full swap history. It streams Initialize, Mint, Burn, and
1575    /// fee-protocol updates only, then hydrates the exact target block from the pool contract.
1576    ///
1577    /// # Errors
1578    ///
1579    /// Returns an error if database is not initialized, event streaming fails, or RPC hydration fails.
1580    pub async fn bootstrap_pool_profiler_from_rpc_snapshot(
1581        &mut self,
1582        pool: &SharedPool,
1583        to_block: u64,
1584    ) -> anyhow::Result<(PoolProfiler, bool)> {
1585        if self.cache.database.is_none() {
1586            anyhow::bail!(
1587                "Database is not initialized, so we cannot bootstrap the pool profiler from an RPC snapshot"
1588            );
1589        }
1590
1591        self.construct_pool_profiler_from_hypersync_rpc(
1592            PoolProfiler::new(Arc::clone(pool)),
1593            None,
1594            to_block,
1595        )
1596        .await
1597    }
1598
1599    /// Advances an RPC-hydrated profiler to a later checkpoint.
1600    ///
1601    /// The profiler must come from [`Self::bootstrap_pool_profiler_from_rpc_snapshot`] or an earlier
1602    /// call to this method. This keeps one command incremental without trusting an unproven stored
1603    /// snapshot as the topology source.
1604    ///
1605    /// # Errors
1606    ///
1607    /// Returns an error if the profiler has no RPC snapshot watermark, the target precedes that
1608    /// watermark, event streaming fails, or RPC hydration fails.
1609    pub async fn advance_pool_profiler_from_rpc_snapshot(
1610        &mut self,
1611        profiler: PoolProfiler,
1612        to_block: u64,
1613    ) -> anyhow::Result<(PoolProfiler, bool)> {
1614        let from_position = profiler.last_processed_event.clone().ok_or_else(|| {
1615            anyhow::anyhow!("cannot advance an RPC profiler without a snapshot watermark")
1616        })?;
1617
1618        if to_block < from_position.number {
1619            anyhow::bail!(
1620                "cannot advance RPC profiler from block {} to earlier block {to_block}",
1621                from_position.number
1622            );
1623        }
1624
1625        self.construct_pool_profiler_from_hypersync_rpc(profiler, Some(from_position), to_block)
1626            .await
1627    }
1628
1629    async fn seed_pool_profiler_from_latest_snapshot(
1630        &self,
1631        pool: &SharedPool,
1632        to_block: u64,
1633    ) -> anyhow::Result<(PoolProfiler, Option<BlockPosition>)> {
1634        let mut profiler = PoolProfiler::new(Arc::clone(pool));
1635
1636        let from_position = match self
1637            .cache
1638            .database
1639            .as_ref()
1640            .expect("database presence is checked by caller")
1641            .load_latest_pool_snapshot(
1642                pool.chain.chain_id,
1643                &pool.pool_identifier,
1644                Some(to_block),
1645                true,
1646            )
1647            .await
1648        {
1649            Ok(Some(snapshot)) => {
1650                // Empty snapshots at the pool's creation block are stubs left behind by an
1651                // earlier bootstrap that bailed before any liquidity events landed. Restoring
1652                // marks the profiler as initialized, which then conflicts with the Initialize
1653                // event that hypersync re-emits at the same block. Fall through to a fresh
1654                // bootstrap rather than trust the stub.
1655                if snapshot.positions.is_empty()
1656                    && snapshot.ticks.is_empty()
1657                    && snapshot.block_position.number == pool.creation_block
1658                {
1659                    log::warn!(
1660                        "Ignoring empty stub snapshot at pool creation block {} for {}; rebuilding from events",
1661                        snapshot.block_position.number.separate_with_commas(),
1662                        pool.instrument_id,
1663                    );
1664                    None
1665                } else {
1666                    log::debug!(
1667                        "Loaded valid snapshot from block {} which contains {} positions and {} ticks",
1668                        snapshot.block_position.number.separate_with_commas(),
1669                        snapshot.positions.len(),
1670                        snapshot.ticks.len()
1671                    );
1672                    let block_position = snapshot.block_position.clone();
1673                    profiler.restore_from_snapshot(snapshot)?;
1674                    log::debug!("Restored profiler from snapshot");
1675                    Some(block_position)
1676                }
1677            }
1678            _ => {
1679                log::debug!("No valid snapshot found, processing from beginning");
1680                None
1681            }
1682        };
1683
1684        Ok((profiler, from_position))
1685    }
1686
1687    /// Constructs a pool profiler by fetching events directly from HyperSync RPC.
1688    ///
1689    /// This method is used when the pool has never been synced to the database. It streams
1690    /// liquidity events (mints, burns) directly from HyperSync and processes them
1691    /// to build up the profiler's state in real-time. After processing all events, it
1692    /// restores the profiler from on-chain state at the target block with the provided ticks and
1693    /// positions.
1694    ///
1695    /// # Returns
1696    ///
1697    /// Returns a tuple of:
1698    /// - `PoolProfiler`: The hydrated profiler with state built from events
1699    /// - `bool`: Always `true` to indicate the profiler state was valid, and it was constructed from RPC
1700    ///
1701    /// # Errors
1702    ///
1703    /// Returns an error if:
1704    /// - Event streaming from HyperSync fails.
1705    /// - Event parsing or processing fails.
1706    /// - DEX configuration is invalid.
1707    async fn construct_pool_profiler_from_hypersync_rpc(
1708        &mut self,
1709        mut profiler: PoolProfiler,
1710        from_position: Option<BlockPosition>,
1711        to_block: u64,
1712    ) -> anyhow::Result<(PoolProfiler, bool)> {
1713        log::debug!("Constructing pool profiler from hypersync stream and RPC target block state");
1714        let dex_extended = self.get_dex_extended(&profiler.pool.dex.name)?.clone();
1715        let mint_event_signature = dex_extended.mint_created_event.as_ref();
1716        let burn_event_signature = dex_extended.burn_created_event.as_ref();
1717        let initialize_event_signature =
1718            if let Some(initialize_event) = &dex_extended.initialize_event {
1719                initialize_event.as_ref()
1720            } else {
1721                anyhow::bail!(
1722                    "DEX {} does not have initialize event set.",
1723                    profiler.pool.dex.name
1724                );
1725            };
1726        let mint_sig_bytes = hex::decode(
1727            mint_event_signature
1728                .strip_prefix("0x")
1729                .unwrap_or(mint_event_signature),
1730        )?;
1731        let burn_sig_bytes = hex::decode(
1732            burn_event_signature
1733                .strip_prefix("0x")
1734                .unwrap_or(burn_event_signature),
1735        )?;
1736        let initialize_sig_bytes = hex::decode(
1737            initialize_event_signature
1738                .strip_prefix("0x")
1739                .unwrap_or(initialize_event_signature),
1740        )?;
1741        let protocol_update_event_signature = dex_extended.fee_protocol_update_event.as_deref();
1742        let protocol_update_sig_bytes = protocol_update_event_signature
1743            .map(|s| hex::decode(s.strip_prefix("0x").unwrap_or(s)).unwrap_or_default());
1744        let protocol_collect_event_signature = dex_extended.fee_protocol_collect_event.as_deref();
1745        let protocol_collect_sig_bytes = protocol_collect_event_signature
1746            .map(|s| hex::decode(s.strip_prefix("0x").unwrap_or(s)).unwrap_or_default());
1747
1748        let from_block = from_position.map_or(profiler.pool.creation_block, |block_position| {
1749            block_position.number
1750        });
1751        let total_blocks = to_block.saturating_sub(from_block) + 1;
1752
1753        log::debug!(
1754            "Bootstrapping pool profiler for pool {} from block {} to {} (total: {} blocks)",
1755            profiler.pool.address,
1756            from_block.separate_with_commas(),
1757            to_block.separate_with_commas(),
1758            total_blocks.separate_with_commas()
1759        );
1760
1761        // Enable embedded profiler reporting
1762        profiler.enable_reporting(from_block, total_blocks, BLOCKS_PROCESS_IN_SYNC_REPORT);
1763
1764        let mut event_signatures = vec![
1765            mint_event_signature,
1766            burn_event_signature,
1767            initialize_event_signature,
1768        ];
1769
1770        if let Some(event) = protocol_update_event_signature {
1771            event_signatures.push(event);
1772        }
1773
1774        if let Some(event) = protocol_collect_event_signature {
1775            event_signatures.push(event);
1776        }
1777
1778        let pool_events_stream = self
1779            .hypersync_client
1780            .request_contract_events_stream(
1781                from_block,
1782                Some(to_block),
1783                &profiler.pool.address,
1784                event_signatures,
1785            )
1786            .await;
1787        tokio::pin!(pool_events_stream);
1788        let mut block_batch: Vec<Block> = Vec::with_capacity(POOL_EVENT_BLOCK_BATCH_SIZE);
1789
1790        while let Some(item) = pool_events_stream.next().await {
1791            let log = match item {
1792                PoolEventStreamItem::Block(block) => {
1793                    self.record_pool_event_block(block, &mut block_batch)
1794                        .await?;
1795                    continue;
1796                }
1797                PoolEventStreamItem::Log(log) => log,
1798            };
1799            let event_sig_bytes = extract_event_signature_bytes(&log)?;
1800
1801            if event_sig_bytes == initialize_sig_bytes {
1802                if profiler.is_initialized {
1803                    // Profiler was restored from a snapshot at or after this block; the
1804                    // initialize state is already in place. Skip the re-init that would
1805                    // otherwise trip AlreadyInitialized.
1806                    log::debug!(
1807                        "Profiler already initialized; skipping Initialize event at block {}",
1808                        extract_block_number(&log)?.separate_with_commas(),
1809                    );
1810                } else {
1811                    let initialize_event = dex_extended.parse_initialize_event_hypersync(&log)?;
1812                    profiler.initialize(initialize_event.sqrt_price_x96)?;
1813                    self.cache
1814                        .database
1815                        .as_ref()
1816                        .unwrap()
1817                        .update_pool_initial_price_tick(self.chain.chain_id, &initialize_event)
1818                        .await?;
1819                }
1820            } else if event_sig_bytes == mint_sig_bytes {
1821                let mint_event = dex_extended.parse_mint_event_hypersync(&log)?;
1822                let liquidity_update = self
1823                    .process_pool_mint_event(&mint_event, &profiler.pool, &dex_extended)
1824                    .with_context(|| {
1825                        format!(
1826                            "failed to process mint event at block {}",
1827                            mint_event.block_number
1828                        )
1829                    })?;
1830                profiler.process(&DexPoolData::LiquidityUpdate(liquidity_update))?;
1831            } else if event_sig_bytes == burn_sig_bytes {
1832                let burn_event = dex_extended.parse_burn_event_hypersync(&log)?;
1833                let liquidity_update = self
1834                    .process_pool_burn_event(&burn_event, &profiler.pool, &dex_extended)
1835                    .with_context(|| {
1836                        format!(
1837                            "failed to process burn event at block {}",
1838                            burn_event.block_number
1839                        )
1840                    })?;
1841                profiler.process(&DexPoolData::LiquidityUpdate(liquidity_update))?;
1842            } else if protocol_update_sig_bytes
1843                .as_ref()
1844                .is_some_and(|sig| sig.as_slice() == event_sig_bytes)
1845            {
1846                let fee_protocol_update_event =
1847                    dex_extended.parse_fee_protocol_update_event_hypersync(&log)?;
1848                let update = self
1849                    .process_pool_fee_protocol_update_event(
1850                        &fee_protocol_update_event,
1851                        &profiler.pool,
1852                    )
1853                    .with_context(|| {
1854                        format!(
1855                            "failed to process SetFeeProtocol event at block {}",
1856                            fee_protocol_update_event.block_number
1857                        )
1858                    })?;
1859                profiler.process(&DexPoolData::FeeProtocolUpdate(update))?;
1860            } else if protocol_collect_sig_bytes
1861                .as_ref()
1862                .is_some_and(|sig| sig.as_slice() == event_sig_bytes)
1863            {
1864                let fee_protocol_collect_event =
1865                    dex_extended.parse_fee_protocol_collect_event_hypersync(&log)?;
1866                let collect = self
1867                    .process_pool_fee_protocol_collect_event(
1868                        &fee_protocol_collect_event,
1869                        &profiler.pool,
1870                    )
1871                    .with_context(|| {
1872                        format!(
1873                            "failed to process CollectProtocol event at block {}",
1874                            fee_protocol_collect_event.block_number
1875                        )
1876                    })?;
1877                profiler.process(&DexPoolData::FeeProtocolCollect(collect))?;
1878            } else {
1879                let event_signature = hex::encode(event_sig_bytes);
1880                anyhow::bail!(
1881                    "unexpected event signature in bootstrap_latest_pool_profiler: {event_signature} for log {log:?}"
1882                );
1883            }
1884        }
1885
1886        self.flush_pool_event_blocks(&mut block_batch).await?;
1887        profiler.finalize_reporting();
1888
1889        let snapshot_block_position = self.block_scoped_snapshot_position(to_block).await?;
1890        let on_chain_snapshot = self
1891            .get_on_chain_snapshot_at_position(&profiler, snapshot_block_position)
1892            .await
1893            .with_context(|| {
1894                format!(
1895                    "failed to restore pool {} from RPC snapshot at target block {} with {} ticks and {} positions",
1896                    profiler.pool.address,
1897                    to_block.separate_with_commas(),
1898                    profiler.get_active_tick_values().len().separate_with_commas(),
1899                    profiler.get_all_position_keys().len().separate_with_commas()
1900                )
1901            })?;
1902        Self::validate_rpc_snapshot_topology(&profiler, &on_chain_snapshot).with_context(|| {
1903            format!(
1904                "RPC snapshot topology validation failed for pool {} at block {}",
1905                profiler.pool.address,
1906                to_block.separate_with_commas()
1907            )
1908        })?;
1909        profiler.restore_from_snapshot(on_chain_snapshot)?;
1910
1911        Ok((profiler, true))
1912    }
1913
1914    /// Validates a pool profiler's state against on-chain data for accuracy verification.
1915    ///
1916    /// This method performs integrity checking by comparing the profiler's internal state
1917    /// (positions, ticks, liquidity) with the actual on-chain smart contract state. For UniswapV3
1918    /// pools, it fetches current on-chain data and verifies that the profiler's tracked state matches.
1919    /// Returns [`SnapshotValidation::OnChain`] when the profiler matches on-chain state,
1920    /// [`SnapshotValidation::Invalid`] when it does not, and [`SnapshotValidation::Replay`] when the
1921    /// on-chain state could not be fetched (e.g. a non-archive RPC for a historical block); in the
1922    /// last case the replay-derived snapshot is kept. The resolved state is persisted for the
1923    /// `OnChain` and `Invalid` outcomes; `Replay` leaves the snapshot at its inserted default so a
1924    /// transient RPC failure cannot clobber a prior definitive verdict.
1925    ///
1926    /// # Errors
1927    ///
1928    /// Returns an error if database operations fail when persisting the validation state.
1929    ///
1930    /// # Panics
1931    ///
1932    /// Panics if the profiler does not have a last_processed_event when already_validated is true.
1933    pub async fn check_snapshot_validity(
1934        &self,
1935        profiler: &PoolProfiler,
1936        already_validated: bool,
1937    ) -> anyhow::Result<SnapshotValidation> {
1938        let (validation, block_position) = if already_validated {
1939            // Skip RPC call - profiler was validated during construction from RPC
1940            log::debug!("Snapshot already validated from RPC, skipping on-chain comparison");
1941            let last_event = profiler
1942                .last_processed_event
1943                .clone()
1944                .expect("Profiler should have last_processed_event");
1945            (SnapshotValidation::OnChain, Some(last_event))
1946        } else {
1947            // Fetch on-chain state and compare
1948            match self.get_on_chain_snapshot(profiler).await {
1949                Ok(on_chain_snapshot) => {
1950                    log::debug!("Comparing profiler state with on-chain state...");
1951                    let comparison = compare_pool_profiler_detailed(profiler, &on_chain_snapshot);
1952                    let validation = if comparison.is_valid_for_snapshot() {
1953                        if !comparison.is_exact_match() {
1954                            log::warn!(
1955                                "Pool profiler snapshot has a non-structural mismatch (sqrt ratio, fee protocol, or protocol fees); accepting snapshot"
1956                            );
1957                        }
1958                        SnapshotValidation::OnChain
1959                    } else {
1960                        log::error!(
1961                            "Pool profiler state does NOT match on-chain smart contract state"
1962                        );
1963                        SnapshotValidation::Invalid
1964                    };
1965                    (validation, Some(on_chain_snapshot.block_position))
1966                }
1967                Err(e) => {
1968                    log::warn!(
1969                        "Could not validate snapshot against on-chain state, keeping replay-derived snapshot: {e}"
1970                    );
1971                    // RPC could not reach the block. Report any stored verdict so stdout agrees with
1972                    // a pre-existing on_chain/invalid row; the None block position below skips the
1973                    // persist step, so a transient failure cannot clobber that verdict.
1974                    let reported = self
1975                        .stored_snapshot_validation(profiler)
1976                        .await?
1977                        .unwrap_or(SnapshotValidation::Replay);
1978                    (reported, None)
1979                }
1980            }
1981        };
1982
1983        if let (Some(block_position), Some(cache_database)) = (block_position, &self.cache.database)
1984        {
1985            cache_database
1986                .set_pool_snapshot_validation_state(
1987                    profiler.pool.chain.chain_id,
1988                    &profiler.pool.pool_identifier,
1989                    block_position.number,
1990                    block_position.transaction_index,
1991                    block_position.log_index,
1992                    validation.as_str(),
1993                )
1994                .await?;
1995            log::debug!(
1996                "Set pool snapshot validation state to {}",
1997                validation.as_str()
1998            );
1999        }
2000
2001        Ok(validation)
2002    }
2003
2004    /// Reads the persisted [`SnapshotValidation`] for the profiler's current snapshot watermark.
2005    ///
2006    /// Returns `None` when no database is configured, the profiler has no processed event, or no
2007    /// snapshot row exists at that watermark.
2008    async fn stored_snapshot_validation(
2009        &self,
2010        profiler: &PoolProfiler,
2011    ) -> anyhow::Result<Option<SnapshotValidation>> {
2012        let (Some(block_position), Some(cache_database)) =
2013            (profiler.last_processed_event.as_ref(), &self.cache.database)
2014        else {
2015            return Ok(None);
2016        };
2017
2018        let stored = cache_database
2019            .get_pool_snapshot_validation_state(
2020                profiler.pool.chain.chain_id,
2021                &profiler.pool.pool_identifier,
2022                block_position.number,
2023                block_position.transaction_index,
2024                block_position.log_index,
2025            )
2026            .await?;
2027
2028        Ok(stored.and_then(|token| SnapshotValidation::from_db_token(&token)))
2029    }
2030
2031    /// Fetches current on-chain pool state at the last processed block.
2032    ///
2033    /// Queries the pool smart contract to retrieve active tick liquidity and position data,
2034    /// using the profiler's active positions and last processed block number.
2035    /// Used for profiler state restoration after bootstrapping and validation.
2036    async fn get_on_chain_snapshot(&self, profiler: &PoolProfiler) -> anyhow::Result<PoolSnapshot> {
2037        let last_processed_event = Self::last_processed_event_for_on_chain_snapshot(profiler)?;
2038        self.get_on_chain_snapshot_at_position(profiler, last_processed_event)
2039            .await
2040    }
2041
2042    async fn get_on_chain_snapshot_at_position(
2043        &self,
2044        profiler: &PoolProfiler,
2045        block_position: BlockPosition,
2046    ) -> anyhow::Result<PoolSnapshot> {
2047        // PancakeSwap V3 shares the Uniswap V3 pool read ABI except slot0.feeProtocol width.
2048        if matches!(
2049            profiler.pool.dex.name,
2050            DexType::UniswapV3 | DexType::PancakeSwapV3
2051        ) {
2052            let fee_protocol_encoding = match profiler.pool.dex.name {
2053                DexType::PancakeSwapV3 => FeeProtocolEncoding::PancakeSwapV3BasisPoints,
2054                _ => FeeProtocolEncoding::UniswapV3Packed,
2055            };
2056            let timestamp = Self::timestamp_for_on_chain_snapshot(
2057                profiler,
2058                self.cache
2059                    .get_block_timestamp(block_position.number)
2060                    .copied(),
2061            )?;
2062            let on_chain_snapshot = self
2063                .univ3_pool
2064                .fetch_snapshot(
2065                    &profiler.pool.address,
2066                    profiler.pool.instrument_id,
2067                    profiler.get_active_tick_values().as_slice(),
2068                    &profiler.get_all_position_keys(),
2069                    block_position,
2070                    timestamp, // ts_event
2071                    timestamp, // ts_init (same block timestamp)
2072                    fee_protocol_encoding,
2073                )
2074                .await?;
2075
2076            Ok(on_chain_snapshot)
2077        } else {
2078            anyhow::bail!(
2079                "Fetching on-chain snapshot for Dex protocol {} is not supported yet.",
2080                profiler.pool.dex.name
2081            )
2082        }
2083    }
2084
2085    fn validate_rpc_snapshot_topology(
2086        profiler: &PoolProfiler,
2087        snapshot: &PoolSnapshot,
2088    ) -> anyhow::Result<()> {
2089        let tick_spacing = i32::try_from(
2090            profiler
2091                .pool
2092                .tick_spacing
2093                .context("pool tick spacing is not set")?,
2094        )?;
2095        let expected_positions: AHashMap<_, _> = profiler
2096            .get_all_positions()
2097            .into_iter()
2098            .map(|position| {
2099                (
2100                    (position.owner, position.tick_lower, position.tick_upper),
2101                    position.liquidity,
2102                )
2103            })
2104            .collect();
2105        let actual_positions: AHashMap<_, _> = snapshot
2106            .positions
2107            .iter()
2108            .map(|position| {
2109                (
2110                    (position.owner, position.tick_lower, position.tick_upper),
2111                    position.liquidity,
2112                )
2113            })
2114            .collect();
2115
2116        if actual_positions.len() != snapshot.positions.len()
2117            || actual_positions != expected_positions
2118        {
2119            anyhow::bail!(
2120                "RPC positions do not match the complete HyperSync topology: expected {} positions, received {}",
2121                expected_positions.len(),
2122                actual_positions.len()
2123            );
2124        }
2125
2126        let actual_ticks: AHashMap<_, _> = snapshot
2127            .ticks
2128            .iter()
2129            .map(|tick| (tick.value, tick))
2130            .collect();
2131
2132        if actual_ticks.len() != snapshot.ticks.len() {
2133            anyhow::bail!("RPC snapshot contains duplicate ticks");
2134        }
2135
2136        let expected_tick_values = profiler.get_active_tick_values();
2137        if actual_ticks.len() != expected_tick_values.len() {
2138            anyhow::bail!(
2139                "RPC ticks do not match the complete HyperSync topology: expected {} ticks, received {}",
2140                expected_tick_values.len(),
2141                actual_ticks.len()
2142            );
2143        }
2144
2145        for tick_value in expected_tick_values {
2146            let expected_tick = profiler
2147                .get_tick(tick_value)
2148                .with_context(|| format!("missing replay tick {tick_value}"))?;
2149            let actual_tick = actual_ticks
2150                .get(&tick_value)
2151                .with_context(|| format!("RPC snapshot omitted tick {tick_value}"))?;
2152
2153            if !actual_tick.initialized
2154                || actual_tick.liquidity_gross == 0
2155                || actual_tick.liquidity_gross != expected_tick.liquidity_gross
2156                || actual_tick.liquidity_net != expected_tick.liquidity_net
2157            {
2158                anyhow::bail!(
2159                    "RPC tick {tick_value} topology mismatch: expected gross={} net={}, received gross={} net={} initialized={}",
2160                    expected_tick.liquidity_gross,
2161                    expected_tick.liquidity_net,
2162                    actual_tick.liquidity_gross,
2163                    actual_tick.liquidity_net,
2164                    actual_tick.initialized
2165                );
2166            }
2167        }
2168
2169        let mut derived_ticks: AHashMap<i32, (u128, i128)> = AHashMap::new();
2170        let mut active_liquidity = 0_u128;
2171
2172        for position in &snapshot.positions {
2173            if position.tick_lower >= position.tick_upper
2174                || position.tick_lower < PoolTick::MIN_TICK
2175                || position.tick_upper > PoolTick::MAX_TICK
2176                || position.tick_lower % tick_spacing != 0
2177                || position.tick_upper % tick_spacing != 0
2178            {
2179                anyhow::bail!(
2180                    "RPC position {} has invalid tick range [{}, {}) for spacing {tick_spacing}",
2181                    position.owner,
2182                    position.tick_lower,
2183                    position.tick_upper
2184                );
2185            }
2186
2187            if position.liquidity == 0 {
2188                continue;
2189            }
2190
2191            let liquidity_net = i128::try_from(position.liquidity).with_context(|| {
2192                format!(
2193                    "RPC position {} liquidity exceeds i128::MAX",
2194                    position.owner
2195                )
2196            })?;
2197            let lower = derived_ticks.entry(position.tick_lower).or_default();
2198            lower.0 = lower
2199                .0
2200                .checked_add(position.liquidity)
2201                .context("lower tick liquidity gross overflow")?;
2202            lower.1 = lower
2203                .1
2204                .checked_add(liquidity_net)
2205                .context("lower tick liquidity net overflow")?;
2206            let upper = derived_ticks.entry(position.tick_upper).or_default();
2207            upper.0 = upper
2208                .0
2209                .checked_add(position.liquidity)
2210                .context("upper tick liquidity gross overflow")?;
2211            upper.1 = upper
2212                .1
2213                .checked_sub(liquidity_net)
2214                .context("upper tick liquidity net underflow")?;
2215
2216            if position.tick_lower <= snapshot.state.current_tick
2217                && snapshot.state.current_tick < position.tick_upper
2218            {
2219                active_liquidity = active_liquidity
2220                    .checked_add(position.liquidity)
2221                    .context("active position liquidity overflow")?;
2222            }
2223        }
2224
2225        if active_liquidity != snapshot.state.liquidity {
2226            anyhow::bail!(
2227                "RPC active liquidity mismatch: positions sum to {active_liquidity}, global state reports {}",
2228                snapshot.state.liquidity
2229            );
2230        }
2231
2232        if derived_ticks.len() != actual_ticks.len() {
2233            anyhow::bail!(
2234                "RPC tick topology does not match positions: derived {} ticks, received {}",
2235                derived_ticks.len(),
2236                actual_ticks.len()
2237            );
2238        }
2239
2240        for (tick_value, (liquidity_gross, liquidity_net)) in derived_ticks {
2241            let actual_tick = actual_ticks
2242                .get(&tick_value)
2243                .with_context(|| format!("RPC snapshot omitted position boundary {tick_value}"))?;
2244
2245            if actual_tick.liquidity_gross != liquidity_gross
2246                || actual_tick.liquidity_net != liquidity_net
2247            {
2248                anyhow::bail!(
2249                    "RPC tick {tick_value} does not match positions: derived gross={liquidity_gross} net={liquidity_net}, received gross={} net={}",
2250                    actual_tick.liquidity_gross,
2251                    actual_tick.liquidity_net
2252                );
2253            }
2254        }
2255
2256        Ok(())
2257    }
2258
2259    fn timestamp_for_on_chain_snapshot(
2260        profiler: &PoolProfiler,
2261        cached_timestamp: Option<UnixNanos>,
2262    ) -> anyhow::Result<UnixNanos> {
2263        cached_timestamp
2264            .or(profiler.last_processed_ts)
2265            .context("missing block timestamp for on-chain snapshot")
2266    }
2267
2268    fn last_processed_event_for_on_chain_snapshot(
2269        profiler: &PoolProfiler,
2270    ) -> anyhow::Result<BlockPosition> {
2271        profiler.last_processed_event.clone().with_context(|| {
2272            format!(
2273                "cannot fetch on-chain snapshot for pool {} without a processed event",
2274                profiler.pool.address
2275            )
2276        })
2277    }
2278
2279    async fn block_scoped_snapshot_position(
2280        &mut self,
2281        block_number: u64,
2282    ) -> anyhow::Result<BlockPosition> {
2283        let blocks_stream = self
2284            .hypersync_client
2285            .request_blocks_stream(block_number, Some(block_number))
2286            .await;
2287        tokio::pin!(blocks_stream);
2288        let block = blocks_stream
2289            .next()
2290            .await
2291            .with_context(|| format!("failed to fetch block {block_number} for RPC snapshot"))?;
2292
2293        let block_position =
2294            Self::block_scoped_snapshot_position_from_block(&mut self.cache, &block, block_number)?;
2295        self.cache.add_pool_event_blocks_batch(vec![block]).await?;
2296
2297        Ok(block_position)
2298    }
2299
2300    fn block_scoped_snapshot_position_from_block(
2301        cache: &mut BlockchainCache,
2302        block: &Block,
2303        block_number: u64,
2304    ) -> anyhow::Result<BlockPosition> {
2305        if block.number != block_number {
2306            anyhow::bail!(
2307                "Fetched block {} while requesting RPC snapshot block {}",
2308                block.number,
2309                block_number
2310            );
2311        }
2312
2313        cache.cache_block_metadata(block);
2314
2315        Ok(BlockPosition::new(
2316            block.number,
2317            block.hash.clone(),
2318            BLOCK_SCOPED_SNAPSHOT_INDEX,
2319            BLOCK_SCOPED_SNAPSHOT_INDEX,
2320        )
2321        .with_block_hash(Some(block.hash.clone())))
2322    }
2323
2324    /// Replays historical events for a pool to hydrate its profiler state.
2325    ///
2326    /// Streams all historical swap, liquidity, and fee collect events from the database
2327    /// and sends them through the normal data event pipeline to build up pool profiler state.
2328    ///
2329    /// # Errors
2330    ///
2331    /// Returns an error if database streaming fails or event processing fails.
2332    pub async fn replay_pool_events(&self, pool: &Pool, dex: &SharedDex) -> anyhow::Result<()> {
2333        if let Some(database) = &self.cache.database {
2334            log::debug!(
2335                "Replaying historical events for pool {} to hydrate profiler",
2336                pool.instrument_id
2337            );
2338
2339            let mut event_stream = database.stream_pool_events(
2340                Arc::clone(&self.chain),
2341                Arc::clone(dex),
2342                pool.instrument_id,
2343                pool.pool_identifier,
2344                None,
2345                None,
2346            );
2347            let mut event_count = 0;
2348
2349            while let Some(event_result) = event_stream.next().await {
2350                match event_result {
2351                    Ok(event) => {
2352                        let data_event = match event {
2353                            DexPoolData::Swap(swap) => DataEvent::DeFi(DefiData::PoolSwap(swap)),
2354                            DexPoolData::LiquidityUpdate(update) => {
2355                                DataEvent::DeFi(DefiData::PoolLiquidityUpdate(update))
2356                            }
2357                            DexPoolData::FeeCollect(collect) => {
2358                                DataEvent::DeFi(DefiData::PoolFeeCollect(collect))
2359                            }
2360                            DexPoolData::FeeProtocolUpdate(update) => {
2361                                DataEvent::DeFi(DefiData::PoolFeeProtocolUpdate(update))
2362                            }
2363                            DexPoolData::FeeProtocolCollect(collect) => {
2364                                DataEvent::DeFi(DefiData::PoolFeeProtocolCollect(collect))
2365                            }
2366                            DexPoolData::Flash(flash) => {
2367                                DataEvent::DeFi(DefiData::PoolFlash(flash))
2368                            }
2369                        };
2370                        self.send_data(data_event);
2371                        event_count += 1;
2372                    }
2373                    Err(e) => {
2374                        log::error!("Error streaming event for pool {}: {e}", pool.instrument_id);
2375                    }
2376                }
2377            }
2378
2379            log::debug!(
2380                "Replayed {event_count} historical events for pool {}",
2381                pool.instrument_id
2382            );
2383        } else {
2384            log::debug!(
2385                "No database available, skipping event replay for pool {}",
2386                pool.instrument_id
2387            );
2388        }
2389
2390        Ok(())
2391    }
2392
2393    /// Determines the starting block for syncing operations.
2394    fn determine_from_block(&self) -> u64 {
2395        self.config
2396            .from_block
2397            .unwrap_or_else(|| self.cache.min_dex_creation_block().unwrap_or(0))
2398    }
2399
2400    /// Retrieves extended DEX information for a registered DEX.
2401    fn get_dex_extended(&self, dex_id: &DexType) -> anyhow::Result<&DexExtended> {
2402        if !self.cache.get_registered_dexes().contains(dex_id) {
2403            anyhow::bail!("DEX {dex_id} is not registered in the data client");
2404        }
2405
2406        match get_dex_extended(self.chain.name, dex_id) {
2407            Some(dex) => Ok(dex),
2408            None => anyhow::bail!("Dex {dex_id} doesn't exist for chain {}", self.chain.name),
2409        }
2410    }
2411
2412    /// Retrieves a pool from the cache by its address.
2413    ///
2414    /// # Errors
2415    ///
2416    /// Returns an error if the pool is not registered in the cache.
2417    pub fn get_pool(&self, pool_identifier: &PoolIdentifier) -> anyhow::Result<&SharedPool> {
2418        match self.cache.get_pool(pool_identifier) {
2419            Some(pool) => Ok(pool),
2420            None => anyhow::bail!("Pool {pool_identifier} is not registered"),
2421        }
2422    }
2423
2424    /// Sends a data event to all subscribers through the data channel.
2425    pub fn send_data(&self, data: DataEvent) {
2426        if let Some(data_tx) = &self.data_tx {
2427            log::debug!("Sending {data}");
2428
2429            if let Err(e) = data_tx.send(data) {
2430                log::error!("Failed to send data: {e}");
2431            }
2432        } else {
2433            log::error!("No data event channel for sending data");
2434        }
2435    }
2436
2437    /// Disconnects all active connections and cleanup resources.
2438    ///
2439    /// This method should be called when shutting down the client to ensure
2440    /// proper cleanup of network connections and background tasks.
2441    pub async fn disconnect(&mut self) {
2442        self.subscription_manager.clear_block_demand();
2443        self.hypersync_client.disconnect().await;
2444    }
2445}
2446
2447#[cfg(test)]
2448mod tests {
2449    use alloy::primitives::{Address, U160, U256, address};
2450    use nautilus_core::UnixNanos;
2451    use nautilus_model::defi::{
2452        Chain, Token,
2453        pool_analysis::{
2454            position::PoolPosition,
2455            snapshot::{PoolAnalytics, PoolState},
2456        },
2457    };
2458    use rstest::rstest;
2459    use ustr::Ustr;
2460
2461    use super::*;
2462
2463    const WETH_USDT_CREATION_BLOCK: u64 = 12_375_326;
2464
2465    #[rstest]
2466    #[case(SnapshotValidation::OnChain, "on_chain", true)]
2467    #[case(SnapshotValidation::Replay, "replay", true)]
2468    #[case(SnapshotValidation::Invalid, "invalid", false)]
2469    fn snapshot_validation_db_token_and_usability(
2470        #[case] validation: SnapshotValidation,
2471        #[case] expected_str: &str,
2472        #[case] expected_usable: bool,
2473    ) {
2474        // as_str must match the pool_snapshot.validation_state CHECK values and the JSON contract;
2475        // is_usable must match the load filter `validation_state <> 'invalid'`.
2476        assert_eq!(validation.as_str(), expected_str);
2477        assert_eq!(validation.is_usable(), expected_usable);
2478        // from_db_token round-trips a stored token back to the enum, so a read-back verdict
2479        // reports the same state that was persisted.
2480        assert_eq!(
2481            SnapshotValidation::from_db_token(expected_str),
2482            Some(validation)
2483        );
2484    }
2485
2486    #[rstest]
2487    fn snapshot_validation_from_db_token_rejects_unknown() {
2488        assert_eq!(SnapshotValidation::from_db_token("bogus"), None);
2489    }
2490
2491    #[rstest]
2492    fn last_processed_event_for_on_chain_snapshot_rejects_unprocessed_profiler() {
2493        let mut profiler = PoolProfiler::new(weth_usdt_pool());
2494        profiler
2495            .initialize(U160::from_str_radix("3cb0adde486484998be0b", 16).unwrap())
2496            .expect("Known WETH/USDT initial sqrt price should initialize");
2497
2498        let error = BlockchainDataClientCore::last_processed_event_for_on_chain_snapshot(&profiler)
2499            .expect_err("unprocessed profiler should not fetch on-chain state");
2500
2501        assert_eq!(
2502            error.to_string(),
2503            format!(
2504                "cannot fetch on-chain snapshot for pool {} without a processed event",
2505                profiler.pool.address
2506            )
2507        );
2508    }
2509
2510    #[rstest]
2511    fn validate_rpc_snapshot_topology_accepts_consistent_snapshot() {
2512        let (profiler, snapshot) = rpc_topology_fixture();
2513
2514        let result = BlockchainDataClientCore::validate_rpc_snapshot_topology(&profiler, &snapshot);
2515
2516        assert!(result.is_ok());
2517    }
2518
2519    #[rstest]
2520    fn validate_rpc_snapshot_topology_rejects_missing_tick() {
2521        let (profiler, mut snapshot) = rpc_topology_fixture();
2522        snapshot.ticks.pop();
2523
2524        let error = BlockchainDataClientCore::validate_rpc_snapshot_topology(&profiler, &snapshot)
2525            .unwrap_err();
2526
2527        assert!(error.to_string().contains("expected 2 ticks, received 1"));
2528    }
2529
2530    #[rstest]
2531    fn validate_rpc_snapshot_topology_rejects_zeroed_position() {
2532        let (profiler, mut snapshot) = rpc_topology_fixture();
2533        snapshot.positions[0].liquidity = 0;
2534
2535        let error = BlockchainDataClientCore::validate_rpc_snapshot_topology(&profiler, &snapshot)
2536            .unwrap_err();
2537
2538        assert!(
2539            error
2540                .to_string()
2541                .contains("RPC positions do not match the complete HyperSync topology")
2542        );
2543    }
2544
2545    #[rstest]
2546    fn validate_rpc_snapshot_topology_rejects_active_liquidity_mismatch() {
2547        let (profiler, mut snapshot) = rpc_topology_fixture();
2548        snapshot.state.liquidity -= 1;
2549
2550        let error = BlockchainDataClientCore::validate_rpc_snapshot_topology(&profiler, &snapshot)
2551            .unwrap_err();
2552
2553        assert!(error.to_string().contains("RPC active liquidity mismatch"));
2554    }
2555
2556    #[rstest]
2557    fn validate_rpc_snapshot_topology_rejects_tick_liquidity_mismatch() {
2558        let (profiler, mut snapshot) = rpc_topology_fixture();
2559        snapshot.ticks[0].liquidity_gross -= 1;
2560
2561        let error = BlockchainDataClientCore::validate_rpc_snapshot_topology(&profiler, &snapshot)
2562            .unwrap_err();
2563
2564        assert!(error.to_string().contains("RPC tick -60 topology mismatch"));
2565    }
2566
2567    #[rstest]
2568    fn timestamp_for_on_chain_snapshot_prefers_cached_block_timestamp() {
2569        let pool = weth_usdt_pool();
2570        let mut profiler = PoolProfiler::new(pool);
2571        let cached_ts = UnixNanos::from(1_700_000_001_000_000_000);
2572        let profiler_ts = UnixNanos::from(1_700_000_000_000_000_000);
2573        profiler.last_processed_ts = Some(profiler_ts);
2574
2575        let timestamp =
2576            BlockchainDataClientCore::timestamp_for_on_chain_snapshot(&profiler, Some(cached_ts))
2577                .unwrap();
2578
2579        assert_eq!(timestamp, cached_ts);
2580    }
2581
2582    #[rstest]
2583    fn timestamp_for_on_chain_snapshot_falls_back_to_profiler_timestamp() {
2584        let pool = weth_usdt_pool();
2585        let mut profiler = PoolProfiler::new(pool);
2586        let profiler_ts = UnixNanos::from(1_700_000_000_000_000_000);
2587        profiler.last_processed_ts = Some(profiler_ts);
2588
2589        let timestamp =
2590            BlockchainDataClientCore::timestamp_for_on_chain_snapshot(&profiler, None).unwrap();
2591
2592        assert_eq!(timestamp, profiler_ts);
2593    }
2594
2595    #[rstest]
2596    fn timestamp_for_on_chain_snapshot_rejects_missing_timestamp() {
2597        let pool = weth_usdt_pool();
2598        let profiler = PoolProfiler::new(pool);
2599
2600        let error = BlockchainDataClientCore::timestamp_for_on_chain_snapshot(&profiler, None)
2601            .expect_err("missing timestamps should fail");
2602
2603        assert_eq!(
2604            error.to_string(),
2605            "missing block timestamp for on-chain snapshot"
2606        );
2607    }
2608
2609    #[rstest]
2610    fn block_scoped_snapshot_position_from_block_caches_timestamp_and_uses_sentinel_indexes() {
2611        let chain = Arc::new(
2612            Chain::from_chain_id(42161)
2613                .expect("Arbitrum chain should exist")
2614                .clone(),
2615        );
2616        let mut cache = BlockchainCache::new(chain);
2617        let timestamp = UnixNanos::from(1_700_000_002_000_000_000);
2618        let block = test_block(123, timestamp);
2619
2620        let position = BlockchainDataClientCore::block_scoped_snapshot_position_from_block(
2621            &mut cache, &block, 123,
2622        )
2623        .unwrap();
2624
2625        assert_eq!(position.number, 123);
2626        assert_eq!(position.transaction_hash, block.hash);
2627        assert_eq!(position.transaction_index, BLOCK_SCOPED_SNAPSHOT_INDEX);
2628        assert_eq!(position.log_index, BLOCK_SCOPED_SNAPSHOT_INDEX);
2629        assert_eq!(cache.get_block_timestamp(123), Some(&timestamp));
2630    }
2631
2632    #[rstest]
2633    fn block_scoped_snapshot_position_from_block_rejects_mismatched_block() {
2634        let chain = Arc::new(
2635            Chain::from_chain_id(42161)
2636                .expect("Arbitrum chain should exist")
2637                .clone(),
2638        );
2639        let mut cache = BlockchainCache::new(chain);
2640        let block = test_block(122, UnixNanos::from(1_700_000_002_000_000_000));
2641
2642        let error = BlockchainDataClientCore::block_scoped_snapshot_position_from_block(
2643            &mut cache, &block, 123,
2644        )
2645        .expect_err("mismatched block should fail");
2646
2647        assert_eq!(
2648            error.to_string(),
2649            "Fetched block 122 while requesting RPC snapshot block 123"
2650        );
2651        assert_eq!(cache.get_block_timestamp(122), None);
2652    }
2653
2654    #[rstest]
2655    #[case(100, 50, Some(99))]
2656    #[case(50, 50, None)]
2657    #[case(0, 0, None)]
2658    fn completed_pool_event_checkpoint_excludes_in_flight_block(
2659        #[case] block_number: u64,
2660        #[case] effective_from_block: u64,
2661        #[case] expected: Option<u64>,
2662    ) {
2663        let checkpoint = BlockchainDataClientCore::completed_pool_event_checkpoint(
2664            block_number,
2665            effective_from_block,
2666        );
2667
2668        assert_eq!(checkpoint, expected);
2669    }
2670
2671    #[rstest]
2672    fn pool_event_sync_ranges_preserve_legacy_history_during_new_family_backfill() {
2673        let families = vec![
2674            PoolEventFamily {
2675                name: "swap",
2676                signature: "swap".to_string(),
2677                introduced_version: 0,
2678            },
2679            PoolEventFamily {
2680                name: "fee_protocol_update",
2681                signature: "fee_protocol_update".to_string(),
2682                introduced_version: 1,
2683            },
2684            PoolEventFamily {
2685                name: "fee_protocol_collect",
2686                signature: "fee_protocol_collect".to_string(),
2687                introduced_version: 1,
2688            },
2689        ];
2690        let state = PoolEventSyncState {
2691            version: 0,
2692            last_full_sync_block: Some(100),
2693            family_blocks: Vec::new(),
2694        };
2695
2696        let ranges = BlockchainDataClientCore::pool_event_sync_ranges(&families, &state, 10, 110);
2697
2698        assert_eq!(
2699            ranges,
2700            vec![
2701                PoolEventSyncRange {
2702                    from_block: 10,
2703                    families: vec![families[1].clone(), families[2].clone()],
2704                },
2705                PoolEventSyncRange {
2706                    from_block: 101,
2707                    families: vec![families[0].clone()],
2708                },
2709            ]
2710        );
2711    }
2712
2713    #[rstest]
2714    fn pool_event_sync_ranges_group_families_by_checkpoint() {
2715        let families = vec![
2716            PoolEventFamily {
2717                name: "swap",
2718                signature: "swap".to_string(),
2719                introduced_version: 0,
2720            },
2721            PoolEventFamily {
2722                name: "fee_protocol_update",
2723                signature: "fee_protocol_update".to_string(),
2724                introduced_version: 1,
2725            },
2726            PoolEventFamily {
2727                name: "fee_protocol_collect",
2728                signature: "fee_protocol_collect".to_string(),
2729                introduced_version: 1,
2730            },
2731        ];
2732        let state = PoolEventSyncState {
2733            version: 1,
2734            last_full_sync_block: Some(100),
2735            family_blocks: vec![
2736                ("fee_protocol_collect".to_string(), 75),
2737                ("fee_protocol_update".to_string(), 50),
2738                ("swap".to_string(), 100),
2739            ],
2740        };
2741
2742        let ranges = BlockchainDataClientCore::pool_event_sync_ranges(&families, &state, 10, 100);
2743
2744        assert_eq!(
2745            ranges,
2746            vec![
2747                PoolEventSyncRange {
2748                    from_block: 51,
2749                    families: vec![families[1].clone()],
2750                },
2751                PoolEventSyncRange {
2752                    from_block: 76,
2753                    families: vec![families[2].clone()],
2754                },
2755            ]
2756        );
2757    }
2758
2759    #[rstest]
2760    fn pool_event_sync_ranges_backfill_future_family_without_schema_migration() {
2761        let families = vec![
2762            PoolEventFamily {
2763                name: "swap",
2764                signature: "swap".to_string(),
2765                introduced_version: 0,
2766            },
2767            PoolEventFamily {
2768                name: "future",
2769                signature: "future".to_string(),
2770                introduced_version: 2,
2771            },
2772        ];
2773        let state = PoolEventSyncState {
2774            version: 1,
2775            last_full_sync_block: Some(100),
2776            family_blocks: vec![("swap".to_string(), 100)],
2777        };
2778
2779        let ranges = BlockchainDataClientCore::pool_event_sync_ranges(&families, &state, 10, 100);
2780
2781        assert_eq!(
2782            ranges,
2783            vec![PoolEventSyncRange {
2784                from_block: 10,
2785                families: vec![families[1].clone()],
2786            }]
2787        );
2788    }
2789
2790    fn weth_usdt_pool() -> SharedPool {
2791        let chain = Arc::new(
2792            Chain::from_chain_id(1)
2793                .expect("Ethereum chain should exist")
2794                .clone(),
2795        );
2796        let dex = Arc::clone(
2797            &get_dex_extended(chain.name, &DexType::UniswapV3)
2798                .expect("Ethereum UniswapV3 should be registered")
2799                .dex,
2800        );
2801        let pool_address = address!("4e68ccd3e89f51c3074ca5072bbac773960dfa36");
2802        let token0 = Token::new(
2803            Arc::clone(&chain),
2804            address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
2805            "Wrapped Ether".to_string(),
2806            "WETH".to_string(),
2807            18,
2808        );
2809        let token1 = Token::new(
2810            Arc::clone(&chain),
2811            address!("dAC17F958D2ee523a2206206994597C13D831ec7"),
2812            "Tether USD".to_string(),
2813            "USDT".to_string(),
2814            6,
2815        );
2816
2817        Arc::new(Pool::new(
2818            chain,
2819            dex,
2820            pool_address,
2821            PoolIdentifier::from_address(pool_address),
2822            WETH_USDT_CREATION_BLOCK,
2823            token0,
2824            token1,
2825            Some(3_000),
2826            Some(60),
2827            UnixNanos::default(),
2828        ))
2829    }
2830
2831    fn rpc_topology_fixture() -> (PoolProfiler, PoolSnapshot) {
2832        let pool = weth_usdt_pool();
2833        let owner = Address::ZERO;
2834        let liquidity = 1_000_u128;
2835        let state = PoolState {
2836            current_tick: 0,
2837            liquidity,
2838            ..Default::default()
2839        };
2840        let position = PoolPosition::new(owner, -60, 60, liquidity as i128);
2841        let ticks = vec![
2842            PoolTick::new(
2843                -60,
2844                liquidity,
2845                liquidity as i128,
2846                U256::ZERO,
2847                U256::ZERO,
2848                true,
2849                0,
2850            ),
2851            PoolTick::new(
2852                60,
2853                liquidity,
2854                -(liquidity as i128),
2855                U256::ZERO,
2856                U256::ZERO,
2857                true,
2858                0,
2859            ),
2860        ];
2861        let timestamp = UnixNanos::from(1_700_000_000_000_000_000);
2862        let snapshot = PoolSnapshot::new(
2863            pool.instrument_id,
2864            state,
2865            vec![position],
2866            ticks,
2867            PoolAnalytics::default(),
2868            BlockPosition::new(100, "0xabc".to_string(), 0, 0),
2869            timestamp,
2870            timestamp,
2871        );
2872        let mut profiler = PoolProfiler::new(pool);
2873        profiler.restore_from_snapshot(snapshot.clone()).unwrap();
2874
2875        (profiler, snapshot)
2876    }
2877
2878    fn test_block(number: u64, timestamp: UnixNanos) -> Block {
2879        Block::new(
2880            format!("0x{number:064x}"),
2881            String::from("0x0"),
2882            number,
2883            Ustr::from("0x0000000000000000000000000000000000000000"),
2884            30_000_000,
2885            21_000,
2886            timestamp,
2887            Some(Blockchain::Arbitrum),
2888        )
2889    }
2890}