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