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