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