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, database::BlockchainCacheDatabase,
41        rows::PoolRow,
42    },
43    events::initialize::InitializeEvent,
44};
45
46pub mod consistency;
47pub mod copy;
48pub mod database;
49pub mod rows;
50pub mod types;
51
52/// Provides caching functionality for various blockchain domain objects.
53#[derive(Debug)]
54pub struct BlockchainCache {
55    /// The blockchain chain this cache is associated with.
56    chain: SharedChain,
57    /// Map of block numbers to their corresponding timestamp
58    block_timestamps: BTreeMap<u64, UnixNanos>,
59    /// Map of DEX identifiers to their corresponding DEX objects.
60    dexes: HashMap<DexType, SharedDex>,
61    /// Map of token addresses to their corresponding `Token` objects.
62    tokens: HashMap<Address, Token>,
63    /// Cached set of invalid token addresses that failed validation or processing.
64    invalid_tokens: HashSet<Address>,
65    /// Map of pool identifiers to their corresponding `Pool` objects.
66    pools: HashMap<PoolIdentifier, SharedPool>,
67    /// Optional database connection for persistent storage.
68    pub database: Option<BlockchainCacheDatabase>,
69}
70
71impl BlockchainCache {
72    /// Creates a new in-memory blockchain cache for the specified chain.
73    #[must_use]
74    pub fn new(chain: SharedChain) -> Self {
75        Self {
76            chain,
77            dexes: HashMap::new(),
78            tokens: HashMap::new(),
79            invalid_tokens: HashSet::new(),
80            pools: HashMap::new(),
81            block_timestamps: BTreeMap::new(),
82            database: None,
83        }
84    }
85
86    /// Returns the highest continuous block number currently cached, if any.
87    pub async fn get_cache_block_consistency_status(
88        &self,
89    ) -> Option<CachedBlocksConsistencyStatus> {
90        let database = self.database.as_ref()?;
91        database
92            .get_block_consistency_status(&self.chain)
93            .await
94            .map_err(|e| log::error!("Error getting block consistency status: {e}"))
95            .ok()
96    }
97
98    /// Returns the earliest block number where any DEX in the cache was created on the blockchain.
99    #[must_use]
100    pub fn min_dex_creation_block(&self) -> Option<u64> {
101        self.dexes
102            .values()
103            .map(|dex| dex.factory_creation_block)
104            .min()
105    }
106
107    /// Returns the timestamp for the specified block number if it exists in the cache.
108    #[must_use]
109    pub fn get_block_timestamp(&self, block_number: u64) -> Option<&UnixNanos> {
110        self.block_timestamps.get(&block_number)
111    }
112
113    /// Records a block timestamp in the in-memory cache without persisting it.
114    ///
115    /// Used while streaming pool events so event conversion can resolve `ts_event` for blocks
116    /// that have not been persisted via [`Self::add_block`].
117    pub fn cache_block_timestamp(&mut self, number: u64, timestamp: UnixNanos) {
118        self.block_timestamps.insert(number, timestamp);
119    }
120
121    /// Initializes the database connection for persistent storage.
122    pub async fn initialize_database(&mut self, pg_connect_options: PgConnectOptions) {
123        let database = BlockchainCacheDatabase::init(pg_connect_options).await;
124        self.database = Some(database);
125    }
126
127    /// Toggles performance optimization settings in the database.
128    ///
129    /// # Errors
130    ///
131    /// Returns an error if the database is not initialized or the operation fails.
132    pub async fn toggle_performance_settings(&self, enable: bool) -> anyhow::Result<()> {
133        if let Some(database) = &self.database {
134            database.toggle_perf_sync_settings(enable).await
135        } else {
136            log::warn!("Database not initialized, skipping performance settings toggle");
137            Ok(())
138        }
139    }
140
141    /// Initializes the chain by seeding it in the database and creating necessary partitions.
142    ///
143    /// This method sets up the blockchain chain in the database, creates block and token
144    /// partitions for optimal performance, and loads existing tokens into the cache.
145    pub async fn initialize_chain(&mut self) {
146        // Seed target adapter chain in database
147        if let Some(database) = &self.database {
148            if let Err(e) = database.seed_chain(&self.chain).await {
149                log::error!(
150                    "Error seeding chain in database: {e}. Continuing without database cache functionality"
151                );
152                return;
153            }
154            log::debug!("Chain seeded in the database");
155
156            match database.create_block_partition(&self.chain).await {
157                Ok(message) => log::debug!("Executing block partition creation: {message}"),
158                Err(e) => log::error!(
159                    "Error creating block partition for chain {}: {e}. Continuing without partition creation...",
160                    self.chain.chain_id
161                ),
162            }
163
164            match database.create_token_partition(&self.chain).await {
165                Ok(message) => log::debug!("Executing token partition creation: {message}"),
166                Err(e) => log::error!(
167                    "Error creating token partition for chain {}: {e}. Continuing without partition creation...",
168                    self.chain.chain_id
169                ),
170            }
171        }
172
173        if let Err(e) = self.load_tokens().await {
174            log::error!("Error loading tokens from the database: {e}");
175        }
176    }
177
178    /// Connects to the database and loads initial data.
179    ///
180    /// # Errors
181    ///
182    /// Returns an error if database seeding, token loading, or block loading fails.
183    pub async fn connect(&mut self, from_block: u64) -> anyhow::Result<()> {
184        log::debug!("Connecting and loading from_block {from_block}");
185
186        if let Err(e) = self.load_tokens().await {
187            log::error!("Error loading tokens from the database: {e}");
188        }
189
190        // TODO disable block syncing for now as we don't have timestamps yet configured
191        // if let Err(e) = self.load_blocks(from_block).await {
192        //     log::error!("Error loading blocks from database: {e}");
193        // }
194
195        Ok(())
196    }
197
198    /// Loads tokens from the database into the in-memory cache.
199    async fn load_tokens(&mut self) -> anyhow::Result<()> {
200        if let Some(database) = &self.database {
201            let (tokens, invalid_tokens) = tokio::try_join!(
202                database.load_tokens(self.chain.clone()),
203                database.load_invalid_token_addresses(self.chain.chain_id)
204            )?;
205
206            log::debug!(
207                "Loading {} valid tokens and {} invalid tokens from cache database",
208                tokens.len(),
209                invalid_tokens.len()
210            );
211
212            self.tokens
213                .extend(tokens.into_iter().map(|token| (token.address, token)));
214            self.invalid_tokens.extend(invalid_tokens);
215        }
216        Ok(())
217    }
218
219    /// Loads DEX exchange pools from the database into the in-memory cache.
220    ///
221    /// Returns the loaded pools.
222    ///
223    /// # Errors
224    ///
225    /// Returns an error if the DEX has not been registered or if database operations fail.
226    pub async fn load_pools(&mut self, dex_id: &DexType) -> anyhow::Result<Vec<Pool>> {
227        let mut loaded_pools = Vec::new();
228
229        if let Some(database) = &self.database {
230            let dex = self
231                .get_dex(dex_id)
232                .ok_or_else(|| anyhow::anyhow!("DEX {dex_id:?} has not been registered"))?;
233            let pool_rows = database
234                .load_pools(self.chain.clone(), &dex_id.to_string())
235                .await?;
236            log::debug!(
237                "Loading {} pools for DEX {} from cache database",
238                pool_rows.len(),
239                dex_id,
240            );
241
242            for pool_row in pool_rows {
243                if let Some(pool) = self.build_pool_from_row(&pool_row, &dex) {
244                    loaded_pools.push(pool.clone());
245                    self.pools.insert(pool.pool_identifier, Arc::new(pool));
246                }
247            }
248        }
249        Ok(loaded_pools)
250    }
251
252    /// Loads a single DEX pool from the database into the in-memory cache.
253    ///
254    /// Returns the loaded pool, or `None` when it is absent from the database. Unlike
255    /// [`load_pools`](Self::load_pools), this loads only the requested pool, so per-pool tools do
256    /// not pay the cost of loading the whole DEX pool set.
257    ///
258    /// # Errors
259    ///
260    /// Returns an error if the DEX has not been registered or if database operations fail.
261    pub async fn load_pool(
262        &mut self,
263        dex_id: &DexType,
264        pool_identifier: &PoolIdentifier,
265    ) -> anyhow::Result<Option<Pool>> {
266        let dex = self
267            .get_dex(dex_id)
268            .ok_or_else(|| anyhow::anyhow!("DEX {dex_id:?} has not been registered"))?;
269
270        let pool_row = {
271            let Some(database) = &self.database else {
272                return Ok(None);
273            };
274            database
275                .load_pool(self.chain.clone(), &dex_id.to_string(), pool_identifier)
276                .await?
277        };
278
279        let Some(pool_row) = pool_row else {
280            return Ok(None);
281        };
282        let Some(pool) = self.build_pool_from_row(&pool_row, &dex) else {
283            return Ok(None);
284        };
285        self.pools
286            .insert(pool.pool_identifier, Arc::new(pool.clone()));
287        Ok(Some(pool))
288    }
289
290    /// Builds a [`Pool`] from a database row using cached tokens.
291    ///
292    /// Returns `None` (after logging the reason) when a referenced token is missing from the cache
293    /// or the stored pool identifier cannot be parsed.
294    fn build_pool_from_row(&self, pool_row: &PoolRow, dex: &SharedDex) -> Option<Pool> {
295        let Some(token0) = self.tokens.get(&pool_row.token0_address) else {
296            log::error!(
297                "Failed to load pool {} for DEX {}: Token0 with address {} not found in cache. \
298                     This may indicate the token was not properly loaded from the database or the pool references an unknown token",
299                pool_row.address,
300                dex.name,
301                pool_row.token0_address
302            );
303            return None;
304        };
305
306        let Some(token1) = self.tokens.get(&pool_row.token1_address) else {
307            log::error!(
308                "Failed to load pool {} for DEX {}: Token1 with address {} not found in cache. \
309                     This may indicate the token was not properly loaded from the database or the pool references an unknown token",
310                pool_row.address,
311                dex.name,
312                pool_row.token1_address
313            );
314            return None;
315        };
316
317        let Some(pool_identifier) = pool_row.pool_identifier.parse().ok() else {
318            log::error!(
319                "Invalid pool identifier '{}' in database for pool {}, skipping",
320                pool_row.pool_identifier,
321                pool_row.address
322            );
323            return None;
324        };
325
326        let ts_init = pool_row.creation_block_timestamp.unwrap_or_default();
327
328        let mut pool = Pool::new(
329            self.chain.clone(),
330            dex.clone(),
331            pool_row.address,
332            pool_identifier,
333            pool_row.creation_block as u64,
334            token0.clone(),
335            token1.clone(),
336            pool_row.fee.map(|fee| fee as u32),
337            pool_row
338                .tick_spacing
339                .map(|tick_spacing| tick_spacing as u32),
340            ts_init,
341        );
342
343        if let Some(ref hook_address_str) = pool_row.hook_address
344            && let Ok(hooks) = hook_address_str.parse()
345        {
346            pool.set_hooks(hooks);
347        }
348
349        if let Some(initial_sqrt_price_x96_str) = &pool_row.initial_sqrt_price_x96
350            && let Ok(initial_sqrt_price_x96) = initial_sqrt_price_x96_str.parse()
351            && let Some(initial_tick) = pool_row.initial_tick
352        {
353            pool.initialize(initial_sqrt_price_x96, initial_tick);
354        }
355
356        Some(pool)
357    }
358
359    /// Loads block timestamps from the database starting `from_block` number
360    /// into the in-memory cache.
361    #[allow(dead_code)]
362    async fn load_blocks(&mut self, from_block: u64) -> anyhow::Result<()> {
363        if let Some(database) = &self.database {
364            let block_timestamps = database
365                .load_block_timestamps(self.chain.clone(), from_block)
366                .await?;
367
368            // Verify block number sequence consistency
369            if !block_timestamps.is_empty() {
370                let first = block_timestamps.first().unwrap().number;
371                let last = block_timestamps.last().unwrap().number;
372                let expected_len = (last - first + 1) as usize;
373                if block_timestamps.len() != expected_len {
374                    anyhow::bail!(
375                        "Block timestamps are not consistent and sequential. Expected {expected_len} blocks but got {}",
376                        block_timestamps.len()
377                    );
378                }
379            }
380
381            if block_timestamps.is_empty() {
382                log::debug!("No blocks found in database");
383                return Ok(());
384            }
385
386            log::debug!(
387                "Loading {} blocks timestamps from the cache database with last block number {}",
388                block_timestamps.len(),
389                block_timestamps.last().unwrap().number,
390            );
391
392            for block in block_timestamps {
393                self.block_timestamps.insert(block.number, block.timestamp);
394            }
395        }
396        Ok(())
397    }
398
399    /// Adds a block to the cache and persists it to the database if available.
400    ///
401    /// # Errors
402    ///
403    /// Returns an error if adding the block to the database fails.
404    pub async fn add_block(&mut self, block: Block) -> anyhow::Result<()> {
405        // Populate in-memory first so the timestamp resolves even if persistence fails
406        self.block_timestamps.insert(block.number, block.timestamp);
407        if let Some(database) = &self.database {
408            database.add_block(self.chain.chain_id, &block).await?;
409        }
410        Ok(())
411    }
412
413    /// Adds multiple blocks to the cache and persists them to the database in batch if available.
414    ///
415    /// # Errors
416    ///
417    /// Returns an error if adding the blocks to the database fails.
418    pub async fn add_blocks_batch(
419        &mut self,
420        blocks: Vec<Block>,
421        use_copy_command: bool,
422    ) -> anyhow::Result<()> {
423        if blocks.is_empty() {
424            return Ok(());
425        }
426
427        if let Some(database) = &self.database {
428            if use_copy_command {
429                database
430                    .add_blocks_copy(self.chain.chain_id, &blocks)
431                    .await?;
432            } else {
433                database
434                    .add_blocks_batch(self.chain.chain_id, &blocks)
435                    .await?;
436            }
437        }
438
439        // Update in-memory cache
440        for block in blocks {
441            self.block_timestamps.insert(block.number, block.timestamp);
442        }
443
444        Ok(())
445    }
446
447    /// Adds block timestamps observed while streaming pool events.
448    ///
449    /// # Errors
450    ///
451    /// Returns an error if adding the block timestamps to the database fails.
452    pub async fn add_pool_event_blocks_batch(&mut self, blocks: Vec<Block>) -> anyhow::Result<()> {
453        if blocks.is_empty() {
454            return Ok(());
455        }
456
457        if let Some(database) = &self.database {
458            database
459                .add_pool_event_blocks_batch(self.chain.chain_id, &blocks)
460                .await?;
461        }
462
463        for block in blocks {
464            self.block_timestamps.insert(block.number, block.timestamp);
465        }
466
467        Ok(())
468    }
469
470    /// Adds a DEX to the cache with the specified identifier.
471    ///
472    /// # Errors
473    ///
474    /// Returns an error if adding the DEX to the database fails.
475    pub async fn add_dex(&mut self, dex: SharedDex) -> anyhow::Result<()> {
476        log::debug!("Adding dex {} to the cache", dex.name);
477
478        if let Some(database) = &self.database {
479            database.add_dex(dex.clone()).await?;
480        }
481
482        self.dexes.insert(dex.name, dex);
483        Ok(())
484    }
485
486    /// Adds a liquidity pool/pair to the cache.
487    ///
488    /// # Errors
489    ///
490    /// Returns an error if adding the pool to the database fails.
491    pub async fn add_pool(&mut self, pool: Pool) -> anyhow::Result<()> {
492        if let Some(database) = &self.database {
493            database.add_pool(&pool).await?;
494        }
495
496        self.pools.insert(pool.pool_identifier, Arc::new(pool));
497        Ok(())
498    }
499
500    /// Adds multiple pools to the cache and persists them to the database in batch if available.
501    ///
502    /// # Errors
503    ///
504    /// Returns an error if adding the pools to the database fails.
505    pub async fn add_pools_batch(&mut self, pools: Vec<Pool>) -> anyhow::Result<()> {
506        if pools.is_empty() {
507            return Ok(());
508        }
509
510        if let Some(database) = &self.database {
511            database.add_pools_copy(self.chain.chain_id, &pools).await?;
512        }
513        self.pools.extend(
514            pools
515                .into_iter()
516                .map(|pool| (pool.pool_identifier, Arc::new(pool))),
517        );
518
519        Ok(())
520    }
521
522    /// Adds a [`Token`] to the cache.
523    ///
524    /// # Errors
525    ///
526    /// Returns an error if adding the token to the database fails.
527    pub async fn add_token(&mut self, token: Token) -> anyhow::Result<()> {
528        if let Some(database) = &self.database {
529            database.add_token(&token).await?;
530        }
531        self.tokens.insert(token.address, token);
532        Ok(())
533    }
534
535    /// Adds multiple tokens to the cache and persists them to the database in batch if available.
536    ///
537    /// # Errors
538    ///
539    /// Returns an error if adding the tokens to the database fails.
540    pub async fn add_tokens_batch(&mut self, tokens: Vec<Token>) -> anyhow::Result<()> {
541        if tokens.is_empty() {
542            return Ok(());
543        }
544
545        if let Some(database) = &self.database {
546            database
547                .add_tokens_copy(self.chain.chain_id, &tokens)
548                .await?;
549        }
550
551        self.tokens
552            .extend(tokens.into_iter().map(|token| (token.address, token)));
553
554        Ok(())
555    }
556
557    /// Updates the in-memory token cache without persisting to the database.
558    pub fn insert_token_in_memory(&mut self, token: Token) {
559        self.tokens.insert(token.address, token);
560    }
561
562    /// Marks a token address as invalid in the in-memory cache without persisting to the database.
563    pub fn insert_invalid_token_in_memory(&mut self, address: Address) {
564        self.invalid_tokens.insert(address);
565    }
566
567    /// Adds an invalid token address with associated error information to the cache.
568    ///
569    /// # Errors
570    ///
571    /// Returns an error if adding the invalid token to the database fails.
572    pub async fn add_invalid_token(
573        &mut self,
574        address: Address,
575        error_string: &str,
576    ) -> anyhow::Result<()> {
577        if let Some(database) = &self.database {
578            database
579                .add_invalid_token(self.chain.chain_id, &address, error_string)
580                .await?;
581        }
582        self.invalid_tokens.insert(address);
583        Ok(())
584    }
585
586    /// Adds a [`PoolSwap`] to the cache database if available.
587    ///
588    /// # Errors
589    ///
590    /// Returns an error if adding the swap to the database fails.
591    pub async fn add_pool_swap(&self, swap: &PoolSwap) -> anyhow::Result<()> {
592        if let Some(database) = &self.database {
593            database.add_swap(self.chain.chain_id, swap).await?;
594        }
595
596        Ok(())
597    }
598
599    /// Adds a [`PoolLiquidityUpdate`] to the cache database if available.
600    ///
601    /// # Errors
602    ///
603    /// Returns an error if adding the liquidity update to the database fails.
604    pub async fn add_liquidity_update(
605        &self,
606        liquidity_update: &PoolLiquidityUpdate,
607    ) -> anyhow::Result<()> {
608        if let Some(database) = &self.database {
609            database
610                .add_pool_liquidity_update(self.chain.chain_id, liquidity_update)
611                .await?;
612        }
613
614        Ok(())
615    }
616
617    /// Adds multiple [`PoolSwap`]s to the cache database in a single batch operation if available.
618    ///
619    /// # Errors
620    ///
621    /// Returns an error if adding the swaps to the database fails.
622    pub async fn add_pool_swaps_batch(
623        &self,
624        swaps: &[PoolSwap],
625        use_copy_command: bool,
626    ) -> anyhow::Result<()> {
627        if let Some(database) = &self.database {
628            if use_copy_command {
629                database
630                    .add_pool_swaps_copy(self.chain.chain_id, swaps)
631                    .await?;
632            } else {
633                database
634                    .add_pool_swaps_batch(self.chain.chain_id, swaps)
635                    .await?;
636            }
637        }
638
639        Ok(())
640    }
641
642    /// Adds multiple [`PoolLiquidityUpdate`]s to the cache database in a single batch operation if available.
643    ///
644    /// # Errors
645    ///
646    /// Returns an error if adding the liquidity updates to the database fails.
647    pub async fn add_pool_liquidity_updates_batch(
648        &self,
649        updates: &[PoolLiquidityUpdate],
650        use_copy_command: bool,
651    ) -> anyhow::Result<()> {
652        if let Some(database) = &self.database {
653            if use_copy_command {
654                database
655                    .add_pool_liquidity_updates_copy(self.chain.chain_id, updates)
656                    .await?;
657            } else {
658                database
659                    .add_pool_liquidity_updates_batch(self.chain.chain_id, updates)
660                    .await?;
661            }
662        }
663
664        Ok(())
665    }
666
667    /// Adds a batch of pool fee collect events to the cache.
668    ///
669    /// # Errors
670    ///
671    /// Returns an error if adding the fee collects to the database fails.
672    pub async fn add_pool_fee_collects_batch(
673        &self,
674        collects: &[PoolFeeCollect],
675        use_copy_command: bool,
676    ) -> anyhow::Result<()> {
677        if let Some(database) = &self.database {
678            if use_copy_command {
679                database
680                    .copy_pool_fee_collects_batch(self.chain.chain_id, collects)
681                    .await?;
682            } else {
683                database
684                    .add_pool_collects_batch(self.chain.chain_id, collects)
685                    .await?;
686            }
687        }
688
689        Ok(())
690    }
691
692    /// Adds a batch of pool flash events to the cache.
693    ///
694    /// # Errors
695    ///
696    /// Returns an error if adding the flash events to the database fails.
697    pub async fn add_pool_flash_batch(&self, flash_events: &[PoolFlash]) -> anyhow::Result<()> {
698        if let Some(database) = &self.database {
699            database
700                .add_pool_flash_batch(self.chain.chain_id, flash_events)
701                .await?;
702        }
703
704        Ok(())
705    }
706
707    /// Adds a batch of pool fee-protocol update events to the cache database.
708    ///
709    /// # Errors
710    ///
711    /// Returns an error if adding the fee-protocol update events to the database fails.
712    pub async fn add_pool_fee_protocol_updates_batch(
713        &self,
714        updates: &[PoolFeeProtocolUpdate],
715    ) -> anyhow::Result<()> {
716        if let Some(database) = &self.database {
717            database
718                .add_pool_fee_protocol_updates_batch(self.chain.chain_id, updates)
719                .await?;
720        }
721
722        Ok(())
723    }
724
725    /// Adds a batch of pool protocol-fee withdrawal events to the cache database.
726    ///
727    /// # Errors
728    ///
729    /// Returns an error if adding the protocol-fee withdrawal events to the database fails.
730    pub async fn add_pool_fee_protocol_collect_batch(
731        &self,
732        collects: &[PoolFeeProtocolCollect],
733    ) -> anyhow::Result<()> {
734        if let Some(database) = &self.database {
735            database
736                .add_pool_fee_protocol_collect_batch(self.chain.chain_id, collects)
737                .await?;
738        }
739
740        Ok(())
741    }
742
743    /// Adds a pool snapshot to the cache database.
744    ///
745    /// This method saves the complete snapshot including:
746    /// - Pool state and analytics (pool_snapshot table)
747    /// - All positions at this snapshot (pool_position table)
748    /// - All ticks at this snapshot (pool_tick table)
749    ///
750    /// # Errors
751    ///
752    /// Returns an error if adding the snapshot to the database fails.
753    pub async fn add_pool_snapshot(
754        &self,
755        dex: &DexType,
756        pool_identifier: &PoolIdentifier,
757        snapshot: &PoolSnapshot,
758    ) -> anyhow::Result<()> {
759        // Reject stub snapshots at the pool's creation block: empty positions, empty ticks,
760        // and the snapshot block matching pool creation indicates a bootstrap that bailed
761        // before any liquidity events landed. A legitimately empty pool (e.g., fully burned)
762        // would have its last_processed_event at the burn block, not at creation, so the
763        // creation-block check preserves those valid checkpoints.
764        if snapshot.positions.is_empty()
765            && snapshot.ticks.is_empty()
766            && let Some(pool) = self.pools.get(pool_identifier)
767            && snapshot.block_position.number == pool.creation_block
768        {
769            log::warn!(
770                "Refusing to persist empty stub snapshot for {} at pool creation block {}",
771                snapshot.instrument_id,
772                snapshot.block_position.number,
773            );
774            return Ok(());
775        }
776
777        if let Some(database) = &self.database {
778            // Save snapshot first (required for foreign key constraints)
779            database
780                .add_pool_snapshot(self.chain.chain_id, dex, pool_identifier, snapshot)
781                .await?;
782
783            let positions: Vec<(PoolIdentifier, PoolPosition)> = snapshot
784                .positions
785                .iter()
786                .map(|pos| (*pool_identifier, pos.clone()))
787                .collect();
788
789            if !positions.is_empty() {
790                database
791                    .add_pool_positions_batch(
792                        self.chain.chain_id,
793                        snapshot.block_position.number,
794                        snapshot.block_position.transaction_index,
795                        snapshot.block_position.log_index,
796                        &positions,
797                    )
798                    .await?;
799            }
800
801            let ticks: Vec<(PoolIdentifier, &PoolTick)> = snapshot
802                .ticks
803                .iter()
804                .map(|tick| (*pool_identifier, tick))
805                .collect();
806
807            if !ticks.is_empty() {
808                database
809                    .add_pool_ticks_batch(
810                        self.chain.chain_id,
811                        snapshot.block_position.number,
812                        snapshot.block_position.transaction_index,
813                        snapshot.block_position.log_index,
814                        &ticks,
815                    )
816                    .await?;
817            }
818        }
819
820        Ok(())
821    }
822
823    /// Updates the initial price and tick for a pool.
824    ///
825    /// # Errors
826    ///
827    /// Returns an error if the database update fails.
828    pub async fn update_pool_initialize_price_tick(
829        &mut self,
830        initialize_event: &InitializeEvent,
831    ) -> anyhow::Result<()> {
832        if let Some(database) = &self.database {
833            database
834                .update_pool_initial_price_tick(self.chain.chain_id, initialize_event)
835                .await?;
836        }
837
838        // Update the cached pool if it exists
839        let pool_identifier = initialize_event.pool_identifier;
840        if let Some(cached_pool) = self.pools.get(&pool_identifier) {
841            let mut updated_pool = (**cached_pool).clone();
842            updated_pool.initialize(initialize_event.sqrt_price_x96, initialize_event.tick);
843
844            self.pools.insert(pool_identifier, Arc::new(updated_pool));
845        }
846
847        Ok(())
848    }
849
850    /// Returns a reference to the `DexExtended` associated with the given name.
851    #[must_use]
852    pub fn get_dex(&self, dex_id: &DexType) -> Option<SharedDex> {
853        self.dexes.get(dex_id).cloned()
854    }
855
856    /// Returns a list of registered `DexType` in the cache.
857    #[must_use]
858    pub fn get_registered_dexes(&self) -> HashSet<DexType> {
859        self.dexes.keys().copied().collect()
860    }
861
862    /// Returns a reference to the pool associated with the given address.
863    #[must_use]
864    pub fn get_pool(&self, pool_identifier: &PoolIdentifier) -> Option<&SharedPool> {
865        self.pools.get(pool_identifier)
866    }
867
868    /// Returns a reference to the `Token` associated with the given address.
869    #[must_use]
870    pub fn get_token(&self, address: &Address) -> Option<&Token> {
871        self.tokens.get(address)
872    }
873
874    /// Checks if a token address is marked as invalid in the cache.
875    ///
876    /// Returns `true` if the address was previously recorded as invalid due to
877    /// validation or processing failures.
878    #[must_use]
879    pub fn is_invalid_token(&self, address: &Address) -> bool {
880        self.invalid_tokens.contains(address)
881    }
882
883    /// Saves the checkpoint block number indicating the last completed pool synchronization for a specific DEX.
884    ///
885    /// # Errors
886    ///
887    /// Returns an error if the database operation fails.
888    pub async fn update_dex_last_synced_block(
889        &self,
890        dex: &DexType,
891        block_number: u64,
892    ) -> anyhow::Result<()> {
893        if let Some(database) = &self.database {
894            database
895                .update_dex_last_synced_block(self.chain.chain_id, dex, block_number)
896                .await
897        } else {
898            Ok(())
899        }
900    }
901
902    /// Updates the last synced block number for a pool.
903    ///
904    /// # Errors
905    ///
906    /// Returns an error if the database update fails.
907    pub async fn update_pool_last_synced_block(
908        &self,
909        dex: &DexType,
910        pool_identifier: &PoolIdentifier,
911        block_number: u64,
912    ) -> anyhow::Result<()> {
913        if let Some(database) = &self.database {
914            database
915                .update_pool_last_synced_block(
916                    self.chain.chain_id,
917                    dex,
918                    pool_identifier,
919                    block_number,
920                )
921                .await
922        } else {
923            Ok(())
924        }
925    }
926
927    /// Retrieves the saved checkpoint block number from the last completed pool synchronization for a specific DEX.
928    ///
929    /// # Errors
930    ///
931    /// Returns an error if the database query fails.
932    pub async fn get_dex_last_synced_block(&self, dex: &DexType) -> anyhow::Result<Option<u64>> {
933        if let Some(database) = &self.database {
934            database
935                .get_dex_last_synced_block(self.chain.chain_id, dex)
936                .await
937        } else {
938            Ok(None)
939        }
940    }
941
942    /// Retrieves the last synced block number for a pool.
943    ///
944    /// # Errors
945    ///
946    /// Returns an error if the database query fails.
947    pub async fn get_pool_last_synced_block(
948        &self,
949        dex: &DexType,
950        pool_identifier: &PoolIdentifier,
951    ) -> anyhow::Result<Option<u64>> {
952        if let Some(database) = &self.database {
953            database
954                .get_pool_last_synced_block(self.chain.chain_id, dex, pool_identifier)
955                .await
956        } else {
957            Ok(None)
958        }
959    }
960
961    /// Retrieves the maximum block number across all pool event tables for a given pool.
962    ///
963    /// # Errors
964    ///
965    /// Returns an error if any of the database queries fail.
966    pub async fn get_pool_event_tables_last_block(
967        &self,
968        pool_identifier: &PoolIdentifier,
969    ) -> anyhow::Result<Option<u64>> {
970        if let Some(database) = &self.database {
971            let (swaps_last_block, liquidity_last_block, collect_last_block, flash_last_block) = tokio::try_join!(
972                database.get_table_last_block(
973                    self.chain.chain_id,
974                    "pool_swap_event",
975                    pool_identifier
976                ),
977                database.get_table_last_block(
978                    self.chain.chain_id,
979                    "pool_liquidity_event",
980                    pool_identifier
981                ),
982                database.get_table_last_block(
983                    self.chain.chain_id,
984                    "pool_collect_event",
985                    pool_identifier
986                ),
987                database.get_table_last_block(
988                    self.chain.chain_id,
989                    "pool_flash_event",
990                    pool_identifier
991                ),
992            )?;
993
994            let max_block = [
995                swaps_last_block,
996                liquidity_last_block,
997                collect_last_block,
998                flash_last_block,
999            ]
1000            .into_iter()
1001            .flatten()
1002            .max();
1003            Ok(max_block)
1004        } else {
1005            Ok(None)
1006        }
1007    }
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012    use std::{
1013        sync::Arc,
1014        time::{SystemTime, UNIX_EPOCH},
1015    };
1016
1017    use alloy::primitives::{U160, address};
1018    use futures_util::TryStreamExt;
1019    use nautilus_core::UnixNanos;
1020    use nautilus_infrastructure::sql::pg::{PostgresConnectOptions, get_postgres_connect_options};
1021    use nautilus_model::defi::{
1022        AmmType, Block, Blockchain, Chain, Dex, PoolProfiler, SharedChain, SharedDex, Token,
1023        data::{DexPoolData, block::BlockPosition},
1024        pool_analysis::snapshot::{PoolAnalytics, PoolState},
1025    };
1026    use rstest::rstest;
1027    use sqlx::{
1028        AssertSqlSafe, Error as SqlxError, PgPool,
1029        postgres::{PgConnectOptions, PgPoolOptions},
1030    };
1031    use tokio_util::sync::CancellationToken;
1032    use ustr::Ustr;
1033
1034    use super::*;
1035    use crate::{
1036        config::BlockchainDataClientConfig,
1037        data::core::{BlockchainDataClientCore, SnapshotValidation},
1038    };
1039
1040    fn test_cache() -> BlockchainCache {
1041        BlockchainCache::new(Arc::new(Chain::new(Blockchain::Ethereum, 1)))
1042    }
1043
1044    #[rstest]
1045    fn cache_block_timestamp_records_in_memory() {
1046        let mut cache = test_cache();
1047        assert_eq!(cache.get_block_timestamp(100), None);
1048
1049        cache.cache_block_timestamp(100, UnixNanos::from(1_700_000_000_000_000_000));
1050
1051        assert_eq!(
1052            cache.get_block_timestamp(100),
1053            Some(&UnixNanos::from(1_700_000_000_000_000_000))
1054        );
1055    }
1056
1057    #[tokio::test]
1058    async fn add_block_populates_timestamp_without_database() {
1059        let mut cache = test_cache();
1060        let block = Block::new(
1061            "0x1".to_string(),
1062            "0x0".to_string(),
1063            42,
1064            Ustr::from("miner"),
1065            30_000_000,
1066            21_000,
1067            UnixNanos::from(1_700_000_000_000_000_000),
1068            Some(Blockchain::Ethereum),
1069        );
1070
1071        cache.add_block(block).await.unwrap();
1072
1073        assert_eq!(
1074            cache.get_block_timestamp(42),
1075            Some(&UnixNanos::from(1_700_000_000_000_000_000))
1076        );
1077    }
1078
1079    #[tokio::test]
1080    async fn stream_pool_events_uses_pool_event_block_timestamp_without_full_block()
1081    -> anyhow::Result<()> {
1082        let Some((database, schema)) = connect_cache_test_database().await? else {
1083            return Ok(());
1084        };
1085        let chain = arbitrum();
1086        let dex = uniswap_v3(&chain);
1087        let pool_address = address!("0xd13040d4fe917EE704158CfCB3338dCd2838B245");
1088        let pool_identifier = PoolIdentifier::from_address(pool_address);
1089        let instrument_id = Pool::create_instrument_id(chain.name, &dex, pool_identifier.as_str());
1090        let expected_ts = UnixNanos::from(1_700_000_000_123_456_789);
1091
1092        database
1093            .add_pool_event_blocks_batch(chain.chain_id, &[test_block(12, expected_ts)])
1094            .await?;
1095        insert_pool_swap_event(
1096            &schema.admin_pool,
1097            &schema.name,
1098            chain.chain_id,
1099            &pool_identifier,
1100            12,
1101        )
1102        .await?;
1103        let events_result = database
1104            .stream_pool_events(chain, dex, instrument_id, pool_identifier, None, Some(12))
1105            .try_collect::<Vec<_>>()
1106            .await;
1107
1108        drop(database);
1109        schema.cleanup().await?;
1110
1111        let events = events_result?;
1112        let observed_timestamps = match events.as_slice() {
1113            [DexPoolData::Swap(swap)] => Some((swap.ts_event, swap.ts_init)),
1114            _ => None,
1115        };
1116
1117        let expected_timestamps = Some((expected_ts, expected_ts));
1118        if observed_timestamps != expected_timestamps {
1119            anyhow::bail!(
1120                "unexpected stream timestamps: expected {expected_timestamps:?}, observed {observed_timestamps:?}"
1121            );
1122        }
1123        Ok(())
1124    }
1125
1126    #[tokio::test]
1127    async fn stream_pool_events_round_trips_fee_protocol_update_in_order() -> anyhow::Result<()> {
1128        let Some((database, schema)) = connect_cache_test_database().await? else {
1129            return Ok(());
1130        };
1131        let chain = arbitrum();
1132        let dex = uniswap_v3(&chain);
1133        let pool_address = address!("0xd13040d4fe917EE704158CfCB3338dCd2838B245");
1134        let pool_identifier = PoolIdentifier::from_address(pool_address);
1135        let instrument_id = Pool::create_instrument_id(chain.name, &dex, pool_identifier.as_str());
1136        let ts = UnixNanos::from(1_700_000_000_000_000_000);
1137
1138        database
1139            .add_pool_event_blocks_batch(chain.chain_id, &[test_block(12, ts), test_block(13, ts)])
1140            .await?;
1141        // Swap at block 12, SetFeeProtocol at block 13: stream must order swap before update.
1142        insert_pool_swap_event(
1143            &schema.admin_pool,
1144            &schema.name,
1145            chain.chain_id,
1146            &pool_identifier,
1147            12,
1148        )
1149        .await?;
1150        // Asymmetric values (4, 6) catch a token0/token1 column swap.
1151        let update = PoolFeeProtocolUpdate::new(
1152            chain.clone(),
1153            dex.clone(),
1154            instrument_id,
1155            pool_identifier,
1156            13,
1157            "0x00000000000000000000000000000000000000000000000000000000000000ab".to_string(),
1158            0,
1159            0,
1160            4,
1161            6,
1162            ts,
1163            ts,
1164        );
1165        database
1166            .add_pool_fee_protocol_updates_batch(chain.chain_id, std::slice::from_ref(&update))
1167            .await?;
1168
1169        let events_result = database
1170            .stream_pool_events(chain, dex, instrument_id, pool_identifier, None, Some(13))
1171            .try_collect::<Vec<_>>()
1172            .await;
1173
1174        drop(database);
1175        schema.cleanup().await?;
1176
1177        let events = events_result?;
1178        match events.as_slice() {
1179            [DexPoolData::Swap(swap), DexPoolData::FeeProtocolUpdate(fp)] => {
1180                // Swap (block 12) must order before SetFeeProtocol (block 13); the asymmetric
1181                // (4, 6) values catch a token0/token1 column swap, and ts confirms the timestamp.
1182                let observed = (
1183                    swap.block,
1184                    fp.block,
1185                    fp.fee_protocol0_new,
1186                    fp.fee_protocol1_new,
1187                    fp.ts_event,
1188                );
1189
1190                if observed != (12, 13, 4, 6, ts) {
1191                    anyhow::bail!("unexpected fee protocol round-trip: {observed:?}");
1192                }
1193            }
1194            other => anyhow::bail!("unexpected stream events: {other:?}"),
1195        }
1196        Ok(())
1197    }
1198
1199    #[tokio::test]
1200    async fn stream_pool_events_round_trips_fee_protocol_collect_in_order() -> anyhow::Result<()> {
1201        let Some((database, schema)) = connect_cache_test_database().await? else {
1202            return Ok(());
1203        };
1204        let chain = arbitrum();
1205        let dex = uniswap_v3(&chain);
1206        let pool_address = address!("0xd13040d4fe917EE704158CfCB3338dCd2838B245");
1207        let pool_identifier = PoolIdentifier::from_address(pool_address);
1208        let instrument_id = Pool::create_instrument_id(chain.name, &dex, pool_identifier.as_str());
1209        let ts = UnixNanos::from(1_700_000_000_000_000_000);
1210
1211        database
1212            .add_pool_event_blocks_batch(chain.chain_id, &[test_block(12, ts), test_block(13, ts)])
1213            .await?;
1214        // Swap at block 12, CollectProtocol at block 13: stream must order swap before withdrawal.
1215        insert_pool_swap_event(
1216            &schema.admin_pool,
1217            &schema.name,
1218            chain.chain_id,
1219            &pool_identifier,
1220            12,
1221        )
1222        .await?;
1223        // Asymmetric amounts (111, 222) catch a token0/token1 column swap.
1224        let collect = PoolFeeProtocolCollect::new(
1225            chain.clone(),
1226            dex.clone(),
1227            instrument_id,
1228            pool_identifier,
1229            13,
1230            "0x00000000000000000000000000000000000000000000000000000000000000cd".to_string(),
1231            0,
1232            0,
1233            address!("0xc36442b4a4522e871399cd717abdd847ab11fe88"),
1234            address!("0xa61da382c18d9d5beb905ea192bae25e4c15d512"),
1235            111,
1236            222,
1237            ts,
1238            ts,
1239        );
1240        database
1241            .add_pool_fee_protocol_collect_batch(chain.chain_id, std::slice::from_ref(&collect))
1242            .await?;
1243
1244        let events_result = database
1245            .stream_pool_events(chain, dex, instrument_id, pool_identifier, None, Some(13))
1246            .try_collect::<Vec<_>>()
1247            .await;
1248
1249        drop(database);
1250        schema.cleanup().await?;
1251
1252        let events = events_result?;
1253        match events.as_slice() {
1254            [DexPoolData::Swap(swap), DexPoolData::FeeProtocolCollect(cp)] => {
1255                // Swap (block 12) must order before CollectProtocol (block 13); the asymmetric
1256                // (111, 222) amounts catch a token0/token1 column swap, and ts confirms the timestamp.
1257                let observed = (swap.block, cp.block, cp.amount0, cp.amount1, cp.ts_event);
1258                if observed != (12, 13, 111, 222, ts) {
1259                    anyhow::bail!("unexpected fee protocol collect round-trip: {observed:?}");
1260                }
1261
1262                if cp.sender != address!("0xc36442b4a4522e871399cd717abdd847ab11fe88")
1263                    || cp.recipient != address!("0xa61da382c18d9d5beb905ea192bae25e4c15d512")
1264                {
1265                    anyhow::bail!(
1266                        "unexpected fee protocol collect addresses: sender={}, recipient={}",
1267                        cp.sender,
1268                        cp.recipient
1269                    );
1270                }
1271            }
1272            other => anyhow::bail!("unexpected stream events: {other:?}"),
1273        }
1274        Ok(())
1275    }
1276
1277    #[tokio::test]
1278    async fn load_block_timestamps_prefers_full_block_over_pool_event_block() -> anyhow::Result<()>
1279    {
1280        let Some((database, schema)) = connect_cache_test_database().await? else {
1281            return Ok(());
1282        };
1283        let chain = arbitrum();
1284        let fallback_ts = UnixNanos::from(1_700_000_000_000_000_000);
1285        let pool_event_ts = UnixNanos::from(1_700_000_002_000_000_000);
1286        let full_block_ts = UnixNanos::from(1_700_000_001_000_000_000);
1287
1288        database
1289            .add_pool_event_blocks_batch(
1290                chain.chain_id,
1291                &[test_block(20, fallback_ts), test_block(21, pool_event_ts)],
1292            )
1293            .await?;
1294        database
1295            .add_block(chain.chain_id, &test_block(21, full_block_ts))
1296            .await?;
1297
1298        let rows_result = database.load_block_timestamps(chain, 20).await;
1299
1300        drop(database);
1301        schema.cleanup().await?;
1302
1303        let rows = rows_result?;
1304        let observed = rows
1305            .into_iter()
1306            .map(|row| (row.number, row.timestamp))
1307            .collect::<Vec<_>>();
1308
1309        let expected = vec![(20, fallback_ts), (21, full_block_ts)];
1310        if observed != expected {
1311            anyhow::bail!(
1312                "unexpected block timestamps: expected {expected:?}, observed {observed:?}"
1313            );
1314        }
1315        Ok(())
1316    }
1317
1318    #[tokio::test]
1319    async fn load_block_timestamps_uses_pool_event_block_when_full_block_timestamp_is_null()
1320    -> anyhow::Result<()> {
1321        let Some((database, schema)) = connect_cache_test_database().await? else {
1322            return Ok(());
1323        };
1324        let chain = arbitrum();
1325        let fallback_ts = UnixNanos::from(1_700_000_004_000_000_000);
1326
1327        database
1328            .add_pool_event_blocks_batch(chain.chain_id, &[test_block(22, fallback_ts)])
1329            .await?;
1330        insert_block_without_timestamp(&schema.admin_pool, &schema.name, chain.chain_id, 22)
1331            .await?;
1332
1333        let rows_result = database.load_block_timestamps(chain, 22).await;
1334
1335        drop(database);
1336        schema.cleanup().await?;
1337
1338        let rows = rows_result?;
1339        let observed = rows
1340            .into_iter()
1341            .map(|row| (row.number, row.timestamp))
1342            .collect::<Vec<_>>();
1343
1344        let expected = vec![(22, fallback_ts)];
1345        if observed != expected {
1346            anyhow::bail!(
1347                "unexpected block timestamps: expected {expected:?}, observed {observed:?}"
1348            );
1349        }
1350        Ok(())
1351    }
1352
1353    #[tokio::test]
1354    async fn load_pools_sets_pool_timestamps_from_pool_event_block() -> anyhow::Result<()> {
1355        let Some((database, schema)) = connect_cache_test_database().await? else {
1356            return Ok(());
1357        };
1358        let chain = arbitrum();
1359        let dex = uniswap_v3(&chain);
1360        let token0 = weth(&chain);
1361        let token1 = usdc(&chain);
1362        let pool_address = address!("0xd13040d4fe917EE704158CfCB3338dCd2838B245");
1363        let pool_identifier = PoolIdentifier::from_address(pool_address);
1364        let creation_block = 30;
1365        let creation_ts = UnixNanos::from(1_700_000_003_000_000_000);
1366
1367        let pool = Pool::new(
1368            chain.clone(),
1369            dex.clone(),
1370            pool_address,
1371            pool_identifier,
1372            creation_block,
1373            token0.clone(),
1374            token1.clone(),
1375            Some(500),
1376            Some(10),
1377            UnixNanos::default(),
1378        );
1379
1380        let mut cache = BlockchainCache::new(chain.clone());
1381        cache.database = Some(database);
1382
1383        cache.add_dex(dex).await?;
1384        cache.add_token(token0).await?;
1385        cache.add_token(token1).await?;
1386        cache.add_pool(pool).await?;
1387        let Some(database) = cache.database.as_ref() else {
1388            anyhow::bail!("cache database must be set");
1389        };
1390        database
1391            .add_pool_event_blocks_batch(chain.chain_id, &[test_block(creation_block, creation_ts)])
1392            .await?;
1393
1394        let pools_result = cache.load_pools(&DexType::UniswapV3).await;
1395
1396        cache.database = None;
1397        schema.cleanup().await?;
1398
1399        let pools = pools_result?;
1400        let observed_timestamps = pools
1401            .first()
1402            .map(|pool| (pool.ts_event, pool.ts_init, pools.len()));
1403
1404        let expected_timestamps = Some((creation_ts, creation_ts, 1));
1405        if observed_timestamps != expected_timestamps {
1406            anyhow::bail!(
1407                "unexpected pool timestamps: expected {expected_timestamps:?}, observed {observed_timestamps:?}"
1408            );
1409        }
1410        Ok(())
1411    }
1412
1413    #[tokio::test]
1414    async fn load_latest_pool_snapshot_filters_by_validation_state() -> anyhow::Result<()> {
1415        let Some((database, schema)) = connect_cache_test_database().await? else {
1416            return Ok(());
1417        };
1418        let chain = arbitrum();
1419        let dex = uniswap_v3(&chain);
1420        let token0 = weth(&chain);
1421        let token1 = usdc(&chain);
1422        let pool_address = address!("0xd13040d4fe917EE704158CfCB3338dCd2838B245");
1423        let pool_identifier = PoolIdentifier::from_address(pool_address);
1424
1425        let pool = Pool::new(
1426            chain.clone(),
1427            dex.clone(),
1428            pool_address,
1429            pool_identifier,
1430            10, // creation block, distinct from the snapshot blocks below
1431            token0.clone(),
1432            token1.clone(),
1433            Some(500),
1434            Some(10),
1435            UnixNanos::default(),
1436        );
1437        let instrument_id = pool.instrument_id;
1438        let mut cache = BlockchainCache::new(chain.clone());
1439        cache.database = Some(database);
1440        cache.add_dex(dex).await?;
1441        cache.add_token(token0).await?;
1442        cache.add_token(token1).await?;
1443        cache.add_pool(pool).await?;
1444
1445        let ts = UnixNanos::from(1_700_000_000_000_000_000);
1446        let database = cache.database.as_ref().expect("cache database must be set");
1447        database
1448            .add_pool_event_blocks_batch(
1449                chain.chain_id,
1450                &[
1451                    test_block(100, ts),
1452                    test_block(150, ts),
1453                    test_block(200, ts),
1454                ],
1455            )
1456            .await?;
1457
1458        // replay@100, on_chain@150, invalid@200: one snapshot per block with a distinct verdict.
1459        for (block, state) in [
1460            (100u64, "replay"),
1461            (150u64, "on_chain"),
1462            (200u64, "invalid"),
1463        ] {
1464            let snapshot = PoolSnapshot::new(
1465                instrument_id,
1466                PoolState::default(),
1467                Vec::new(),
1468                Vec::new(),
1469                PoolAnalytics::default(),
1470                BlockPosition::new(block, "0xabc".to_string(), 0, 0),
1471                ts,
1472                ts,
1473            );
1474            cache
1475                .add_pool_snapshot(&DexType::UniswapV3, &pool_identifier, &snapshot)
1476                .await?;
1477
1478            if state != "replay" {
1479                database
1480                    .set_pool_snapshot_validation_state(
1481                        chain.chain_id,
1482                        &pool_identifier,
1483                        block,
1484                        0,
1485                        0,
1486                        state,
1487                    )
1488                    .await?;
1489            }
1490        }
1491
1492        let latest_valid = database
1493            .load_latest_pool_snapshot(chain.chain_id, &pool_identifier, None, true)
1494            .await;
1495        let latest_any = database
1496            .load_latest_pool_snapshot(chain.chain_id, &pool_identifier, None, false)
1497            .await;
1498        let stored_invalid = database
1499            .get_pool_snapshot_validation_state(chain.chain_id, &pool_identifier, 200, 0, 0)
1500            .await;
1501        let stored_on_chain = database
1502            .get_pool_snapshot_validation_state(chain.chain_id, &pool_identifier, 150, 0, 0)
1503            .await;
1504
1505        cache.database = None;
1506        schema.cleanup().await?;
1507
1508        let latest_valid_block = latest_valid?.map(|s| s.block_position.number);
1509        let latest_any_block = latest_any?.map(|s| s.block_position.number);
1510        let stored_invalid = stored_invalid?;
1511        let stored_on_chain = stored_on_chain?;
1512
1513        // require_valid excludes 'invalid', so the latest usable snapshot is on_chain@150; without
1514        // the filter the newest row (invalid@200) wins. The stored verdict stays readable by primary
1515        // key and untouched by the load filter.
1516        if latest_valid_block != Some(150)
1517            || latest_any_block != Some(200)
1518            || stored_invalid.as_deref() != Some("invalid")
1519            || stored_on_chain.as_deref() != Some("on_chain")
1520        {
1521            anyhow::bail!(
1522                "unexpected load filter result: latest_valid_block={latest_valid_block:?}, latest_any_block={latest_any_block:?}, stored_invalid={stored_invalid:?}, stored_on_chain={stored_on_chain:?}"
1523            );
1524        }
1525
1526        Ok(())
1527    }
1528
1529    #[tokio::test]
1530    async fn load_pool_loads_only_the_requested_pool() -> anyhow::Result<()> {
1531        let Some((database, schema)) = connect_cache_test_database().await? else {
1532            return Ok(());
1533        };
1534        let chain = arbitrum();
1535        let dex = uniswap_v3(&chain);
1536        let token0 = weth(&chain);
1537        let token1 = usdc(&chain);
1538        let pool_address = address!("0xd13040d4fe917EE704158CfCB3338dCd2838B245");
1539        let pool_identifier = PoolIdentifier::from_address(pool_address);
1540        let absent_identifier =
1541            PoolIdentifier::from_address(address!("0x1111111111111111111111111111111111111111"));
1542
1543        let pool = Pool::new(
1544            chain.clone(),
1545            dex.clone(),
1546            pool_address,
1547            pool_identifier,
1548            30,
1549            token0.clone(),
1550            token1.clone(),
1551            Some(500),
1552            Some(10),
1553            UnixNanos::default(),
1554        );
1555        let mut cache = BlockchainCache::new(chain.clone());
1556        cache.database = Some(database);
1557        cache.add_dex(dex).await?;
1558        cache.add_token(token0).await?;
1559        cache.add_token(token1).await?;
1560        cache.add_pool(pool).await?;
1561
1562        // Drop the in-memory pool so load_pool must read it back from the database, then prove it
1563        // repopulates the cache for exactly the requested pool and reports None for an absent one.
1564        cache.pools.clear();
1565        let loaded = cache.load_pool(&DexType::UniswapV3, &pool_identifier).await;
1566        let cached_after_load = cache.get_pool(&pool_identifier).is_some();
1567        let absent = cache
1568            .load_pool(&DexType::UniswapV3, &absent_identifier)
1569            .await;
1570
1571        cache.database = None;
1572        schema.cleanup().await?;
1573
1574        let loaded_id = loaded?.map(|pool| pool.pool_identifier);
1575        let absent_is_some = absent?.is_some();
1576
1577        if loaded_id != Some(pool_identifier) || !cached_after_load || absent_is_some {
1578            anyhow::bail!(
1579                "unexpected load_pool result: loaded_id={loaded_id:?}, cached_after_load={cached_after_load}, absent_is_some={absent_is_some}"
1580            );
1581        }
1582
1583        Ok(())
1584    }
1585
1586    // check_snapshot_validity lives on the data client but exercises the cache DB read path, so its
1587    // RPC-unreachable test reuses this module's isolated-schema scaffolding. It runs fully only when
1588    // both Postgres and an ENVIO_API_TOKEN are present (the live-smoke setup) and skips otherwise.
1589    #[tokio::test]
1590    async fn check_snapshot_validity_reports_stored_verdict_when_rpc_unreachable()
1591    -> anyhow::Result<()> {
1592        // BlockchainDataClientCore::new builds a HyperSyncClient, which requires a UUID token; the
1593        // crate denies unsafe_code, so the test cannot inject one. Skip when it is absent (checked
1594        // before opening a schema to avoid leaking it).
1595        if std::env::var("ENVIO_API_TOKEN").is_err() {
1596            return Ok(());
1597        }
1598        let Some((database, schema)) = connect_cache_test_database().await? else {
1599            return Ok(());
1600        };
1601        let chain = arbitrum();
1602        let dex = uniswap_v3(&chain);
1603        let token0 = weth(&chain);
1604        let token1 = usdc(&chain);
1605        let pool_address = address!("0xd13040d4fe917EE704158CfCB3338dCd2838B245");
1606        let pool_identifier = PoolIdentifier::from_address(pool_address);
1607        let pool = Pool::new(
1608            chain.clone(),
1609            dex.clone(),
1610            pool_address,
1611            pool_identifier,
1612            10,
1613            token0.clone(),
1614            token1.clone(),
1615            Some(500),
1616            Some(10),
1617            UnixNanos::default(),
1618        );
1619        let instrument_id = pool.instrument_id;
1620
1621        // Unreachable RPC so the on-chain compare cannot fetch the block and must fall back to the
1622        // stored verdict.
1623        let config = BlockchainDataClientConfig::builder()
1624            .chain(chain.clone())
1625            .dex_ids(vec![DexType::UniswapV3])
1626            .http_rpc_url("http://127.0.0.1:9".to_string())
1627            .use_hypersync_for_live_data(true)
1628            .build();
1629        let mut core = BlockchainDataClientCore::new(config, None, None, CancellationToken::new());
1630        core.cache.database = Some(database);
1631        core.cache.add_dex(dex).await?;
1632        core.cache.add_token(token0).await?;
1633        core.cache.add_token(token1).await?;
1634        core.cache.add_pool(pool.clone()).await?;
1635
1636        // Persist an `invalid` verdict at the watermark the profiler will report.
1637        let ts = UnixNanos::from(1_700_000_000_000_000_000);
1638        let block_position = BlockPosition::new(200, "0xabc".to_string(), 0, 0);
1639        let snapshot = PoolSnapshot::new(
1640            instrument_id,
1641            PoolState::default(),
1642            Vec::new(),
1643            Vec::new(),
1644            PoolAnalytics::default(),
1645            block_position.clone(),
1646            ts,
1647            ts,
1648        );
1649        core.cache
1650            .add_pool_snapshot(&DexType::UniswapV3, &pool_identifier, &snapshot)
1651            .await?;
1652        core.cache
1653            .database
1654            .as_ref()
1655            .expect("cache database must be set")
1656            .set_pool_snapshot_validation_state(
1657                chain.chain_id,
1658                &pool_identifier,
1659                200,
1660                0,
1661                0,
1662                "invalid",
1663            )
1664            .await?;
1665
1666        let mut profiler = PoolProfiler::new(Arc::new(pool));
1667        profiler
1668            .initialize(U160::from_str_radix("3cb0adde486484998be0b", 16).unwrap())
1669            .expect("profiler should initialize from a known sqrt price");
1670        profiler.last_processed_event = Some(block_position);
1671        profiler.last_processed_ts = Some(ts);
1672
1673        let reported = core.check_snapshot_validity(&profiler, false).await;
1674        let stored_after = core
1675            .cache
1676            .database
1677            .as_ref()
1678            .expect("cache database must be set")
1679            .get_pool_snapshot_validation_state(chain.chain_id, &pool_identifier, 200, 0, 0)
1680            .await;
1681
1682        core.cache.database = None;
1683        schema.cleanup().await?;
1684
1685        // The RPC could not reach the block, so the reported verdict comes from the stored row, and
1686        // the stored row is left untouched (a transient RPC failure must not clobber a definitive
1687        // verdict).
1688        let reported = reported?;
1689        let stored_after = stored_after?;
1690        if reported != SnapshotValidation::Invalid || stored_after.as_deref() != Some("invalid") {
1691            anyhow::bail!(
1692                "unexpected validity result: reported={reported:?}, stored_after={stored_after:?}"
1693            );
1694        }
1695
1696        Ok(())
1697    }
1698
1699    async fn connect_cache_test_database()
1700    -> anyhow::Result<Option<(BlockchainCacheDatabase, TestSchema)>> {
1701        let config = get_postgres_connect_options(None, None, None, None, None);
1702        let mut connect_options: PgConnectOptions = config.clone().into();
1703        let Some(mut admin_pool) =
1704            connect_cache_test_pool(connect_options.clone(), &config.username).await
1705        else {
1706            return Ok(None);
1707        };
1708        let schema_name = cache_test_schema_name();
1709
1710        if let Err(e) = create_cache_test_schema(&admin_pool, &schema_name).await {
1711            if !is_database_create_permission_denied(&e) || config.username == "postgres" {
1712                return Err(e);
1713            }
1714
1715            eprintln!(
1716                "Postgres role {} cannot create isolated blockchain cache test schema; retrying with postgres role: {e}",
1717                config.username
1718            );
1719            admin_pool.close().await;
1720            connect_options = postgres_test_connect_options(&config);
1721            let Some(postgres_pool) =
1722                connect_cache_test_pool(connect_options.clone(), "postgres").await
1723            else {
1724                return Err(e);
1725            };
1726            admin_pool = postgres_pool;
1727            create_cache_test_schema(&admin_pool, &schema_name).await?;
1728        }
1729
1730        let database = BlockchainCacheDatabase::connect(
1731            connect_options.options([("search_path", format!("{schema_name},public"))]),
1732        )
1733        .await?;
1734
1735        Ok(Some((
1736            database,
1737            TestSchema {
1738                admin_pool,
1739                name: schema_name,
1740            },
1741        )))
1742    }
1743
1744    async fn connect_cache_test_pool(
1745        connect_options: PgConnectOptions,
1746        username: &str,
1747    ) -> Option<PgPool> {
1748        match PgPoolOptions::new()
1749            .max_connections(1)
1750            .connect_with(connect_options)
1751            .await
1752        {
1753            Ok(pool) => Some(pool),
1754            Err(e) => {
1755                eprintln!(
1756                    "Postgres connection as {username} failed; skipping blockchain cache DB test: {e}"
1757                );
1758                None
1759            }
1760        }
1761    }
1762
1763    fn postgres_test_connect_options(config: &PostgresConnectOptions) -> PgConnectOptions {
1764        PostgresConnectOptions::new(
1765            config.host.clone(),
1766            config.port,
1767            String::from("postgres"),
1768            config.password.clone(),
1769            config.database.clone(),
1770        )
1771        .into()
1772    }
1773
1774    fn is_database_create_permission_denied(error: &anyhow::Error) -> bool {
1775        match error.downcast_ref::<SqlxError>() {
1776            Some(SqlxError::Database(database_error)) => {
1777                database_error
1778                    .code()
1779                    .is_some_and(|code| code.as_ref() == "42501")
1780                    && database_error
1781                        .message()
1782                        .contains("permission denied for database")
1783            }
1784            _ => false,
1785        }
1786    }
1787
1788    struct TestSchema {
1789        admin_pool: PgPool,
1790        name: String,
1791    }
1792
1793    impl TestSchema {
1794        async fn cleanup(self) -> anyhow::Result<()> {
1795            drop_cache_test_schema(&self.admin_pool, &self.name).await?;
1796            self.admin_pool.close().await;
1797            Ok(())
1798        }
1799    }
1800
1801    #[expect(
1802        clippy::too_many_lines,
1803        reason = "test schema declares the narrow table set used by cache SQL"
1804    )]
1805    async fn create_cache_test_schema(pool: &PgPool, schema: &str) -> anyhow::Result<()> {
1806        execute_schema_statement(pool, format!("CREATE SCHEMA {schema}")).await?;
1807
1808        let statements = [
1809            format!("CREATE DOMAIN {schema}.U256 AS NUMERIC(78, 0)"),
1810            format!("CREATE DOMAIN {schema}.U160 AS NUMERIC(49, 0)"),
1811            format!("CREATE DOMAIN {schema}.U128 AS NUMERIC(39, 0)"),
1812            format!(
1813                r#"
1814                CREATE TABLE {schema}."chain" (
1815                    chain_id INTEGER PRIMARY KEY,
1816                    name TEXT NOT NULL
1817                )
1818                "#
1819            ),
1820            format!(
1821                r#"
1822                CREATE TABLE {schema}."block" (
1823                    chain_id INTEGER NOT NULL,
1824                    number BIGINT NOT NULL,
1825                    hash TEXT,
1826                    parent_hash TEXT,
1827                    miner TEXT,
1828                    gas_limit BIGINT,
1829                    gas_used BIGINT,
1830                    timestamp TEXT,
1831                    base_fee_per_gas TEXT,
1832                    blob_gas_used TEXT,
1833                    excess_blob_gas TEXT,
1834                    l1_gas_price TEXT,
1835                    l1_gas_used BIGINT,
1836                    l1_fee_scalar BIGINT,
1837                    PRIMARY KEY (chain_id, number)
1838                )
1839                "#
1840            ),
1841            format!(
1842                r#"
1843                CREATE TABLE {schema}."pool_event_block" (
1844                    chain_id INTEGER NOT NULL,
1845                    number BIGINT NOT NULL,
1846                    timestamp TEXT NOT NULL,
1847                    PRIMARY KEY (chain_id, number)
1848                )
1849                "#
1850            ),
1851            format!(
1852                r#"
1853                CREATE TABLE {schema}."token" (
1854                    chain_id INTEGER NOT NULL,
1855                    address TEXT NOT NULL,
1856                    symbol TEXT,
1857                    name TEXT,
1858                    decimals INTEGER,
1859                    error TEXT,
1860                    PRIMARY KEY (chain_id, address)
1861                )
1862                "#
1863            ),
1864            format!(
1865                r#"
1866                CREATE TABLE {schema}."dex" (
1867                    chain_id INTEGER NOT NULL,
1868                    name TEXT NOT NULL,
1869                    factory_address TEXT NOT NULL,
1870                    creation_block BIGINT NOT NULL,
1871                    last_full_sync_pools_block_number BIGINT,
1872                    PRIMARY KEY (chain_id, name),
1873                    UNIQUE (chain_id, factory_address)
1874                )
1875                "#
1876            ),
1877            format!(
1878                r#"
1879                CREATE TABLE {schema}."pool" (
1880                    chain_id INTEGER NOT NULL,
1881                    dex_name TEXT NOT NULL,
1882                    address TEXT NOT NULL,
1883                    pool_identifier TEXT NOT NULL,
1884                    creation_block BIGINT NOT NULL,
1885                    token0_chain INTEGER NOT NULL,
1886                    token0_address TEXT NOT NULL,
1887                    token1_chain INTEGER NOT NULL,
1888                    token1_address TEXT NOT NULL,
1889                    fee INTEGER,
1890                    tick_spacing INTEGER,
1891                    initial_tick INTEGER,
1892                    initial_sqrt_price_x96 TEXT,
1893                    hook_address TEXT,
1894                    last_full_sync_block_number BIGINT,
1895                    PRIMARY KEY (chain_id, dex_name, pool_identifier)
1896                )
1897                "#
1898            ),
1899            format!(
1900                r#"
1901                CREATE TABLE {schema}."pool_swap_event" (
1902                    chain_id INTEGER NOT NULL,
1903                    pool_identifier TEXT NOT NULL,
1904                    dex_name TEXT NOT NULL,
1905                    block BIGINT NOT NULL,
1906                    transaction_hash TEXT NOT NULL,
1907                    transaction_index INTEGER NOT NULL,
1908                    log_index INTEGER NOT NULL,
1909                    sender TEXT NOT NULL,
1910                    recipient TEXT NOT NULL,
1911                    sqrt_price_x96 TEXT NOT NULL,
1912                    liquidity TEXT NOT NULL,
1913                    tick INTEGER NOT NULL,
1914                    amount0 TEXT NOT NULL,
1915                    amount1 TEXT NOT NULL,
1916                    order_side TEXT,
1917                    base_quantity NUMERIC,
1918                    quote_quantity NUMERIC,
1919                    spot_price NUMERIC,
1920                    execution_price NUMERIC,
1921                    UNIQUE(chain_id, transaction_hash, log_index)
1922                )
1923                "#
1924            ),
1925            format!(
1926                r#"
1927                CREATE TABLE {schema}."pool_liquidity_event" (
1928                    chain_id INTEGER NOT NULL,
1929                    pool_identifier TEXT NOT NULL,
1930                    dex_name TEXT NOT NULL,
1931                    block BIGINT NOT NULL,
1932                    transaction_hash TEXT NOT NULL,
1933                    transaction_index INTEGER NOT NULL,
1934                    log_index INTEGER NOT NULL,
1935                    event_type TEXT NOT NULL,
1936                    sender TEXT,
1937                    owner TEXT NOT NULL,
1938                    position_liquidity TEXT NOT NULL,
1939                    amount0 TEXT NOT NULL,
1940                    amount1 TEXT NOT NULL,
1941                    tick_lower INTEGER NOT NULL,
1942                    tick_upper INTEGER NOT NULL,
1943                    UNIQUE(chain_id, transaction_hash, log_index)
1944                )
1945                "#
1946            ),
1947            format!(
1948                r#"
1949                CREATE TABLE {schema}."pool_collect_event" (
1950                    chain_id INTEGER NOT NULL,
1951                    pool_identifier TEXT NOT NULL,
1952                    dex_name TEXT NOT NULL,
1953                    block BIGINT NOT NULL,
1954                    transaction_hash TEXT NOT NULL,
1955                    transaction_index INTEGER NOT NULL,
1956                    log_index INTEGER NOT NULL,
1957                    owner TEXT NOT NULL,
1958                    amount0 TEXT NOT NULL,
1959                    amount1 TEXT NOT NULL,
1960                    tick_lower INTEGER NOT NULL,
1961                    tick_upper INTEGER NOT NULL,
1962                    UNIQUE(chain_id, transaction_hash, log_index)
1963                )
1964                "#
1965            ),
1966            format!(
1967                r#"
1968                CREATE TABLE {schema}."pool_flash_event" (
1969                    chain_id INTEGER NOT NULL,
1970                    pool_identifier TEXT NOT NULL,
1971                    dex_name TEXT NOT NULL,
1972                    block BIGINT NOT NULL,
1973                    transaction_hash TEXT NOT NULL,
1974                    transaction_index INTEGER NOT NULL,
1975                    log_index INTEGER NOT NULL,
1976                    sender TEXT NOT NULL,
1977                    recipient TEXT NOT NULL,
1978                    amount0 TEXT NOT NULL,
1979                    amount1 TEXT NOT NULL,
1980                    paid0 TEXT NOT NULL,
1981                    paid1 TEXT NOT NULL,
1982                    UNIQUE(chain_id, transaction_hash, log_index)
1983                )
1984                "#
1985            ),
1986            format!(
1987                r#"
1988                CREATE TABLE {schema}."pool_fee_protocol_update_event" (
1989                    chain_id INTEGER NOT NULL,
1990                    pool_identifier TEXT NOT NULL,
1991                    dex_name TEXT NOT NULL,
1992                    block BIGINT NOT NULL,
1993                    transaction_hash TEXT NOT NULL,
1994                    transaction_index INTEGER NOT NULL,
1995                    log_index INTEGER NOT NULL,
1996                    fee_protocol0_new SMALLINT NOT NULL,
1997                    fee_protocol1_new SMALLINT NOT NULL,
1998                    UNIQUE(chain_id, transaction_hash, log_index)
1999                )
2000                "#
2001            ),
2002            format!(
2003                r#"
2004                CREATE TABLE {schema}."pool_fee_protocol_collect_event" (
2005                    chain_id INTEGER NOT NULL,
2006                    pool_identifier TEXT NOT NULL,
2007                    dex_name TEXT NOT NULL,
2008                    block BIGINT NOT NULL,
2009                    transaction_hash TEXT NOT NULL,
2010                    transaction_index INTEGER NOT NULL,
2011                    log_index INTEGER NOT NULL,
2012                    sender TEXT NOT NULL,
2013                    recipient TEXT NOT NULL,
2014                    amount0 TEXT NOT NULL,
2015                    amount1 TEXT NOT NULL,
2016                    UNIQUE(chain_id, transaction_hash, log_index)
2017                )
2018                "#
2019            ),
2020            format!(
2021                r#"
2022                CREATE TABLE {schema}."pool_snapshot" (
2023                    chain_id INTEGER NOT NULL,
2024                    pool_identifier TEXT NOT NULL,
2025                    dex_name TEXT NOT NULL,
2026                    block BIGINT NOT NULL,
2027                    transaction_index INTEGER NOT NULL,
2028                    log_index INTEGER NOT NULL,
2029                    transaction_hash TEXT NOT NULL,
2030                    current_tick INTEGER NOT NULL,
2031                    price_sqrt_ratio_x96 NUMERIC NOT NULL,
2032                    liquidity NUMERIC NOT NULL,
2033                    protocol_fees_token0 NUMERIC NOT NULL,
2034                    protocol_fees_token1 NUMERIC NOT NULL,
2035                    fee_protocol SMALLINT NOT NULL,
2036                    fee_growth_global_0 NUMERIC NOT NULL,
2037                    fee_growth_global_1 NUMERIC NOT NULL,
2038                    total_amount0_deposited NUMERIC NOT NULL,
2039                    total_amount1_deposited NUMERIC NOT NULL,
2040                    total_amount0_collected NUMERIC NOT NULL,
2041                    total_amount1_collected NUMERIC NOT NULL,
2042                    total_swaps INTEGER NOT NULL,
2043                    total_mints INTEGER NOT NULL,
2044                    total_burns INTEGER NOT NULL,
2045                    total_fee_collects INTEGER NOT NULL,
2046                    total_flashes INTEGER NOT NULL,
2047                    liquidity_utilization_rate DOUBLE PRECISION,
2048                    validation_state TEXT NOT NULL DEFAULT 'replay' CHECK (validation_state IN ('on_chain', 'replay', 'invalid')),
2049                    PRIMARY KEY (chain_id, pool_identifier, block, transaction_index, log_index)
2050                )
2051                "#
2052            ),
2053            format!(
2054                r#"
2055                CREATE TABLE {schema}."pool_position" (
2056                    chain_id INTEGER NOT NULL,
2057                    pool_identifier TEXT NOT NULL,
2058                    snapshot_block BIGINT NOT NULL,
2059                    snapshot_transaction_index INTEGER NOT NULL,
2060                    snapshot_log_index INTEGER NOT NULL,
2061                    owner TEXT NOT NULL,
2062                    tick_lower INTEGER NOT NULL,
2063                    tick_upper INTEGER NOT NULL,
2064                    liquidity NUMERIC NOT NULL,
2065                    fee_growth_inside_0_last NUMERIC NOT NULL,
2066                    fee_growth_inside_1_last NUMERIC NOT NULL,
2067                    tokens_owed_0 NUMERIC NOT NULL,
2068                    tokens_owed_1 NUMERIC NOT NULL,
2069                    total_amount0_deposited NUMERIC,
2070                    total_amount1_deposited NUMERIC,
2071                    total_amount0_collected NUMERIC,
2072                    total_amount1_collected NUMERIC,
2073                    PRIMARY KEY (chain_id, pool_identifier, snapshot_block, snapshot_transaction_index, snapshot_log_index, owner, tick_lower, tick_upper)
2074                )
2075                "#
2076            ),
2077            format!(
2078                r#"
2079                CREATE TABLE {schema}."pool_tick" (
2080                    chain_id INTEGER NOT NULL,
2081                    pool_identifier TEXT NOT NULL,
2082                    snapshot_block BIGINT NOT NULL,
2083                    snapshot_transaction_index INTEGER NOT NULL,
2084                    snapshot_log_index INTEGER NOT NULL,
2085                    tick_value INTEGER NOT NULL,
2086                    liquidity_gross NUMERIC NOT NULL,
2087                    liquidity_net NUMERIC NOT NULL,
2088                    fee_growth_outside_0 NUMERIC NOT NULL,
2089                    fee_growth_outside_1 NUMERIC NOT NULL,
2090                    initialized BOOLEAN NOT NULL,
2091                    last_updated_block BIGINT NOT NULL,
2092                    PRIMARY KEY (chain_id, pool_identifier, snapshot_block, snapshot_transaction_index, snapshot_log_index, tick_value)
2093                )
2094                "#
2095            ),
2096        ];
2097
2098        for statement in statements {
2099            execute_schema_statement(pool, statement).await?;
2100        }
2101
2102        Ok(())
2103    }
2104
2105    async fn insert_pool_swap_event(
2106        pool: &PgPool,
2107        schema: &str,
2108        chain_id: u32,
2109        pool_identifier: &PoolIdentifier,
2110        block: u64,
2111    ) -> anyhow::Result<()> {
2112        sqlx::query(AssertSqlSafe(format!(
2113            r#"
2114            INSERT INTO {schema}."pool_swap_event" (
2115                chain_id, pool_identifier, dex_name, block, transaction_hash, transaction_index,
2116                log_index, sender, recipient, sqrt_price_x96, liquidity, tick, amount0, amount1
2117            )
2118            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
2119            "#
2120        )))
2121        .bind(chain_id as i32)
2122        .bind(pool_identifier.to_string())
2123        .bind(DexType::UniswapV3.to_string())
2124        .bind(block as i64)
2125        .bind("0x000000000000000000000000000000000000000000000000000000000000000c")
2126        .bind(0_i32)
2127        .bind(0_i32)
2128        .bind("0x1111111111111111111111111111111111111111")
2129        .bind("0x2222222222222222222222222222222222222222")
2130        .bind("79228162514264337593543950336")
2131        .bind("1000000")
2132        .bind(0_i32)
2133        .bind("-1000000000000000000")
2134        .bind("2000000")
2135        .execute(pool)
2136        .await?;
2137        Ok(())
2138    }
2139
2140    async fn insert_block_without_timestamp(
2141        pool: &PgPool,
2142        schema: &str,
2143        chain_id: u32,
2144        number: u64,
2145    ) -> anyhow::Result<()> {
2146        sqlx::query(AssertSqlSafe(format!(
2147            r#"
2148            INSERT INTO {schema}."block" (
2149                chain_id, number, hash, parent_hash, miner, gas_limit, gas_used, timestamp
2150            )
2151            VALUES ($1, $2, $3, $4, $5, $6, $7, NULL)
2152            "#
2153        )))
2154        .bind(chain_id as i32)
2155        .bind(number as i64)
2156        .bind(format!("0x{number:064x}"))
2157        .bind("0x0")
2158        .bind("0x0000000000000000000000000000000000000000")
2159        .bind(30_000_000_i64)
2160        .bind(21_000_i64)
2161        .execute(pool)
2162        .await?;
2163        Ok(())
2164    }
2165
2166    async fn drop_cache_test_schema(pool: &PgPool, schema: &str) -> anyhow::Result<()> {
2167        execute_schema_statement(pool, format!("DROP SCHEMA IF EXISTS {schema} CASCADE")).await
2168    }
2169
2170    async fn execute_schema_statement(pool: &PgPool, statement: String) -> anyhow::Result<()> {
2171        sqlx::query(AssertSqlSafe(statement)).execute(pool).await?;
2172        Ok(())
2173    }
2174
2175    fn cache_test_schema_name() -> String {
2176        let nanos = SystemTime::now()
2177            .duration_since(UNIX_EPOCH)
2178            .expect("system clock must be after UNIX epoch")
2179            .as_nanos();
2180
2181        format!("nt_blockchain_cache_test_{}_{}", std::process::id(), nanos)
2182    }
2183
2184    fn arbitrum() -> SharedChain {
2185        let Some(chain) = Chain::from_chain_id(42161) else {
2186            panic!("Arbitrum chain must exist in model definitions");
2187        };
2188
2189        Arc::new(chain.clone())
2190    }
2191
2192    fn uniswap_v3(chain: &SharedChain) -> SharedDex {
2193        Arc::new(Dex::new(
2194            (**chain).clone(),
2195            DexType::UniswapV3,
2196            "0x1F98431c8aD98523631AE4a59f267346ea31F984",
2197            0,
2198            AmmType::CLAMM,
2199            "PoolCreated",
2200            "Swap",
2201            "Mint",
2202            "Burn",
2203            "Collect",
2204        ))
2205    }
2206
2207    fn weth(chain: &SharedChain) -> Token {
2208        Token::new(
2209            chain.clone(),
2210            address!("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"),
2211            "Wrapped Ether".to_string(),
2212            "WETH".to_string(),
2213            18,
2214        )
2215    }
2216
2217    fn usdc(chain: &SharedChain) -> Token {
2218        Token::new(
2219            chain.clone(),
2220            address!("0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8"),
2221            "USD Coin".to_string(),
2222            "USDC".to_string(),
2223            6,
2224        )
2225    }
2226
2227    fn test_block(number: u64, timestamp: UnixNanos) -> Block {
2228        Block::new(
2229            format!("0x{number:064x}"),
2230            String::from("0x0"),
2231            number,
2232            Ustr::from("0x0000000000000000000000000000000000000000"),
2233            30_000_000,
2234            21_000,
2235            timestamp,
2236            Some(Blockchain::Arbitrum),
2237        )
2238    }
2239}