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