Skip to main content

nautilus_blockchain/services/
pool_discovery.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::{cmp::max, collections::HashSet};
17
18use alloy::primitives::Address;
19use futures_util::StreamExt;
20use nautilus_core::string::formatting::Separable;
21use nautilus_model::defi::{
22    Block, SharedDex,
23    amm::Pool,
24    chain::SharedChain,
25    reporting::{BlockchainSyncReportItems, BlockchainSyncReporter},
26    token::Token,
27};
28use tokio_util::sync::CancellationToken;
29
30use crate::{
31    cache::BlockchainCache,
32    config::BlockchainDataClientConfig,
33    contracts::erc20::Erc20Contract,
34    events::pool_created::PoolCreatedEvent,
35    exchanges::extended::DexExtended,
36    hypersync::{
37        client::{HyperSyncClient, PoolEventStreamItem},
38        helpers::extract_block_number,
39    },
40};
41
42const BLOCKS_PROCESS_IN_SYNC_REPORT: u64 = 50_000;
43const POOL_DB_BATCH_SIZE: usize = 2000;
44const POOL_EVENT_BLOCK_DB_BATCH_SIZE: usize = 20_000;
45
46/// Sanitizes a string by removing null bytes and other invalid characters for PostgreSQL UTF-8.
47///
48/// This function strips null bytes (0x00) and other problematic control characters that are
49/// invalid in PostgreSQL's UTF-8 text fields. Common with malformed on-chain token metadata.
50/// Preserves printable characters and common whitespace (space, tab, newline).
51fn sanitize_string(s: &str) -> String {
52    s.chars()
53        .filter(|c| {
54            // Keep printable characters and common whitespace, but filter null bytes
55            // and other problematic control characters
56            *c != '\0' && (*c >= ' ' || *c == '\t' || *c == '\n' || *c == '\r')
57        })
58        .collect()
59}
60
61/// Service responsible for discovering DEX liquidity pools from blockchain events.
62///
63/// This service handles the synchronization of pool creation events from various DEXes,
64/// managing token metadata fetching, buffering strategies, and database persistence.
65#[derive(Debug)]
66pub struct PoolDiscoveryService<'a> {
67    /// The blockchain network being synced
68    chain: SharedChain,
69    /// Cache for tokens and pools
70    cache: &'a mut BlockchainCache,
71    /// ERC20 contract interface for token metadata
72    erc20_contract: &'a Erc20Contract,
73    /// HyperSync client for event streaming
74    hypersync_client: &'a HyperSyncClient,
75    /// Cancellation token for graceful shutdown
76    cancellation_token: CancellationToken,
77    /// Configuration for sync operations
78    config: BlockchainDataClientConfig,
79}
80
81impl<'a> PoolDiscoveryService<'a> {
82    /// Creates a new [`PoolDiscoveryService`] instance.
83    #[must_use]
84    pub const fn new(
85        chain: SharedChain,
86        cache: &'a mut BlockchainCache,
87        erc20_contract: &'a Erc20Contract,
88        hypersync_client: &'a HyperSyncClient,
89        cancellation_token: CancellationToken,
90        config: BlockchainDataClientConfig,
91    ) -> Self {
92        Self {
93            chain,
94            cache,
95            erc20_contract,
96            hypersync_client,
97            cancellation_token,
98            config,
99        }
100    }
101
102    /// Synchronizes pools for a specific DEX within a given block range.
103    ///
104    /// # Errors
105    ///
106    /// Returns an error if:
107    /// - HyperSync streaming fails
108    /// - Token RPC calls fail
109    /// - Database operations fail
110    /// - Sync is cancelled
111    pub async fn sync_pools(
112        &mut self,
113        dex: &DexExtended,
114        from_block: u64,
115        to_block: Option<u64>,
116        reset: bool,
117    ) -> anyhow::Result<()> {
118        // Determine effective sync range
119        let (last_synced_block, effective_from_block) = if reset {
120            (None, from_block)
121        } else {
122            let last_synced_block = self.cache.get_dex_last_synced_block(&dex.dex.name).await?;
123            let effective_from_block = last_synced_block
124                .map_or(from_block, |last_synced| max(from_block, last_synced + 1));
125            (last_synced_block, effective_from_block)
126        };
127
128        let to_block = match to_block {
129            Some(block) => block,
130            None => self.hypersync_client.current_block().await,
131        };
132
133        // Skip sync if already up to date
134        if effective_from_block > to_block {
135            log::debug!(
136                "DEX {} already synced to block {} (current: {}), skipping sync",
137                dex.dex.name,
138                last_synced_block.unwrap_or(0).separate_with_commas(),
139                to_block.separate_with_commas()
140            );
141            return Ok(());
142        }
143
144        let total_blocks = to_block.saturating_sub(effective_from_block) + 1;
145        log::debug!(
146            "Syncing DEX exchange pools from {} to {} (total: {} blocks){}",
147            effective_from_block.separate_with_commas(),
148            to_block.separate_with_commas(),
149            total_blocks.separate_with_commas(),
150            if let Some(last_synced) = last_synced_block {
151                format!(
152                    " - resuming from last synced block {}",
153                    last_synced.separate_with_commas()
154                )
155            } else {
156                String::new()
157            },
158        );
159        log::debug!(
160            "Syncing {} pool creation events from factory contract {} on chain {}",
161            dex.dex.name,
162            dex.factory,
163            self.chain.name
164        );
165
166        // Enable performance settings for sync operations
167        if let Err(e) = self.cache.toggle_performance_settings(true).await {
168            log::warn!("Failed to enable performance settings: {e}");
169        }
170
171        let mut metrics = BlockchainSyncReporter::new(
172            BlockchainSyncReportItems::PoolCreatedEvents,
173            effective_from_block,
174            total_blocks,
175            BLOCKS_PROCESS_IN_SYNC_REPORT,
176        );
177
178        let factory_address = &dex.factory;
179        let pair_created_event_signature = dex.pool_created_event.as_ref();
180        let pools_stream = self
181            .hypersync_client
182            .request_contract_events_stream(
183                effective_from_block,
184                Some(to_block),
185                factory_address,
186                vec![pair_created_event_signature],
187            )
188            .await;
189
190        tokio::pin!(pools_stream);
191
192        // LEVEL 1: RPC buffers (small, constrained by rate limits)
193        let token_rpc_batch_size = (self.config.multicall_calls_per_rpc_request / 3) as usize;
194        let mut token_rpc_buffer: HashSet<Address> = HashSet::new();
195
196        // LEVEL 2: DB buffers (large, optimize for throughput)
197        let mut token_db_buffer: Vec<Token> = Vec::new();
198        let mut pool_events_buffer: Vec<PoolCreatedEvent> = Vec::new();
199        let mut block_db_buffer: Vec<Block> = Vec::new();
200
201        let mut last_block_saved = effective_from_block;
202
203        // Tracking counters
204        let mut total_discovered = 0;
205        let mut total_skipped_exists = 0;
206        let mut total_skipped_invalid_tokens = 0;
207        let mut total_saved = 0;
208
209        let cancellation_token = self.cancellation_token.clone();
210        let sync_result = tokio::select! {
211            () = cancellation_token.cancelled() => {
212                log::debug!("Exchange pool sync cancelled");
213                Err(anyhow::anyhow!("Sync cancelled"))
214            }
215
216            result = async {
217                while let Some(item) = pools_stream.next().await {
218                    let log = match item {
219                        PoolEventStreamItem::Block(block) => {
220                            self.cache.cache_block_timestamp(block.number, block.timestamp);
221                            block_db_buffer.push(block);
222                            if block_db_buffer.len() >= POOL_EVENT_BLOCK_DB_BATCH_SIZE {
223                                self.flush_pool_event_blocks(&mut block_db_buffer).await?;
224                            }
225                            continue;
226                        }
227                        PoolEventStreamItem::Log(log) => log,
228                    };
229                    let block_number = extract_block_number(&log)?;
230                    let blocks_progress = block_number - last_block_saved;
231                    last_block_saved = block_number;
232
233                    let pool = dex.parse_pool_created_event_hypersync(log)?;
234                    total_discovered += 1;
235
236                    if self.cache.get_pool(&pool.pool_identifier).is_some() {
237                        // Pool is already initialized and cached.
238                        total_skipped_exists += 1;
239                        continue;
240                    }
241
242                    if self.cache.is_invalid_token(&pool.token0)
243                        || self.cache.is_invalid_token(&pool.token1)
244                    {
245                        // Skip pools with invalid tokens as they cannot be properly processed or traded.
246                        total_skipped_invalid_tokens += 1;
247                        continue;
248                    }
249
250                    // Collect tokens needed for RPC fetch
251                    if self.cache.get_token(&pool.token0).is_none() {
252                        token_rpc_buffer.insert(pool.token0);
253                    }
254
255                    if self.cache.get_token(&pool.token1).is_none() {
256                        token_rpc_buffer.insert(pool.token1);
257                    }
258
259                    // Buffer the pool for later processing
260                    pool_events_buffer.push(pool);
261
262                    // ==== RPC FLUSHING (small batches) ====
263                    if token_rpc_buffer.len() >= token_rpc_batch_size {
264                        let fetched_tokens = self
265                            .fetch_and_cache_tokens_in_memory(&mut token_rpc_buffer)
266                            .await?;
267
268                        // Accumulate for later DB write
269                        token_db_buffer.extend(fetched_tokens);
270                    }
271
272                    // ==== DB FLUSHING (large batches) ====
273                    // Process pools when buffer is full
274                    if pool_events_buffer.len() >= POOL_DB_BATCH_SIZE {
275                        // 1. Fetch any remaining tokens in RPC buffer (needed for pool construction)
276                        if !token_rpc_buffer.is_empty() {
277                            let fetched_tokens = self
278                                .fetch_and_cache_tokens_in_memory(&mut token_rpc_buffer)
279                                .await?;
280                            token_db_buffer.extend(fetched_tokens);
281                        }
282
283                        // 2. Flush ALL tokens to DB (satisfy foreign key constraints)
284                        if !token_db_buffer.is_empty() {
285                            self.cache
286                                .add_tokens_batch(std::mem::take(&mut token_db_buffer))
287                                .await?;
288                        }
289
290                        // 3. Now safe to construct and flush pools
291                        let pools = self
292                            .construct_pools_batch(&mut pool_events_buffer, &dex.dex)
293                            .await?;
294                        total_saved += pools.len();
295                        self.cache.add_pools_batch(pools).await?;
296                    }
297
298                    metrics.update(blocks_progress as usize);
299                    // Log progress if needed
300                    if metrics.should_log_progress(block_number, to_block) {
301                        metrics.log_progress(block_number);
302                    }
303                }
304
305                // ==== FINAL FLUSH (all remaining data) ====
306                // 1. Fetch any remaining tokens
307                if !token_rpc_buffer.is_empty() {
308                    let fetched_tokens = self
309                        .fetch_and_cache_tokens_in_memory(&mut token_rpc_buffer)
310                        .await?;
311                    token_db_buffer.extend(fetched_tokens);
312                }
313
314                // 2. Flush all tokens to DB
315                if !token_db_buffer.is_empty() {
316                    self.cache
317                        .add_tokens_batch(std::mem::take(&mut token_db_buffer))
318                        .await?;
319                }
320
321                // 3. Process and flush all pools
322                if !pool_events_buffer.is_empty() {
323                    let pools = self
324                        .construct_pools_batch(&mut pool_events_buffer, &dex.dex)
325                        .await?;
326                    total_saved += pools.len();
327                    self.cache.add_pools_batch(pools).await?;
328                }
329
330                self.flush_pool_event_blocks(&mut block_db_buffer).await?;
331                metrics.log_final_stats();
332
333                // Update the last synced block after successful completion.
334                self.cache
335                    .update_dex_last_synced_block(&dex.dex.name, to_block)
336                    .await?;
337
338                log::debug!(
339                    "Successfully synced DEX {} pools up to block {} | Summary: discovered={}, saved={}, skipped_exists={}, skipped_invalid_tokens={}",
340                    dex.dex.name,
341                    to_block.separate_with_commas(),
342                    total_discovered,
343                    total_saved,
344                    total_skipped_exists,
345                    total_skipped_invalid_tokens
346                );
347
348                Ok(())
349            } => result
350        };
351
352        sync_result?;
353
354        // Restore default safe settings after sync completion
355        if let Err(e) = self.cache.toggle_performance_settings(false).await {
356            log::warn!("Failed to restore default settings: {e}");
357        }
358
359        Ok(())
360    }
361
362    async fn flush_pool_event_blocks(&mut self, blocks: &mut Vec<Block>) -> anyhow::Result<()> {
363        if blocks.is_empty() {
364            return Ok(());
365        }
366
367        self.cache
368            .add_pool_event_blocks_batch(std::mem::take(blocks))
369            .await
370    }
371
372    /// Fetches token metadata via RPC and updates in-memory cache immediately.
373    ///
374    /// This method fetches token information using multicall, updates the in-memory cache right away
375    /// (so pool construction can proceed), and returns valid tokens for later batch DB writes.
376    ///
377    /// # Errors
378    ///
379    /// Returns an error if the RPC multicall fails or database operations fail.
380    async fn fetch_and_cache_tokens_in_memory(
381        &mut self,
382        token_buffer: &mut HashSet<Address>,
383    ) -> anyhow::Result<Vec<Token>> {
384        let batch_addresses: Vec<Address> = token_buffer.drain().collect();
385        let token_infos = self
386            .erc20_contract
387            .batch_fetch_token_info(&batch_addresses)
388            .await?;
389
390        let mut valid_tokens = Vec::new();
391
392        for (token_address, token_info) in token_infos {
393            match token_info {
394                Ok(token_info) => {
395                    // Sanitize token metadata to remove null bytes and invalid UTF-8 characters
396                    let sanitized_name = sanitize_string(&token_info.name);
397                    let sanitized_symbol = sanitize_string(&token_info.symbol);
398
399                    let token = Token::new(
400                        self.chain.clone(),
401                        token_address,
402                        sanitized_name,
403                        sanitized_symbol,
404                        token_info.decimals,
405                    );
406
407                    // Update in-memory cache IMMEDIATELY (so construct_pool can read it)
408                    self.cache.insert_token_in_memory(token.clone());
409
410                    // Collect for LATER DB write
411                    valid_tokens.push(token);
412                }
413                Err(token_info_error) => {
414                    self.cache.insert_invalid_token_in_memory(token_address);
415                    if let Some(database) = &self.cache.database {
416                        let sanitized_error = sanitize_string(&token_info_error.to_string());
417                        database
418                            .add_invalid_token(
419                                self.chain.chain_id,
420                                &token_address,
421                                &sanitized_error,
422                            )
423                            .await?;
424                    }
425                }
426            }
427        }
428
429        Ok(valid_tokens)
430    }
431
432    /// Constructs multiple pools from pool creation events.
433    ///
434    /// Assumes all required tokens are already in the in-memory cache.
435    ///
436    /// # Errors
437    ///
438    /// Logs errors for pools that cannot be constructed (missing tokens),
439    /// but does not fail the entire batch.
440    async fn construct_pools_batch(
441        &self,
442        pool_events: &mut Vec<PoolCreatedEvent>,
443        dex: &SharedDex,
444    ) -> anyhow::Result<Vec<Pool>> {
445        let mut pools = Vec::with_capacity(pool_events.len());
446
447        for pool_event in pool_events.drain(..) {
448            // Both tokens should be in cache now
449            let token0 = match self.cache.get_token(&pool_event.token0) {
450                Some(token) => token.clone(),
451                None => {
452                    if !self.cache.is_invalid_token(&pool_event.token0) {
453                        log::warn!(
454                            "Skipping pool {}: Token0 {} not in cache and not marked as invalid",
455                            pool_event.pool_address,
456                            pool_event.token0
457                        );
458                    }
459                    continue;
460                }
461            };
462
463            let token1 = match self.cache.get_token(&pool_event.token1) {
464                Some(token) => token.clone(),
465                None => {
466                    if !self.cache.is_invalid_token(&pool_event.token1) {
467                        log::warn!(
468                            "Skipping pool {}: Token1 {} not in cache and not marked as invalid",
469                            pool_event.pool_address,
470                            pool_event.token1
471                        );
472                    }
473                    continue;
474                }
475            };
476
477            let ts_init = self
478                .cache
479                .get_block_timestamp(pool_event.block_number)
480                .copied()
481                .unwrap_or_default();
482
483            let mut pool = Pool::new(
484                self.chain.clone(),
485                dex.clone(),
486                pool_event.pool_address,
487                pool_event.pool_identifier,
488                pool_event.block_number,
489                token0,
490                token1,
491                pool_event.fee,
492                pool_event.tick_spacing,
493                ts_init,
494            );
495
496            // Set hooks if available (UniswapV4)
497            if let Some(hooks) = pool_event.hooks {
498                pool.set_hooks(hooks);
499            }
500
501            // Initialize pool with sqrt_price_x96 and tick if available (UniswapV4)
502            if let (Some(sqrt_price_x96), Some(tick)) = (pool_event.sqrt_price_x96, pool_event.tick)
503            {
504                pool.initialize(sqrt_price_x96, tick);
505            }
506
507            pools.push(pool);
508        }
509
510        Ok(pools)
511    }
512}