Skip to main content

nautilus_blockchain/cache/
mod.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
16//! Caching layer for blockchain entities and domain objects.
17//!
18//! This module provides an in-memory cache with optional PostgreSQL persistence for storing
19//! and retrieving blockchain-related data such as blocks, tokens, pools, swaps, and other
20//! DeFi protocol events.
21
22use std::{
23    collections::{BTreeMap, HashMap, HashSet},
24    sync::Arc,
25};
26
27use alloy::primitives::Address;
28use nautilus_core::UnixNanos;
29use nautilus_model::defi::{
30    Block, DexType, Pool, PoolIdentifier, PoolLiquidityUpdate, PoolSwap, SharedChain, SharedDex,
31    SharedPool, Token,
32    data::{PoolFeeCollect, PoolFeeProtocolCollect, PoolFeeProtocolUpdate, PoolFlash},
33    pool_analysis::{position::PoolPosition, snapshot::PoolSnapshot},
34    tick_map::tick::PoolTick,
35};
36use sqlx::postgres::PgConnectOptions;
37
38use crate::{
39    cache::{
40        consistency::CachedBlocksConsistencyStatus,
41        database::BlockchainCacheDatabase,
42        rows::{ExecutionTransactionRow, PoolRow},
43    },
44    events::initialize::InitializeEvent,
45};
46
47pub mod consistency;
48pub mod copy;
49pub mod database;
50pub mod rows;
51pub mod types;
52
53#[derive(Debug, Clone, Default, PartialEq, Eq)]
54pub(crate) struct PoolEventSyncState {
55    pub version: u32,
56    pub last_full_sync_block: Option<u64>,
57    pub family_blocks: Vec<(String, u64)>,
58}
59
60/// Provides caching functionality for various blockchain domain objects.
61#[derive(Debug)]
62pub struct BlockchainCache {
63    /// The blockchain chain this cache is associated with.
64    chain: SharedChain,
65    /// Map of block numbers to their corresponding timestamp
66    block_timestamps: BTreeMap<u64, UnixNanos>,
67    /// Map of block numbers to the hashes observed with those timestamps.
68    block_hashes: BTreeMap<u64, String>,
69    /// Map of DEX identifiers to their corresponding DEX objects.
70    dexes: HashMap<DexType, SharedDex>,
71    /// Map of token addresses to their corresponding `Token` objects.
72    tokens: HashMap<Address, Token>,
73    /// Cached set of invalid token addresses that failed validation or processing.
74    invalid_tokens: HashSet<Address>,
75    /// Map of pool identifiers to their corresponding `Pool` objects.
76    pools: HashMap<PoolIdentifier, SharedPool>,
77    /// Optional database connection for persistent storage.
78    pub database: Option<BlockchainCacheDatabase>,
79}
80
81impl BlockchainCache {
82    /// Creates a new in-memory blockchain cache for the specified chain.
83    #[must_use]
84    pub fn new(chain: SharedChain) -> Self {
85        Self {
86            chain,
87            dexes: HashMap::new(),
88            tokens: HashMap::new(),
89            invalid_tokens: HashSet::new(),
90            pools: HashMap::new(),
91            block_timestamps: BTreeMap::new(),
92            block_hashes: BTreeMap::new(),
93            database: None,
94        }
95    }
96
97    /// Returns the highest continuous block number currently cached, if any.
98    pub async fn get_cache_block_consistency_status(
99        &self,
100    ) -> Option<CachedBlocksConsistencyStatus> {
101        let database = self.database.as_ref()?;
102        database
103            .get_block_consistency_status(&self.chain)
104            .await
105            .map_err(|e| log::error!("Error getting block consistency status: {e}"))
106            .ok()
107    }
108
109    /// Returns the earliest block number where any DEX in the cache was created on the blockchain.
110    #[must_use]
111    pub fn min_dex_creation_block(&self) -> Option<u64> {
112        self.dexes
113            .values()
114            .map(|dex| dex.factory_creation_block)
115            .min()
116    }
117
118    /// Returns the timestamp for the specified block number if it exists in the cache.
119    #[must_use]
120    pub fn get_block_timestamp(&self, block_number: u64) -> Option<&UnixNanos> {
121        self.block_timestamps.get(&block_number)
122    }
123
124    /// Returns the observed hash for the specified block number, if cached.
125    #[must_use]
126    pub fn get_block_hash(&self, block_number: u64) -> Option<&str> {
127        self.block_hashes.get(&block_number).map(String::as_str)
128    }
129
130    /// Records a block timestamp in the in-memory cache without persisting it.
131    ///
132    /// Used while streaming pool events so event conversion can resolve `ts_event` for blocks
133    /// that have not been persisted via [`Self::add_block`].
134    pub fn cache_block_timestamp(&mut self, number: u64, timestamp: UnixNanos) {
135        self.block_timestamps.insert(number, timestamp);
136    }
137
138    /// Records the hash and timestamp observed for one block without persisting it.
139    pub fn cache_block_metadata(&mut self, block: &Block) {
140        self.block_timestamps.insert(block.number, block.timestamp);
141        self.block_hashes.insert(block.number, block.hash.clone());
142    }
143
144    /// Initializes the database connection for persistent storage.
145    pub async fn initialize_database(&mut self, pg_connect_options: PgConnectOptions) {
146        let database = BlockchainCacheDatabase::init(pg_connect_options).await;
147        self.database = Some(database);
148    }
149
150    /// Returns whether a persistent database is attached to the cache.
151    #[must_use]
152    pub const fn has_database(&self) -> bool {
153        self.database.is_some()
154    }
155
156    /// Persists an execution transaction record, failing closed when no database is attached.
157    ///
158    /// # Errors
159    ///
160    /// Returns an error if no database is configured or the database operation fails.
161    #[expect(
162        clippy::too_many_arguments,
163        reason = "the parameters mirror the persisted execution transaction fields"
164    )]
165    pub async fn add_execution_transaction(
166        &self,
167        chain_id: u32,
168        wallet_address: &str,
169        nonce: u64,
170        transaction_hash: &str,
171        purpose: &str,
172        status: &str,
173        client_order_id: Option<&str>,
174    ) -> anyhow::Result<()> {
175        let database = self.database.as_ref().ok_or_else(|| {
176            anyhow::anyhow!(
177                "No durable store configured; refusing to persist execution transaction"
178            )
179        })?;
180
181        database
182            .add_execution_transaction(
183                chain_id,
184                wallet_address,
185                nonce,
186                transaction_hash,
187                purpose,
188                status,
189                client_order_id,
190            )
191            .await
192    }
193
194    /// Migrates the execution transaction table and installs its signer and order uniqueness
195    /// constraints, failing closed when no database is attached.
196    ///
197    /// # Errors
198    ///
199    /// Returns an error if no database is configured or the database operation fails.
200    pub async fn ensure_execution_transaction_schema(&self) -> anyhow::Result<()> {
201        let database = self.database.as_ref().ok_or_else(|| {
202            anyhow::anyhow!(
203                "No durable store configured; refusing to migrate execution transaction"
204            )
205        })?;
206
207        database.ensure_execution_transaction_schema().await
208    }
209
210    /// Updates the status of a persisted execution transaction record, failing closed when no
211    /// database is attached.
212    ///
213    /// # Errors
214    ///
215    /// Returns an error if no database is configured or the database operation fails.
216    pub async fn update_execution_transaction_status(
217        &self,
218        chain_id: u32,
219        transaction_hash: &str,
220        status: &str,
221    ) -> anyhow::Result<()> {
222        let database = self.database.as_ref().ok_or_else(|| {
223            anyhow::anyhow!("No durable store configured; refusing to update execution transaction")
224        })?;
225
226        database
227            .update_execution_transaction_status(chain_id, transaction_hash, status)
228            .await
229    }
230
231    /// Loads an execution transaction record by chain ID and transaction hash, failing closed
232    /// when no database is attached.
233    ///
234    /// # Errors
235    ///
236    /// Returns an error if no database is configured or the database operation fails.
237    pub async fn get_execution_transaction(
238        &self,
239        chain_id: u32,
240        transaction_hash: &str,
241    ) -> anyhow::Result<Option<ExecutionTransactionRow>> {
242        let database = self.database.as_ref().ok_or_else(|| {
243            anyhow::anyhow!("No durable store configured; refusing to load execution transaction")
244        })?;
245
246        database
247            .get_execution_transaction(chain_id, transaction_hash)
248            .await
249    }
250
251    /// Toggles performance optimization settings in the database.
252    ///
253    /// # Errors
254    ///
255    /// Returns an error if the database is not initialized or the operation fails.
256    pub async fn toggle_performance_settings(&self, enable: bool) -> anyhow::Result<()> {
257        if let Some(database) = &self.database {
258            database.toggle_perf_sync_settings(enable).await
259        } else {
260            log::warn!("Database not initialized, skipping performance settings toggle");
261            Ok(())
262        }
263    }
264
265    /// Initializes the chain by seeding it in the database and creating necessary partitions.
266    ///
267    /// This method sets up the blockchain chain in the database, creates block and token
268    /// partitions for optimal performance, and loads existing tokens into the cache.
269    pub async fn initialize_chain(&mut self) {
270        // Seed target adapter chain in database
271        if let Some(database) = &self.database {
272            if let Err(e) = database.seed_chain(&self.chain).await {
273                log::error!(
274                    "Error seeding chain in database: {e}. Continuing without database cache functionality"
275                );
276                return;
277            }
278            log::debug!("Chain seeded in the database");
279
280            if let Err(e) = database.ensure_pool_event_block_hash_schema().await {
281                log::error!("Error adding pool event block hash storage: {e}");
282                return;
283            }
284
285            match database.create_block_partition(&self.chain).await {
286                Ok(message) => log::debug!("Executing block partition creation: {message}"),
287                Err(e) => log::error!(
288                    "Error creating block partition for chain {}: {e}. Continuing without partition creation...",
289                    self.chain.chain_id
290                ),
291            }
292
293            match database.create_token_partition(&self.chain).await {
294                Ok(message) => log::debug!("Executing token partition creation: {message}"),
295                Err(e) => log::error!(
296                    "Error creating token partition for chain {}: {e}. Continuing without partition creation...",
297                    self.chain.chain_id
298                ),
299            }
300        }
301
302        if let Err(e) = self.load_tokens().await {
303            log::error!("Error loading tokens from the database: {e}");
304        }
305    }
306
307    /// Connects to the database and loads initial data.
308    ///
309    /// # Errors
310    ///
311    /// Returns an error if database seeding, token loading, or block loading fails.
312    pub async fn connect(&mut self, from_block: u64) -> anyhow::Result<()> {
313        log::debug!("Connecting and loading from_block {from_block}");
314
315        if let Err(e) = self.load_tokens().await {
316            log::error!("Error loading tokens from the database: {e}");
317        }
318
319        // TODO disable block syncing for now as we don't have timestamps yet configured
320        // if let Err(e) = self.load_blocks(from_block).await {
321        //     log::error!("Error loading blocks from database: {e}");
322        // }
323
324        Ok(())
325    }
326
327    /// Loads tokens from the database into the in-memory cache.
328    async fn load_tokens(&mut self) -> anyhow::Result<()> {
329        if let Some(database) = &self.database {
330            let (tokens, invalid_tokens) = tokio::try_join!(
331                database.load_tokens(self.chain.clone()),
332                database.load_invalid_token_addresses(self.chain.chain_id)
333            )?;
334
335            log::debug!(
336                "Loading {} valid tokens and {} invalid tokens from cache database",
337                tokens.len(),
338                invalid_tokens.len()
339            );
340
341            self.tokens
342                .extend(tokens.into_iter().map(|token| (token.address, token)));
343            self.invalid_tokens.extend(invalid_tokens);
344        }
345        Ok(())
346    }
347
348    /// Loads DEX exchange pools from the database into the in-memory cache.
349    ///
350    /// Returns the loaded pools.
351    ///
352    /// # Errors
353    ///
354    /// Returns an error if the DEX has not been registered or if database operations fail.
355    pub async fn load_pools(&mut self, dex_id: &DexType) -> anyhow::Result<Vec<Pool>> {
356        let mut loaded_pools = Vec::new();
357
358        if let Some(database) = &self.database {
359            let dex = self
360                .get_dex(dex_id)
361                .ok_or_else(|| anyhow::anyhow!("DEX {dex_id:?} has not been registered"))?;
362            let pool_rows = database
363                .load_pools(self.chain.clone(), &dex_id.to_string())
364                .await?;
365            log::debug!(
366                "Loading {} pools for DEX {} from cache database",
367                pool_rows.len(),
368                dex_id,
369            );
370
371            for pool_row in pool_rows {
372                if let Some(pool) = self.build_pool_from_row(&pool_row, &dex) {
373                    loaded_pools.push(pool.clone());
374                    self.pools.insert(pool.pool_identifier, Arc::new(pool));
375                }
376            }
377        }
378        Ok(loaded_pools)
379    }
380
381    /// Loads a single DEX pool from the database into the in-memory cache.
382    ///
383    /// Returns the loaded pool, or `None` when it is absent from the database. Unlike
384    /// [`load_pools`](Self::load_pools), this loads only the requested pool, so per-pool tools do
385    /// not pay the cost of loading the whole DEX pool set.
386    ///
387    /// # Errors
388    ///
389    /// Returns an error if the DEX has not been registered or if database operations fail.
390    pub async fn load_pool(
391        &mut self,
392        dex_id: &DexType,
393        pool_identifier: &PoolIdentifier,
394    ) -> anyhow::Result<Option<Pool>> {
395        let dex = self
396            .get_dex(dex_id)
397            .ok_or_else(|| anyhow::anyhow!("DEX {dex_id:?} has not been registered"))?;
398
399        let pool_row = {
400            let Some(database) = &self.database else {
401                return Ok(None);
402            };
403            database
404                .load_pool(self.chain.clone(), &dex_id.to_string(), pool_identifier)
405                .await?
406        };
407
408        let Some(pool_row) = pool_row else {
409            return Ok(None);
410        };
411        let Some(pool) = self.build_pool_from_row(&pool_row, &dex) else {
412            return Ok(None);
413        };
414        self.pools
415            .insert(pool.pool_identifier, Arc::new(pool.clone()));
416        Ok(Some(pool))
417    }
418
419    /// Builds a [`Pool`] from a database row using cached tokens.
420    ///
421    /// Returns `None` (after logging the reason) when a referenced token is missing from the cache
422    /// or the stored pool identifier cannot be parsed.
423    fn build_pool_from_row(&self, pool_row: &PoolRow, dex: &SharedDex) -> Option<Pool> {
424        let Some(token0) = self.tokens.get(&pool_row.token0_address) else {
425            log::error!(
426                "Failed to load pool {} for DEX {}: Token0 with address {} not found in cache. \
427                     This may indicate the token was not properly loaded from the database or the pool references an unknown token",
428                pool_row.address,
429                dex.name,
430                pool_row.token0_address
431            );
432            return None;
433        };
434
435        let Some(token1) = self.tokens.get(&pool_row.token1_address) else {
436            log::error!(
437                "Failed to load pool {} for DEX {}: Token1 with address {} not found in cache. \
438                     This may indicate the token was not properly loaded from the database or the pool references an unknown token",
439                pool_row.address,
440                dex.name,
441                pool_row.token1_address
442            );
443            return None;
444        };
445
446        let Some(pool_identifier) = pool_row.pool_identifier.parse().ok() else {
447            log::error!(
448                "Invalid pool identifier '{}' in database for pool {}, skipping",
449                pool_row.pool_identifier,
450                pool_row.address
451            );
452            return None;
453        };
454
455        let ts_init = pool_row.creation_block_timestamp.unwrap_or_default();
456
457        let mut pool = Pool::new(
458            self.chain.clone(),
459            dex.clone(),
460            pool_row.address,
461            pool_identifier,
462            pool_row.creation_block,
463            token0.clone(),
464            token1.clone(),
465            pool_row.fee,
466            pool_row.tick_spacing,
467            ts_init,
468        );
469
470        if let Some(ref hook_address_str) = pool_row.hook_address
471            && let Ok(hooks) = hook_address_str.parse()
472        {
473            pool.set_hooks(hooks);
474        }
475
476        if let Some(initial_sqrt_price_x96_str) = &pool_row.initial_sqrt_price_x96
477            && let Ok(initial_sqrt_price_x96) = initial_sqrt_price_x96_str.parse()
478            && let Some(initial_tick) = pool_row.initial_tick
479        {
480            pool.initialize(initial_sqrt_price_x96, initial_tick);
481        }
482
483        Some(pool)
484    }
485
486    /// Loads block timestamps from the database starting `from_block` number
487    /// into the in-memory cache.
488    #[allow(dead_code)]
489    async fn load_blocks(&mut self, from_block: u64) -> anyhow::Result<()> {
490        if let Some(database) = &self.database {
491            let block_timestamps = database
492                .load_block_timestamps(self.chain.clone(), from_block)
493                .await?;
494
495            // Verify block number sequence consistency
496            if !block_timestamps.is_empty() {
497                let first = block_timestamps.first().unwrap().number;
498                let last = block_timestamps.last().unwrap().number;
499                let expected_len = (last - first + 1) as usize;
500                if block_timestamps.len() != expected_len {
501                    anyhow::bail!(
502                        "Block timestamps are not consistent and sequential. Expected {expected_len} blocks but got {}",
503                        block_timestamps.len()
504                    );
505                }
506            }
507
508            if block_timestamps.is_empty() {
509                log::debug!("No blocks found in database");
510                return Ok(());
511            }
512
513            log::debug!(
514                "Loading {} blocks timestamps from the cache database with last block number {}",
515                block_timestamps.len(),
516                block_timestamps.last().unwrap().number,
517            );
518
519            for block in block_timestamps {
520                self.block_timestamps.insert(block.number, block.timestamp);
521            }
522        }
523        Ok(())
524    }
525
526    /// Adds a block to the cache and persists it to the database if available.
527    ///
528    /// # Errors
529    ///
530    /// Returns an error if adding the block to the database fails.
531    pub async fn add_block(&mut self, block: Block) -> anyhow::Result<()> {
532        // Populate in-memory first so the timestamp resolves even if persistence fails
533        self.cache_block_metadata(&block);
534        if let Some(database) = &self.database {
535            database.add_block(self.chain.chain_id, &block).await?;
536        }
537        Ok(())
538    }
539
540    /// Adds multiple blocks to the cache and persists them to the database in batch if available.
541    ///
542    /// # Errors
543    ///
544    /// Returns an error if adding the blocks to the database fails.
545    pub async fn add_blocks_batch(
546        &mut self,
547        blocks: Vec<Block>,
548        use_copy_command: bool,
549    ) -> anyhow::Result<()> {
550        if blocks.is_empty() {
551            return Ok(());
552        }
553
554        if let Some(database) = &self.database {
555            if use_copy_command {
556                database
557                    .add_blocks_copy(self.chain.chain_id, &blocks)
558                    .await?;
559            } else {
560                database
561                    .add_blocks_batch(self.chain.chain_id, &blocks)
562                    .await?;
563            }
564        }
565
566        // Update in-memory cache
567        for block in blocks {
568            self.block_timestamps.insert(block.number, block.timestamp);
569            self.block_hashes.insert(block.number, block.hash);
570        }
571
572        Ok(())
573    }
574
575    /// Adds block timestamps observed while streaming pool events.
576    ///
577    /// # Errors
578    ///
579    /// Returns an error if adding the block timestamps to the database fails.
580    pub async fn add_pool_event_blocks_batch(&mut self, blocks: Vec<Block>) -> anyhow::Result<()> {
581        if blocks.is_empty() {
582            return Ok(());
583        }
584
585        if let Some(database) = &self.database {
586            database
587                .add_pool_event_blocks_batch(self.chain.chain_id, &blocks)
588                .await?;
589        }
590
591        for block in blocks {
592            self.block_timestamps.insert(block.number, block.timestamp);
593            self.block_hashes.insert(block.number, block.hash);
594        }
595
596        Ok(())
597    }
598
599    /// Adds a DEX to the cache with the specified identifier.
600    ///
601    /// # Errors
602    ///
603    /// Returns an error if adding the DEX to the database fails.
604    pub async fn add_dex(&mut self, dex: SharedDex) -> anyhow::Result<()> {
605        log::debug!("Adding dex {} to the cache", dex.name);
606
607        if let Some(database) = &self.database {
608            database.add_dex(dex.clone()).await?;
609        }
610
611        self.dexes.insert(dex.name, dex);
612        Ok(())
613    }
614
615    /// Adds a liquidity pool/pair to the cache.
616    ///
617    /// # Errors
618    ///
619    /// Returns an error if adding the pool to the database fails.
620    pub async fn add_pool(&mut self, pool: Pool) -> anyhow::Result<()> {
621        if let Some(database) = &self.database {
622            database.add_pool(&pool).await?;
623        }
624
625        self.pools.insert(pool.pool_identifier, Arc::new(pool));
626        Ok(())
627    }
628
629    /// Adds multiple pools to the cache and persists them to the database in batch if available.
630    ///
631    /// # Errors
632    ///
633    /// Returns an error if adding the pools to the database fails.
634    pub async fn add_pools_batch(&mut self, pools: Vec<Pool>) -> anyhow::Result<()> {
635        if pools.is_empty() {
636            return Ok(());
637        }
638
639        if let Some(database) = &self.database {
640            database.add_pools_copy(self.chain.chain_id, &pools).await?;
641        }
642        self.pools.extend(
643            pools
644                .into_iter()
645                .map(|pool| (pool.pool_identifier, Arc::new(pool))),
646        );
647
648        Ok(())
649    }
650
651    /// Adds a [`Token`] to the cache.
652    ///
653    /// # Errors
654    ///
655    /// Returns an error if adding the token to the database fails.
656    pub async fn add_token(&mut self, token: Token) -> anyhow::Result<()> {
657        if let Some(database) = &self.database {
658            database.add_token(&token).await?;
659        }
660        self.tokens.insert(token.address, token);
661        Ok(())
662    }
663
664    /// Adds multiple tokens to the cache and persists them to the database in batch if available.
665    ///
666    /// # Errors
667    ///
668    /// Returns an error if adding the tokens to the database fails.
669    pub async fn add_tokens_batch(&mut self, tokens: Vec<Token>) -> anyhow::Result<()> {
670        if tokens.is_empty() {
671            return Ok(());
672        }
673
674        if let Some(database) = &self.database {
675            database
676                .add_tokens_copy(self.chain.chain_id, &tokens)
677                .await?;
678        }
679
680        self.tokens
681            .extend(tokens.into_iter().map(|token| (token.address, token)));
682
683        Ok(())
684    }
685
686    /// Updates the in-memory token cache without persisting to the database.
687    pub fn insert_token_in_memory(&mut self, token: Token) {
688        self.tokens.insert(token.address, token);
689    }
690
691    /// Marks a token address as invalid in the in-memory cache without persisting to the database.
692    pub fn insert_invalid_token_in_memory(&mut self, address: Address) {
693        self.invalid_tokens.insert(address);
694    }
695
696    /// Adds an invalid token address with associated error information to the cache.
697    ///
698    /// # Errors
699    ///
700    /// Returns an error if adding the invalid token to the database fails.
701    pub async fn add_invalid_token(
702        &mut self,
703        address: Address,
704        error_string: &str,
705    ) -> anyhow::Result<()> {
706        if let Some(database) = &self.database {
707            database
708                .add_invalid_token(self.chain.chain_id, &address, error_string)
709                .await?;
710        }
711        self.invalid_tokens.insert(address);
712        Ok(())
713    }
714
715    /// Adds a [`PoolSwap`] to the cache database if available.
716    ///
717    /// # Errors
718    ///
719    /// Returns an error if adding the swap to the database fails.
720    pub async fn add_pool_swap(&self, swap: &PoolSwap) -> anyhow::Result<()> {
721        if let Some(database) = &self.database {
722            database.add_swap(self.chain.chain_id, swap).await?;
723        }
724
725        Ok(())
726    }
727
728    /// Adds a [`PoolLiquidityUpdate`] to the cache database if available.
729    ///
730    /// # Errors
731    ///
732    /// Returns an error if adding the liquidity update to the database fails.
733    pub async fn add_liquidity_update(
734        &self,
735        liquidity_update: &PoolLiquidityUpdate,
736    ) -> anyhow::Result<()> {
737        if let Some(database) = &self.database {
738            database
739                .add_pool_liquidity_update(self.chain.chain_id, liquidity_update)
740                .await?;
741        }
742
743        Ok(())
744    }
745
746    /// Adds multiple [`PoolSwap`]s to the cache database in a single batch operation if available.
747    ///
748    /// # Errors
749    ///
750    /// Returns an error if adding the swaps to the database fails.
751    pub async fn add_pool_swaps_batch(
752        &self,
753        swaps: &[PoolSwap],
754        use_copy_command: bool,
755    ) -> anyhow::Result<()> {
756        if let Some(database) = &self.database {
757            if use_copy_command {
758                database
759                    .add_pool_swaps_copy(self.chain.chain_id, swaps)
760                    .await?;
761            } else {
762                database
763                    .add_pool_swaps_batch(self.chain.chain_id, swaps)
764                    .await?;
765            }
766        }
767
768        Ok(())
769    }
770
771    /// Adds multiple [`PoolLiquidityUpdate`]s to the cache database in a single batch operation if available.
772    ///
773    /// # Errors
774    ///
775    /// Returns an error if adding the liquidity updates to the database fails.
776    pub async fn add_pool_liquidity_updates_batch(
777        &self,
778        updates: &[PoolLiquidityUpdate],
779        use_copy_command: bool,
780    ) -> anyhow::Result<()> {
781        if let Some(database) = &self.database {
782            if use_copy_command {
783                database
784                    .add_pool_liquidity_updates_copy(self.chain.chain_id, updates)
785                    .await?;
786            } else {
787                database
788                    .add_pool_liquidity_updates_batch(self.chain.chain_id, updates)
789                    .await?;
790            }
791        }
792
793        Ok(())
794    }
795
796    /// Adds a batch of pool fee collect events to the cache.
797    ///
798    /// # Errors
799    ///
800    /// Returns an error if adding the fee collects to the database fails.
801    pub async fn add_pool_fee_collects_batch(
802        &self,
803        collects: &[PoolFeeCollect],
804        use_copy_command: bool,
805    ) -> anyhow::Result<()> {
806        if let Some(database) = &self.database {
807            if use_copy_command {
808                database
809                    .copy_pool_fee_collects_batch(self.chain.chain_id, collects)
810                    .await?;
811            } else {
812                database
813                    .add_pool_collects_batch(self.chain.chain_id, collects)
814                    .await?;
815            }
816        }
817
818        Ok(())
819    }
820
821    /// Adds a batch of pool flash events to the cache.
822    ///
823    /// # Errors
824    ///
825    /// Returns an error if adding the flash events to the database fails.
826    pub async fn add_pool_flash_batch(&self, flash_events: &[PoolFlash]) -> anyhow::Result<()> {
827        if let Some(database) = &self.database {
828            database
829                .add_pool_flash_batch(self.chain.chain_id, flash_events)
830                .await?;
831        }
832
833        Ok(())
834    }
835
836    /// Adds a batch of pool fee-protocol update events to the cache database.
837    ///
838    /// # Errors
839    ///
840    /// Returns an error if adding the fee-protocol update events to the database fails.
841    pub async fn add_pool_fee_protocol_updates_batch(
842        &self,
843        updates: &[PoolFeeProtocolUpdate],
844    ) -> anyhow::Result<()> {
845        if let Some(database) = &self.database {
846            database
847                .add_pool_fee_protocol_updates_batch(self.chain.chain_id, updates)
848                .await?;
849        }
850
851        Ok(())
852    }
853
854    /// Adds a batch of pool protocol-fee withdrawal events to the cache database.
855    ///
856    /// # Errors
857    ///
858    /// Returns an error if adding the protocol-fee withdrawal events to the database fails.
859    pub async fn add_pool_fee_protocol_collect_batch(
860        &self,
861        collects: &[PoolFeeProtocolCollect],
862    ) -> anyhow::Result<()> {
863        if let Some(database) = &self.database {
864            database
865                .add_pool_fee_protocol_collect_batch(self.chain.chain_id, collects)
866                .await?;
867        }
868
869        Ok(())
870    }
871
872    /// Adds a pool snapshot to the cache database.
873    ///
874    /// This method saves the complete snapshot including:
875    /// - Pool state and analytics (pool_snapshot table)
876    /// - All positions at this snapshot (pool_position table)
877    /// - All ticks at this snapshot (pool_tick table)
878    ///
879    /// # Errors
880    ///
881    /// Returns an error if adding the snapshot to the database fails.
882    pub async fn add_pool_snapshot(
883        &self,
884        dex: &DexType,
885        pool_identifier: &PoolIdentifier,
886        snapshot: &PoolSnapshot,
887    ) -> anyhow::Result<()> {
888        // Reject stub snapshots at the pool's creation block: empty positions, empty ticks,
889        // and the snapshot block matching pool creation indicates a bootstrap that bailed
890        // before any liquidity events landed. A legitimately empty pool (e.g., fully burned)
891        // would have its last_processed_event at the burn block, not at creation, so the
892        // creation-block check preserves those valid checkpoints.
893        if snapshot.positions.is_empty()
894            && snapshot.ticks.is_empty()
895            && let Some(pool) = self.pools.get(pool_identifier)
896            && snapshot.block_position.number == pool.creation_block
897        {
898            log::warn!(
899                "Refusing to persist empty stub snapshot for {} at pool creation block {}",
900                snapshot.instrument_id,
901                snapshot.block_position.number,
902            );
903            return Ok(());
904        }
905
906        if let Some(database) = &self.database {
907            // Save snapshot first (required for foreign key constraints)
908            database
909                .add_pool_snapshot(self.chain.chain_id, dex, pool_identifier, snapshot)
910                .await?;
911
912            let positions: Vec<(PoolIdentifier, PoolPosition)> = snapshot
913                .positions
914                .iter()
915                .map(|pos| (*pool_identifier, pos.clone()))
916                .collect();
917
918            if !positions.is_empty() {
919                database
920                    .add_pool_positions_batch(
921                        self.chain.chain_id,
922                        snapshot.block_position.number,
923                        snapshot.block_position.transaction_index,
924                        snapshot.block_position.log_index,
925                        &positions,
926                    )
927                    .await?;
928            }
929
930            let ticks: Vec<(PoolIdentifier, &PoolTick)> = snapshot
931                .ticks
932                .iter()
933                .map(|tick| (*pool_identifier, tick))
934                .collect();
935
936            if !ticks.is_empty() {
937                database
938                    .add_pool_ticks_batch(
939                        self.chain.chain_id,
940                        snapshot.block_position.number,
941                        snapshot.block_position.transaction_index,
942                        snapshot.block_position.log_index,
943                        &ticks,
944                    )
945                    .await?;
946            }
947        }
948
949        Ok(())
950    }
951
952    /// Updates the initial price and tick for a pool.
953    ///
954    /// # Errors
955    ///
956    /// Returns an error if the database update fails.
957    pub async fn update_pool_initialize_price_tick(
958        &mut self,
959        initialize_event: &InitializeEvent,
960    ) -> anyhow::Result<()> {
961        if let Some(database) = &self.database {
962            database
963                .update_pool_initial_price_tick(self.chain.chain_id, initialize_event)
964                .await?;
965        }
966
967        // Update the cached pool if it exists
968        let pool_identifier = initialize_event.pool_identifier;
969        if let Some(cached_pool) = self.pools.get(&pool_identifier) {
970            let mut updated_pool = (**cached_pool).clone();
971            updated_pool.initialize(initialize_event.sqrt_price_x96, initialize_event.tick);
972
973            self.pools.insert(pool_identifier, Arc::new(updated_pool));
974        }
975
976        Ok(())
977    }
978
979    /// Returns a reference to the `DexExtended` associated with the given name.
980    #[must_use]
981    pub fn get_dex(&self, dex_id: &DexType) -> Option<SharedDex> {
982        self.dexes.get(dex_id).cloned()
983    }
984
985    /// Returns a list of registered `DexType` in the cache.
986    #[must_use]
987    pub fn get_registered_dexes(&self) -> HashSet<DexType> {
988        self.dexes.keys().copied().collect()
989    }
990
991    /// Returns a reference to the pool associated with the given address.
992    #[must_use]
993    pub fn get_pool(&self, pool_identifier: &PoolIdentifier) -> Option<&SharedPool> {
994        self.pools.get(pool_identifier)
995    }
996
997    /// Returns a reference to the `Token` associated with the given address.
998    #[must_use]
999    pub fn get_token(&self, address: &Address) -> Option<&Token> {
1000        self.tokens.get(address)
1001    }
1002
1003    /// Checks if a token address is marked as invalid in the cache.
1004    ///
1005    /// Returns `true` if the address was previously recorded as invalid due to
1006    /// validation or processing failures.
1007    #[must_use]
1008    pub fn is_invalid_token(&self, address: &Address) -> bool {
1009        self.invalid_tokens.contains(address)
1010    }
1011
1012    /// Saves the checkpoint block number indicating the last completed pool synchronization for a specific DEX.
1013    ///
1014    /// # Errors
1015    ///
1016    /// Returns an error if the database operation fails.
1017    pub async fn update_dex_last_synced_block(
1018        &self,
1019        dex: &DexType,
1020        block_number: u64,
1021    ) -> anyhow::Result<()> {
1022        if let Some(database) = &self.database {
1023            database
1024                .update_dex_last_synced_block(self.chain.chain_id, dex, block_number)
1025                .await
1026        } else {
1027            Ok(())
1028        }
1029    }
1030
1031    /// Updates the last synced block number for a pool.
1032    ///
1033    /// # Errors
1034    ///
1035    /// Returns an error if the database update fails.
1036    pub async fn update_pool_last_synced_block(
1037        &self,
1038        dex: &DexType,
1039        pool_identifier: &PoolIdentifier,
1040        block_number: u64,
1041    ) -> anyhow::Result<()> {
1042        if let Some(database) = &self.database {
1043            database
1044                .update_pool_last_synced_block(
1045                    self.chain.chain_id,
1046                    dex,
1047                    pool_identifier,
1048                    block_number,
1049                )
1050                .await
1051        } else {
1052            Ok(())
1053        }
1054    }
1055
1056    /// Retrieves the saved checkpoint block number from the last completed pool synchronization for a specific DEX.
1057    ///
1058    /// # Errors
1059    ///
1060    /// Returns an error if the database query fails.
1061    pub async fn get_dex_last_synced_block(&self, dex: &DexType) -> anyhow::Result<Option<u64>> {
1062        if let Some(database) = &self.database {
1063            database
1064                .get_dex_last_synced_block(self.chain.chain_id, dex)
1065                .await
1066        } else {
1067            Ok(None)
1068        }
1069    }
1070
1071    /// Retrieves the last synced block number for a pool.
1072    ///
1073    /// # Errors
1074    ///
1075    /// Returns an error if the database query fails.
1076    pub async fn get_pool_last_synced_block(
1077        &self,
1078        dex: &DexType,
1079        pool_identifier: &PoolIdentifier,
1080    ) -> anyhow::Result<Option<u64>> {
1081        if let Some(database) = &self.database {
1082            database
1083                .get_pool_last_synced_block(self.chain.chain_id, dex, pool_identifier)
1084                .await
1085        } else {
1086            Ok(None)
1087        }
1088    }
1089
1090    pub(crate) async fn get_pool_event_sync_state(
1091        &self,
1092        dex: &DexType,
1093        pool_identifier: &PoolIdentifier,
1094    ) -> anyhow::Result<PoolEventSyncState> {
1095        if let Some(database) = &self.database {
1096            database
1097                .get_pool_event_sync_state(self.chain.chain_id, dex, pool_identifier)
1098                .await
1099        } else {
1100            Ok(PoolEventSyncState::default())
1101        }
1102    }
1103
1104    pub(crate) async fn update_pool_event_sync(
1105        &self,
1106        dex: &DexType,
1107        pool_identifier: &PoolIdentifier,
1108        event_families: &[&str],
1109        block_number: u64,
1110        version: Option<u32>,
1111    ) -> anyhow::Result<()> {
1112        if let Some(database) = &self.database {
1113            database
1114                .update_pool_event_sync(
1115                    self.chain.chain_id,
1116                    dex,
1117                    pool_identifier,
1118                    event_families,
1119                    block_number,
1120                    version,
1121                )
1122                .await
1123        } else {
1124            Ok(())
1125        }
1126    }
1127
1128    /// Retrieves the maximum block number across all pool event tables for a given pool.
1129    ///
1130    /// # Errors
1131    ///
1132    /// Returns an error if any of the database queries fail.
1133    pub async fn get_pool_event_tables_last_block(
1134        &self,
1135        pool_identifier: &PoolIdentifier,
1136    ) -> anyhow::Result<Option<u64>> {
1137        if let Some(database) = &self.database {
1138            let (swaps_last_block, liquidity_last_block, collect_last_block, flash_last_block) = tokio::try_join!(
1139                database.get_table_last_block(
1140                    self.chain.chain_id,
1141                    "pool_swap_event",
1142                    pool_identifier
1143                ),
1144                database.get_table_last_block(
1145                    self.chain.chain_id,
1146                    "pool_liquidity_event",
1147                    pool_identifier
1148                ),
1149                database.get_table_last_block(
1150                    self.chain.chain_id,
1151                    "pool_collect_event",
1152                    pool_identifier
1153                ),
1154                database.get_table_last_block(
1155                    self.chain.chain_id,
1156                    "pool_flash_event",
1157                    pool_identifier
1158                ),
1159            )?;
1160
1161            let max_block = [
1162                swaps_last_block,
1163                liquidity_last_block,
1164                collect_last_block,
1165                flash_last_block,
1166            ]
1167            .into_iter()
1168            .flatten()
1169            .max();
1170            Ok(max_block)
1171        } else {
1172            Ok(None)
1173        }
1174    }
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179    use std::{
1180        sync::Arc,
1181        time::{SystemTime, UNIX_EPOCH},
1182    };
1183
1184    use alloy::primitives::{I256, U160, U256, address};
1185    use futures_util::TryStreamExt;
1186    use nautilus_core::UnixNanos;
1187    use nautilus_infrastructure::sql::pg::{PostgresConnectOptions, get_postgres_connect_options};
1188    use nautilus_model::defi::{
1189        AmmType, Block, Blockchain, Chain, Dex, PoolProfiler, SharedChain, SharedDex, Token,
1190        data::{DexPoolData, block::BlockPosition},
1191        pool_analysis::snapshot::{PoolAnalytics, PoolSnapshot, PoolState},
1192    };
1193    use rstest::rstest;
1194    use sqlx::{
1195        AssertSqlSafe, Error as SqlxError, PgPool,
1196        postgres::{PgConnectOptions, PgPoolOptions},
1197    };
1198    use tokio_util::sync::CancellationToken;
1199    use ustr::Ustr;
1200
1201    use super::*;
1202    use crate::{
1203        config::BlockchainDataClientConfig,
1204        data::core::{BlockchainDataClientCore, SnapshotValidation},
1205    };
1206
1207    fn test_cache() -> BlockchainCache {
1208        BlockchainCache::new(Arc::new(Chain::new(Blockchain::Ethereum, 1)))
1209    }
1210
1211    #[rstest]
1212    fn cache_block_timestamp_records_in_memory() {
1213        let mut cache = test_cache();
1214        assert_eq!(cache.get_block_timestamp(100), None);
1215
1216        cache.cache_block_timestamp(100, UnixNanos::from(1_700_000_000_000_000_000));
1217
1218        assert_eq!(
1219            cache.get_block_timestamp(100),
1220            Some(&UnixNanos::from(1_700_000_000_000_000_000))
1221        );
1222    }
1223
1224    #[tokio::test]
1225    async fn add_block_populates_metadata_without_database() {
1226        let mut cache = test_cache();
1227        let block = Block::new(
1228            "0x1".to_string(),
1229            "0x0".to_string(),
1230            42,
1231            Ustr::from("miner"),
1232            30_000_000,
1233            21_000,
1234            UnixNanos::from(1_700_000_000_000_000_000),
1235            Some(Blockchain::Ethereum),
1236        );
1237
1238        cache.add_block(block).await.unwrap();
1239
1240        assert_eq!(
1241            cache.get_block_timestamp(42),
1242            Some(&UnixNanos::from(1_700_000_000_000_000_000))
1243        );
1244        assert_eq!(cache.get_block_hash(42), Some("0x1"));
1245    }
1246
1247    #[tokio::test]
1248    async fn pool_event_block_hash_schema_upgrades_legacy_table() -> anyhow::Result<()> {
1249        let Some((database, schema)) = connect_cache_test_database().await? else {
1250            return Ok(());
1251        };
1252        execute_schema_statement(
1253            &schema.admin_pool,
1254            format!(
1255                "ALTER TABLE {}.pool_event_block DROP COLUMN hash",
1256                schema.name
1257            ),
1258        )
1259        .await?;
1260
1261        database.ensure_pool_event_block_hash_schema().await?;
1262        database
1263            .add_pool_event_blocks_batch(
1264                1,
1265                &[test_block(42, UnixNanos::from(1_700_000_000_000_000_000))],
1266            )
1267            .await?;
1268
1269        let hash: String = sqlx::query_scalar(AssertSqlSafe(format!(
1270            "SELECT hash FROM {}.pool_event_block WHERE chain_id = 1 AND number = 42",
1271            schema.name
1272        )))
1273        .fetch_one(&schema.admin_pool)
1274        .await?;
1275        let expected_hash = format!("0x{:064x}", 42);
1276        anyhow::ensure!(
1277            hash == expected_hash,
1278            "Unexpected persisted pool event block hash: {hash}"
1279        );
1280
1281        schema.cleanup().await?;
1282        Ok(())
1283    }
1284
1285    #[tokio::test]
1286    async fn stream_pool_events_uses_pool_event_block_metadata_without_full_block()
1287    -> anyhow::Result<()> {
1288        let Some((database, schema)) = connect_cache_test_database().await? else {
1289            return Ok(());
1290        };
1291        let chain = arbitrum();
1292        let dex = uniswap_v3(&chain);
1293        let pool_address = address!("0xd13040d4fe917EE704158CfCB3338dCd2838B245");
1294        let pool_identifier = PoolIdentifier::from_address(pool_address);
1295        let instrument_id = Pool::create_instrument_id(chain.name, &dex, pool_identifier.as_str());
1296        let expected_ts = UnixNanos::from(1_700_000_000_123_456_789);
1297
1298        database
1299            .add_pool_event_blocks_batch(chain.chain_id, &[test_block(12, expected_ts)])
1300            .await?;
1301        insert_pool_swap_event(
1302            &schema.admin_pool,
1303            &schema.name,
1304            chain.chain_id,
1305            &pool_identifier,
1306            12,
1307        )
1308        .await?;
1309        let events_result = database
1310            .stream_pool_events(chain, dex, instrument_id, pool_identifier, None, Some(12))
1311            .try_collect::<Vec<_>>()
1312            .await;
1313
1314        drop(database);
1315        schema.cleanup().await?;
1316
1317        let events = events_result?;
1318        let observed_metadata = match events.as_slice() {
1319            [DexPoolData::Swap(swap)] => {
1320                Some((swap.ts_event, swap.ts_init, swap.block_hash.clone()))
1321            }
1322            _ => None,
1323        };
1324
1325        let expected_metadata = Some((expected_ts, expected_ts, Some(format!("0x{:064x}", 12))));
1326        if observed_metadata != expected_metadata {
1327            anyhow::bail!(
1328                "unexpected stream metadata: expected {expected_metadata:?}, observed {observed_metadata:?}"
1329            );
1330        }
1331        Ok(())
1332    }
1333
1334    #[tokio::test]
1335    async fn stream_pool_events_round_trips_fee_protocol_update_in_order() -> anyhow::Result<()> {
1336        let Some((database, schema)) = connect_cache_test_database().await? else {
1337            return Ok(());
1338        };
1339        let chain = arbitrum();
1340        let dex = uniswap_v3(&chain);
1341        let pool_address = address!("0xd13040d4fe917EE704158CfCB3338dCd2838B245");
1342        let pool_identifier = PoolIdentifier::from_address(pool_address);
1343        let instrument_id = Pool::create_instrument_id(chain.name, &dex, pool_identifier.as_str());
1344        let ts = UnixNanos::from(1_700_000_000_000_000_000);
1345
1346        database
1347            .add_pool_event_blocks_batch(chain.chain_id, &[test_block(12, ts), test_block(13, ts)])
1348            .await?;
1349        // Swap at block 12, SetFeeProtocol at block 13: stream must order swap before update.
1350        insert_pool_swap_event(
1351            &schema.admin_pool,
1352            &schema.name,
1353            chain.chain_id,
1354            &pool_identifier,
1355            12,
1356        )
1357        .await?;
1358        // Asymmetric values (4, 6) catch a token0/token1 column swap.
1359        let update = PoolFeeProtocolUpdate::new(
1360            chain.clone(),
1361            dex.clone(),
1362            instrument_id,
1363            pool_identifier,
1364            13,
1365            "0x00000000000000000000000000000000000000000000000000000000000000ab".to_string(),
1366            0,
1367            0,
1368            4,
1369            6,
1370            ts,
1371            ts,
1372        );
1373        database
1374            .add_pool_fee_protocol_updates_batch(chain.chain_id, std::slice::from_ref(&update))
1375            .await?;
1376
1377        let events_result = database
1378            .stream_pool_events(chain, dex, instrument_id, pool_identifier, None, Some(13))
1379            .try_collect::<Vec<_>>()
1380            .await;
1381
1382        drop(database);
1383        schema.cleanup().await?;
1384
1385        let events = events_result?;
1386        match events.as_slice() {
1387            [DexPoolData::Swap(swap), DexPoolData::FeeProtocolUpdate(fp)] => {
1388                // Swap (block 12) must order before SetFeeProtocol (block 13); the asymmetric
1389                // (4, 6) values catch a token0/token1 column swap, and ts confirms the timestamp.
1390                let observed = (
1391                    swap.block,
1392                    fp.block,
1393                    fp.fee_protocol0_new,
1394                    fp.fee_protocol1_new,
1395                    fp.ts_event,
1396                );
1397
1398                if observed != (12, 13, 4, 6, ts) {
1399                    anyhow::bail!("unexpected fee protocol round-trip: {observed:?}");
1400                }
1401            }
1402            other => anyhow::bail!("unexpected stream events: {other:?}"),
1403        }
1404        Ok(())
1405    }
1406
1407    #[tokio::test]
1408    async fn stream_pool_events_round_trips_fee_protocol_update_integer_width() -> anyhow::Result<()>
1409    {
1410        let Some((database, schema)) = connect_cache_test_database().await? else {
1411            return Ok(());
1412        };
1413        let chain = arbitrum();
1414        let dex = pancakeswap_v3(&chain);
1415        let pool_address = address!("0xd13040d4fe917EE704158CfCB3338dCd2838B245");
1416        let pool_identifier = PoolIdentifier::from_address(pool_address);
1417        let instrument_id = Pool::create_instrument_id(chain.name, &dex, pool_identifier.as_str());
1418        let ts = UnixNanos::from(1_700_000_000_000_000_000);
1419
1420        database
1421            .add_pool_event_blocks_batch(chain.chain_id, &[test_block(13, ts)])
1422            .await?;
1423        let update = PoolFeeProtocolUpdate::new(
1424            chain.clone(),
1425            dex.clone(),
1426            instrument_id,
1427            pool_identifier,
1428            13,
1429            "0x00000000000000000000000000000000000000000000000000000000000000ac".to_string(),
1430            0,
1431            0,
1432            40_000,
1433            65_535,
1434            ts,
1435            ts,
1436        );
1437        database
1438            .add_pool_fee_protocol_updates_batch(chain.chain_id, std::slice::from_ref(&update))
1439            .await?;
1440
1441        let events_result = database
1442            .stream_pool_events(chain, dex, instrument_id, pool_identifier, None, Some(13))
1443            .try_collect::<Vec<_>>()
1444            .await;
1445
1446        drop(database);
1447        schema.cleanup().await?;
1448
1449        let events = events_result?;
1450        match events.as_slice() {
1451            [DexPoolData::FeeProtocolUpdate(fp)] => {
1452                let observed = (fp.fee_protocol0_new, fp.fee_protocol1_new, fp.ts_event);
1453
1454                if observed != (40_000, 65_535, ts) {
1455                    anyhow::bail!("unexpected integer-width fee protocol round-trip: {observed:?}");
1456                }
1457            }
1458            other => anyhow::bail!("unexpected stream events: {other:?}"),
1459        }
1460        Ok(())
1461    }
1462
1463    #[tokio::test]
1464    async fn stream_pool_events_round_trips_fee_protocol_collect_in_order() -> anyhow::Result<()> {
1465        let Some((database, schema)) = connect_cache_test_database().await? else {
1466            return Ok(());
1467        };
1468        let chain = arbitrum();
1469        let dex = uniswap_v3(&chain);
1470        let pool_address = address!("0xd13040d4fe917EE704158CfCB3338dCd2838B245");
1471        let pool_identifier = PoolIdentifier::from_address(pool_address);
1472        let instrument_id = Pool::create_instrument_id(chain.name, &dex, pool_identifier.as_str());
1473        let ts = UnixNanos::from(1_700_000_000_000_000_000);
1474
1475        database
1476            .add_pool_event_blocks_batch(chain.chain_id, &[test_block(12, ts), test_block(13, ts)])
1477            .await?;
1478        // Swap at block 12, CollectProtocol at block 13: stream must order swap before withdrawal.
1479        insert_pool_swap_event(
1480            &schema.admin_pool,
1481            &schema.name,
1482            chain.chain_id,
1483            &pool_identifier,
1484            12,
1485        )
1486        .await?;
1487        // Asymmetric amounts (111, 222) catch a token0/token1 column swap.
1488        let collect = PoolFeeProtocolCollect::new(
1489            chain.clone(),
1490            dex.clone(),
1491            instrument_id,
1492            pool_identifier,
1493            13,
1494            "0x00000000000000000000000000000000000000000000000000000000000000cd".to_string(),
1495            0,
1496            0,
1497            address!("0xc36442b4a4522e871399cd717abdd847ab11fe88"),
1498            address!("0xa61da382c18d9d5beb905ea192bae25e4c15d512"),
1499            111,
1500            222,
1501            ts,
1502            ts,
1503        );
1504        database
1505            .add_pool_fee_protocol_collect_batch(chain.chain_id, std::slice::from_ref(&collect))
1506            .await?;
1507
1508        let events_result = database
1509            .stream_pool_events(chain, dex, instrument_id, pool_identifier, None, Some(13))
1510            .try_collect::<Vec<_>>()
1511            .await;
1512
1513        drop(database);
1514        schema.cleanup().await?;
1515
1516        let events = events_result?;
1517        match events.as_slice() {
1518            [DexPoolData::Swap(swap), DexPoolData::FeeProtocolCollect(cp)] => {
1519                // Swap (block 12) must order before CollectProtocol (block 13); the asymmetric
1520                // (111, 222) amounts catch a token0/token1 column swap, and ts confirms the timestamp.
1521                let observed = (swap.block, cp.block, cp.amount0, cp.amount1, cp.ts_event);
1522                if observed != (12, 13, 111, 222, ts) {
1523                    anyhow::bail!("unexpected fee protocol collect round-trip: {observed:?}");
1524                }
1525
1526                if cp.sender != address!("0xc36442b4a4522e871399cd717abdd847ab11fe88")
1527                    || cp.recipient != address!("0xa61da382c18d9d5beb905ea192bae25e4c15d512")
1528                {
1529                    anyhow::bail!(
1530                        "unexpected fee protocol collect addresses: sender={}, recipient={}",
1531                        cp.sender,
1532                        cp.recipient
1533                    );
1534                }
1535            }
1536            other => anyhow::bail!("unexpected stream events: {other:?}"),
1537        }
1538        Ok(())
1539    }
1540
1541    #[tokio::test]
1542    #[expect(clippy::panic_in_result_fn)]
1543    async fn pre_upgrade_pool_event_cache_backfills_protocol_families_idempotently()
1544    -> anyhow::Result<()> {
1545        let Some(mut clean) = pool_event_sync_fixture().await? else {
1546            return Ok(());
1547        };
1548        let Some(mut migrated) = pool_event_sync_fixture().await? else {
1549            clean.cache.database = None;
1550            clean.schema.cleanup().await?;
1551            return Ok(());
1552        };
1553        let target_block = 13;
1554        let event_families = [
1555            "swap",
1556            "mint",
1557            "burn",
1558            "collect",
1559            "initialize",
1560            "flash",
1561            "fee_protocol_update",
1562            "fee_protocol_collect",
1563        ];
1564        let blocks = [
1565            test_block(11, UnixNanos::from(1_700_000_011_000_000_000)),
1566            test_block(12, UnixNanos::from(1_700_000_012_000_000_000)),
1567            test_block(13, UnixNanos::from(1_700_000_013_000_000_000)),
1568        ];
1569
1570        for fixture in [&mut clean, &mut migrated] {
1571            fixture
1572                .database()
1573                .add_pool_event_blocks_batch(fixture.chain.chain_id, &blocks)
1574                .await?;
1575            insert_pool_swap_event(
1576                &fixture.schema.admin_pool,
1577                &fixture.schema.name,
1578                fixture.chain.chain_id,
1579                &fixture.pool.pool_identifier,
1580                11,
1581            )
1582            .await?;
1583        }
1584
1585        let (mut update, mut collect) = protocol_fee_events(&clean);
1586        clean
1587            .database()
1588            .add_pool_fee_protocol_updates_batch(
1589                clean.chain.chain_id,
1590                std::slice::from_ref(&update),
1591            )
1592            .await?;
1593        clean
1594            .database()
1595            .add_pool_fee_protocol_collect_batch(
1596                clean.chain.chain_id,
1597                std::slice::from_ref(&collect),
1598            )
1599            .await?;
1600        clean
1601            .cache
1602            .update_pool_event_sync(
1603                &DexType::UniswapV3,
1604                &clean.pool.pool_identifier,
1605                &event_families,
1606                target_block,
1607                Some(1),
1608            )
1609            .await?;
1610
1611        migrated
1612            .cache
1613            .update_pool_last_synced_block(
1614                &DexType::UniswapV3,
1615                &migrated.pool.pool_identifier,
1616                target_block,
1617            )
1618            .await?;
1619        let pre_upgrade_state = migrated
1620            .cache
1621            .get_pool_event_sync_state(&DexType::UniswapV3, &migrated.pool.pool_identifier)
1622            .await?;
1623
1624        migrated
1625            .database()
1626            .add_pool_fee_protocol_updates_batch(
1627                migrated.chain.chain_id,
1628                std::slice::from_ref(&update),
1629            )
1630            .await?;
1631        migrated
1632            .cache
1633            .update_pool_event_sync(
1634                &DexType::UniswapV3,
1635                &migrated.pool.pool_identifier,
1636                &["fee_protocol_update"],
1637                12,
1638                None,
1639            )
1640            .await?;
1641        let interrupted_state = migrated
1642            .cache
1643            .get_pool_event_sync_state(&DexType::UniswapV3, &migrated.pool.pool_identifier)
1644            .await?;
1645
1646        migrated
1647            .database()
1648            .add_pool_fee_protocol_updates_batch(
1649                migrated.chain.chain_id,
1650                std::slice::from_ref(&update),
1651            )
1652            .await?;
1653        migrated
1654            .database()
1655            .add_pool_fee_protocol_collect_batch(
1656                migrated.chain.chain_id,
1657                std::slice::from_ref(&collect),
1658            )
1659            .await?;
1660        migrated
1661            .cache
1662            .update_pool_event_sync(
1663                &DexType::UniswapV3,
1664                &migrated.pool.pool_identifier,
1665                &["fee_protocol_update", "fee_protocol_collect"],
1666                target_block,
1667                None,
1668            )
1669            .await?;
1670        migrated
1671            .cache
1672            .update_pool_event_sync(
1673                &DexType::UniswapV3,
1674                &migrated.pool.pool_identifier,
1675                &event_families,
1676                target_block,
1677                Some(1),
1678            )
1679            .await?;
1680
1681        let final_state = migrated
1682            .cache
1683            .get_pool_event_sync_state(&DexType::UniswapV3, &migrated.pool.pool_identifier)
1684            .await?;
1685        let clean_events = clean.events(target_block).await?;
1686        let migrated_events = migrated.events(target_block).await?;
1687        let mut swap = expected_swap(&clean);
1688        swap.block_hash = Some(blocks[0].hash.clone());
1689        update.block_hash = Some(blocks[1].hash.clone());
1690        collect.block_hash = Some(blocks[2].hash.clone());
1691        let expected_events = vec![
1692            DexPoolData::Swap(swap),
1693            DexPoolData::FeeProtocolUpdate(update),
1694            DexPoolData::FeeProtocolCollect(collect),
1695        ];
1696
1697        clean.cache.database = None;
1698        migrated.cache.database = None;
1699        clean.schema.cleanup().await?;
1700        migrated.schema.cleanup().await?;
1701
1702        assert_eq!(
1703            pre_upgrade_state,
1704            PoolEventSyncState {
1705                version: 0,
1706                last_full_sync_block: Some(target_block),
1707                family_blocks: Vec::new(),
1708            }
1709        );
1710        assert_eq!(
1711            interrupted_state,
1712            PoolEventSyncState {
1713                version: 0,
1714                last_full_sync_block: Some(target_block),
1715                family_blocks: vec![("fee_protocol_update".to_string(), 12)],
1716            }
1717        );
1718        assert_eq!(
1719            final_state,
1720            PoolEventSyncState {
1721                version: 1,
1722                last_full_sync_block: Some(target_block),
1723                family_blocks: vec![
1724                    ("burn".to_string(), target_block),
1725                    ("collect".to_string(), target_block),
1726                    ("fee_protocol_collect".to_string(), target_block),
1727                    ("fee_protocol_update".to_string(), target_block),
1728                    ("flash".to_string(), target_block),
1729                    ("initialize".to_string(), target_block),
1730                    ("mint".to_string(), target_block),
1731                    ("swap".to_string(), target_block),
1732                ],
1733            }
1734        );
1735        assert_eq!(clean_events, expected_events);
1736        assert_eq!(migrated_events, clean_events);
1737        Ok(())
1738    }
1739
1740    #[tokio::test]
1741    async fn load_block_timestamps_prefers_full_block_over_pool_event_block() -> anyhow::Result<()>
1742    {
1743        let Some((database, schema)) = connect_cache_test_database().await? else {
1744            return Ok(());
1745        };
1746        let chain = arbitrum();
1747        let fallback_ts = UnixNanos::from(1_700_000_000_000_000_000);
1748        let pool_event_ts = UnixNanos::from(1_700_000_002_000_000_000);
1749        let full_block_ts = UnixNanos::from(1_700_000_001_000_000_000);
1750
1751        database
1752            .add_pool_event_blocks_batch(
1753                chain.chain_id,
1754                &[test_block(20, fallback_ts), test_block(21, pool_event_ts)],
1755            )
1756            .await?;
1757        database
1758            .add_block(chain.chain_id, &test_block(21, full_block_ts))
1759            .await?;
1760
1761        let rows_result = database.load_block_timestamps(chain, 20).await;
1762
1763        drop(database);
1764        schema.cleanup().await?;
1765
1766        let rows = rows_result?;
1767        let observed = rows
1768            .into_iter()
1769            .map(|row| (row.number, row.timestamp))
1770            .collect::<Vec<_>>();
1771
1772        let expected = vec![(20, fallback_ts), (21, full_block_ts)];
1773        if observed != expected {
1774            anyhow::bail!(
1775                "unexpected block timestamps: expected {expected:?}, observed {observed:?}"
1776            );
1777        }
1778        Ok(())
1779    }
1780
1781    #[tokio::test]
1782    async fn load_block_timestamps_uses_pool_event_block_when_full_block_timestamp_is_null()
1783    -> anyhow::Result<()> {
1784        let Some((database, schema)) = connect_cache_test_database().await? else {
1785            return Ok(());
1786        };
1787        let chain = arbitrum();
1788        let fallback_ts = UnixNanos::from(1_700_000_004_000_000_000);
1789
1790        database
1791            .add_pool_event_blocks_batch(chain.chain_id, &[test_block(22, fallback_ts)])
1792            .await?;
1793        insert_block_without_timestamp(&schema.admin_pool, &schema.name, chain.chain_id, 22)
1794            .await?;
1795
1796        let rows_result = database.load_block_timestamps(chain, 22).await;
1797
1798        drop(database);
1799        schema.cleanup().await?;
1800
1801        let rows = rows_result?;
1802        let observed = rows
1803            .into_iter()
1804            .map(|row| (row.number, row.timestamp))
1805            .collect::<Vec<_>>();
1806
1807        let expected = vec![(22, fallback_ts)];
1808        if observed != expected {
1809            anyhow::bail!(
1810                "unexpected block timestamps: expected {expected:?}, observed {observed:?}"
1811            );
1812        }
1813        Ok(())
1814    }
1815
1816    #[tokio::test]
1817    async fn load_pools_sets_pool_timestamps_from_pool_event_block() -> anyhow::Result<()> {
1818        let Some((database, schema)) = connect_cache_test_database().await? else {
1819            return Ok(());
1820        };
1821        let chain = arbitrum();
1822        let dex = uniswap_v3(&chain);
1823        let token0 = weth(&chain);
1824        let token1 = usdc(&chain);
1825        let pool_address = address!("0xd13040d4fe917EE704158CfCB3338dCd2838B245");
1826        let pool_identifier = PoolIdentifier::from_address(pool_address);
1827        let creation_block = 30;
1828        let creation_ts = UnixNanos::from(1_700_000_003_000_000_000);
1829
1830        let pool = Pool::new(
1831            chain.clone(),
1832            dex.clone(),
1833            pool_address,
1834            pool_identifier,
1835            creation_block,
1836            token0.clone(),
1837            token1.clone(),
1838            Some(500),
1839            Some(10),
1840            UnixNanos::default(),
1841        );
1842
1843        let mut cache = BlockchainCache::new(chain.clone());
1844        cache.database = Some(database);
1845
1846        cache.add_dex(dex).await?;
1847        cache.add_token(token0).await?;
1848        cache.add_token(token1).await?;
1849        cache.add_pool(pool).await?;
1850        let Some(database) = cache.database.as_ref() else {
1851            anyhow::bail!("cache database must be set");
1852        };
1853        database
1854            .add_pool_event_blocks_batch(chain.chain_id, &[test_block(creation_block, creation_ts)])
1855            .await?;
1856
1857        let pools_result = cache.load_pools(&DexType::UniswapV3).await;
1858
1859        cache.database = None;
1860        schema.cleanup().await?;
1861
1862        let pools = pools_result?;
1863        let observed_timestamps = pools
1864            .first()
1865            .map(|pool| (pool.ts_event, pool.ts_init, pools.len()));
1866
1867        let expected_timestamps = Some((creation_ts, creation_ts, 1));
1868        if observed_timestamps != expected_timestamps {
1869            anyhow::bail!(
1870                "unexpected pool timestamps: expected {expected_timestamps:?}, observed {observed_timestamps:?}"
1871            );
1872        }
1873        Ok(())
1874    }
1875
1876    #[tokio::test]
1877    async fn load_latest_pool_snapshot_filters_by_validation_state() -> anyhow::Result<()> {
1878        let Some((database, schema)) = connect_cache_test_database().await? else {
1879            return Ok(());
1880        };
1881        let chain = arbitrum();
1882        let dex = uniswap_v3(&chain);
1883        let token0 = weth(&chain);
1884        let token1 = usdc(&chain);
1885        let pool_address = address!("0xd13040d4fe917EE704158CfCB3338dCd2838B245");
1886        let pool_identifier = PoolIdentifier::from_address(pool_address);
1887
1888        let pool = Pool::new(
1889            chain.clone(),
1890            dex.clone(),
1891            pool_address,
1892            pool_identifier,
1893            10, // creation block, distinct from the snapshot blocks below
1894            token0.clone(),
1895            token1.clone(),
1896            Some(500),
1897            Some(10),
1898            UnixNanos::default(),
1899        );
1900        let instrument_id = pool.instrument_id;
1901        let mut cache = BlockchainCache::new(chain.clone());
1902        cache.database = Some(database);
1903        cache.add_dex(dex).await?;
1904        cache.add_token(token0).await?;
1905        cache.add_token(token1).await?;
1906        cache.add_pool(pool).await?;
1907
1908        let ts = UnixNanos::from(1_700_000_000_000_000_000);
1909        let database = cache.database.as_ref().expect("cache database must be set");
1910        database
1911            .add_pool_event_blocks_batch(
1912                chain.chain_id,
1913                &[
1914                    test_block(100, ts),
1915                    test_block(150, ts),
1916                    test_block(200, ts),
1917                ],
1918            )
1919            .await?;
1920
1921        // replay@100, on_chain@150, invalid@200: one snapshot per block with a distinct verdict.
1922        for (block, state) in [
1923            (100u64, "replay"),
1924            (150u64, "on_chain"),
1925            (200u64, "invalid"),
1926        ] {
1927            let snapshot = PoolSnapshot::new(
1928                instrument_id,
1929                PoolState::default(),
1930                Vec::new(),
1931                Vec::new(),
1932                PoolAnalytics::default(),
1933                BlockPosition::new(block, "0xabc".to_string(), 0, 0),
1934                ts,
1935                ts,
1936            );
1937            cache
1938                .add_pool_snapshot(&DexType::UniswapV3, &pool_identifier, &snapshot)
1939                .await?;
1940
1941            if state != "replay" {
1942                database
1943                    .set_pool_snapshot_validation_state(
1944                        chain.chain_id,
1945                        &pool_identifier,
1946                        block,
1947                        0,
1948                        0,
1949                        state,
1950                    )
1951                    .await?;
1952            }
1953        }
1954
1955        let latest_valid = database
1956            .load_latest_pool_snapshot(chain.chain_id, &pool_identifier, None, true)
1957            .await;
1958        let latest_any = database
1959            .load_latest_pool_snapshot(chain.chain_id, &pool_identifier, None, false)
1960            .await;
1961        let stored_invalid = database
1962            .get_pool_snapshot_validation_state(chain.chain_id, &pool_identifier, 200, 0, 0)
1963            .await;
1964        let stored_on_chain = database
1965            .get_pool_snapshot_validation_state(chain.chain_id, &pool_identifier, 150, 0, 0)
1966            .await;
1967
1968        cache.database = None;
1969        schema.cleanup().await?;
1970
1971        let latest_valid_metadata = latest_valid?.map(|snapshot| {
1972            (
1973                snapshot.block_position.number,
1974                snapshot.block_position.block_hash,
1975            )
1976        });
1977        let latest_any_block = latest_any?.map(|s| s.block_position.number);
1978        let stored_invalid = stored_invalid?;
1979        let stored_on_chain = stored_on_chain?;
1980
1981        // require_valid excludes 'invalid', so the latest usable snapshot is on_chain@150; without
1982        // the filter the newest row (invalid@200) wins. The stored verdict stays readable by primary
1983        // key and untouched by the load filter.
1984        if latest_valid_metadata != Some((150, Some(format!("0x{:064x}", 150))))
1985            || latest_any_block != Some(200)
1986            || stored_invalid.as_deref() != Some("invalid")
1987            || stored_on_chain.as_deref() != Some("on_chain")
1988        {
1989            anyhow::bail!(
1990                "unexpected load filter result: latest_valid_metadata={latest_valid_metadata:?}, latest_any_block={latest_any_block:?}, stored_invalid={stored_invalid:?}, stored_on_chain={stored_on_chain:?}"
1991            );
1992        }
1993
1994        Ok(())
1995    }
1996
1997    #[tokio::test]
1998    async fn add_pool_snapshot_upserts_existing_snapshot() -> anyhow::Result<()> {
1999        let Some((database, schema)) = connect_cache_test_database().await? else {
2000            return Ok(());
2001        };
2002        let chain = arbitrum();
2003        let dex = pancakeswap_v3(&chain);
2004        let token0 = weth(&chain);
2005        let token1 = usdc(&chain);
2006        let pool_address = address!("0xd13040d4fe917EE704158CfCB3338dCd2838B245");
2007        let pool_identifier = PoolIdentifier::from_address(pool_address);
2008
2009        let pool = Pool::new(
2010            chain.clone(),
2011            dex.clone(),
2012            pool_address,
2013            pool_identifier,
2014            10,
2015            token0.clone(),
2016            token1.clone(),
2017            Some(500),
2018            Some(10),
2019            UnixNanos::default(),
2020        );
2021        let instrument_id = pool.instrument_id;
2022        let mut cache = BlockchainCache::new(chain.clone());
2023        cache.database = Some(database);
2024        cache.add_dex(dex).await?;
2025        cache.add_token(token0).await?;
2026        cache.add_token(token1).await?;
2027        cache.add_pool(pool).await?;
2028
2029        let ts = UnixNanos::from(1_700_000_000_000_000_000);
2030        let database = cache.database.as_ref().expect("cache database must be set");
2031        database
2032            .add_pool_event_blocks_batch(chain.chain_id, &[test_block(100, ts)])
2033            .await?;
2034
2035        let mut state = PoolState::default();
2036        state.set_protocol_fee_basis_points(3_200, 4_000);
2037        let owner = address!("0000000000000000000000000000000000000001");
2038        let mut snapshot = PoolSnapshot::new(
2039            instrument_id,
2040            state,
2041            vec![PoolPosition::new(owner, -10, 10, 100)],
2042            vec![PoolTick::new(
2043                -10,
2044                100,
2045                100,
2046                U256::ZERO,
2047                U256::ZERO,
2048                true,
2049                100,
2050            )],
2051            PoolAnalytics::default(),
2052            BlockPosition::new(100, "0xabc".to_string(), 0, 0),
2053            ts,
2054            ts,
2055        );
2056        cache
2057            .add_pool_snapshot(&DexType::PancakeSwapV3, &pool_identifier, &snapshot)
2058            .await?;
2059        database
2060            .set_pool_snapshot_validation_state(
2061                chain.chain_id,
2062                &pool_identifier,
2063                100,
2064                0,
2065                0,
2066                "on_chain",
2067            )
2068            .await?;
2069
2070        snapshot.state.set_protocol_fee_basis_points(3_300, 4_100);
2071        snapshot.positions[0].liquidity = 200;
2072        snapshot.ticks[0].liquidity_gross = 200;
2073        snapshot.ticks[0].liquidity_net = 200;
2074        cache
2075            .add_pool_snapshot(&DexType::PancakeSwapV3, &pool_identifier, &snapshot)
2076            .await?;
2077
2078        let loaded = database
2079            .load_latest_pool_snapshot(chain.chain_id, &pool_identifier, None, false)
2080            .await?;
2081        let validation = database
2082            .get_pool_snapshot_validation_state(chain.chain_id, &pool_identifier, 100, 0, 0)
2083            .await?;
2084
2085        cache.database = None;
2086        schema.cleanup().await?;
2087
2088        let Some(loaded) = loaded else {
2089            anyhow::bail!("expected snapshot to load");
2090        };
2091        let observed = (
2092            loaded.state.fee_protocol,
2093            loaded.state.fee_protocol0_basis_points,
2094            loaded.state.fee_protocol1_basis_points,
2095            loaded.positions[0].liquidity,
2096            loaded.ticks[0].liquidity_gross,
2097            loaded.ticks[0].liquidity_net,
2098            validation,
2099        );
2100
2101        if observed
2102            != (
2103                0,
2104                Some(3_300),
2105                Some(4_100),
2106                200,
2107                200,
2108                200,
2109                Some("on_chain".to_string()),
2110            )
2111        {
2112            anyhow::bail!("unexpected upserted snapshot state: {observed:?}");
2113        }
2114        Ok(())
2115    }
2116
2117    #[tokio::test]
2118    async fn load_pool_loads_only_the_requested_pool() -> anyhow::Result<()> {
2119        let Some((database, schema)) = connect_cache_test_database().await? else {
2120            return Ok(());
2121        };
2122        let chain = arbitrum();
2123        let dex = uniswap_v3(&chain);
2124        let token0 = weth(&chain);
2125        let token1 = usdc(&chain);
2126        let pool_address = address!("0xd13040d4fe917EE704158CfCB3338dCd2838B245");
2127        let pool_identifier = PoolIdentifier::from_address(pool_address);
2128        let absent_identifier =
2129            PoolIdentifier::from_address(address!("0x1111111111111111111111111111111111111111"));
2130
2131        let pool = Pool::new(
2132            chain.clone(),
2133            dex.clone(),
2134            pool_address,
2135            pool_identifier,
2136            30,
2137            token0.clone(),
2138            token1.clone(),
2139            Some(500),
2140            Some(10),
2141            UnixNanos::default(),
2142        );
2143        let mut cache = BlockchainCache::new(chain.clone());
2144        cache.database = Some(database);
2145        cache.add_dex(dex).await?;
2146        cache.add_token(token0).await?;
2147        cache.add_token(token1).await?;
2148        cache.add_pool(pool).await?;
2149
2150        // Drop the in-memory pool so load_pool must read it back from the database, then prove it
2151        // repopulates the cache for exactly the requested pool and reports None for an absent one.
2152        cache.pools.clear();
2153        let loaded = cache.load_pool(&DexType::UniswapV3, &pool_identifier).await;
2154        let cached_after_load = cache.get_pool(&pool_identifier).is_some();
2155        let absent = cache
2156            .load_pool(&DexType::UniswapV3, &absent_identifier)
2157            .await;
2158
2159        cache.database = None;
2160        schema.cleanup().await?;
2161
2162        let loaded_id = loaded?.map(|pool| pool.pool_identifier);
2163        let absent_is_some = absent?.is_some();
2164
2165        if loaded_id != Some(pool_identifier) || !cached_after_load || absent_is_some {
2166            anyhow::bail!(
2167                "unexpected load_pool result: loaded_id={loaded_id:?}, cached_after_load={cached_after_load}, absent_is_some={absent_is_some}"
2168            );
2169        }
2170
2171        Ok(())
2172    }
2173
2174    // check_snapshot_validity lives on the data client but exercises the cache DB read path, so its
2175    // RPC-unreachable test reuses this module's isolated-schema scaffolding. It runs fully only when
2176    // both Postgres and an ENVIO_API_TOKEN are present (the live-smoke setup) and skips otherwise.
2177    #[tokio::test]
2178    async fn check_snapshot_validity_reports_stored_verdict_when_rpc_unreachable()
2179    -> anyhow::Result<()> {
2180        // BlockchainDataClientCore::new builds a HyperSyncClient, which requires a UUID token; the
2181        // crate denies unsafe_code, so the test cannot inject one. Skip when it is absent (checked
2182        // before opening a schema to avoid leaking it).
2183        if std::env::var("ENVIO_API_TOKEN").is_err() {
2184            return Ok(());
2185        }
2186        let Some((database, schema)) = connect_cache_test_database().await? else {
2187            return Ok(());
2188        };
2189        let chain = arbitrum();
2190        let dex = uniswap_v3(&chain);
2191        let token0 = weth(&chain);
2192        let token1 = usdc(&chain);
2193        let pool_address = address!("0xd13040d4fe917EE704158CfCB3338dCd2838B245");
2194        let pool_identifier = PoolIdentifier::from_address(pool_address);
2195        let pool = Pool::new(
2196            chain.clone(),
2197            dex.clone(),
2198            pool_address,
2199            pool_identifier,
2200            10,
2201            token0.clone(),
2202            token1.clone(),
2203            Some(500),
2204            Some(10),
2205            UnixNanos::default(),
2206        );
2207        let instrument_id = pool.instrument_id;
2208
2209        // Unreachable RPC so the on-chain compare cannot fetch the block and must fall back to the
2210        // stored verdict.
2211        let config = BlockchainDataClientConfig::builder()
2212            .chain(chain.clone())
2213            .dex_ids(vec![DexType::UniswapV3])
2214            .http_rpc_url("http://127.0.0.1:9".to_string())
2215            .use_hypersync_for_live_data(true)
2216            .build();
2217        let mut core = BlockchainDataClientCore::new(config, None, None, CancellationToken::new());
2218        core.cache.database = Some(database);
2219        core.cache.add_dex(dex).await?;
2220        core.cache.add_token(token0).await?;
2221        core.cache.add_token(token1).await?;
2222        core.cache.add_pool(pool.clone()).await?;
2223
2224        // Persist an `invalid` verdict at the watermark the profiler will report.
2225        let ts = UnixNanos::from(1_700_000_000_000_000_000);
2226        let block_position = BlockPosition::new(200, "0xabc".to_string(), 0, 0);
2227        let snapshot = PoolSnapshot::new(
2228            instrument_id,
2229            PoolState::default(),
2230            Vec::new(),
2231            Vec::new(),
2232            PoolAnalytics::default(),
2233            block_position.clone(),
2234            ts,
2235            ts,
2236        );
2237        core.cache
2238            .add_pool_snapshot(&DexType::UniswapV3, &pool_identifier, &snapshot)
2239            .await?;
2240        core.cache
2241            .database
2242            .as_ref()
2243            .expect("cache database must be set")
2244            .set_pool_snapshot_validation_state(
2245                chain.chain_id,
2246                &pool_identifier,
2247                200,
2248                0,
2249                0,
2250                "invalid",
2251            )
2252            .await?;
2253        core.cache
2254            .add_pool_snapshot(&DexType::UniswapV3, &pool_identifier, &snapshot)
2255            .await?;
2256
2257        let mut profiler = PoolProfiler::new(Arc::new(pool));
2258        profiler
2259            .initialize(U160::from_str_radix("3cb0adde486484998be0b", 16).unwrap())
2260            .expect("profiler should initialize from a known sqrt price");
2261        profiler.last_processed_event = Some(block_position);
2262        profiler.last_processed_ts = Some(ts);
2263
2264        let reported = core.check_snapshot_validity(&profiler, false).await;
2265        let stored_after = core
2266            .cache
2267            .database
2268            .as_ref()
2269            .expect("cache database must be set")
2270            .get_pool_snapshot_validation_state(chain.chain_id, &pool_identifier, 200, 0, 0)
2271            .await;
2272
2273        core.cache.database = None;
2274        schema.cleanup().await?;
2275
2276        // The RPC could not reach the block, so the reported verdict comes from the stored row, and
2277        // the stored row is left untouched (a transient RPC failure must not clobber a definitive
2278        // verdict).
2279        let reported = reported?;
2280        let stored_after = stored_after?;
2281        if reported != SnapshotValidation::Invalid || stored_after.as_deref() != Some("invalid") {
2282            anyhow::bail!(
2283                "unexpected validity result: reported={reported:?}, stored_after={stored_after:?}"
2284            );
2285        }
2286
2287        Ok(())
2288    }
2289
2290    struct PoolEventSyncFixture {
2291        cache: BlockchainCache,
2292        schema: TestSchema,
2293        chain: SharedChain,
2294        dex: SharedDex,
2295        pool: Pool,
2296    }
2297
2298    impl PoolEventSyncFixture {
2299        fn database(&self) -> &BlockchainCacheDatabase {
2300            self.cache
2301                .database
2302                .as_ref()
2303                .expect("cache database must be set")
2304        }
2305
2306        async fn events(&self, to_block: u64) -> anyhow::Result<Vec<DexPoolData>> {
2307            self.database()
2308                .stream_pool_events(
2309                    self.chain.clone(),
2310                    self.dex.clone(),
2311                    self.pool.instrument_id,
2312                    self.pool.pool_identifier,
2313                    None,
2314                    Some(to_block),
2315                )
2316                .try_collect()
2317                .await
2318        }
2319    }
2320
2321    async fn pool_event_sync_fixture() -> anyhow::Result<Option<PoolEventSyncFixture>> {
2322        let Some((database, schema)) = connect_cache_test_database().await? else {
2323            return Ok(None);
2324        };
2325        let chain = arbitrum();
2326        let dex = uniswap_v3(&chain);
2327        let token0 = weth(&chain);
2328        let token1 = usdc(&chain);
2329        let pool_address = address!("0xd13040d4fe917EE704158CfCB3338dCd2838B245");
2330        let pool = Pool::new(
2331            chain.clone(),
2332            dex.clone(),
2333            pool_address,
2334            PoolIdentifier::from_address(pool_address),
2335            10,
2336            token0.clone(),
2337            token1.clone(),
2338            Some(500),
2339            Some(10),
2340            UnixNanos::default(),
2341        );
2342        let mut cache = BlockchainCache::new(chain.clone());
2343        cache.database = Some(database);
2344        cache.add_dex(dex.clone()).await?;
2345        cache.add_token(token0).await?;
2346        cache.add_token(token1).await?;
2347        cache.add_pool(pool.clone()).await?;
2348
2349        Ok(Some(PoolEventSyncFixture {
2350            cache,
2351            schema,
2352            chain,
2353            dex,
2354            pool,
2355        }))
2356    }
2357
2358    fn protocol_fee_events(
2359        fixture: &PoolEventSyncFixture,
2360    ) -> (PoolFeeProtocolUpdate, PoolFeeProtocolCollect) {
2361        let update = PoolFeeProtocolUpdate::new(
2362            fixture.chain.clone(),
2363            fixture.dex.clone(),
2364            fixture.pool.instrument_id,
2365            fixture.pool.pool_identifier,
2366            12,
2367            "0x00000000000000000000000000000000000000000000000000000000000000ab".to_string(),
2368            1,
2369            1,
2370            4,
2371            6,
2372            UnixNanos::from(1_700_000_012_000_000_000),
2373            UnixNanos::from(1_700_000_012_000_000_000),
2374        );
2375        let collect = PoolFeeProtocolCollect::new(
2376            fixture.chain.clone(),
2377            fixture.dex.clone(),
2378            fixture.pool.instrument_id,
2379            fixture.pool.pool_identifier,
2380            13,
2381            "0x00000000000000000000000000000000000000000000000000000000000000cd".to_string(),
2382            2,
2383            2,
2384            address!("0xc36442b4a4522e871399cd717abdd847ab11fe88"),
2385            address!("0xa61da382c18d9d5beb905ea192bae25e4c15d512"),
2386            111,
2387            222,
2388            UnixNanos::from(1_700_000_013_000_000_000),
2389            UnixNanos::from(1_700_000_013_000_000_000),
2390        );
2391        (update, collect)
2392    }
2393
2394    fn expected_swap(fixture: &PoolEventSyncFixture) -> PoolSwap {
2395        PoolSwap::new(
2396            fixture.chain.clone(),
2397            fixture.dex.clone(),
2398            fixture.pool.instrument_id,
2399            fixture.pool.pool_identifier,
2400            11,
2401            "0x000000000000000000000000000000000000000000000000000000000000000c".to_string(),
2402            0,
2403            0,
2404            UnixNanos::from(1_700_000_011_000_000_000),
2405            UnixNanos::from(1_700_000_011_000_000_000),
2406            address!("0x1111111111111111111111111111111111111111"),
2407            address!("0x2222222222222222222222222222222222222222"),
2408            I256::try_from(-1_000_000_000_000_000_000_i128).unwrap(),
2409            I256::try_from(2_000_000_i128).unwrap(),
2410            U160::from(79_228_162_514_264_337_593_543_950_336_u128),
2411            1_000_000,
2412            0,
2413        )
2414    }
2415
2416    async fn connect_cache_test_database()
2417    -> anyhow::Result<Option<(BlockchainCacheDatabase, TestSchema)>> {
2418        let config = get_postgres_connect_options(None, None, None, None, None);
2419        let mut connect_options: PgConnectOptions = config.clone().into();
2420        let Some(mut admin_pool) =
2421            connect_cache_test_pool(connect_options.clone(), &config.username).await
2422        else {
2423            return Ok(None);
2424        };
2425        let schema_name = cache_test_schema_name();
2426
2427        if let Err(e) = create_cache_test_schema(&admin_pool, &schema_name).await {
2428            if !is_database_create_permission_denied(&e) || config.username == "postgres" {
2429                return Err(e);
2430            }
2431
2432            eprintln!(
2433                "Postgres role {} cannot create isolated blockchain cache test schema; retrying with postgres role: {e}",
2434                config.username
2435            );
2436            admin_pool.close().await;
2437            connect_options = postgres_test_connect_options(&config);
2438            let Some(postgres_pool) =
2439                connect_cache_test_pool(connect_options.clone(), "postgres").await
2440            else {
2441                return Err(e);
2442            };
2443            admin_pool = postgres_pool;
2444            create_cache_test_schema(&admin_pool, &schema_name).await?;
2445        }
2446
2447        let database = crate::cache::database::tests::connect_test_database(
2448            connect_options.options([("search_path", format!("{schema_name},public"))]),
2449        )
2450        .await?;
2451
2452        Ok(Some((
2453            database,
2454            TestSchema {
2455                admin_pool,
2456                name: schema_name,
2457            },
2458        )))
2459    }
2460
2461    async fn connect_cache_test_pool(
2462        connect_options: PgConnectOptions,
2463        username: &str,
2464    ) -> Option<PgPool> {
2465        match PgPoolOptions::new()
2466            .max_connections(1)
2467            .connect_with(connect_options)
2468            .await
2469        {
2470            Ok(pool) => Some(pool),
2471            Err(e) => {
2472                eprintln!(
2473                    "Postgres connection as {username} failed; skipping blockchain cache DB test: {e}"
2474                );
2475                None
2476            }
2477        }
2478    }
2479
2480    fn postgres_test_connect_options(config: &PostgresConnectOptions) -> PgConnectOptions {
2481        PostgresConnectOptions::new(
2482            config.host.clone(),
2483            config.port,
2484            String::from("postgres"),
2485            config.password.clone(),
2486            config.database.clone(),
2487        )
2488        .into()
2489    }
2490
2491    fn is_database_create_permission_denied(error: &anyhow::Error) -> bool {
2492        match error.downcast_ref::<SqlxError>() {
2493            Some(SqlxError::Database(database_error)) => {
2494                database_error
2495                    .code()
2496                    .is_some_and(|code| code.as_ref() == "42501")
2497                    && database_error
2498                        .message()
2499                        .contains("permission denied for database")
2500            }
2501            _ => false,
2502        }
2503    }
2504
2505    struct TestSchema {
2506        admin_pool: PgPool,
2507        name: String,
2508    }
2509
2510    impl TestSchema {
2511        async fn cleanup(self) -> anyhow::Result<()> {
2512            drop_cache_test_schema(&self.admin_pool, &self.name).await?;
2513            self.admin_pool.close().await;
2514            Ok(())
2515        }
2516    }
2517
2518    #[expect(
2519        clippy::too_many_lines,
2520        reason = "test schema declares the narrow table set used by cache SQL"
2521    )]
2522    async fn create_cache_test_schema(pool: &PgPool, schema: &str) -> anyhow::Result<()> {
2523        execute_schema_statement(pool, format!("CREATE SCHEMA {schema}")).await?;
2524
2525        let statements = [
2526            format!("CREATE DOMAIN {schema}.U256 AS NUMERIC(78, 0)"),
2527            format!("CREATE DOMAIN {schema}.U160 AS NUMERIC(49, 0)"),
2528            format!("CREATE DOMAIN {schema}.U128 AS NUMERIC(39, 0)"),
2529            format!(
2530                r#"
2531                CREATE TABLE {schema}."chain" (
2532                    chain_id INTEGER PRIMARY KEY,
2533                    name TEXT NOT NULL
2534                )
2535                "#
2536            ),
2537            format!(
2538                r#"
2539                CREATE TABLE {schema}."block" (
2540                    chain_id INTEGER NOT NULL,
2541                    number BIGINT NOT NULL,
2542                    hash TEXT,
2543                    parent_hash TEXT,
2544                    miner TEXT,
2545                    gas_limit BIGINT,
2546                    gas_used BIGINT,
2547                    timestamp TEXT,
2548                    base_fee_per_gas TEXT,
2549                    blob_gas_used TEXT,
2550                    excess_blob_gas TEXT,
2551                    l1_gas_price TEXT,
2552                    l1_gas_used BIGINT,
2553                    l1_fee_scalar BIGINT,
2554                    PRIMARY KEY (chain_id, number)
2555                )
2556                "#
2557            ),
2558            format!(
2559                r#"
2560                CREATE TABLE {schema}."pool_event_block" (
2561                    chain_id INTEGER NOT NULL,
2562                    number BIGINT NOT NULL,
2563                    hash TEXT,
2564                    timestamp TEXT NOT NULL,
2565                    PRIMARY KEY (chain_id, number)
2566                )
2567                "#
2568            ),
2569            format!(
2570                r#"
2571                CREATE TABLE {schema}."token" (
2572                    chain_id INTEGER NOT NULL,
2573                    address TEXT NOT NULL,
2574                    symbol TEXT,
2575                    name TEXT,
2576                    decimals INTEGER,
2577                    error TEXT,
2578                    PRIMARY KEY (chain_id, address)
2579                )
2580                "#
2581            ),
2582            format!(
2583                r#"
2584                CREATE TABLE {schema}."dex" (
2585                    chain_id INTEGER NOT NULL,
2586                    name TEXT NOT NULL,
2587                    factory_address TEXT NOT NULL,
2588                    creation_block BIGINT NOT NULL,
2589                    last_full_sync_pools_block_number BIGINT,
2590                    PRIMARY KEY (chain_id, name),
2591                    UNIQUE (chain_id, factory_address)
2592                )
2593                "#
2594            ),
2595            format!(
2596                r#"
2597                CREATE TABLE {schema}."pool" (
2598                    chain_id INTEGER NOT NULL,
2599                    dex_name TEXT NOT NULL,
2600                    address TEXT NOT NULL,
2601                    pool_identifier TEXT NOT NULL,
2602                    creation_block BIGINT NOT NULL,
2603                    token0_chain INTEGER NOT NULL,
2604                    token0_address TEXT NOT NULL,
2605                    token1_chain INTEGER NOT NULL,
2606                    token1_address TEXT NOT NULL,
2607                    fee INTEGER,
2608                    tick_spacing INTEGER,
2609                    initial_tick INTEGER,
2610                    initial_sqrt_price_x96 TEXT,
2611                    hook_address TEXT,
2612                    last_full_sync_block_number BIGINT,
2613                    event_sync_version INTEGER NOT NULL DEFAULT 0,
2614                    PRIMARY KEY (chain_id, dex_name, pool_identifier)
2615                )
2616                "#
2617            ),
2618            format!(
2619                r#"
2620                CREATE TABLE {schema}."pool_event_sync" (
2621                    chain_id INTEGER NOT NULL,
2622                    dex_name TEXT NOT NULL,
2623                    pool_identifier TEXT NOT NULL,
2624                    event_family TEXT NOT NULL,
2625                    last_full_sync_block_number BIGINT NOT NULL,
2626                    PRIMARY KEY (chain_id, dex_name, pool_identifier, event_family)
2627                )
2628                "#
2629            ),
2630            format!(
2631                r#"
2632                CREATE TABLE {schema}."pool_swap_event" (
2633                    chain_id INTEGER NOT NULL,
2634                    pool_identifier TEXT NOT NULL,
2635                    dex_name TEXT NOT NULL,
2636                    block BIGINT NOT NULL,
2637                    transaction_hash TEXT NOT NULL,
2638                    transaction_index INTEGER NOT NULL,
2639                    log_index INTEGER NOT NULL,
2640                    sender TEXT NOT NULL,
2641                    recipient TEXT NOT NULL,
2642                    sqrt_price_x96 TEXT NOT NULL,
2643                    liquidity TEXT NOT NULL,
2644                    tick INTEGER NOT NULL,
2645                    amount0 TEXT NOT NULL,
2646                    amount1 TEXT NOT NULL,
2647                    order_side TEXT,
2648                    base_quantity NUMERIC,
2649                    quote_quantity NUMERIC,
2650                    spot_price NUMERIC,
2651                    execution_price NUMERIC,
2652                    UNIQUE(chain_id, transaction_hash, log_index)
2653                )
2654                "#
2655            ),
2656            format!(
2657                r#"
2658                CREATE TABLE {schema}."pool_liquidity_event" (
2659                    chain_id INTEGER NOT NULL,
2660                    pool_identifier TEXT NOT NULL,
2661                    dex_name TEXT NOT NULL,
2662                    block BIGINT NOT NULL,
2663                    transaction_hash TEXT NOT NULL,
2664                    transaction_index INTEGER NOT NULL,
2665                    log_index INTEGER NOT NULL,
2666                    event_type TEXT NOT NULL,
2667                    sender TEXT,
2668                    owner TEXT NOT NULL,
2669                    position_liquidity TEXT NOT NULL,
2670                    amount0 TEXT NOT NULL,
2671                    amount1 TEXT NOT NULL,
2672                    tick_lower INTEGER NOT NULL,
2673                    tick_upper INTEGER NOT NULL,
2674                    UNIQUE(chain_id, transaction_hash, log_index)
2675                )
2676                "#
2677            ),
2678            format!(
2679                r#"
2680                CREATE TABLE {schema}."pool_collect_event" (
2681                    chain_id INTEGER NOT NULL,
2682                    pool_identifier TEXT NOT NULL,
2683                    dex_name TEXT NOT NULL,
2684                    block BIGINT NOT NULL,
2685                    transaction_hash TEXT NOT NULL,
2686                    transaction_index INTEGER NOT NULL,
2687                    log_index INTEGER NOT NULL,
2688                    owner TEXT NOT NULL,
2689                    amount0 TEXT NOT NULL,
2690                    amount1 TEXT NOT NULL,
2691                    tick_lower INTEGER NOT NULL,
2692                    tick_upper INTEGER NOT NULL,
2693                    UNIQUE(chain_id, transaction_hash, log_index)
2694                )
2695                "#
2696            ),
2697            format!(
2698                r#"
2699                CREATE TABLE {schema}."pool_flash_event" (
2700                    chain_id INTEGER NOT NULL,
2701                    pool_identifier TEXT NOT NULL,
2702                    dex_name TEXT NOT NULL,
2703                    block BIGINT NOT NULL,
2704                    transaction_hash TEXT NOT NULL,
2705                    transaction_index INTEGER NOT NULL,
2706                    log_index INTEGER NOT NULL,
2707                    sender TEXT NOT NULL,
2708                    recipient TEXT NOT NULL,
2709                    amount0 TEXT NOT NULL,
2710                    amount1 TEXT NOT NULL,
2711                    paid0 TEXT NOT NULL,
2712                    paid1 TEXT NOT NULL,
2713                    UNIQUE(chain_id, transaction_hash, log_index)
2714                )
2715                "#
2716            ),
2717            format!(
2718                r#"
2719                CREATE TABLE {schema}."pool_fee_protocol_update_event" (
2720                    chain_id INTEGER NOT NULL,
2721                    pool_identifier TEXT NOT NULL,
2722                    dex_name TEXT NOT NULL,
2723                    block BIGINT NOT NULL,
2724                    transaction_hash TEXT NOT NULL,
2725                    transaction_index INTEGER NOT NULL,
2726                    log_index INTEGER NOT NULL,
2727                    fee_protocol0_new INTEGER NOT NULL,
2728                    fee_protocol1_new INTEGER NOT NULL,
2729                    UNIQUE(chain_id, transaction_hash, log_index)
2730                )
2731                "#
2732            ),
2733            format!(
2734                r#"
2735                CREATE TABLE {schema}."pool_fee_protocol_collect_event" (
2736                    chain_id INTEGER NOT NULL,
2737                    pool_identifier TEXT NOT NULL,
2738                    dex_name TEXT NOT NULL,
2739                    block BIGINT NOT NULL,
2740                    transaction_hash TEXT NOT NULL,
2741                    transaction_index INTEGER NOT NULL,
2742                    log_index INTEGER NOT NULL,
2743                    sender TEXT NOT NULL,
2744                    recipient TEXT NOT NULL,
2745                    amount0 TEXT NOT NULL,
2746                    amount1 TEXT NOT NULL,
2747                    UNIQUE(chain_id, transaction_hash, log_index)
2748                )
2749                "#
2750            ),
2751            format!(
2752                r#"
2753                CREATE TABLE {schema}."pool_snapshot" (
2754                    chain_id INTEGER NOT NULL,
2755                    pool_identifier TEXT NOT NULL,
2756                    dex_name TEXT NOT NULL,
2757                    block BIGINT NOT NULL,
2758                    transaction_index INTEGER NOT NULL,
2759                    log_index INTEGER NOT NULL,
2760                    transaction_hash TEXT NOT NULL,
2761                    current_tick INTEGER NOT NULL,
2762                    price_sqrt_ratio_x96 NUMERIC NOT NULL,
2763                    liquidity NUMERIC NOT NULL,
2764                    protocol_fees_token0 NUMERIC NOT NULL,
2765                    protocol_fees_token1 NUMERIC NOT NULL,
2766                    fee_protocol SMALLINT NOT NULL,
2767                    fee_protocol0_basis_points INTEGER,
2768                    fee_protocol1_basis_points INTEGER,
2769                    fee_growth_global_0 NUMERIC NOT NULL,
2770                    fee_growth_global_1 NUMERIC NOT NULL,
2771                    total_amount0_deposited NUMERIC NOT NULL,
2772                    total_amount1_deposited NUMERIC NOT NULL,
2773                    total_amount0_collected NUMERIC NOT NULL,
2774                    total_amount1_collected NUMERIC NOT NULL,
2775                    total_swaps INTEGER NOT NULL,
2776                    total_mints INTEGER NOT NULL,
2777                    total_burns INTEGER NOT NULL,
2778                    total_fee_collects INTEGER NOT NULL,
2779                    total_flashes INTEGER NOT NULL,
2780                    liquidity_utilization_rate DOUBLE PRECISION,
2781                    validation_state TEXT NOT NULL DEFAULT 'replay' CHECK (validation_state IN ('on_chain', 'replay', 'invalid')),
2782                    PRIMARY KEY (chain_id, pool_identifier, block, transaction_index, log_index)
2783                )
2784                "#
2785            ),
2786            format!(
2787                r#"
2788                CREATE TABLE {schema}."pool_position" (
2789                    chain_id INTEGER NOT NULL,
2790                    pool_identifier TEXT NOT NULL,
2791                    snapshot_block BIGINT NOT NULL,
2792                    snapshot_transaction_index INTEGER NOT NULL,
2793                    snapshot_log_index INTEGER NOT NULL,
2794                    owner TEXT NOT NULL,
2795                    tick_lower INTEGER NOT NULL,
2796                    tick_upper INTEGER NOT NULL,
2797                    liquidity NUMERIC NOT NULL,
2798                    fee_growth_inside_0_last NUMERIC NOT NULL,
2799                    fee_growth_inside_1_last NUMERIC NOT NULL,
2800                    tokens_owed_0 NUMERIC NOT NULL,
2801                    tokens_owed_1 NUMERIC NOT NULL,
2802                    total_amount0_deposited NUMERIC,
2803                    total_amount1_deposited NUMERIC,
2804                    total_amount0_collected NUMERIC,
2805                    total_amount1_collected NUMERIC,
2806                    PRIMARY KEY (chain_id, pool_identifier, snapshot_block, snapshot_transaction_index, snapshot_log_index, owner, tick_lower, tick_upper)
2807                )
2808                "#
2809            ),
2810            format!(
2811                r#"
2812                CREATE TABLE {schema}."pool_tick" (
2813                    chain_id INTEGER NOT NULL,
2814                    pool_identifier TEXT NOT NULL,
2815                    snapshot_block BIGINT NOT NULL,
2816                    snapshot_transaction_index INTEGER NOT NULL,
2817                    snapshot_log_index INTEGER NOT NULL,
2818                    tick_value INTEGER NOT NULL,
2819                    liquidity_gross NUMERIC NOT NULL,
2820                    liquidity_net NUMERIC NOT NULL,
2821                    fee_growth_outside_0 NUMERIC NOT NULL,
2822                    fee_growth_outside_1 NUMERIC NOT NULL,
2823                    initialized BOOLEAN NOT NULL,
2824                    last_updated_block BIGINT NOT NULL,
2825                    PRIMARY KEY (chain_id, pool_identifier, snapshot_block, snapshot_transaction_index, snapshot_log_index, tick_value)
2826                )
2827                "#
2828            ),
2829        ];
2830
2831        for statement in statements {
2832            execute_schema_statement(pool, statement).await?;
2833        }
2834
2835        Ok(())
2836    }
2837
2838    async fn insert_pool_swap_event(
2839        pool: &PgPool,
2840        schema: &str,
2841        chain_id: u32,
2842        pool_identifier: &PoolIdentifier,
2843        block: u64,
2844    ) -> anyhow::Result<()> {
2845        sqlx::query(AssertSqlSafe(format!(
2846            r#"
2847            INSERT INTO {schema}."pool_swap_event" (
2848                chain_id, pool_identifier, dex_name, block, transaction_hash, transaction_index,
2849                log_index, sender, recipient, sqrt_price_x96, liquidity, tick, amount0, amount1
2850            )
2851            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
2852            "#
2853        )))
2854        .bind(chain_id as i32)
2855        .bind(pool_identifier.to_string())
2856        .bind(DexType::UniswapV3.to_string())
2857        .bind(block as i64)
2858        .bind("0x000000000000000000000000000000000000000000000000000000000000000c")
2859        .bind(0_i32)
2860        .bind(0_i32)
2861        .bind("0x1111111111111111111111111111111111111111")
2862        .bind("0x2222222222222222222222222222222222222222")
2863        .bind("79228162514264337593543950336")
2864        .bind("1000000")
2865        .bind(0_i32)
2866        .bind("-1000000000000000000")
2867        .bind("2000000")
2868        .execute(pool)
2869        .await?;
2870        Ok(())
2871    }
2872
2873    async fn insert_block_without_timestamp(
2874        pool: &PgPool,
2875        schema: &str,
2876        chain_id: u32,
2877        number: u64,
2878    ) -> anyhow::Result<()> {
2879        sqlx::query(AssertSqlSafe(format!(
2880            r#"
2881            INSERT INTO {schema}."block" (
2882                chain_id, number, hash, parent_hash, miner, gas_limit, gas_used, timestamp
2883            )
2884            VALUES ($1, $2, $3, $4, $5, $6, $7, NULL)
2885            "#
2886        )))
2887        .bind(chain_id as i32)
2888        .bind(number as i64)
2889        .bind(format!("0x{number:064x}"))
2890        .bind("0x0")
2891        .bind("0x0000000000000000000000000000000000000000")
2892        .bind(30_000_000_i64)
2893        .bind(21_000_i64)
2894        .execute(pool)
2895        .await?;
2896        Ok(())
2897    }
2898
2899    async fn drop_cache_test_schema(pool: &PgPool, schema: &str) -> anyhow::Result<()> {
2900        execute_schema_statement(pool, format!("DROP SCHEMA IF EXISTS {schema} CASCADE")).await
2901    }
2902
2903    async fn execute_schema_statement(pool: &PgPool, statement: String) -> anyhow::Result<()> {
2904        sqlx::query(AssertSqlSafe(statement)).execute(pool).await?;
2905        Ok(())
2906    }
2907
2908    fn cache_test_schema_name() -> String {
2909        let nanos = SystemTime::now()
2910            .duration_since(UNIX_EPOCH)
2911            .expect("system clock must be after UNIX epoch")
2912            .as_nanos();
2913
2914        format!("nt_blockchain_cache_test_{}_{}", std::process::id(), nanos)
2915    }
2916
2917    fn arbitrum() -> SharedChain {
2918        let Some(chain) = Chain::from_chain_id(42161) else {
2919            panic!("Arbitrum chain must exist in model definitions");
2920        };
2921
2922        Arc::new(chain.clone())
2923    }
2924
2925    fn uniswap_v3(chain: &SharedChain) -> SharedDex {
2926        Arc::new(Dex::new(
2927            (**chain).clone(),
2928            DexType::UniswapV3,
2929            "0x1F98431c8aD98523631AE4a59f267346ea31F984",
2930            0,
2931            AmmType::CLAMM,
2932            "PoolCreated",
2933            "Swap",
2934            "Mint",
2935            "Burn",
2936            "Collect",
2937        ))
2938    }
2939
2940    fn pancakeswap_v3(chain: &SharedChain) -> SharedDex {
2941        Arc::new(Dex::new(
2942            (**chain).clone(),
2943            DexType::PancakeSwapV3,
2944            "0x0BFbCF9fa4f9C56B0F40a671Ad40E0805A091865",
2945            0,
2946            AmmType::CLAMM,
2947            "PoolCreated",
2948            "Swap",
2949            "Mint",
2950            "Burn",
2951            "Collect",
2952        ))
2953    }
2954
2955    fn weth(chain: &SharedChain) -> Token {
2956        Token::new(
2957            chain.clone(),
2958            address!("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"),
2959            "Wrapped Ether".to_string(),
2960            "WETH".to_string(),
2961            18,
2962        )
2963    }
2964
2965    fn usdc(chain: &SharedChain) -> Token {
2966        Token::new(
2967            chain.clone(),
2968            address!("0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8"),
2969            "USD Coin".to_string(),
2970            "USDC".to_string(),
2971            6,
2972        )
2973    }
2974
2975    fn test_block(number: u64, timestamp: UnixNanos) -> Block {
2976        Block::new(
2977            format!("0x{number:064x}"),
2978            String::from("0x0"),
2979            number,
2980            Ustr::from("0x0000000000000000000000000000000000000000"),
2981            30_000_000,
2982            21_000,
2983            timestamp,
2984            Some(Blockchain::Arbitrum),
2985        )
2986    }
2987}