Skip to main content

nautilus_blockchain/cache/
database.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::pin::Pin;
17
18use alloy::primitives::{Address, U256};
19use futures_util::{Stream, StreamExt};
20use nautilus_model::{
21    defi::{
22        Block, Chain, DexType, Pool, PoolIdentifier, PoolLiquidityUpdate, PoolSwap, SharedChain,
23        SharedDex, Token,
24        data::{
25            DexPoolData, PoolFeeCollect, PoolFeeProtocolCollect, PoolFeeProtocolUpdate, PoolFlash,
26            block::BlockPosition,
27        },
28        pool_analysis::{
29            position::PoolPosition,
30            snapshot::{PoolAnalytics, PoolSnapshot, PoolState},
31        },
32        tick_map::tick::PoolTick,
33        validation::validate_address,
34    },
35    identifiers::InstrumentId,
36};
37use rust_decimal::Decimal;
38use sqlx::{AssertSqlSafe, PgPool, Row, postgres::PgConnectOptions};
39
40use crate::{
41    cache::{
42        consistency::CachedBlocksConsistencyStatus,
43        copy::PostgresCopyHandler,
44        rows::{
45            BlockTimestampRow, PoolRow, TokenRow, parse_cached_block_timestamp,
46            transform_row_to_dex_pool_data,
47        },
48        types::{U128Pg, U256Pg},
49    },
50    events::initialize::InitializeEvent,
51};
52
53/// Database interface for persisting and retrieving blockchain entities and domain objects.
54#[derive(Debug)]
55pub struct BlockchainCacheDatabase {
56    /// PostgreSQL connection pool used for database operations.
57    pool: PgPool,
58}
59
60// Shared SELECT column list for `pool` row queries (load_pools, load_pool).
61const POOL_ROW_COLUMNS: &str = "
62    address,
63    pool_identifier,
64    dex_name,
65    creation_block,
66    COALESCE(
67        (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool.chain_id AND block.number = pool.creation_block),
68        (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool.chain_id AND pool_event_block.number = pool.creation_block)
69    ) as creation_block_timestamp,
70    token0_chain,
71    token0_address,
72    token1_chain,
73    token1_address,
74    fee,
75    tick_spacing,
76    initial_tick,
77    initial_sqrt_price_x96,
78    hook_address
79";
80
81impl BlockchainCacheDatabase {
82    /// Initializes a new database instance by establishing a connection to PostgreSQL.
83    ///
84    /// # Panics
85    ///
86    /// Panics if unable to connect to PostgreSQL with the provided options.
87    pub async fn init(pg_options: PgConnectOptions) -> Self {
88        Self::connect(pg_options)
89            .await
90            .expect("Error connecting to Postgres")
91    }
92
93    /// Establishes a connection to PostgreSQL and returns a new database instance.
94    ///
95    /// # Errors
96    ///
97    /// Returns an error if a connection cannot be established with the provided options.
98    pub async fn connect(pg_options: PgConnectOptions) -> anyhow::Result<Self> {
99        let pool = sqlx::postgres::PgPoolOptions::new()
100            .max_connections(32) // Increased from default 10
101            .min_connections(5) // Keep some connections warm
102            .acquire_timeout(std::time::Duration::from_secs(3))
103            .connect_with(pg_options)
104            .await?;
105        Ok(Self { pool })
106    }
107
108    /// Seeds the database with a blockchain chain record.
109    ///
110    /// # Errors
111    ///
112    /// Returns an error if the database operation fails.
113    pub async fn seed_chain(&self, chain: &Chain) -> anyhow::Result<()> {
114        sqlx::query(
115            "
116            INSERT INTO chain (
117                chain_id, name
118            ) VALUES ($1,$2)
119            ON CONFLICT (chain_id)
120            DO NOTHING
121        ",
122        )
123        .bind(chain.chain_id as i32)
124        .bind(chain.name.to_string())
125        .execute(&self.pool)
126        .await
127        .map(|_| ())
128        .map_err(|e| anyhow::anyhow!("Failed to seed chain table: {e}"))
129    }
130
131    /// Creates a table partition for the block table specific to the given chain
132    /// by calling the existing PostgreSQL function `create_block_partition`.
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if the database operation fails.
137    pub async fn create_block_partition(&self, chain: &Chain) -> anyhow::Result<String> {
138        let result: (String,) = sqlx::query_as("SELECT create_block_partition($1)")
139            .bind(chain.chain_id as i32)
140            .fetch_one(&self.pool)
141            .await
142            .map_err(|e| {
143                anyhow::anyhow!(
144                    "Failed to call create_block_partition for chain {}: {e}",
145                    chain.chain_id
146                )
147            })?;
148
149        Ok(result.0)
150    }
151
152    /// Creates a table partition for the token table specific to the given chain
153    /// by calling the existing PostgreSQL function `create_token_partition`.
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if the database operation fails.
158    pub async fn create_token_partition(&self, chain: &Chain) -> anyhow::Result<String> {
159        let result: (String,) = sqlx::query_as("SELECT create_token_partition($1)")
160            .bind(chain.chain_id as i32)
161            .fetch_one(&self.pool)
162            .await
163            .map_err(|e| {
164                anyhow::anyhow!(
165                    "Failed to call create_token_partition for chain {}: {e}",
166                    chain.chain_id
167                )
168            })?;
169
170        Ok(result.0)
171    }
172
173    /// Returns the highest block number that maintains data continuity in the database.
174    ///
175    /// # Errors
176    ///
177    /// Returns an error if the database query fails.
178    pub async fn get_block_consistency_status(
179        &self,
180        chain: &Chain,
181    ) -> anyhow::Result<CachedBlocksConsistencyStatus> {
182        log::debug!("Fetching block consistency status");
183
184        let result: (i64, i64) = sqlx::query_as(
185            "
186            SELECT
187                COALESCE((SELECT number FROM block WHERE chain_id = $1 ORDER BY number DESC LIMIT 1), 0) as max_block,
188                get_last_continuous_block($1) as last_continuous_block
189            "
190        )
191        .bind(chain.chain_id as i32)
192        .fetch_one(&self.pool)
193        .await
194        .map_err(|e| {
195            anyhow::anyhow!(
196                "Failed to get block info for chain {}: {}",
197                chain.chain_id,
198                e
199            )
200        })?;
201
202        Ok(CachedBlocksConsistencyStatus::new(
203            result.0 as u64,
204            result.1 as u64,
205        ))
206    }
207
208    /// Inserts or updates a block record in the database.
209    ///
210    /// # Errors
211    ///
212    /// Returns an error if the database operation fails.
213    pub async fn add_block(&self, chain_id: u32, block: &Block) -> anyhow::Result<()> {
214        sqlx::query(
215            "
216            INSERT INTO block (
217                chain_id, number, hash, parent_hash, miner, gas_limit, gas_used, timestamp,
218                base_fee_per_gas, blob_gas_used, excess_blob_gas,
219                l1_gas_price, l1_gas_used, l1_fee_scalar
220            ) VALUES (
221                $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
222            )
223            ON CONFLICT (chain_id, number)
224            DO UPDATE
225            SET
226                hash = $3,
227                parent_hash = $4,
228                miner = $5,
229                gas_limit = $6,
230                gas_used = $7,
231                timestamp = $8,
232                base_fee_per_gas = $9,
233                blob_gas_used = $10,
234                excess_blob_gas = $11,
235                l1_gas_price = $12,
236                l1_gas_used = $13,
237                l1_fee_scalar = $14
238        ",
239        )
240        .bind(chain_id as i32)
241        .bind(block.number as i64)
242        .bind(block.hash.as_str())
243        .bind(block.parent_hash.as_str())
244        .bind(block.miner.as_str())
245        .bind(block.gas_limit as i64)
246        .bind(block.gas_used as i64)
247        .bind(block.timestamp.to_string())
248        .bind(block.base_fee_per_gas.as_ref().map(U256::to_string))
249        .bind(block.blob_gas_used.as_ref().map(U256::to_string))
250        .bind(block.excess_blob_gas.as_ref().map(U256::to_string))
251        .bind(block.l1_gas_price.as_ref().map(U256::to_string))
252        .bind(block.l1_gas_used.map(|v| v as i64))
253        .bind(block.l1_fee_scalar.map(|v| v as i64))
254        .execute(&self.pool)
255        .await
256        .map(|_| ())
257        .map_err(|e| anyhow::anyhow!("Failed to insert into block table: {e}"))
258    }
259
260    /// Inserts multiple blocks in a single database operation using UNNEST for optimal performance.
261    ///
262    /// # Errors
263    ///
264    /// Returns an error if the database operation fails.
265    pub async fn add_blocks_batch(&self, chain_id: u32, blocks: &[Block]) -> anyhow::Result<()> {
266        if blocks.is_empty() {
267            return Ok(());
268        }
269
270        // Prepare vectors for each column
271        let mut numbers: Vec<i64> = Vec::with_capacity(blocks.len());
272        let mut hashes: Vec<String> = Vec::with_capacity(blocks.len());
273        let mut parent_hashes: Vec<String> = Vec::with_capacity(blocks.len());
274        let mut miners: Vec<String> = Vec::with_capacity(blocks.len());
275        let mut gas_limits: Vec<i64> = Vec::with_capacity(blocks.len());
276        let mut gas_useds: Vec<i64> = Vec::with_capacity(blocks.len());
277        let mut timestamps: Vec<String> = Vec::with_capacity(blocks.len());
278        let mut base_fee_per_gases: Vec<Option<String>> = Vec::with_capacity(blocks.len());
279        let mut blob_gas_useds: Vec<Option<String>> = Vec::with_capacity(blocks.len());
280        let mut excess_blob_gases: Vec<Option<String>> = Vec::with_capacity(blocks.len());
281        let mut l1_gas_prices: Vec<Option<String>> = Vec::with_capacity(blocks.len());
282        let mut l1_gas_useds: Vec<Option<i64>> = Vec::with_capacity(blocks.len());
283        let mut l1_fee_scalars: Vec<Option<i64>> = Vec::with_capacity(blocks.len());
284
285        // Fill vectors from blocks
286        for block in blocks {
287            numbers.push(block.number as i64);
288            hashes.push(block.hash.clone());
289            parent_hashes.push(block.parent_hash.clone());
290            miners.push(block.miner.to_string());
291            gas_limits.push(block.gas_limit as i64);
292            gas_useds.push(block.gas_used as i64);
293            timestamps.push(block.timestamp.to_string());
294            base_fee_per_gases.push(block.base_fee_per_gas.as_ref().map(U256::to_string));
295            blob_gas_useds.push(block.blob_gas_used.as_ref().map(U256::to_string));
296            excess_blob_gases.push(block.excess_blob_gas.as_ref().map(U256::to_string));
297            l1_gas_prices.push(block.l1_gas_price.as_ref().map(U256::to_string));
298            l1_gas_useds.push(block.l1_gas_used.map(|v| v as i64));
299            l1_fee_scalars.push(block.l1_fee_scalar.map(|v| v as i64));
300        }
301
302        // Execute batch insert with UNNEST
303        sqlx::query(
304            "
305            INSERT INTO block (
306                chain_id, number, hash, parent_hash, miner, gas_limit, gas_used, timestamp,
307                base_fee_per_gas, blob_gas_used, excess_blob_gas,
308                l1_gas_price, l1_gas_used, l1_fee_scalar
309            )
310            SELECT
311                $1, *
312            FROM UNNEST(
313                $2::int8[], $3::text[], $4::text[], $5::text[],
314                $6::int8[], $7::int8[], $8::text[],
315                $9::text[], $10::text[], $11::text[],
316                $12::text[], $13::int8[], $14::int8[]
317            )
318            ON CONFLICT (chain_id, number) DO NOTHING
319           ",
320        )
321        .bind(chain_id as i32)
322        .bind(&numbers[..])
323        .bind(&hashes[..])
324        .bind(&parent_hashes[..])
325        .bind(&miners[..])
326        .bind(&gas_limits[..])
327        .bind(&gas_useds[..])
328        .bind(&timestamps[..])
329        .bind(&base_fee_per_gases as &[Option<String>])
330        .bind(&blob_gas_useds as &[Option<String>])
331        .bind(&excess_blob_gases as &[Option<String>])
332        .bind(&l1_gas_prices as &[Option<String>])
333        .bind(&l1_gas_useds as &[Option<i64>])
334        .bind(&l1_fee_scalars as &[Option<i64>])
335        .execute(&self.pool)
336        .await
337        .map(|_| ())
338        .map_err(|e| anyhow::anyhow!("Failed to batch insert into block table: {e}"))
339    }
340
341    /// Inserts block timestamps observed while streaming pool events.
342    ///
343    /// # Errors
344    ///
345    /// Returns an error if the database operation fails.
346    pub async fn add_pool_event_blocks_batch(
347        &self,
348        chain_id: u32,
349        blocks: &[Block],
350    ) -> anyhow::Result<()> {
351        if blocks.is_empty() {
352            return Ok(());
353        }
354
355        let mut numbers: Vec<i64> = Vec::with_capacity(blocks.len());
356        let mut timestamps: Vec<String> = Vec::with_capacity(blocks.len());
357
358        for block in blocks {
359            numbers.push(block.number as i64);
360            timestamps.push(block.timestamp.to_string());
361        }
362
363        sqlx::query(
364            "
365            INSERT INTO pool_event_block (
366                chain_id, number, timestamp
367            )
368            SELECT
369                $1, *
370            FROM UNNEST(
371                $2::int8[], $3::text[]
372            )
373            ON CONFLICT (chain_id, number)
374            DO UPDATE SET timestamp = EXCLUDED.timestamp
375           ",
376        )
377        .bind(chain_id as i32)
378        .bind(&numbers[..])
379        .bind(&timestamps[..])
380        .execute(&self.pool)
381        .await
382        .map(|_| ())
383        .map_err(|e| anyhow::anyhow!("Failed to batch insert into pool_event_block table: {e}"))
384    }
385
386    /// Inserts blocks using PostgreSQL COPY BINARY for maximum performance.
387    ///
388    /// This method is significantly faster than INSERT for bulk operations as it bypasses
389    /// SQL parsing and uses PostgreSQL's native binary protocol.
390    ///
391    /// # Errors
392    ///
393    /// Returns an error if the COPY operation fails.
394    pub async fn add_blocks_copy(&self, chain_id: u32, blocks: &[Block]) -> anyhow::Result<()> {
395        let copy_handler = PostgresCopyHandler::new(&self.pool);
396        copy_handler.copy_blocks(chain_id, blocks).await
397    }
398
399    /// Inserts tokens using PostgreSQL COPY BINARY for maximum performance.
400    ///
401    /// # Errors
402    ///
403    /// Returns an error if the COPY operation fails.
404    pub async fn add_tokens_copy(&self, chain_id: u32, tokens: &[Token]) -> anyhow::Result<()> {
405        let copy_handler = PostgresCopyHandler::new(&self.pool);
406        copy_handler.copy_tokens(chain_id, tokens).await
407    }
408
409    /// Inserts pools using PostgreSQL COPY BINARY for maximum performance.
410    ///
411    /// # Errors
412    ///
413    /// Returns an error if the COPY operation fails.
414    pub async fn add_pools_copy(&self, chain_id: u32, pools: &[Pool]) -> anyhow::Result<()> {
415        let copy_handler = PostgresCopyHandler::new(&self.pool);
416        copy_handler.copy_pools(chain_id, pools).await
417    }
418
419    /// Inserts pool swaps using PostgreSQL COPY BINARY for maximum performance.
420    ///
421    /// This method is significantly faster than INSERT for bulk operations as it bypasses
422    /// SQL parsing and uses PostgreSQL's native binary protocol.
423    ///
424    /// # Errors
425    ///
426    /// Returns an error if the COPY operation fails.
427    pub async fn add_pool_swaps_copy(
428        &self,
429        chain_id: u32,
430        swaps: &[PoolSwap],
431    ) -> anyhow::Result<()> {
432        let copy_handler = PostgresCopyHandler::new(&self.pool);
433        copy_handler.copy_pool_swaps(chain_id, swaps).await
434    }
435
436    /// Inserts pool liquidity updates using PostgreSQL COPY BINARY for maximum performance.
437    ///
438    /// This method is significantly faster than INSERT for bulk operations as it bypasses
439    /// SQL parsing and uses PostgreSQL's native binary protocol.
440    ///
441    /// # Errors
442    ///
443    /// Returns an error if the COPY operation fails.
444    pub async fn add_pool_liquidity_updates_copy(
445        &self,
446        chain_id: u32,
447        updates: &[PoolLiquidityUpdate],
448    ) -> anyhow::Result<()> {
449        let copy_handler = PostgresCopyHandler::new(&self.pool);
450        copy_handler
451            .copy_pool_liquidity_updates(chain_id, updates)
452            .await
453    }
454
455    /// Inserts pool fee collect events using PostgreSQL COPY BINARY for maximum performance.
456    ///
457    /// This method is significantly faster than INSERT for bulk operations as it bypasses
458    /// SQL parsing and most database validation checks.
459    ///
460    /// # Errors
461    ///
462    /// Returns an error if the COPY operation fails.
463    pub async fn copy_pool_fee_collects_batch(
464        &self,
465        chain_id: u32,
466        collects: &[PoolFeeCollect],
467    ) -> anyhow::Result<()> {
468        let copy_handler = PostgresCopyHandler::new(&self.pool);
469        copy_handler.copy_pool_collects(chain_id, collects).await
470    }
471
472    /// Retrieves block timestamps for a given chain starting from a specific block number.
473    ///
474    /// # Errors
475    ///
476    /// Returns an error if the database query fails.
477    pub async fn load_block_timestamps(
478        &self,
479        chain: SharedChain,
480        from_block: u64,
481    ) -> anyhow::Result<Vec<BlockTimestampRow>> {
482        sqlx::query_as::<_, BlockTimestampRow>(
483            "
484            SELECT DISTINCT ON (number)
485                number,
486                timestamp
487            FROM (
488                SELECT number, timestamp, 0 AS source_order
489                FROM block
490                WHERE chain_id = $1 AND number >= $2 AND timestamp IS NOT NULL
491                UNION ALL
492                SELECT number, timestamp, 1 AS source_order
493                FROM pool_event_block
494                WHERE chain_id = $1 AND number >= $2 AND timestamp IS NOT NULL
495            ) AS block_timestamps
496            ORDER BY number ASC, source_order ASC
497            ",
498        )
499        .bind(chain.chain_id as i32)
500        .bind(from_block as i64)
501        .fetch_all(&self.pool)
502        .await
503        .map_err(|e| anyhow::anyhow!("Failed to load block timestamps: {e}"))
504    }
505
506    /// Adds or updates a DEX (Decentralized Exchange) record in the database.
507    ///
508    /// # Errors
509    ///
510    /// Returns an error if the database operation fails.
511    pub async fn add_dex(&self, dex: SharedDex) -> anyhow::Result<()> {
512        sqlx::query(
513            "
514            INSERT INTO dex (
515                chain_id, name, factory_address, creation_block
516            ) VALUES ($1, $2, $3, $4)
517            ON CONFLICT (chain_id, name)
518            DO UPDATE
519            SET
520                factory_address = $3,
521                creation_block = $4
522        ",
523        )
524        .bind(dex.chain.chain_id as i32)
525        .bind(dex.name.to_string())
526        .bind(dex.factory.to_string())
527        .bind(dex.factory_creation_block as i64)
528        .execute(&self.pool)
529        .await
530        .map(|_| ())
531        .map_err(|e| anyhow::anyhow!("Failed to insert into dex table: {e}"))
532    }
533
534    /// Adds or updates a liquidity pool/pair record in the database.
535    ///
536    /// # Errors
537    ///
538    /// Returns an error if the database operation fails.
539    pub async fn add_pool(&self, pool: &Pool) -> anyhow::Result<()> {
540        sqlx::query(
541            "
542            INSERT INTO pool (
543                chain_id, address, pool_identifier, dex_name, creation_block,
544                token0_chain, token0_address,
545                token1_chain, token1_address,
546                fee, tick_spacing, initial_tick, initial_sqrt_price_x96, hook_address
547            ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
548            ON CONFLICT (chain_id, dex_name, pool_identifier)
549            DO UPDATE
550            SET
551                address = $2,
552                creation_block = $5,
553                token0_chain = $6,
554                token0_address = $7,
555                token1_chain = $8,
556                token1_address = $9,
557                fee = $10,
558                tick_spacing = $11,
559                initial_tick = $12,
560                initial_sqrt_price_x96 = $13,
561                hook_address = $14
562        ",
563        )
564        .bind(pool.chain.chain_id as i32)
565        .bind(pool.address.to_string())
566        .bind(pool.pool_identifier.as_ref())
567        .bind(pool.dex.name.to_string())
568        .bind(pool.creation_block as i64)
569        .bind(pool.token0.chain.chain_id as i32)
570        .bind(pool.token0.address.to_string())
571        .bind(pool.token1.chain.chain_id as i32)
572        .bind(pool.token1.address.to_string())
573        .bind(pool.fee.map(|fee| fee as i32))
574        .bind(pool.tick_spacing.map(|tick_spacing| tick_spacing as i32))
575        .bind(pool.initial_tick)
576        .bind(pool.initial_sqrt_price_x96.as_ref().map(|p| p.to_string()))
577        .bind(pool.hooks.as_ref().map(|h| h.to_string()))
578        .execute(&self.pool)
579        .await
580        .map(|_| ())
581        .map_err(|e| anyhow::anyhow!("Failed to insert into pool table: {e}"))
582    }
583
584    /// Inserts multiple pools in a single database operation using UNNEST for optimal performance.
585    ///
586    /// # Errors
587    ///
588    /// Returns an error if the database operation fails.
589    pub async fn add_pools_batch(&self, pools: &[Pool]) -> anyhow::Result<()> {
590        if pools.is_empty() {
591            return Ok(());
592        }
593
594        // Prepare vectors for each column
595        let len = pools.len();
596        let mut addresses: Vec<String> = Vec::with_capacity(len);
597        let mut pool_identifiers: Vec<String> = Vec::with_capacity(len);
598        let mut dex_names: Vec<String> = Vec::with_capacity(len);
599        let mut creation_blocks: Vec<i64> = Vec::with_capacity(len);
600        let mut token0_chains: Vec<i32> = Vec::with_capacity(len);
601        let mut token0_addresses: Vec<String> = Vec::with_capacity(len);
602        let mut token1_chains: Vec<i32> = Vec::with_capacity(len);
603        let mut token1_addresses: Vec<String> = Vec::with_capacity(len);
604        let mut fees: Vec<Option<i32>> = Vec::with_capacity(len);
605        let mut tick_spacings: Vec<Option<i32>> = Vec::with_capacity(len);
606        let mut initial_ticks: Vec<Option<i32>> = Vec::with_capacity(len);
607        let mut initial_sqrt_price_x96s: Vec<Option<String>> = Vec::with_capacity(len);
608        let mut hook_addresses: Vec<Option<String>> = Vec::with_capacity(len);
609        let mut chain_ids: Vec<i32> = Vec::with_capacity(len);
610
611        // Fill vectors from pools
612        for pool in pools {
613            chain_ids.push(pool.chain.chain_id as i32);
614            addresses.push(pool.address.to_string());
615            pool_identifiers.push(pool.pool_identifier.to_string());
616            dex_names.push(pool.dex.name.to_string());
617            creation_blocks.push(pool.creation_block as i64);
618            token0_chains.push(pool.token0.chain.chain_id as i32);
619            token0_addresses.push(pool.token0.address.to_string());
620            token1_chains.push(pool.token1.chain.chain_id as i32);
621            token1_addresses.push(pool.token1.address.to_string());
622            fees.push(pool.fee.map(|fee| fee as i32));
623            tick_spacings.push(pool.tick_spacing.map(|tick_spacing| tick_spacing as i32));
624            initial_ticks.push(pool.initial_tick);
625            initial_sqrt_price_x96s
626                .push(pool.initial_sqrt_price_x96.as_ref().map(|p| p.to_string()));
627            hook_addresses.push(pool.hooks.as_ref().map(|h| h.to_string()));
628        }
629
630        // Execute batch insert with UNNEST
631        sqlx::query(
632            "
633            INSERT INTO pool (
634                chain_id, address, pool_identifier, dex_name, creation_block,
635                token0_chain, token0_address,
636                token1_chain, token1_address,
637                fee, tick_spacing, initial_tick, initial_sqrt_price_x96, hook_address
638            )
639            SELECT *
640            FROM UNNEST(
641                $1::int4[], $2::text[], $3::text[], $4::text[], $5::int8[],
642                $6::int4[], $7::text[], $8::int4[], $9::text[],
643                $10::int4[], $11::int4[], $12::int4[], $13::text[], $14::text[]
644            )
645            ON CONFLICT (chain_id, dex_name, pool_identifier) DO NOTHING
646           ",
647        )
648        .bind(&chain_ids[..])
649        .bind(&addresses[..])
650        .bind(&pool_identifiers[..])
651        .bind(&dex_names[..])
652        .bind(&creation_blocks[..])
653        .bind(&token0_chains[..])
654        .bind(&token0_addresses[..])
655        .bind(&token1_chains[..])
656        .bind(&token1_addresses[..])
657        .bind(&fees[..])
658        .bind(&tick_spacings[..])
659        .bind(&initial_ticks[..])
660        .bind(&initial_sqrt_price_x96s[..])
661        .bind(&hook_addresses as &[Option<String>])
662        .execute(&self.pool)
663        .await
664        .map(|_| ())
665        .map_err(|e| anyhow::anyhow!("Failed to batch insert into pool table: {e}"))
666    }
667
668    /// Inserts multiple pool swaps in a single database operation using UNNEST for optimal performance.
669    ///
670    /// # Errors
671    ///
672    /// Returns an error if the database operation fails.
673    pub async fn add_pool_swaps_batch(
674        &self,
675        chain_id: u32,
676        swaps: &[PoolSwap],
677    ) -> anyhow::Result<()> {
678        if swaps.is_empty() {
679            return Ok(());
680        }
681
682        // Prepare vectors for each column
683        let len = swaps.len();
684        let mut chain_ids: Vec<i32> = Vec::with_capacity(len);
685        let mut dex_names: Vec<String> = Vec::with_capacity(len);
686        let mut pool_identifiers: Vec<String> = Vec::with_capacity(len);
687        let mut blocks: Vec<i64> = Vec::with_capacity(len);
688        let mut transaction_hashes: Vec<String> = Vec::with_capacity(len);
689        let mut transaction_indices: Vec<i32> = Vec::with_capacity(len);
690        let mut log_indices: Vec<i32> = Vec::with_capacity(len);
691        let mut senders: Vec<String> = Vec::with_capacity(len);
692        let mut recipients: Vec<String> = Vec::with_capacity(len);
693        let mut sqrt_price_x96s: Vec<String> = Vec::with_capacity(len);
694        let mut liquidities: Vec<String> = Vec::with_capacity(len);
695        let mut ticks: Vec<i32> = Vec::with_capacity(len);
696        let mut amount0s: Vec<String> = Vec::with_capacity(len);
697        let mut amount1s: Vec<String> = Vec::with_capacity(len);
698        let mut order_sides: Vec<Option<String>> = Vec::with_capacity(len);
699        let mut base_quantities: Vec<Option<Decimal>> = Vec::with_capacity(len);
700        let mut quote_quantities: Vec<Option<Decimal>> = Vec::with_capacity(len);
701        let mut spot_prices: Vec<Option<Decimal>> = Vec::with_capacity(len);
702        let mut execution_prices: Vec<Option<Decimal>> = Vec::with_capacity(len);
703
704        // Fill vectors from swaps
705        for swap in swaps {
706            chain_ids.push(chain_id as i32);
707            dex_names.push(swap.dex.name.to_string());
708            pool_identifiers.push(swap.pool_identifier.to_string());
709            blocks.push(swap.block as i64);
710            transaction_hashes.push(swap.transaction_hash.clone());
711            transaction_indices.push(swap.transaction_index as i32);
712            log_indices.push(swap.log_index as i32);
713            senders.push(swap.sender.to_string());
714            recipients.push(swap.recipient.to_string());
715            sqrt_price_x96s.push(swap.sqrt_price_x96.to_string());
716            liquidities.push(swap.liquidity.to_string());
717            ticks.push(swap.tick);
718            amount0s.push(swap.amount0.to_string());
719            amount1s.push(swap.amount1.to_string());
720
721            // Extract trade_info fields if available
722            if let Some(ref trade_info) = swap.trade_info {
723                order_sides.push(Some(trade_info.order_side.to_string()));
724                base_quantities.push(Some(trade_info.quantity_base.as_decimal()));
725                quote_quantities.push(Some(trade_info.quantity_quote.as_decimal()));
726                spot_prices.push(Some(trade_info.spot_price.as_decimal()));
727                execution_prices.push(Some(trade_info.execution_price.as_decimal()));
728            } else {
729                order_sides.push(None);
730                base_quantities.push(None);
731                quote_quantities.push(None);
732                spot_prices.push(None);
733                execution_prices.push(None);
734            }
735        }
736
737        // Execute batch insert with UNNEST
738        sqlx::query(
739            "
740            INSERT INTO pool_swap_event (
741                chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
742                log_index, sender, recipient, sqrt_price_x96, liquidity, tick, amount0, amount1,
743                order_side, base_quantity, quote_quantity, spot_price, execution_price
744            )
745            SELECT
746                chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index, log_index, sender, recipient,
747                sqrt_price_x96::U160, liquidity::U128, tick, amount0::I256, amount1::I256,
748                order_side, base_quantity, quote_quantity, spot_price, execution_price
749            FROM UNNEST(
750                $1::INT[], $2::TEXT[], $3::TEXT[], $4::BIGINT[], $5::TEXT[], $6::INT[], $7::INT[],
751                $8::TEXT[], $9::TEXT[], $10::TEXT[], $11::TEXT[], $12::INT[], $13::TEXT[], $14::TEXT[],
752                $15::TEXT[], $16, $17, $18, $19
753            ) AS t(chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
754                   log_index, sender, recipient, sqrt_price_x96, liquidity, tick, amount0, amount1,
755                   order_side, base_quantity, quote_quantity, spot_price, execution_price)
756            ON CONFLICT (chain_id, transaction_hash, log_index) DO NOTHING
757           ",
758        )
759        .bind(&chain_ids[..])
760        .bind(&dex_names[..])
761        .bind(&pool_identifiers[..])
762        .bind(&blocks[..])
763        .bind(&transaction_hashes[..])
764        .bind(&transaction_indices[..])
765        .bind(&log_indices[..])
766        .bind(&senders[..])
767        .bind(&recipients[..])
768        .bind(&sqrt_price_x96s[..])
769        .bind(&liquidities[..])
770        .bind(&ticks[..])
771        .bind(&amount0s[..])
772        .bind(&amount1s[..])
773        .bind(&order_sides[..])
774        .bind(&base_quantities[..])
775        .bind(&quote_quantities[..])
776        .bind(&spot_prices[..])
777        .bind(&execution_prices[..])
778        .execute(&self.pool)
779        .await
780        .map(|_| ())
781        .map_err(|e| anyhow::anyhow!("Failed to batch insert into pool_swap_event table: {e}"))
782    }
783
784    /// Inserts multiple pool liquidity updates in a single database operation using UNNEST for optimal performance.
785    ///
786    /// # Errors
787    ///
788    /// Returns an error if the database operation fails.
789    pub async fn add_pool_liquidity_updates_batch(
790        &self,
791        chain_id: u32,
792        updates: &[PoolLiquidityUpdate],
793    ) -> anyhow::Result<()> {
794        if updates.is_empty() {
795            return Ok(());
796        }
797
798        // Prepare vectors for each column
799        let len = updates.len();
800        let mut chain_ids: Vec<i32> = Vec::with_capacity(len);
801        let mut dex_names: Vec<String> = Vec::with_capacity(len);
802        let mut pool_identifiers: Vec<String> = Vec::with_capacity(len);
803        let mut blocks: Vec<i64> = Vec::with_capacity(len);
804        let mut transaction_hashes: Vec<String> = Vec::with_capacity(len);
805        let mut transaction_indices: Vec<i32> = Vec::with_capacity(len);
806        let mut log_indices: Vec<i32> = Vec::with_capacity(len);
807        let mut event_types: Vec<String> = Vec::with_capacity(len);
808        let mut senders: Vec<Option<String>> = Vec::with_capacity(len);
809        let mut owners: Vec<String> = Vec::with_capacity(len);
810        let mut position_liquidities: Vec<String> = Vec::with_capacity(len);
811        let mut amount0s: Vec<String> = Vec::with_capacity(len);
812        let mut amount1s: Vec<String> = Vec::with_capacity(len);
813        let mut tick_lowers: Vec<i32> = Vec::with_capacity(len);
814        let mut tick_uppers: Vec<i32> = Vec::with_capacity(len);
815
816        // Fill vectors from updates
817        for update in updates {
818            chain_ids.push(chain_id as i32);
819            dex_names.push(update.dex.name.to_string());
820            pool_identifiers.push(update.pool_identifier.to_string());
821            blocks.push(update.block as i64);
822            transaction_hashes.push(update.transaction_hash.clone());
823            transaction_indices.push(update.transaction_index as i32);
824            log_indices.push(update.log_index as i32);
825            event_types.push(update.kind.to_string());
826            senders.push(update.sender.map(|s| s.to_string()));
827            owners.push(update.owner.to_string());
828            position_liquidities.push(update.position_liquidity.to_string());
829            amount0s.push(update.amount0.to_string());
830            amount1s.push(update.amount1.to_string());
831            tick_lowers.push(update.tick_lower);
832            tick_uppers.push(update.tick_upper);
833        }
834
835        // Execute batch insert with UNNEST
836        sqlx::query(
837            "
838            INSERT INTO pool_liquidity_event (
839                chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
840                log_index, event_type, sender, owner, position_liquidity,
841                amount0, amount1, tick_lower, tick_upper
842            )
843            SELECT
844                chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
845                log_index, event_type, sender, owner, position_liquidity::u128,
846                amount0::U256, amount1::U256, tick_lower, tick_upper
847            FROM UNNEST(
848                $1::INT[], $2::TEXT[], $3::TEXT[], $4::INT[], $5::TEXT[], $6::INT[],
849                $7::INT[], $8::TEXT[], $9::TEXT[], $10::TEXT[], $11::TEXT[],
850                $12::TEXT[], $13::TEXT[], $14::INT[], $15::INT[]
851            ) AS t(chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
852                   log_index, event_type, sender, owner, position_liquidity,
853                   amount0, amount1, tick_lower, tick_upper)
854            ON CONFLICT (chain_id, transaction_hash, log_index) DO NOTHING
855           ",
856        )
857        .bind(&chain_ids[..])
858        .bind(&dex_names[..])
859        .bind(&pool_identifiers[..])
860        .bind(&blocks[..])
861        .bind(&transaction_hashes[..])
862        .bind(&transaction_indices[..])
863        .bind(&log_indices[..])
864        .bind(&event_types[..])
865        .bind(&senders[..])
866        .bind(&owners[..])
867        .bind(&position_liquidities[..])
868        .bind(&amount0s[..])
869        .bind(&amount1s[..])
870        .bind(&tick_lowers[..])
871        .bind(&tick_uppers[..])
872        .execute(&self.pool)
873        .await
874        .map(|_| ())
875        .map_err(|e| anyhow::anyhow!("Failed to batch insert into pool_liquidity_event table: {e}"))
876    }
877
878    /// Adds or updates a token record in the database.
879    ///
880    /// # Errors
881    ///
882    /// Returns an error if the database operation fails.
883    pub async fn add_token(&self, token: &Token) -> anyhow::Result<()> {
884        sqlx::query(
885            "
886            INSERT INTO token (
887                chain_id, address, name, symbol, decimals
888            ) VALUES ($1, $2, $3, $4, $5)
889            ON CONFLICT (chain_id, address)
890            DO UPDATE
891            SET
892                name = $3,
893                symbol = $4,
894                decimals = $5
895        ",
896        )
897        .bind(token.chain.chain_id as i32)
898        .bind(token.address.to_string())
899        .bind(token.name.as_str())
900        .bind(token.symbol.as_str())
901        .bind(i32::from(token.decimals))
902        .execute(&self.pool)
903        .await
904        .map(|_| ())
905        .map_err(|e| anyhow::anyhow!("Failed to insert into token table: {e}"))
906    }
907
908    /// Records an invalid token address with associated error information.
909    ///
910    /// # Errors
911    ///
912    /// Returns an error if the database insertion fails.
913    pub async fn add_invalid_token(
914        &self,
915        chain_id: u32,
916        address: &Address,
917        error_string: &str,
918    ) -> anyhow::Result<()> {
919        sqlx::query(
920            "
921            INSERT INTO token (
922                chain_id, address, error
923            ) VALUES ($1, $2, $3)
924            ON CONFLICT (chain_id, address)
925            DO NOTHING;
926        ",
927        )
928        .bind(chain_id as i32)
929        .bind(address.to_string())
930        .bind(error_string)
931        .execute(&self.pool)
932        .await
933        .map(|_| ())
934        .map_err(|e| anyhow::anyhow!("Failed to insert into token table: {e}"))
935    }
936
937    /// Persists a token swap transaction event to the `pool_swap` table.
938    ///
939    /// # Errors
940    ///
941    /// Returns an error if the database operation fails.
942    pub async fn add_swap(&self, chain_id: u32, swap: &PoolSwap) -> anyhow::Result<()> {
943        // Extract trade_info fields if available
944        let (order_side, base_quantity, quote_quantity, spot_price, execution_price) =
945            if let Some(ref trade_info) = swap.trade_info {
946                (
947                    Some(trade_info.order_side.to_string()),
948                    Some(trade_info.quantity_base.as_decimal()),
949                    Some(trade_info.quantity_quote.as_decimal()),
950                    Some(trade_info.spot_price.as_decimal()),
951                    Some(trade_info.execution_price.as_decimal()),
952                )
953            } else {
954                (None, None, None, None, None)
955            };
956
957        sqlx::query(
958            "
959            INSERT INTO pool_swap_event (
960                chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
961                log_index, sender, recipient, sqrt_price_x96, liquidity, tick, amount0, amount1,
962                order_side, base_quantity, quote_quantity, spot_price, execution_price
963            ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::U160, $11::U128, $12, $13::I256, $14::I256, $15, $16, $17, $18, $19)
964            ON CONFLICT (chain_id, transaction_hash, log_index)
965            DO NOTHING
966        ",
967        )
968        .bind(chain_id as i32)
969        .bind(swap.dex.name.to_string())
970        .bind(swap.pool_identifier.as_str())
971        .bind(swap.block as i64)
972        .bind(swap.transaction_hash.as_str())
973        .bind(swap.transaction_index as i32)
974        .bind(swap.log_index as i32)
975        .bind(swap.sender.to_string())
976        .bind(swap.recipient.to_string())
977        .bind(swap.sqrt_price_x96.to_string())
978        .bind(swap.liquidity.to_string())
979        .bind(swap.tick)
980        .bind(swap.amount0.to_string())
981        .bind(swap.amount1.to_string())
982        .bind(order_side)
983        .bind(base_quantity)
984        .bind(quote_quantity)
985        .bind(spot_price)
986        .bind(execution_price)
987        .execute(&self.pool)
988        .await
989        .map(|_| ())
990        .map_err(|e| anyhow::anyhow!("Failed to insert into pool_swap table: {e}"))
991    }
992
993    /// Persists a liquidity position change (mint/burn) event to the `pool_liquidity` table.
994    ///
995    /// # Errors
996    ///
997    /// Returns an error if the database operation fails.
998    pub async fn add_pool_liquidity_update(
999        &self,
1000        chain_id: u32,
1001        liquidity_update: &PoolLiquidityUpdate,
1002    ) -> anyhow::Result<()> {
1003        sqlx::query(
1004            "
1005            INSERT INTO pool_liquidity_event (
1006                chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index, log_index,
1007                event_type, sender, owner, position_liquidity, amount0, amount1, tick_lower, tick_upper
1008            ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
1009            ON CONFLICT (chain_id, transaction_hash, log_index)
1010            DO NOTHING
1011        ",
1012        )
1013        .bind(chain_id as i32)
1014        .bind(liquidity_update.dex.name.to_string())
1015        .bind(liquidity_update.pool_identifier.as_str())
1016        .bind(liquidity_update.block as i64)
1017        .bind(liquidity_update.transaction_hash.as_str())
1018        .bind(liquidity_update.transaction_index as i32)
1019        .bind(liquidity_update.log_index as i32)
1020        .bind(liquidity_update.kind.to_string())
1021        .bind(liquidity_update.sender.map(|sender| sender.to_string()))
1022        .bind(liquidity_update.owner.to_string())
1023        .bind(U128Pg(liquidity_update.position_liquidity))
1024        .bind(U256Pg(liquidity_update.amount0))
1025        .bind(U256Pg(liquidity_update.amount1))
1026        .bind(liquidity_update.tick_lower)
1027        .bind(liquidity_update.tick_upper)
1028        .execute(&self.pool)
1029        .await
1030        .map(|_| ())
1031        .map_err(|e| anyhow::anyhow!("Failed to insert into pool_liquidity table: {e}"))
1032    }
1033
1034    /// Retrieves all valid token records for the given chain and converts them into `Token` domain objects.
1035    ///
1036    /// Only returns tokens that do not contain error information, filtering out invalid tokens
1037    /// that were previously recorded with error details.
1038    ///
1039    /// # Errors
1040    ///
1041    /// Returns an error if the database query fails.
1042    pub async fn load_tokens(&self, chain: SharedChain) -> anyhow::Result<Vec<Token>> {
1043        sqlx::query_as::<_, TokenRow>("SELECT * FROM token WHERE chain_id = $1 AND error IS NULL")
1044            .bind(chain.chain_id as i32)
1045            .fetch_all(&self.pool)
1046            .await
1047            .map(|rows| {
1048                rows.into_iter()
1049                    .map(|token_row| {
1050                        Token::new(
1051                            chain.clone(),
1052                            token_row.address,
1053                            token_row.name,
1054                            token_row.symbol,
1055                            token_row.decimals as u8,
1056                        )
1057                    })
1058                    .collect::<Vec<_>>()
1059            })
1060            .map_err(|e| anyhow::anyhow!("Failed to load tokens: {e}"))
1061    }
1062
1063    /// Retrieves all invalid token addresses for a given chain.
1064    ///
1065    /// # Errors
1066    ///
1067    /// Returns an error if the database query fails or address validation fails.
1068    pub async fn load_invalid_token_addresses(
1069        &self,
1070        chain_id: u32,
1071    ) -> anyhow::Result<Vec<Address>> {
1072        sqlx::query_as::<_, (String,)>(
1073            "SELECT address FROM token WHERE chain_id = $1 AND error IS NOT NULL",
1074        )
1075        .bind(chain_id as i32)
1076        .fetch_all(&self.pool)
1077        .await?
1078        .into_iter()
1079        .map(|(address,)| validate_address(&address))
1080        .collect::<Result<Vec<_>, _>>()
1081        .map_err(|e| anyhow::anyhow!("Failed to load invalid token addresses: {e}"))
1082    }
1083
1084    /// Loads pool data from the database for the specified chain and DEX.
1085    ///
1086    /// # Errors
1087    ///
1088    /// Returns an error if the database query fails, the connection to the database is lost, or the query parameters are invalid.
1089    pub async fn load_pools(
1090        &self,
1091        chain: SharedChain,
1092        dex_id: &str,
1093    ) -> anyhow::Result<Vec<PoolRow>> {
1094        sqlx::query_as::<_, PoolRow>(AssertSqlSafe(format!(
1095            "SELECT {POOL_ROW_COLUMNS} FROM pool WHERE chain_id = $1 AND dex_name = $2 ORDER BY creation_block ASC"
1096        )))
1097        .bind(chain.chain_id as i32)
1098        .bind(dex_id)
1099        .fetch_all(&self.pool)
1100        .await
1101        .map_err(|e| anyhow::anyhow!("Failed to load pools: {e}"))
1102    }
1103
1104    /// Loads a single pool row by its identifier.
1105    ///
1106    /// Returns `None` when the pool is not present in the database. Lets per-pool tools load only
1107    /// the pool they analyze instead of the whole DEX pool set (see [`load_pools`]).
1108    ///
1109    /// [`load_pools`]: Self::load_pools
1110    ///
1111    /// # Errors
1112    ///
1113    /// Returns an error if the database query fails.
1114    pub async fn load_pool(
1115        &self,
1116        chain: SharedChain,
1117        dex_id: &str,
1118        pool_identifier: &PoolIdentifier,
1119    ) -> anyhow::Result<Option<PoolRow>> {
1120        sqlx::query_as::<_, PoolRow>(AssertSqlSafe(format!(
1121            "SELECT {POOL_ROW_COLUMNS} FROM pool WHERE chain_id = $1 AND dex_name = $2 AND pool_identifier = $3"
1122        )))
1123        .bind(chain.chain_id as i32)
1124        .bind(dex_id)
1125        .bind(pool_identifier.as_ref())
1126        .fetch_optional(&self.pool)
1127        .await
1128        .map_err(|e| anyhow::anyhow!("Failed to load pool {pool_identifier}: {e}"))
1129    }
1130
1131    /// Toggles performance optimization settings for sync operations.
1132    ///
1133    /// When enabled (true), applies settings for maximum write performance:
1134    /// - `synchronous_commit` = OFF
1135    /// - `work_mem` increased for bulk operations
1136    ///
1137    /// When disabled (false), restores default safe settings:
1138    /// - `synchronous_commit` = ON (data safety)
1139    /// - `work_mem` back to default
1140    ///
1141    /// # Errors
1142    ///
1143    /// Returns an error if the database operations fail.
1144    pub async fn toggle_perf_sync_settings(&self, enable: bool) -> anyhow::Result<()> {
1145        if enable {
1146            log::debug!("Enabling performance sync settings for bulk operations");
1147
1148            // Set synchronous_commit to OFF for maximum write performance
1149            sqlx::query("SET synchronous_commit = OFF")
1150                .execute(&self.pool)
1151                .await
1152                .map_err(|e| anyhow::anyhow!("Failed to set synchronous_commit OFF: {e}"))?;
1153
1154            // Increase work_mem for bulk operations
1155            sqlx::query("SET work_mem = '256MB'")
1156                .execute(&self.pool)
1157                .await
1158                .map_err(|e| anyhow::anyhow!("Failed to set work_mem: {e}"))?;
1159
1160            log::debug!("Performance settings enabled: synchronous_commit=OFF, work_mem=256MB");
1161        } else {
1162            log::debug!("Restoring default safe database performance settings");
1163
1164            // Restore synchronous_commit to ON for data safety
1165            sqlx::query("SET synchronous_commit = ON")
1166                .execute(&self.pool)
1167                .await
1168                .map_err(|e| anyhow::anyhow!("Failed to set synchronous_commit ON: {e}"))?;
1169
1170            // Reset work_mem to default
1171            sqlx::query("RESET work_mem")
1172                .execute(&self.pool)
1173                .await
1174                .map_err(|e| anyhow::anyhow!("Failed to reset work_mem: {e}"))?;
1175        }
1176
1177        Ok(())
1178    }
1179
1180    /// Saves the checkpoint block number indicating the last completed pool synchronization for a specific DEX.
1181    ///
1182    /// # Errors
1183    ///
1184    /// Returns an error if the database operation fails.
1185    pub async fn update_dex_last_synced_block(
1186        &self,
1187        chain_id: u32,
1188        dex: &DexType,
1189        block_number: u64,
1190    ) -> anyhow::Result<()> {
1191        sqlx::query(
1192            "
1193            UPDATE dex
1194            SET last_full_sync_pools_block_number = $3
1195            WHERE chain_id = $1 AND name = $2
1196            ",
1197        )
1198        .bind(chain_id as i32)
1199        .bind(dex.to_string())
1200        .bind(block_number as i64)
1201        .execute(&self.pool)
1202        .await
1203        .map(|_| ())
1204        .map_err(|e| anyhow::anyhow!("Failed to update dex last synced block: {e}"))
1205    }
1206
1207    /// Updates the last synced block number for a pool.
1208    ///
1209    /// # Errors
1210    ///
1211    /// Returns an error if the database update fails.
1212    pub async fn update_pool_last_synced_block(
1213        &self,
1214        chain_id: u32,
1215        dex: &DexType,
1216        pool_identifier: &PoolIdentifier,
1217        block_number: u64,
1218    ) -> anyhow::Result<()> {
1219        sqlx::query(
1220            "
1221            UPDATE pool
1222            SET last_full_sync_block_number = $4
1223            WHERE chain_id = $1
1224            AND dex_name = $2
1225            AND pool_identifier = $3
1226            ",
1227        )
1228        .bind(chain_id as i32)
1229        .bind(dex.to_string())
1230        .bind(pool_identifier.as_ref())
1231        .bind(block_number as i64)
1232        .execute(&self.pool)
1233        .await
1234        .map(|_| ())
1235        .map_err(|e| anyhow::anyhow!("Failed to update pool last synced block: {e}"))
1236    }
1237
1238    /// Retrieves the saved checkpoint block number from the last completed pool synchronization for a specific DEX.
1239    ///
1240    /// # Errors
1241    ///
1242    /// Returns an error if the database query fails.
1243    pub async fn get_dex_last_synced_block(
1244        &self,
1245        chain_id: u32,
1246        dex: &DexType,
1247    ) -> anyhow::Result<Option<u64>> {
1248        let result = sqlx::query_as::<_, (Option<i64>,)>(
1249            "
1250            SELECT
1251                last_full_sync_pools_block_number
1252            FROM dex
1253            WHERE chain_id = $1
1254            AND name = $2
1255            ",
1256        )
1257        .bind(chain_id as i32)
1258        .bind(dex.to_string())
1259        .fetch_optional(&self.pool)
1260        .await
1261        .map_err(|e| anyhow::anyhow!("Failed to get dex last synced block: {e}"))?;
1262
1263        Ok(result.and_then(|(block_number,)| block_number.map(|b| b as u64)))
1264    }
1265
1266    /// Retrieves the last synced block number for a pool.
1267    ///
1268    /// # Errors
1269    ///
1270    /// Returns an error if the database query fails.
1271    pub async fn get_pool_last_synced_block(
1272        &self,
1273        chain_id: u32,
1274        dex: &DexType,
1275        pool_identifier: &PoolIdentifier,
1276    ) -> anyhow::Result<Option<u64>> {
1277        let result = sqlx::query_as::<_, (Option<i64>,)>(
1278            "
1279            SELECT
1280                last_full_sync_block_number
1281            FROM pool
1282            WHERE chain_id = $1
1283            AND dex_name = $2
1284            AND pool_identifier = $3
1285            ",
1286        )
1287        .bind(chain_id as i32)
1288        .bind(dex.to_string())
1289        .bind(pool_identifier.as_ref())
1290        .fetch_optional(&self.pool)
1291        .await
1292        .map_err(|e| anyhow::anyhow!("Failed to get pool last synced block: {e}"))?;
1293
1294        Ok(result.and_then(|(block_number,)| block_number.map(|b| b as u64)))
1295    }
1296
1297    /// Retrieves the maximum block number from a specific table for a given pool.
1298    /// This is useful to detect orphaned data where events were inserted but progress wasn't updated.
1299    ///
1300    /// # Errors
1301    ///
1302    /// Returns an error if the database query fails.
1303    pub async fn get_table_last_block(
1304        &self,
1305        chain_id: u32,
1306        table_name: &str,
1307        pool_identifier: &PoolIdentifier,
1308    ) -> anyhow::Result<Option<u64>> {
1309        let query = format!(
1310            "SELECT MAX(block) FROM {table_name} WHERE chain_id = $1 AND pool_identifier = $2"
1311        );
1312        let result = sqlx::query_as::<_, (Option<i64>,)>(AssertSqlSafe(query))
1313            .bind(chain_id as i32)
1314            .bind(pool_identifier.as_ref())
1315            .fetch_optional(&self.pool)
1316            .await
1317            .map_err(|e| anyhow::anyhow!("Failed to get table last block for {table_name}: {e}"))?;
1318
1319        Ok(result.and_then(|(block_number,)| block_number.map(|b| b as u64)))
1320    }
1321
1322    /// Adds a batch of pool fee collect events to the database using batch operations.
1323    ///
1324    /// # Errors
1325    ///
1326    /// Returns an error if the database operation fails.
1327    pub async fn add_pool_collects_batch(
1328        &self,
1329        chain_id: u32,
1330        collects: &[PoolFeeCollect],
1331    ) -> anyhow::Result<()> {
1332        if collects.is_empty() {
1333            return Ok(());
1334        }
1335
1336        // Prepare vectors for each column
1337        let len = collects.len();
1338        let mut chain_ids: Vec<i32> = Vec::with_capacity(len);
1339        let mut dex_names: Vec<String> = Vec::with_capacity(len);
1340        let mut pool_identifiers: Vec<String> = Vec::with_capacity(len);
1341        let mut blocks: Vec<i64> = Vec::with_capacity(len);
1342        let mut transaction_hashes: Vec<String> = Vec::with_capacity(len);
1343        let mut transaction_indices: Vec<i32> = Vec::with_capacity(len);
1344        let mut log_indices: Vec<i32> = Vec::with_capacity(len);
1345        let mut owners: Vec<String> = Vec::with_capacity(len);
1346        let mut amount0s: Vec<String> = Vec::with_capacity(len);
1347        let mut amount1s: Vec<String> = Vec::with_capacity(len);
1348        let mut tick_lowers: Vec<i32> = Vec::with_capacity(len);
1349        let mut tick_uppers: Vec<i32> = Vec::with_capacity(len);
1350
1351        // Fill vectors from collects
1352        for collect in collects {
1353            chain_ids.push(chain_id as i32);
1354            dex_names.push(collect.dex.name.to_string());
1355            pool_identifiers.push(collect.pool_identifier.to_string());
1356            blocks.push(collect.block as i64);
1357            transaction_hashes.push(collect.transaction_hash.clone());
1358            transaction_indices.push(collect.transaction_index as i32);
1359            log_indices.push(collect.log_index as i32);
1360            owners.push(collect.owner.to_string());
1361            amount0s.push(collect.amount0.to_string());
1362            amount1s.push(collect.amount1.to_string());
1363            tick_lowers.push(collect.tick_lower);
1364            tick_uppers.push(collect.tick_upper);
1365        }
1366
1367        // Execute batch insert with UNNEST
1368        sqlx::query(
1369            "
1370            INSERT INTO pool_collect_event (
1371                chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1372                log_index, owner, amount0, amount1, tick_lower, tick_upper
1373            )
1374            SELECT
1375                chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1376                log_index, owner, amount0::U256, amount1::U256, tick_lower, tick_upper
1377            FROM UNNEST(
1378                $1::INT[], $2::TEXT[], $3::TEXT[], $4::INT[], $5::TEXT[], $6::INT[],
1379                $7::INT[], $8::TEXT[], $9::TEXT[], $10::TEXT[], $11::INT[], $12::INT[]
1380            ) AS t(chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1381                   log_index, owner, amount0, amount1, tick_lower, tick_upper)
1382            ON CONFLICT (chain_id, transaction_hash, log_index) DO NOTHING
1383           ",
1384        )
1385        .bind(&chain_ids[..])
1386        .bind(&dex_names[..])
1387        .bind(&pool_identifiers[..])
1388        .bind(&blocks[..])
1389        .bind(&transaction_hashes[..])
1390        .bind(&transaction_indices[..])
1391        .bind(&log_indices[..])
1392        .bind(&owners[..])
1393        .bind(&amount0s[..])
1394        .bind(&amount1s[..])
1395        .bind(&tick_lowers[..])
1396        .bind(&tick_uppers[..])
1397        .execute(&self.pool)
1398        .await
1399        .map(|_| ())
1400        .map_err(|e| anyhow::anyhow!("Failed to batch insert into pool_fee_collect table: {e}"))
1401    }
1402
1403    /// Inserts multiple pool flash events in a single database operation using UNNEST for optimal performance.
1404    ///
1405    /// # Errors
1406    ///
1407    /// Returns an error if the database operation fails.
1408    pub async fn add_pool_flash_batch(
1409        &self,
1410        chain_id: u32,
1411        flash_events: &[PoolFlash],
1412    ) -> anyhow::Result<()> {
1413        if flash_events.is_empty() {
1414            return Ok(());
1415        }
1416
1417        // Prepare vectors for each column
1418        let len = flash_events.len();
1419        let mut chain_ids: Vec<i32> = Vec::with_capacity(len);
1420        let mut dex_names: Vec<String> = Vec::with_capacity(len);
1421        let mut pool_identifiers: Vec<String> = Vec::with_capacity(len);
1422        let mut blocks: Vec<i64> = Vec::with_capacity(len);
1423        let mut transaction_hashes: Vec<String> = Vec::with_capacity(len);
1424        let mut transaction_indices: Vec<i32> = Vec::with_capacity(len);
1425        let mut log_indices: Vec<i32> = Vec::with_capacity(len);
1426        let mut senders: Vec<String> = Vec::with_capacity(len);
1427        let mut recipients: Vec<String> = Vec::with_capacity(len);
1428        let mut amount0s: Vec<String> = Vec::with_capacity(len);
1429        let mut amount1s: Vec<String> = Vec::with_capacity(len);
1430        let mut paid0s: Vec<String> = Vec::with_capacity(len);
1431        let mut paid1s: Vec<String> = Vec::with_capacity(len);
1432
1433        // Fill vectors from flash events
1434        for flash in flash_events {
1435            chain_ids.push(chain_id as i32);
1436            dex_names.push(flash.dex.name.to_string());
1437            pool_identifiers.push(flash.pool_identifier.to_string());
1438            blocks.push(flash.block as i64);
1439            transaction_hashes.push(flash.transaction_hash.clone());
1440            transaction_indices.push(flash.transaction_index as i32);
1441            log_indices.push(flash.log_index as i32);
1442            senders.push(flash.sender.to_string());
1443            recipients.push(flash.recipient.to_string());
1444            amount0s.push(flash.amount0.to_string());
1445            amount1s.push(flash.amount1.to_string());
1446            paid0s.push(flash.paid0.to_string());
1447            paid1s.push(flash.paid1.to_string());
1448        }
1449
1450        // Execute batch insert with UNNEST
1451        sqlx::query(
1452            "
1453            INSERT INTO pool_flash_event (
1454                chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1455                log_index, sender, recipient, amount0, amount1, paid0, paid1
1456            )
1457            SELECT
1458                chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1459                log_index, sender, recipient, amount0::U256, amount1::U256, paid0::U256, paid1::U256
1460            FROM UNNEST(
1461                $1::INT[], $2::TEXT[], $3::TEXT[], $4::INT[], $5::TEXT[], $6::INT[],
1462                $7::INT[], $8::TEXT[], $9::TEXT[], $10::TEXT[], $11::TEXT[], $12::TEXT[], $13::TEXT[]
1463            ) AS t(chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1464                   log_index, sender, recipient, amount0, amount1, paid0, paid1)
1465            ON CONFLICT (chain_id, transaction_hash, log_index) DO NOTHING
1466           ",
1467        )
1468        .bind(&chain_ids[..])
1469        .bind(&dex_names[..])
1470        .bind(&pool_identifiers[..])
1471        .bind(&blocks[..])
1472        .bind(&transaction_hashes[..])
1473        .bind(&transaction_indices[..])
1474        .bind(&log_indices[..])
1475        .bind(&senders[..])
1476        .bind(&recipients[..])
1477        .bind(&amount0s[..])
1478        .bind(&amount1s[..])
1479        .bind(&paid0s[..])
1480        .bind(&paid1s[..])
1481        .execute(&self.pool)
1482        .await
1483        .map(|_| ())
1484        .map_err(|e| anyhow::anyhow!("Failed to batch insert into pool_flash_event table: {e}"))
1485    }
1486
1487    /// Inserts multiple pool fee-protocol update events in a single database operation using UNNEST.
1488    ///
1489    /// # Errors
1490    ///
1491    /// Returns an error if the database operation fails.
1492    pub async fn add_pool_fee_protocol_updates_batch(
1493        &self,
1494        chain_id: u32,
1495        updates: &[PoolFeeProtocolUpdate],
1496    ) -> anyhow::Result<()> {
1497        if updates.is_empty() {
1498            return Ok(());
1499        }
1500
1501        // Prepare vectors for each column
1502        let len = updates.len();
1503        let mut chain_ids: Vec<i32> = Vec::with_capacity(len);
1504        let mut dex_names: Vec<String> = Vec::with_capacity(len);
1505        let mut pool_identifiers: Vec<String> = Vec::with_capacity(len);
1506        let mut blocks: Vec<i64> = Vec::with_capacity(len);
1507        let mut transaction_hashes: Vec<String> = Vec::with_capacity(len);
1508        let mut transaction_indices: Vec<i32> = Vec::with_capacity(len);
1509        let mut log_indices: Vec<i32> = Vec::with_capacity(len);
1510        let mut fee_protocol0s: Vec<i16> = Vec::with_capacity(len);
1511        let mut fee_protocol1s: Vec<i16> = Vec::with_capacity(len);
1512
1513        // Fill vectors from updates
1514        for update in updates {
1515            chain_ids.push(chain_id as i32);
1516            dex_names.push(update.dex.name.to_string());
1517            pool_identifiers.push(update.pool_identifier.to_string());
1518            blocks.push(update.block as i64);
1519            transaction_hashes.push(update.transaction_hash.clone());
1520            transaction_indices.push(update.transaction_index as i32);
1521            log_indices.push(update.log_index as i32);
1522            fee_protocol0s.push(i16::from(update.fee_protocol0_new));
1523            fee_protocol1s.push(i16::from(update.fee_protocol1_new));
1524        }
1525
1526        // Execute batch insert with UNNEST
1527        sqlx::query(
1528            "
1529            INSERT INTO pool_fee_protocol_update_event (
1530                chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1531                log_index, fee_protocol0_new, fee_protocol1_new
1532            )
1533            SELECT
1534                chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1535                log_index, fee_protocol0_new, fee_protocol1_new
1536            FROM UNNEST(
1537                $1::INT[], $2::TEXT[], $3::TEXT[], $4::INT[], $5::TEXT[], $6::INT[],
1538                $7::INT[], $8::SMALLINT[], $9::SMALLINT[]
1539            ) AS t(chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1540                   log_index, fee_protocol0_new, fee_protocol1_new)
1541            ON CONFLICT (chain_id, transaction_hash, log_index) DO NOTHING
1542           ",
1543        )
1544        .bind(&chain_ids[..])
1545        .bind(&dex_names[..])
1546        .bind(&pool_identifiers[..])
1547        .bind(&blocks[..])
1548        .bind(&transaction_hashes[..])
1549        .bind(&transaction_indices[..])
1550        .bind(&log_indices[..])
1551        .bind(&fee_protocol0s[..])
1552        .bind(&fee_protocol1s[..])
1553        .execute(&self.pool)
1554        .await
1555        .map(|_| ())
1556        .map_err(|e| {
1557            anyhow::anyhow!("Failed to batch insert into pool_fee_protocol_update_event table: {e}")
1558        })
1559    }
1560
1561    /// Inserts multiple pool protocol-fee withdrawal events in a single database operation using UNNEST.
1562    ///
1563    /// # Errors
1564    ///
1565    /// Returns an error if the database operation fails.
1566    pub async fn add_pool_fee_protocol_collect_batch(
1567        &self,
1568        chain_id: u32,
1569        collects: &[PoolFeeProtocolCollect],
1570    ) -> anyhow::Result<()> {
1571        if collects.is_empty() {
1572            return Ok(());
1573        }
1574
1575        // Prepare vectors for each column
1576        let len = collects.len();
1577        let mut chain_ids: Vec<i32> = Vec::with_capacity(len);
1578        let mut dex_names: Vec<String> = Vec::with_capacity(len);
1579        let mut pool_identifiers: Vec<String> = Vec::with_capacity(len);
1580        let mut blocks: Vec<i64> = Vec::with_capacity(len);
1581        let mut transaction_hashes: Vec<String> = Vec::with_capacity(len);
1582        let mut transaction_indices: Vec<i32> = Vec::with_capacity(len);
1583        let mut log_indices: Vec<i32> = Vec::with_capacity(len);
1584        let mut senders: Vec<String> = Vec::with_capacity(len);
1585        let mut recipients: Vec<String> = Vec::with_capacity(len);
1586        let mut amount0s: Vec<String> = Vec::with_capacity(len);
1587        let mut amount1s: Vec<String> = Vec::with_capacity(len);
1588
1589        // Fill vectors from collects
1590        for collect in collects {
1591            chain_ids.push(chain_id as i32);
1592            dex_names.push(collect.dex.name.to_string());
1593            pool_identifiers.push(collect.pool_identifier.to_string());
1594            blocks.push(collect.block as i64);
1595            transaction_hashes.push(collect.transaction_hash.clone());
1596            transaction_indices.push(collect.transaction_index as i32);
1597            log_indices.push(collect.log_index as i32);
1598            senders.push(collect.sender.to_string());
1599            recipients.push(collect.recipient.to_string());
1600            amount0s.push(collect.amount0.to_string());
1601            amount1s.push(collect.amount1.to_string());
1602        }
1603
1604        // Execute batch insert with UNNEST
1605        sqlx::query(
1606            "
1607            INSERT INTO pool_fee_protocol_collect_event (
1608                chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1609                log_index, sender, recipient, amount0, amount1
1610            )
1611            SELECT
1612                chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1613                log_index, sender, recipient, amount0::U256, amount1::U256
1614            FROM UNNEST(
1615                $1::INT[], $2::TEXT[], $3::TEXT[], $4::INT[], $5::TEXT[], $6::INT[],
1616                $7::INT[], $8::TEXT[], $9::TEXT[], $10::TEXT[], $11::TEXT[]
1617            ) AS t(chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1618                   log_index, sender, recipient, amount0, amount1)
1619            ON CONFLICT (chain_id, transaction_hash, log_index) DO NOTHING
1620           ",
1621        )
1622        .bind(&chain_ids[..])
1623        .bind(&dex_names[..])
1624        .bind(&pool_identifiers[..])
1625        .bind(&blocks[..])
1626        .bind(&transaction_hashes[..])
1627        .bind(&transaction_indices[..])
1628        .bind(&log_indices[..])
1629        .bind(&senders[..])
1630        .bind(&recipients[..])
1631        .bind(&amount0s[..])
1632        .bind(&amount1s[..])
1633        .execute(&self.pool)
1634        .await
1635        .map(|_| ())
1636        .map_err(|e| {
1637            anyhow::anyhow!(
1638                "Failed to batch insert into pool_fee_protocol_collect_event table: {e}"
1639            )
1640        })
1641    }
1642
1643    /// Adds a pool snapshot to the database.
1644    ///
1645    /// # Errors
1646    ///
1647    /// Returns an error if the database insert fails.
1648    pub async fn add_pool_snapshot(
1649        &self,
1650        chain_id: u32,
1651        dex_name: &DexType,
1652        pool_identifier: &PoolIdentifier,
1653        snapshot: &PoolSnapshot,
1654    ) -> anyhow::Result<()> {
1655        sqlx::query(
1656            "
1657            INSERT INTO pool_snapshot (
1658                chain_id, dex_name, pool_identifier, block, transaction_index, log_index, transaction_hash,
1659                current_tick, price_sqrt_ratio_x96, liquidity,
1660                protocol_fees_token0, protocol_fees_token1, fee_protocol,
1661                fee_growth_global_0, fee_growth_global_1,
1662                total_amount0_deposited, total_amount1_deposited,
1663                total_amount0_collected, total_amount1_collected,
1664                total_swaps, total_mints, total_burns, total_fee_collects, total_flashes,
1665                liquidity_utilization_rate
1666            ) VALUES (
1667                $1, $2, $3, $4, $5, $6, $7,
1668                $8, $9::U160, $10::U128, $11::U256, $12::U256, $13,
1669                $14::U256, $15::U256, $16::U256, $17::U256, $18::U256, $19::U256,
1670                $20, $21, $22, $23, $24, $25
1671            )
1672            ON CONFLICT (chain_id, pool_identifier, block, transaction_index, log_index)
1673            DO NOTHING
1674            ",
1675        )
1676        .bind(chain_id as i32)
1677        .bind(dex_name.to_string())
1678        .bind(pool_identifier.as_ref())
1679        .bind(snapshot.block_position.number as i64)
1680        .bind(snapshot.block_position.transaction_index as i32)
1681        .bind(snapshot.block_position.log_index as i32)
1682        .bind(snapshot.block_position.transaction_hash.clone())
1683        .bind(snapshot.state.current_tick)
1684        .bind(snapshot.state.price_sqrt_ratio_x96.to_string())
1685        .bind(snapshot.state.liquidity.to_string())
1686        .bind(snapshot.state.protocol_fees_token0.to_string())
1687        .bind(snapshot.state.protocol_fees_token1.to_string())
1688        .bind(snapshot.state.fee_protocol as i16)
1689        .bind(snapshot.state.fee_growth_global_0.to_string())
1690        .bind(snapshot.state.fee_growth_global_1.to_string())
1691        .bind(snapshot.analytics.total_amount0_deposited.to_string())
1692        .bind(snapshot.analytics.total_amount1_deposited.to_string())
1693        .bind(snapshot.analytics.total_amount0_collected.to_string())
1694        .bind(snapshot.analytics.total_amount1_collected.to_string())
1695        .bind(snapshot.analytics.total_swaps as i32)
1696        .bind(snapshot.analytics.total_mints as i32)
1697        .bind(snapshot.analytics.total_burns as i32)
1698        .bind(snapshot.analytics.total_fee_collects as i32)
1699        .bind(snapshot.analytics.total_flashes as i32)
1700        .bind(snapshot.analytics.liquidity_utilization_rate)
1701        .execute(&self.pool)
1702        .await
1703        .map(|_| ())
1704        .map_err(|e| anyhow::anyhow!("Failed to insert into pool_snapshot table: {e}"))
1705    }
1706
1707    /// Inserts multiple pool positions in a single database operation using UNNEST for optimal performance.
1708    ///
1709    /// # Errors
1710    ///
1711    /// Returns an error if the database operation fails.
1712    pub async fn add_pool_positions_batch(
1713        &self,
1714        chain_id: u32,
1715        snapshot_block: u64,
1716        snapshot_transaction_index: u32,
1717        snapshot_log_index: u32,
1718        positions: &[(PoolIdentifier, PoolPosition)],
1719    ) -> anyhow::Result<()> {
1720        if positions.is_empty() {
1721            return Ok(());
1722        }
1723
1724        // Prepare vectors for each column
1725        let len = positions.len();
1726        let mut pool_identifiers: Vec<String> = Vec::with_capacity(len);
1727        let mut owners: Vec<String> = Vec::with_capacity(len);
1728        let mut tick_lowers: Vec<i32> = Vec::with_capacity(len);
1729        let mut tick_uppers: Vec<i32> = Vec::with_capacity(len);
1730        let mut liquidities: Vec<String> = Vec::with_capacity(len);
1731        let mut fee_growth_inside_0_lasts: Vec<String> = Vec::with_capacity(len);
1732        let mut fee_growth_inside_1_lasts: Vec<String> = Vec::with_capacity(len);
1733        let mut tokens_owed_0s: Vec<String> = Vec::with_capacity(len);
1734        let mut tokens_owed_1s: Vec<String> = Vec::with_capacity(len);
1735        let mut total_amount0_depositeds: Vec<Option<String>> = Vec::with_capacity(len);
1736        let mut total_amount1_depositeds: Vec<Option<String>> = Vec::with_capacity(len);
1737        let mut total_amount0_collecteds: Vec<Option<String>> = Vec::with_capacity(len);
1738        let mut total_amount1_collecteds: Vec<Option<String>> = Vec::with_capacity(len);
1739
1740        // Fill vectors from positions
1741        for (pool_address, position) in positions {
1742            pool_identifiers.push(pool_address.to_string());
1743            owners.push(position.owner.to_string());
1744            tick_lowers.push(position.tick_lower);
1745            tick_uppers.push(position.tick_upper);
1746            liquidities.push(position.liquidity.to_string());
1747            fee_growth_inside_0_lasts.push(position.fee_growth_inside_0_last.to_string());
1748            fee_growth_inside_1_lasts.push(position.fee_growth_inside_1_last.to_string());
1749            tokens_owed_0s.push(position.tokens_owed_0.to_string());
1750            tokens_owed_1s.push(position.tokens_owed_1.to_string());
1751            total_amount0_depositeds.push(Some(position.total_amount0_deposited.to_string()));
1752            total_amount1_depositeds.push(Some(position.total_amount1_deposited.to_string()));
1753            total_amount0_collecteds.push(Some(position.total_amount0_collected.to_string()));
1754            total_amount1_collecteds.push(Some(position.total_amount1_collected.to_string()));
1755        }
1756
1757        // Execute batch insert with UNNEST
1758        sqlx::query(
1759            "
1760            INSERT INTO pool_position (
1761                chain_id, pool_identifier, snapshot_block, snapshot_transaction_index, snapshot_log_index,
1762                owner, tick_lower, tick_upper,
1763                liquidity, fee_growth_inside_0_last, fee_growth_inside_1_last,
1764                tokens_owed_0, tokens_owed_1,
1765                total_amount0_deposited, total_amount1_deposited,
1766                total_amount0_collected, total_amount1_collected
1767            )
1768            SELECT
1769                $1, pool_identifier, $2, $3, $4,
1770                owner, tick_lower, tick_upper,
1771                liquidity::U128, fee_growth_inside_0_last::U256, fee_growth_inside_1_last::U256,
1772                tokens_owed_0::U128, tokens_owed_1::U128,
1773                total_amount0_deposited::U256, total_amount1_deposited::U256,
1774                total_amount0_collected::U128, total_amount1_collected::U128
1775            FROM UNNEST(
1776                $5::TEXT[], $6::TEXT[], $7::INT[], $8::INT[], $9::TEXT[], $10::TEXT[],
1777                $11::TEXT[], $12::TEXT[], $13::TEXT[], $14::TEXT[], $15::TEXT[],
1778                $16::TEXT[], $17::TEXT[]
1779            ) AS t(pool_identifier, owner, tick_lower, tick_upper,
1780                   liquidity, fee_growth_inside_0_last, fee_growth_inside_1_last,
1781                   tokens_owed_0, tokens_owed_1,
1782                   total_amount0_deposited, total_amount1_deposited,
1783                   total_amount0_collected, total_amount1_collected)
1784            ON CONFLICT (chain_id, pool_identifier, snapshot_block, snapshot_transaction_index, snapshot_log_index, owner, tick_lower, tick_upper)
1785            DO NOTHING
1786           ",
1787        )
1788        .bind(chain_id as i32)
1789        .bind(snapshot_block as i64)
1790        .bind(snapshot_transaction_index as i32)
1791        .bind(snapshot_log_index as i32)
1792        .bind(&pool_identifiers[..])
1793        .bind(&owners[..])
1794        .bind(&tick_lowers[..])
1795        .bind(&tick_uppers[..])
1796        .bind(&liquidities[..])
1797        .bind(&fee_growth_inside_0_lasts[..])
1798        .bind(&fee_growth_inside_1_lasts[..])
1799        .bind(&tokens_owed_0s[..])
1800        .bind(&tokens_owed_1s[..])
1801        .bind(&total_amount0_depositeds as &[Option<String>])
1802        .bind(&total_amount1_depositeds as &[Option<String>])
1803        .bind(&total_amount0_collecteds as &[Option<String>])
1804        .bind(&total_amount1_collecteds as &[Option<String>])
1805        .execute(&self.pool)
1806        .await
1807        .map(|_| ())
1808        .map_err(|e| anyhow::anyhow!("Failed to batch insert into pool_position table: {e}"))
1809    }
1810
1811    /// Inserts multiple pool ticks in a single database operation using UNNEST for optimal performance.
1812    ///
1813    /// # Errors
1814    ///
1815    /// Returns an error if the database operation fails.
1816    pub async fn add_pool_ticks_batch(
1817        &self,
1818        chain_id: u32,
1819        snapshot_block: u64,
1820        snapshot_transaction_index: u32,
1821        snapshot_log_index: u32,
1822        ticks: &[(PoolIdentifier, &PoolTick)],
1823    ) -> anyhow::Result<()> {
1824        if ticks.is_empty() {
1825            return Ok(());
1826        }
1827
1828        // Prepare vectors for each column
1829        let len = ticks.len();
1830        let mut pool_identifiers: Vec<String> = Vec::with_capacity(len);
1831        let mut tick_values: Vec<i32> = Vec::with_capacity(len);
1832        let mut liquidity_grosses: Vec<String> = Vec::with_capacity(len);
1833        let mut liquidity_nets: Vec<String> = Vec::with_capacity(len);
1834        let mut fee_growth_outside_0s: Vec<String> = Vec::with_capacity(len);
1835        let mut fee_growth_outside_1s: Vec<String> = Vec::with_capacity(len);
1836        let mut initializeds: Vec<bool> = Vec::with_capacity(len);
1837        let mut last_updated_blocks: Vec<i64> = Vec::with_capacity(len);
1838
1839        // Fill vectors from ticks
1840        for (pool_address, tick) in ticks {
1841            pool_identifiers.push(pool_address.to_string());
1842            tick_values.push(tick.value);
1843            liquidity_grosses.push(tick.liquidity_gross.to_string());
1844            liquidity_nets.push(tick.liquidity_net.to_string());
1845            fee_growth_outside_0s.push(tick.fee_growth_outside_0.to_string());
1846            fee_growth_outside_1s.push(tick.fee_growth_outside_1.to_string());
1847            initializeds.push(tick.initialized);
1848            last_updated_blocks.push(tick.last_updated_block as i64);
1849        }
1850
1851        // Execute batch insert with UNNEST
1852        sqlx::query(
1853            "
1854            INSERT INTO pool_tick (
1855                chain_id, pool_identifier, snapshot_block, snapshot_transaction_index, snapshot_log_index,
1856                tick_value, liquidity_gross, liquidity_net,
1857                fee_growth_outside_0, fee_growth_outside_1, initialized, last_updated_block
1858            )
1859            SELECT
1860                $1, pool_identifier, $2, $3, $4,
1861                tick_value, liquidity_gross::U128, liquidity_net::I128,
1862                fee_growth_outside_0::U256, fee_growth_outside_1::U256, initialized, last_updated_block
1863            FROM UNNEST(
1864                $5::TEXT[], $6::INT[], $7::TEXT[], $8::TEXT[], $9::TEXT[],
1865                $10::TEXT[], $11::BOOLEAN[], $12::BIGINT[]
1866            ) AS t(pool_identifier, tick_value, liquidity_gross, liquidity_net,
1867                   fee_growth_outside_0, fee_growth_outside_1, initialized, last_updated_block)
1868            ON CONFLICT (chain_id, pool_identifier, snapshot_block, snapshot_transaction_index, snapshot_log_index, tick_value)
1869            DO NOTHING
1870           ",
1871        )
1872        .bind(chain_id as i32)
1873        .bind(snapshot_block as i64)
1874        .bind(snapshot_transaction_index as i32)
1875        .bind(snapshot_log_index as i32)
1876        .bind(&pool_identifiers[..])
1877        .bind(&tick_values[..])
1878        .bind(&liquidity_grosses[..])
1879        .bind(&liquidity_nets[..])
1880        .bind(&fee_growth_outside_0s[..])
1881        .bind(&fee_growth_outside_1s[..])
1882        .bind(&initializeds[..])
1883        .bind(&last_updated_blocks[..])
1884        .execute(&self.pool)
1885        .await
1886        .map(|_| ())
1887        .map_err(|e| anyhow::anyhow!("Failed to batch insert into pool_tick table: {e}"))
1888    }
1889
1890    /// Updates the initial price and tick for a pool.
1891    ///
1892    /// # Errors
1893    ///
1894    /// Returns an error if the database update fails.
1895    pub async fn update_pool_initial_price_tick(
1896        &self,
1897        chain_id: u32,
1898        initialize_event: &InitializeEvent,
1899    ) -> anyhow::Result<()> {
1900        sqlx::query(
1901            "
1902            UPDATE pool
1903            SET
1904                initial_tick = $4,
1905                initial_sqrt_price_x96 = $5
1906            WHERE chain_id = $1
1907            AND dex_name = $2
1908            AND pool_identifier = $3
1909            ",
1910        )
1911        .bind(chain_id as i32)
1912        .bind(initialize_event.dex.name.to_string())
1913        .bind(initialize_event.pool_identifier.as_ref())
1914        .bind(initialize_event.tick)
1915        .bind(initialize_event.sqrt_price_x96.to_string())
1916        .execute(&self.pool)
1917        .await
1918        .map(|_| ())
1919        .map_err(|e| anyhow::anyhow!("Failed to update pool initial price and tick: {e}"))
1920    }
1921
1922    /// Loads the latest usable pool snapshot from the database.
1923    ///
1924    /// Returns the most recent snapshot usable as a replay start point: on-chain validated or
1925    /// replay-derived. Snapshots that failed on-chain validation are excluded.
1926    ///
1927    /// # Errors
1928    ///
1929    /// Returns an error if the database query fails.
1930    pub async fn load_latest_valid_pool_snapshot(
1931        &self,
1932        chain_id: u32,
1933        pool_identifier: &PoolIdentifier,
1934    ) -> anyhow::Result<Option<PoolSnapshot>> {
1935        self.load_latest_pool_snapshot(chain_id, pool_identifier, None, true)
1936            .await
1937    }
1938
1939    /// Loads the latest pool snapshot from the database, optionally bounded by block.
1940    ///
1941    /// When `max_block` is `Some`, only snapshots at or before that block are considered, so a
1942    /// backtest can restore pool state as of a replay start. When `require_valid` is `true`,
1943    /// snapshots that failed on-chain validation are excluded (both on-chain validated and
1944    /// replay-derived snapshots are returned).
1945    ///
1946    /// # Errors
1947    ///
1948    /// Returns an error if the database query fails.
1949    pub async fn load_latest_pool_snapshot(
1950        &self,
1951        chain_id: u32,
1952        pool_identifier: &PoolIdentifier,
1953        max_block: Option<u64>,
1954        require_valid: bool,
1955    ) -> anyhow::Result<Option<PoolSnapshot>> {
1956        let allow_invalid = !require_valid;
1957        let result = sqlx::query(
1958            "
1959            SELECT
1960                block, transaction_index, log_index, transaction_hash,
1961                current_tick, price_sqrt_ratio_x96::TEXT, liquidity::TEXT,
1962                protocol_fees_token0::TEXT, protocol_fees_token1::TEXT, fee_protocol,
1963                fee_growth_global_0::TEXT, fee_growth_global_1::TEXT,
1964                total_amount0_deposited::TEXT, total_amount1_deposited::TEXT,
1965                total_amount0_collected::TEXT, total_amount1_collected::TEXT,
1966                total_swaps, total_mints, total_burns, total_fee_collects, total_flashes,
1967                liquidity_utilization_rate,
1968                (SELECT dex_name FROM pool WHERE chain_id = $1 AND address = $2) as dex_name,
1969                COALESCE(
1970                    (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_snapshot.chain_id AND block.number = pool_snapshot.block),
1971                    (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_snapshot.chain_id AND pool_event_block.number = pool_snapshot.block)
1972                ) as block_timestamp
1973            FROM pool_snapshot
1974            WHERE chain_id = $1 AND pool_identifier = $2
1975                AND ($3::BIGINT IS NULL OR block <= $3)
1976                AND ($4 OR validation_state <> 'invalid')
1977            ORDER BY block DESC, transaction_index DESC, log_index DESC
1978            LIMIT 1
1979            ",
1980        )
1981        .bind(chain_id as i32)
1982        .bind(pool_identifier.as_ref())
1983        .bind(max_block.map(|b| b as i64))
1984        .bind(allow_invalid)
1985        .fetch_optional(&self.pool)
1986        .await
1987        .map_err(|e| anyhow::anyhow!("Failed to load latest valid pool snapshot: {e}"))?;
1988
1989        if let Some(row) = result {
1990            // Parse snapshot state
1991            let block: i64 = row.get("block");
1992            let transaction_index: i32 = row.get("transaction_index");
1993            let log_index: i32 = row.get("log_index");
1994            let transaction_hash: String = row.get("transaction_hash");
1995            let block_timestamp = row
1996                .try_get::<Option<String>, _>("block_timestamp")?
1997                .ok_or_else(|| {
1998                    anyhow::anyhow!(
1999                        "Missing block timestamp for pool snapshot {} at block {}",
2000                        pool_identifier,
2001                        block
2002                    )
2003                })?;
2004            let timestamp = parse_cached_block_timestamp(&block_timestamp)
2005                .map_err(|e| anyhow::anyhow!("Invalid block timestamp '{block_timestamp}': {e}"))?;
2006
2007            let block_position = BlockPosition::new(
2008                block as u64,
2009                transaction_hash,
2010                transaction_index as u32,
2011                log_index as u32,
2012            );
2013
2014            let state = PoolState {
2015                current_tick: row.get("current_tick"),
2016                price_sqrt_ratio_x96: row.get::<String, _>("price_sqrt_ratio_x96").parse()?,
2017                liquidity: row.get::<String, _>("liquidity").parse()?,
2018                protocol_fees_token0: row.get::<String, _>("protocol_fees_token0").parse()?,
2019                protocol_fees_token1: row.get::<String, _>("protocol_fees_token1").parse()?,
2020                fee_protocol: row.get::<i16, _>("fee_protocol") as u8,
2021                fee_growth_global_0: row.get::<String, _>("fee_growth_global_0").parse()?,
2022                fee_growth_global_1: row.get::<String, _>("fee_growth_global_1").parse()?,
2023            };
2024
2025            let analytics = PoolAnalytics {
2026                total_amount0_deposited: row.get::<String, _>("total_amount0_deposited").parse()?,
2027                total_amount1_deposited: row.get::<String, _>("total_amount1_deposited").parse()?,
2028                total_amount0_collected: row.get::<String, _>("total_amount0_collected").parse()?,
2029                total_amount1_collected: row.get::<String, _>("total_amount1_collected").parse()?,
2030                total_swaps: row.get::<i32, _>("total_swaps") as u64,
2031                total_mints: row.get::<i32, _>("total_mints") as u64,
2032                total_burns: row.get::<i32, _>("total_burns") as u64,
2033                total_fee_collects: row.get::<i32, _>("total_fee_collects") as u64,
2034                total_flashes: row.get::<i32, _>("total_flashes") as u64,
2035                liquidity_utilization_rate: row.get::<f64, _>("liquidity_utilization_rate"),
2036            };
2037
2038            // Load positions and ticks
2039            let positions = self
2040                .load_pool_positions_for_snapshot(
2041                    chain_id,
2042                    pool_identifier,
2043                    block as u64,
2044                    transaction_index as u32,
2045                    log_index as u32,
2046                )
2047                .await?;
2048
2049            let ticks = self
2050                .load_pool_ticks_for_snapshot(
2051                    chain_id,
2052                    pool_identifier,
2053                    block as u64,
2054                    transaction_index as u32,
2055                    log_index as u32,
2056                )
2057                .await?;
2058
2059            let dex_name = row
2060                .try_get::<Option<String>, _>("dex_name")?
2061                .ok_or_else(|| {
2062                    anyhow::anyhow!("Missing dex_name for pool snapshot {}", pool_identifier)
2063                })?;
2064            let chain = Chain::from_chain_id(chain_id)
2065                .ok_or_else(|| anyhow::anyhow!("Unknown chain_id: {chain_id}"))?;
2066
2067            let dex_type = DexType::from_dex_name(&dex_name)
2068                .ok_or_else(|| anyhow::anyhow!("Unknown dex_name: {dex_name}"))?;
2069
2070            let dex_extended = crate::exchanges::get_dex_extended(chain.name, &dex_type)
2071                .ok_or_else(|| {
2072                    anyhow::anyhow!("No DEX extended found for {} on {}", dex_name, chain.name)
2073                })?;
2074
2075            let instrument_id =
2076                Pool::create_instrument_id(chain.name, &dex_extended.dex, pool_identifier.as_ref());
2077
2078            Ok(Some(PoolSnapshot::new(
2079                instrument_id,
2080                state,
2081                positions,
2082                ticks,
2083                analytics,
2084                block_position,
2085                timestamp, // ts_event
2086                timestamp, // ts_init (same block timestamp)
2087            )))
2088        } else {
2089            Ok(None)
2090        }
2091    }
2092
2093    /// Sets the validation state of a pool snapshot after a validation attempt.
2094    ///
2095    /// `state` is one of `on_chain` (hydrated and matched), `replay` (replay-derived, not checked),
2096    /// or `invalid` (hydrated and mismatched).
2097    ///
2098    /// # Errors
2099    ///
2100    /// Returns an error if the database operation fails.
2101    pub async fn set_pool_snapshot_validation_state(
2102        &self,
2103        chain_id: u32,
2104        pool_identifier: &PoolIdentifier,
2105        block: u64,
2106        transaction_index: u32,
2107        log_index: u32,
2108        state: &str,
2109    ) -> anyhow::Result<()> {
2110        sqlx::query(
2111            "
2112            UPDATE pool_snapshot
2113            SET validation_state = $6
2114            WHERE chain_id = $1
2115            AND pool_identifier = $2
2116            AND block = $3
2117            AND transaction_index = $4
2118            AND log_index = $5
2119            ",
2120        )
2121        .bind(chain_id as i32)
2122        .bind(pool_identifier.as_ref())
2123        .bind(block as i64)
2124        .bind(transaction_index as i32)
2125        .bind(log_index as i32)
2126        .bind(state)
2127        .execute(&self.pool)
2128        .await
2129        .map(|_| ())
2130        .map_err(|e| anyhow::anyhow!("Failed to set pool snapshot validation state: {e}"))
2131    }
2132
2133    /// Reads the stored `validation_state` for the snapshot at the given watermark.
2134    ///
2135    /// Returns `None` when no snapshot row exists at that position. Used to report the persisted
2136    /// verdict (rather than re-deriving `replay`) when on-chain validation cannot reach the block.
2137    ///
2138    /// # Errors
2139    ///
2140    /// Returns an error if the database query fails.
2141    pub async fn get_pool_snapshot_validation_state(
2142        &self,
2143        chain_id: u32,
2144        pool_identifier: &PoolIdentifier,
2145        block: u64,
2146        transaction_index: u32,
2147        log_index: u32,
2148    ) -> anyhow::Result<Option<String>> {
2149        let row = sqlx::query(
2150            "
2151            SELECT validation_state
2152            FROM pool_snapshot
2153            WHERE chain_id = $1
2154            AND pool_identifier = $2
2155            AND block = $3
2156            AND transaction_index = $4
2157            AND log_index = $5
2158            ",
2159        )
2160        .bind(chain_id as i32)
2161        .bind(pool_identifier.as_ref())
2162        .bind(block as i64)
2163        .bind(transaction_index as i32)
2164        .bind(log_index as i32)
2165        .fetch_optional(&self.pool)
2166        .await
2167        .map_err(|e| anyhow::anyhow!("Failed to get pool snapshot validation state: {e}"))?;
2168
2169        Ok(row.map(|row| row.get::<String, _>("validation_state")))
2170    }
2171
2172    /// Loads all positions for a specific snapshot.
2173    ///
2174    /// # Errors
2175    ///
2176    /// Returns an error if the database query fails.
2177    pub async fn load_pool_positions_for_snapshot(
2178        &self,
2179        chain_id: u32,
2180        pool_identifier: &PoolIdentifier,
2181        snapshot_block: u64,
2182        snapshot_transaction_index: u32,
2183        snapshot_log_index: u32,
2184    ) -> anyhow::Result<Vec<PoolPosition>> {
2185        let rows = sqlx::query(
2186            "
2187            SELECT
2188                owner, tick_lower, tick_upper,
2189                liquidity::TEXT, fee_growth_inside_0_last::TEXT, fee_growth_inside_1_last::TEXT,
2190                tokens_owed_0::TEXT, tokens_owed_1::TEXT,
2191                total_amount0_deposited::TEXT, total_amount1_deposited::TEXT,
2192                total_amount0_collected::TEXT, total_amount1_collected::TEXT
2193            FROM pool_position
2194            WHERE chain_id = $1
2195            AND pool_identifier = $2
2196            AND snapshot_block = $3
2197            AND snapshot_transaction_index = $4
2198            AND snapshot_log_index = $5
2199            ",
2200        )
2201        .bind(chain_id as i32)
2202        .bind(pool_identifier.as_ref())
2203        .bind(snapshot_block as i64)
2204        .bind(snapshot_transaction_index as i32)
2205        .bind(snapshot_log_index as i32)
2206        .fetch_all(&self.pool)
2207        .await
2208        .map_err(|e| anyhow::anyhow!("Failed to load pool positions: {e}"))?;
2209
2210        rows.iter()
2211            .map(|row| {
2212                let owner: String = row.get("owner");
2213                let position = PoolPosition {
2214                    owner: validate_address(&owner)?,
2215                    tick_lower: row.get("tick_lower"),
2216                    tick_upper: row.get("tick_upper"),
2217                    liquidity: row.get::<String, _>("liquidity").parse()?,
2218                    fee_growth_inside_0_last: row
2219                        .get::<String, _>("fee_growth_inside_0_last")
2220                        .parse()?,
2221                    fee_growth_inside_1_last: row
2222                        .get::<String, _>("fee_growth_inside_1_last")
2223                        .parse()?,
2224                    tokens_owed_0: row.get::<String, _>("tokens_owed_0").parse()?,
2225                    tokens_owed_1: row.get::<String, _>("tokens_owed_1").parse()?,
2226                    total_amount0_deposited: row
2227                        .get::<String, _>("total_amount0_deposited")
2228                        .parse()?,
2229                    total_amount1_deposited: row
2230                        .get::<String, _>("total_amount1_deposited")
2231                        .parse()?,
2232                    total_amount0_collected: row
2233                        .get::<String, _>("total_amount0_collected")
2234                        .parse()?,
2235                    total_amount1_collected: row
2236                        .get::<String, _>("total_amount1_collected")
2237                        .parse()?,
2238                };
2239                Ok(position)
2240            })
2241            .collect()
2242    }
2243
2244    /// Loads all ticks for a specific snapshot.
2245    ///
2246    /// # Errors
2247    ///
2248    /// Returns an error if the database query fails.
2249    pub async fn load_pool_ticks_for_snapshot(
2250        &self,
2251        chain_id: u32,
2252        pool_identifier: &PoolIdentifier,
2253        snapshot_block: u64,
2254        snapshot_transaction_index: u32,
2255        snapshot_log_index: u32,
2256    ) -> anyhow::Result<Vec<PoolTick>> {
2257        let rows = sqlx::query(
2258            "
2259            SELECT
2260                tick_value, liquidity_gross::TEXT, liquidity_net::TEXT,
2261                fee_growth_outside_0::TEXT, fee_growth_outside_1::TEXT, initialized,
2262                last_updated_block
2263            FROM pool_tick
2264            WHERE chain_id = $1
2265            AND pool_identifier = $2
2266            AND snapshot_block = $3
2267            AND snapshot_transaction_index = $4
2268            AND snapshot_log_index = $5
2269            ",
2270        )
2271        .bind(chain_id as i32)
2272        .bind(pool_identifier.as_ref())
2273        .bind(snapshot_block as i64)
2274        .bind(snapshot_transaction_index as i32)
2275        .bind(snapshot_log_index as i32)
2276        .fetch_all(&self.pool)
2277        .await
2278        .map_err(|e| anyhow::anyhow!("Failed to load pool ticks: {e}"))?;
2279
2280        rows.iter()
2281            .map(|row| {
2282                let tick = PoolTick::new(
2283                    row.get("tick_value"),
2284                    row.get::<String, _>("liquidity_gross").parse()?,
2285                    row.get::<String, _>("liquidity_net").parse()?,
2286                    row.get::<String, _>("fee_growth_outside_0").parse()?,
2287                    row.get::<String, _>("fee_growth_outside_1").parse()?,
2288                    row.get("initialized"),
2289                    row.get::<i64, _>("last_updated_block") as u64,
2290                );
2291                Ok(tick)
2292            })
2293            .collect()
2294    }
2295
2296    /// Streams pool events from all event tables (swap, liquidity, collect) for a specific pool.
2297    ///
2298    /// Creates a unified stream of pool events from multiple tables, ordering them chronologically
2299    /// by block number, transaction index, and log index. Optionally resumes from a specific
2300    /// block position and stops at a maximum block.
2301    ///
2302    /// # Returns
2303    ///
2304    /// A stream of `DexPoolData` events in chronological order.
2305    ///
2306    /// # Errors
2307    ///
2308    /// Returns an error if the database query fails or if event transformation fails.
2309    pub fn stream_pool_events<'a>(
2310        &'a self,
2311        chain: SharedChain,
2312        dex: SharedDex,
2313        instrument_id: InstrumentId,
2314        pool_identifier: PoolIdentifier,
2315        from_position: Option<BlockPosition>,
2316        to_block: Option<u64>,
2317    ) -> Pin<Box<dyn Stream<Item = Result<DexPoolData, anyhow::Error>> + Send + 'a>> {
2318        const QUERY_ALL: &str = "
2319            (SELECT
2320                'swap' as event_type,
2321                chain_id,
2322                pool_identifier,
2323                block,
2324                transaction_hash,
2325                transaction_index,
2326                log_index,
2327                COALESCE(
2328                    (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_swap_event.chain_id AND block.number = pool_swap_event.block),
2329                    (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_swap_event.chain_id AND pool_event_block.number = pool_swap_event.block)
2330                ) as block_timestamp,
2331                sender,
2332                recipient,
2333                NULL::TEXT as owner,
2334                sqrt_price_x96::TEXT,
2335                liquidity::TEXT as swap_liquidity,
2336                tick as swap_tick,
2337                amount0::TEXT as swap_amount0,
2338                amount1::TEXT as swap_amount1,
2339                NULL::TEXT as position_liquidity,
2340                NULL::TEXT as amount0,
2341                NULL::TEXT as amount1,
2342                NULL::INT as tick_lower,
2343                NULL::INT as tick_upper,
2344                NULL::TEXT as liquidity_event_type,
2345                NULL::TEXT as flash_amount0,
2346                NULL::TEXT as flash_amount1,
2347                NULL::TEXT as flash_paid0,
2348                NULL::TEXT as flash_paid1,
2349                NULL::SMALLINT as fee_protocol0_new,
2350                NULL::SMALLINT as fee_protocol1_new
2351            FROM pool_swap_event
2352            WHERE chain_id = $1 AND pool_identifier = $2
2353            AND ($3::BIGINT IS NULL OR block <= $3))
2354            UNION ALL
2355            (SELECT
2356                'liquidity' as event_type,
2357                chain_id,
2358                pool_identifier,
2359                block,
2360                transaction_hash,
2361                transaction_index,
2362                log_index,
2363                COALESCE(
2364                    (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_liquidity_event.chain_id AND block.number = pool_liquidity_event.block),
2365                    (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_liquidity_event.chain_id AND pool_event_block.number = pool_liquidity_event.block)
2366                ) as block_timestamp,
2367                sender,
2368                NULL::TEXT as recipient,
2369                owner,
2370                NULL::text as sqrt_price_x96,
2371                NULL::TEXT as swap_liquidity,
2372                NULL::INT as swap_tick,
2373                amount0::TEXT as swap_amount0,
2374                amount1::TEXT as swap_amount1,
2375                position_liquidity::TEXT,
2376                amount0::TEXT,
2377                amount1::TEXT,
2378                tick_lower::INT,
2379                tick_upper::INT,
2380                event_type as liquidity_event_type,
2381                NULL::TEXT as flash_amount0,
2382                NULL::TEXT as flash_amount1,
2383                NULL::TEXT as flash_paid0,
2384                NULL::TEXT as flash_paid1,
2385                NULL::SMALLINT as fee_protocol0_new,
2386                NULL::SMALLINT as fee_protocol1_new
2387            FROM pool_liquidity_event
2388            WHERE chain_id = $1 AND pool_identifier = $2
2389            AND ($3::BIGINT IS NULL OR block <= $3))
2390            UNION ALL
2391            (SELECT
2392                'collect' as event_type,
2393                chain_id,
2394                pool_identifier,
2395                block,
2396                transaction_hash,
2397                transaction_index,
2398                log_index,
2399                COALESCE(
2400                    (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_collect_event.chain_id AND block.number = pool_collect_event.block),
2401                    (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_collect_event.chain_id AND pool_event_block.number = pool_collect_event.block)
2402                ) as block_timestamp,
2403                NULL::TEXT as sender,
2404                NULL::TEXT as recipient,
2405                owner,
2406                NULL::TEXT as sqrt_price_x96,
2407                NULL::TEXT as swap_liquidity,
2408                NULL::INT AS swap_tick,
2409                amount0::TEXT as swap_amount0,
2410                amount1::TEXT as swap_amount1,
2411                NULL::TEXT as position_liquidity,
2412                amount0::TEXT,
2413                amount1::TEXT,
2414                tick_lower::INT,
2415                tick_upper::INT,
2416                NULL::TEXT as liquidity_event_type,
2417                NULL::TEXT as flash_amount0,
2418                NULL::TEXT as flash_amount1,
2419                NULL::TEXT as flash_paid0,
2420                NULL::TEXT as flash_paid1,
2421                NULL::SMALLINT as fee_protocol0_new,
2422                NULL::SMALLINT as fee_protocol1_new
2423            FROM pool_collect_event
2424            WHERE chain_id = $1 AND pool_identifier = $2
2425            AND ($3::BIGINT IS NULL OR block <= $3))
2426            UNION ALL
2427            (SELECT
2428                'fee_protocol_update' as event_type,
2429                chain_id,
2430                pool_identifier,
2431                block,
2432                transaction_hash,
2433                transaction_index,
2434                log_index,
2435                COALESCE(
2436                    (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_fee_protocol_update_event.chain_id AND block.number = pool_fee_protocol_update_event.block),
2437                    (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_fee_protocol_update_event.chain_id AND pool_event_block.number = pool_fee_protocol_update_event.block)
2438                ) as block_timestamp,
2439                NULL::TEXT as sender,
2440                NULL::TEXT as recipient,
2441                NULL::TEXT as owner,
2442                NULL::TEXT as sqrt_price_x96,
2443                NULL::TEXT as swap_liquidity,
2444                NULL::INT AS swap_tick,
2445                NULL::TEXT as swap_amount0,
2446                NULL::TEXT as swap_amount1,
2447                NULL::TEXT as position_liquidity,
2448                NULL::TEXT as amount0,
2449                NULL::TEXT as amount1,
2450                NULL::INT as tick_lower,
2451                NULL::INT as tick_upper,
2452                NULL::TEXT as liquidity_event_type,
2453                NULL::TEXT as flash_amount0,
2454                NULL::TEXT as flash_amount1,
2455                NULL::TEXT as flash_paid0,
2456                NULL::TEXT as flash_paid1,
2457                fee_protocol0_new::SMALLINT,
2458                fee_protocol1_new::SMALLINT
2459            FROM pool_fee_protocol_update_event
2460            WHERE chain_id = $1 AND pool_identifier = $2
2461            AND ($3::BIGINT IS NULL OR block <= $3))
2462            UNION ALL
2463            (SELECT
2464                'fee_protocol_collect' as event_type,
2465                chain_id,
2466                pool_identifier,
2467                block,
2468                transaction_hash,
2469                transaction_index,
2470                log_index,
2471                COALESCE(
2472                    (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_fee_protocol_collect_event.chain_id AND block.number = pool_fee_protocol_collect_event.block),
2473                    (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_fee_protocol_collect_event.chain_id AND pool_event_block.number = pool_fee_protocol_collect_event.block)
2474                ) as block_timestamp,
2475                sender,
2476                recipient,
2477                NULL::TEXT as owner,
2478                NULL::TEXT as sqrt_price_x96,
2479                NULL::TEXT as swap_liquidity,
2480                NULL::INT AS swap_tick,
2481                NULL::TEXT as swap_amount0,
2482                NULL::TEXT as swap_amount1,
2483                NULL::TEXT as position_liquidity,
2484                amount0::TEXT,
2485                amount1::TEXT,
2486                NULL::INT as tick_lower,
2487                NULL::INT as tick_upper,
2488                NULL::TEXT as liquidity_event_type,
2489                NULL::TEXT as flash_amount0,
2490                NULL::TEXT as flash_amount1,
2491                NULL::TEXT as flash_paid0,
2492                NULL::TEXT as flash_paid1,
2493                NULL::SMALLINT as fee_protocol0_new,
2494                NULL::SMALLINT as fee_protocol1_new
2495            FROM pool_fee_protocol_collect_event
2496            WHERE chain_id = $1 AND pool_identifier = $2
2497            AND ($3::BIGINT IS NULL OR block <= $3))
2498            UNION ALL
2499            (SELECT
2500                'flash' as event_type,
2501                chain_id,
2502                pool_identifier,
2503                block,
2504                transaction_hash,
2505                transaction_index,
2506                log_index,
2507                COALESCE(
2508                    (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_flash_event.chain_id AND block.number = pool_flash_event.block),
2509                    (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_flash_event.chain_id AND pool_event_block.number = pool_flash_event.block)
2510                ) as block_timestamp,
2511                sender,
2512                recipient,
2513                NULL::TEXT as owner,
2514                NULL::TEXT as sqrt_price_x96,
2515                NULL::TEXT as swap_liquidity,
2516                NULL::INT AS swap_tick,
2517                NULL::TEXT as swap_amount0,
2518                NULL::TEXT as swap_amount1,
2519                NULL::TEXT as position_liquidity,
2520                NULL::TEXT as amount0,
2521                NULL::TEXT as amount1,
2522                NULL::INT as tick_lower,
2523                NULL::INT as tick_upper,
2524                NULL::TEXT as liquidity_event_type,
2525                amount0::TEXT as flash_amount0,
2526                amount1::TEXT as flash_amount1,
2527                paid0::TEXT as flash_paid0,
2528                paid1::TEXT as flash_paid1,
2529                NULL::SMALLINT as fee_protocol0_new,
2530                NULL::SMALLINT as fee_protocol1_new
2531            FROM pool_flash_event
2532            WHERE chain_id = $1 AND pool_identifier = $2
2533            AND ($3::BIGINT IS NULL OR block <= $3))
2534            ORDER BY block, transaction_index, log_index";
2535
2536        const QUERY_FROM_POSITION: &str = "
2537            (SELECT
2538                'swap' as event_type,
2539                chain_id,
2540                pool_identifier,
2541                block,
2542                transaction_hash,
2543                transaction_index,
2544                log_index,
2545                COALESCE(
2546                    (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_swap_event.chain_id AND block.number = pool_swap_event.block),
2547                    (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_swap_event.chain_id AND pool_event_block.number = pool_swap_event.block)
2548                ) as block_timestamp,
2549                sender,
2550                recipient,
2551                NULL::TEXT as owner,
2552                sqrt_price_x96::TEXT,
2553                liquidity::TEXT as swap_liquidity,
2554                tick as swap_tick,
2555                amount0::TEXT as swap_amount0,
2556                amount1::TEXT as swap_amount1,
2557                NULL::TEXT as position_liquidity,
2558                NULL::TEXT as amount0,
2559                NULL::TEXT as amount1,
2560                NULL::INT as tick_lower,
2561                NULL::INT as tick_upper,
2562                NULL::TEXT as liquidity_event_type,
2563                NULL::TEXT as flash_amount0,
2564                NULL::TEXT as flash_amount1,
2565                NULL::TEXT as flash_paid0,
2566                NULL::TEXT as flash_paid1,
2567                NULL::SMALLINT as fee_protocol0_new,
2568                NULL::SMALLINT as fee_protocol1_new
2569            FROM pool_swap_event
2570            WHERE chain_id = $1 AND pool_identifier = $2
2571            AND (block > $3 OR (block = $3 AND transaction_index > $4) OR (block = $3 AND transaction_index = $4 AND log_index > $5))
2572            AND ($6::BIGINT IS NULL OR block <= $6))
2573            UNION ALL
2574            (SELECT
2575                'liquidity' as event_type,
2576                chain_id,
2577                pool_identifier,
2578                block,
2579                transaction_hash,
2580                transaction_index,
2581                log_index,
2582                COALESCE(
2583                    (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_liquidity_event.chain_id AND block.number = pool_liquidity_event.block),
2584                    (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_liquidity_event.chain_id AND pool_event_block.number = pool_liquidity_event.block)
2585                ) as block_timestamp,
2586                sender,
2587                NULL::TEXT as recipient,
2588                owner,
2589                NULL::text as sqrt_price_x96,
2590                NULL::TEXT as swap_liquidity,
2591                NULL::INT as swap_tick,
2592                amount0::TEXT as swap_amount0,
2593                amount1::TEXT as swap_amount1,
2594                position_liquidity::TEXT,
2595                amount0::TEXT,
2596                amount1::TEXT,
2597                tick_lower::INT,
2598                tick_upper::INT,
2599                event_type as liquidity_event_type,
2600                NULL::TEXT as flash_amount0,
2601                NULL::TEXT as flash_amount1,
2602                NULL::TEXT as flash_paid0,
2603                NULL::TEXT as flash_paid1,
2604                NULL::SMALLINT as fee_protocol0_new,
2605                NULL::SMALLINT as fee_protocol1_new
2606            FROM pool_liquidity_event
2607            WHERE chain_id = $1 AND pool_identifier = $2
2608            AND (block > $3 OR (block = $3 AND transaction_index > $4) OR (block = $3 AND transaction_index = $4 AND log_index > $5))
2609            AND ($6::BIGINT IS NULL OR block <= $6))
2610            UNION ALL
2611            (SELECT
2612                'collect' as event_type,
2613                chain_id,
2614                pool_identifier,
2615                block,
2616                transaction_hash,
2617                transaction_index,
2618                log_index,
2619                COALESCE(
2620                    (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_collect_event.chain_id AND block.number = pool_collect_event.block),
2621                    (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_collect_event.chain_id AND pool_event_block.number = pool_collect_event.block)
2622                ) as block_timestamp,
2623                NULL::TEXT as sender,
2624                NULL::TEXT as recipient,
2625                owner,
2626                NULL::TEXT as sqrt_price_x96,
2627                NULL::TEXT as swap_liquidity,
2628                NULL::INT AS swap_tick,
2629                amount0::TEXT as swap_amount0,
2630                amount1::TEXT as swap_amount1,
2631                NULL::TEXT as position_liquidity,
2632                amount0::TEXT,
2633                amount1::TEXT,
2634                tick_lower::INT,
2635                tick_upper::INT,
2636                NULL::TEXT as liquidity_event_type,
2637                NULL::TEXT as flash_amount0,
2638                NULL::TEXT as flash_amount1,
2639                NULL::TEXT as flash_paid0,
2640                NULL::TEXT as flash_paid1,
2641                NULL::SMALLINT as fee_protocol0_new,
2642                NULL::SMALLINT as fee_protocol1_new
2643            FROM pool_collect_event
2644            WHERE chain_id = $1 AND pool_identifier = $2
2645            AND (block > $3 OR (block = $3 AND transaction_index > $4) OR (block = $3 AND transaction_index = $4 AND log_index > $5))
2646            AND ($6::BIGINT IS NULL OR block <= $6))
2647            UNION ALL
2648            (SELECT
2649                'fee_protocol_update' as event_type,
2650                chain_id,
2651                pool_identifier,
2652                block,
2653                transaction_hash,
2654                transaction_index,
2655                log_index,
2656                COALESCE(
2657                    (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_fee_protocol_update_event.chain_id AND block.number = pool_fee_protocol_update_event.block),
2658                    (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_fee_protocol_update_event.chain_id AND pool_event_block.number = pool_fee_protocol_update_event.block)
2659                ) as block_timestamp,
2660                NULL::TEXT as sender,
2661                NULL::TEXT as recipient,
2662                NULL::TEXT as owner,
2663                NULL::TEXT as sqrt_price_x96,
2664                NULL::TEXT as swap_liquidity,
2665                NULL::INT AS swap_tick,
2666                NULL::TEXT as swap_amount0,
2667                NULL::TEXT as swap_amount1,
2668                NULL::TEXT as position_liquidity,
2669                NULL::TEXT as amount0,
2670                NULL::TEXT as amount1,
2671                NULL::INT as tick_lower,
2672                NULL::INT as tick_upper,
2673                NULL::TEXT as liquidity_event_type,
2674                NULL::TEXT as flash_amount0,
2675                NULL::TEXT as flash_amount1,
2676                NULL::TEXT as flash_paid0,
2677                NULL::TEXT as flash_paid1,
2678                fee_protocol0_new::SMALLINT,
2679                fee_protocol1_new::SMALLINT
2680            FROM pool_fee_protocol_update_event
2681            WHERE chain_id = $1 AND pool_identifier = $2
2682            AND (block > $3 OR (block = $3 AND transaction_index > $4) OR (block = $3 AND transaction_index = $4 AND log_index > $5))
2683            AND ($6::BIGINT IS NULL OR block <= $6))
2684            UNION ALL
2685            (SELECT
2686                'fee_protocol_collect' as event_type,
2687                chain_id,
2688                pool_identifier,
2689                block,
2690                transaction_hash,
2691                transaction_index,
2692                log_index,
2693                COALESCE(
2694                    (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_fee_protocol_collect_event.chain_id AND block.number = pool_fee_protocol_collect_event.block),
2695                    (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_fee_protocol_collect_event.chain_id AND pool_event_block.number = pool_fee_protocol_collect_event.block)
2696                ) as block_timestamp,
2697                sender,
2698                recipient,
2699                NULL::TEXT as owner,
2700                NULL::TEXT as sqrt_price_x96,
2701                NULL::TEXT as swap_liquidity,
2702                NULL::INT AS swap_tick,
2703                NULL::TEXT as swap_amount0,
2704                NULL::TEXT as swap_amount1,
2705                NULL::TEXT as position_liquidity,
2706                amount0::TEXT,
2707                amount1::TEXT,
2708                NULL::INT as tick_lower,
2709                NULL::INT as tick_upper,
2710                NULL::TEXT as liquidity_event_type,
2711                NULL::TEXT as flash_amount0,
2712                NULL::TEXT as flash_amount1,
2713                NULL::TEXT as flash_paid0,
2714                NULL::TEXT as flash_paid1,
2715                NULL::SMALLINT as fee_protocol0_new,
2716                NULL::SMALLINT as fee_protocol1_new
2717            FROM pool_fee_protocol_collect_event
2718            WHERE chain_id = $1 AND pool_identifier = $2
2719            AND (block > $3 OR (block = $3 AND transaction_index > $4) OR (block = $3 AND transaction_index = $4 AND log_index > $5))
2720            AND ($6::BIGINT IS NULL OR block <= $6))
2721            UNION ALL
2722            (SELECT
2723                'flash' as event_type,
2724                chain_id,
2725                pool_identifier,
2726                block,
2727                transaction_hash,
2728                transaction_index,
2729                log_index,
2730                COALESCE(
2731                    (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_flash_event.chain_id AND block.number = pool_flash_event.block),
2732                    (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_flash_event.chain_id AND pool_event_block.number = pool_flash_event.block)
2733                ) as block_timestamp,
2734                sender,
2735                recipient,
2736                NULL::TEXT as owner,
2737                NULL::TEXT as sqrt_price_x96,
2738                NULL::TEXT as swap_liquidity,
2739                NULL::INT AS swap_tick,
2740                NULL::TEXT as swap_amount0,
2741                NULL::TEXT as swap_amount1,
2742                NULL::TEXT as position_liquidity,
2743                NULL::TEXT as amount0,
2744                NULL::TEXT as amount1,
2745                NULL::INT as tick_lower,
2746                NULL::INT as tick_upper,
2747                NULL::TEXT as liquidity_event_type,
2748                amount0::TEXT as flash_amount0,
2749                amount1::TEXT as flash_amount1,
2750                paid0::TEXT as flash_paid0,
2751                paid1::TEXT as flash_paid1,
2752                NULL::SMALLINT as fee_protocol0_new,
2753                NULL::SMALLINT as fee_protocol1_new
2754            FROM pool_flash_event
2755            WHERE chain_id = $1 AND pool_identifier = $2
2756            AND (block > $3 OR (block = $3 AND transaction_index > $4) OR (block = $3 AND transaction_index = $4 AND log_index > $5))
2757            AND ($6::BIGINT IS NULL OR block <= $6))
2758            ORDER BY block, transaction_index, log_index";
2759
2760        // Build query with appropriate bindings
2761        let query = if let Some(pos) = from_position {
2762            sqlx::query(QUERY_FROM_POSITION)
2763                .bind(chain.chain_id as i32)
2764                .bind(pool_identifier.to_string())
2765                .bind(pos.number as i64)
2766                .bind(pos.transaction_index as i32)
2767                .bind(pos.log_index as i32)
2768                .bind(to_block.map(|block| block as i64))
2769                .fetch(&self.pool)
2770        } else {
2771            sqlx::query(QUERY_ALL)
2772                .bind(chain.chain_id as i32)
2773                .bind(pool_identifier.to_string())
2774                .bind(to_block.map(|block| block as i64))
2775                .fetch(&self.pool)
2776        };
2777
2778        // Transform rows to events
2779        let stream = query.map(move |row_result| match row_result {
2780            Ok(row) => {
2781                transform_row_to_dex_pool_data(&row, chain.clone(), dex.clone(), instrument_id)
2782                    .map_err(|e| anyhow::anyhow!("Steam pool event transform error: {e}"))
2783            }
2784            Err(e) => Err(anyhow::anyhow!("Stream pool events database error: {e}")),
2785        });
2786
2787        Box::pin(stream)
2788    }
2789}
2790
2791#[cfg(test)]
2792mod tests {
2793    use sqlx::postgres::PgConnectOptions;
2794
2795    use super::BlockchainCacheDatabase;
2796
2797    #[tokio::test]
2798    async fn connect_returns_err_for_unreachable_database() {
2799        // `connect` backs the Python `load_pool_snapshot` binding, so a connection
2800        // failure must surface as `Err` rather than panicking across the API boundary.
2801        let options = PgConnectOptions::new()
2802            .host("127.0.0.1")
2803            .port(1)
2804            .username("nautilus")
2805            .password("pass")
2806            .database("nautilus");
2807
2808        let result = BlockchainCacheDatabase::connect(options).await;
2809
2810        assert!(result.is_err());
2811    }
2812}