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