Skip to main content

tycho_client/feed/
synchronizer.rs

1use std::{
2    collections::{HashMap, HashSet},
3    time::Duration,
4};
5
6use async_trait::async_trait;
7use rand::Rng;
8use thiserror::Error;
9use tokio::{
10    select,
11    sync::{
12        mpsc::{channel, error::SendError, Receiver, Sender},
13        oneshot,
14    },
15    task::JoinHandle,
16    time::{sleep, timeout, Instant},
17};
18use tracing::{debug, error, info, instrument, warn};
19use tycho_common::{
20    models::{
21        blockchain::{
22            BlockAggregatedChanges, DCIUpdate, EntryPointWithTracingParams, TracingResult,
23        },
24        contract::Account,
25        protocol::{ProtocolComponent, ProtocolComponentState},
26        Chain, ExtractorIdentity,
27    },
28    Bytes,
29};
30
31use crate::{
32    deltas::{DeltasClient, SubscriptionOptions},
33    feed::{
34        component_tracker::{ComponentFilter, ComponentTracker},
35        BlockHeader, HeaderLike,
36    },
37    rpc::{
38        RPCClient, RPCError, SnapshotParameters, TracedEntryPointsPaginatedParams,
39        RPC_CLIENT_CONCURRENCY,
40    },
41    DeltasError,
42};
43
44/// Default maximum random delay before a restarted synchronizer requests a snapshot.
45pub const DEFAULT_SNAPSHOT_JITTER: Duration = Duration::from_secs(60);
46/// Lowest accepted maximum delay before a restarted synchronizer requests a snapshot.
47pub const MIN_SNAPSHOT_JITTER: Duration = Duration::from_secs(5);
48
49#[derive(Error, Debug)]
50pub enum SynchronizerError {
51    /// RPC client failures.
52    #[error("RPC error: {0}")]
53    RPCError(#[from] RPCError),
54
55    /// Issues with the main channel
56    #[error("{0}")]
57    ChannelError(String),
58
59    /// Timeout elapsed errors.
60    #[error("Timeout error: {0}")]
61    Timeout(String),
62
63    /// Failed to close the synchronizer.
64    #[error("Failed to close synchronizer: {0}")]
65    CloseError(String),
66
67    /// Server connection failures or interruptions.
68    #[error("Connection error: {0}")]
69    ConnectionError(String),
70
71    /// Connection closed
72    #[error("Connection closed")]
73    ConnectionClosed,
74
75    /// Internal error that should not happen under normal operation.
76    #[error("Internal error: {0}")]
77    Internal(String),
78}
79
80pub type SyncResult<T> = Result<T, SynchronizerError>;
81pub type SyncError = (SynchronizerError, Option<oneshot::Receiver<()>>);
82
83impl SynchronizerError {
84    /// Returns true if the error is transient and the failing operation can be retried.
85    ///
86    /// Transient: network/HTTP failures, rate limiting, server unavailability. These are
87    /// infrastructure problems that may resolve without any change to the request.
88    ///
89    /// Permanent: malformed data, fatal server errors, invalid requests. Retrying would produce
90    /// the same failure.
91    pub fn is_transient(&self) -> bool {
92        match self {
93            SynchronizerError::RPCError(e) => matches!(
94                e,
95                RPCError::HttpClient(_, _) |
96                    RPCError::RateLimited(_) |
97                    RPCError::ServerUnreachable(_) |
98                    RPCError::StaleBlock(_)
99            ),
100            SynchronizerError::Timeout(_) |
101            SynchronizerError::ConnectionError(_) |
102            SynchronizerError::ConnectionClosed => true,
103            _ => false,
104        }
105    }
106}
107
108impl<T> From<SendError<T>> for SynchronizerError {
109    fn from(err: SendError<T>) -> Self {
110        SynchronizerError::ChannelError(format!("Failed to send message: {err}"))
111    }
112}
113
114impl From<DeltasError> for SynchronizerError {
115    fn from(err: DeltasError) -> Self {
116        match err {
117            DeltasError::NotConnected => SynchronizerError::ConnectionClosed,
118            _ => SynchronizerError::ConnectionError(err.to_string()),
119        }
120    }
121}
122
123pub struct ProtocolStateSynchronizer<R: RPCClient, D: DeltasClient> {
124    extractor_id: ExtractorIdentity,
125    retrieve_balances: bool,
126    rpc_client: R,
127    deltas_client: D,
128    max_retries: u64,
129    retry_cooldown: Duration,
130    include_snapshots: bool,
131    component_tracker: ComponentTracker<R>,
132    last_synced_block: Option<BlockHeader>,
133    timeout: u64,
134    include_tvl: bool,
135    compression: bool,
136    partial_blocks: bool,
137    uses_dci: bool,
138    snapshot_jitter: Duration,
139    /// Background snapshot tasks spawned for new components. Each task may be in-flight or
140    /// finished; completed ones are harvested at the start of each delta iteration and their
141    /// results included in that block's message.
142    snapshot_tasks: Vec<SnapshotTask>,
143    /// Unfiltered deltas buffered while any snapshot task is in-flight, starting from the block
144    /// at which the oldest task was spawned. Applied to each snapshot at drain time to reconstruct
145    /// the component's current state.
146    buffered_deltas: Vec<BlockAggregatedChanges>,
147    /// State machine tracking components awaiting their initial snapshot. A component lives here
148    /// from the moment it's queued until its snapshot is successfully applied (at which point it
149    /// moves into `component_tracker.components`).
150    snapshot_queue: HashMap<String, SnapshotStatus>,
151}
152
153#[derive(Clone, PartialEq, Debug)]
154pub struct ComponentWithState {
155    pub state: ProtocolComponentState,
156    pub component: ProtocolComponent,
157    pub component_tvl: Option<f64>,
158    pub entrypoints: Vec<(EntryPointWithTracingParams, TracingResult)>,
159}
160
161#[derive(Clone, PartialEq, Debug, Default)]
162pub struct Snapshot {
163    pub states: HashMap<String, ComponentWithState>,
164    pub vm_storage: HashMap<Bytes, Account>,
165}
166
167impl Snapshot {
168    fn extend(&mut self, other: Snapshot) {
169        self.states.extend(other.states);
170        self.vm_storage.extend(other.vm_storage);
171    }
172
173    pub fn get_states(&self) -> &HashMap<String, ComponentWithState> {
174        &self.states
175    }
176
177    pub fn get_vm_storage(&self) -> &HashMap<Bytes, Account> {
178        &self.vm_storage
179    }
180}
181
182#[derive(Clone, PartialEq, Debug, Default)]
183pub struct StateSyncMessage<H>
184where
185    H: HeaderLike,
186{
187    /// The block information for this update.
188    pub header: H,
189    /// Snapshot for new components.
190    pub snapshots: Snapshot,
191    /// A single delta contains state updates for all tracked components, as well as additional
192    /// information about the system components e.g. newly added components (even below tvl), tvl
193    /// updates, balance updates.
194    pub deltas: Option<BlockAggregatedChanges>,
195    /// Components that stopped being tracked.
196    pub removed_components: HashMap<String, ProtocolComponent>,
197}
198
199impl<H> StateSyncMessage<H>
200where
201    H: HeaderLike,
202{
203    pub fn merge(mut self, other: Self) -> Self {
204        // be careful with removed and snapshots attributes here, these can be ambiguous.
205        self.removed_components
206            .retain(|k, _| !other.snapshots.states.contains_key(k));
207        self.snapshots
208            .states
209            .retain(|k, _| !other.removed_components.contains_key(k));
210
211        self.snapshots.extend(other.snapshots);
212        let deltas = match (self.deltas, other.deltas) {
213            (Some(l), Some(r)) => Some(l.merge(r)),
214            (None, Some(r)) => Some(r),
215            (Some(l), None) => Some(l),
216            (None, None) => None,
217        };
218        self.removed_components
219            .extend(other.removed_components);
220        Self {
221            header: other.header,
222            snapshots: self.snapshots,
223            deltas,
224            removed_components: self.removed_components,
225        }
226    }
227}
228
229/// Tracks the lifecycle of a component's initial snapshot request.
230#[derive(Debug, Clone, PartialEq)]
231enum SnapshotStatus {
232    /// Waiting for the next block boundary before fetching. Only used with partial blocks.
233    Deferred,
234    /// A background snapshot task has been spawned and is in-flight.
235    InFlight,
236    /// The last fetch attempt failed transiently; will be re-queued on the next delta.
237    RetryNext,
238    /// The fetch failed permanently; component is excluded until the synchronizer restarts.
239    Blacklisted,
240}
241
242struct SnapshotFetchResult {
243    components: HashMap<String, ProtocolComponent>,
244    contract_ids: HashSet<Bytes>,
245    dci_update: DCIUpdate,
246    snapshot: Snapshot,
247    snapshot_block: u64,
248}
249
250struct SnapshotTask {
251    component_ids: Vec<String>,
252    snapshot_block: u64,
253    receiver: oneshot::Receiver<Result<SnapshotFetchResult, SynchronizerError>>,
254}
255
256/// Handle for controlling a running synchronizer task.
257///
258/// This handle provides methods to gracefully shut down the synchronizer
259/// and await its completion with a timeout.
260pub struct SynchronizerTaskHandle {
261    join_handle: JoinHandle<()>,
262    close_tx: oneshot::Sender<()>,
263}
264
265/// StateSynchronizer
266///
267/// Used to synchronize the state of a single protocol. The synchronizer is responsible for
268/// delivering messages to the client that let him reconstruct subsets of the protocol state.
269///
270/// This involves deciding which components to track according to the clients preferences,
271/// retrieving & emitting snapshots of components which the client has not seen yet and subsequently
272/// delivering delta messages for the components that have changed.
273impl SynchronizerTaskHandle {
274    pub fn new(join_handle: JoinHandle<()>, close_tx: oneshot::Sender<()>) -> Self {
275        Self { join_handle, close_tx }
276    }
277
278    /// Splits the handle into its join handle and close sender.
279    ///
280    /// This allows monitoring the task completion separately from controlling shutdown.
281    /// The join handle can be used with FuturesUnordered for monitoring, while the
282    /// close sender can be used to signal graceful shutdown.
283    pub fn split(self) -> (JoinHandle<()>, oneshot::Sender<()>) {
284        (self.join_handle, self.close_tx)
285    }
286}
287
288#[async_trait]
289pub trait StateSynchronizer: Send + Sync + 'static {
290    async fn initialize(&mut self) -> SyncResult<()>;
291    /// Starts the state synchronization, consuming the synchronizer.
292    /// Returns a handle for controlling the running task and a receiver for messages.
293    async fn start(
294        mut self,
295    ) -> (SynchronizerTaskHandle, Receiver<SyncResult<StateSyncMessage<BlockHeader>>>);
296}
297
298struct FetchSnapshotParams {
299    chain: Chain,
300    protocol_system: String,
301    block_number: u64,
302    uses_dci: bool,
303    retrieve_balances: bool,
304    include_tvl: bool,
305}
306
307/// Fetches a snapshot for given components. If DCI is enabled, also traces entry
308/// points and extends `contract_ids` with any contracts they access.
309///
310/// Returns the snapshot, the DCI update, and the complete set of contract IDs (original +
311/// DCI-discovered).
312async fn fetch_snapshot<R: RPCClient>(
313    rpc_client: &R,
314    components: HashMap<String, ProtocolComponent>,
315    mut contract_ids: HashSet<Bytes>,
316    params: &FetchSnapshotParams,
317) -> Result<(Snapshot, DCIUpdate, HashSet<Bytes>), SynchronizerError> {
318    if components.is_empty() {
319        return Ok((Snapshot::default(), DCIUpdate::default(), contract_ids));
320    }
321
322    let component_ids: Vec<String> = components.keys().cloned().collect();
323
324    let (dci_update, entrypoints_result) = if params.uses_dci {
325        let result = rpc_client
326            .get_traced_entry_points_paginated(TracedEntryPointsPaginatedParams::new(
327                params.chain,
328                &params.protocol_system,
329                component_ids.clone(),
330                RPC_CLIENT_CONCURRENCY,
331            ))
332            .await?;
333        let dci_contracts: HashSet<Bytes> = result
334            .values()
335            .flat_map(|traces| {
336                traces
337                    .iter()
338                    .flat_map(|(_, tr)| tr.accessed_slots.keys().cloned())
339            })
340            .collect();
341        contract_ids.extend(dci_contracts);
342        let eps = result.clone();
343        let dci: DCIUpdate = result.into();
344        (dci, eps)
345    } else {
346        (DCIUpdate::default(), HashMap::new())
347    };
348
349    let contract_ids_vec: Vec<Bytes> = contract_ids.iter().cloned().collect();
350    let request = SnapshotParameters::new(
351        params.chain,
352        &params.protocol_system,
353        &components,
354        &contract_ids_vec,
355        params.block_number,
356    )
357    .entrypoints(&entrypoints_result)
358    .include_balances(params.retrieve_balances)
359    .include_tvl(params.include_tvl);
360
361    let snapshot = rpc_client
362        .get_snapshots(&request, None, RPC_CLIENT_CONCURRENCY)
363        .await?;
364
365    Ok((snapshot, dci_update, contract_ids))
366}
367
368/// Fetches a snapshot for new components not yet in the tracker. Calls `get_protocol_components`
369/// to resolve the components, then delegates to `fetch_snapshot`.
370async fn fetch_snapshot_background<R: RPCClient>(
371    rpc_client: R,
372    component_ids: Vec<String>,
373    params: FetchSnapshotParams,
374) -> Result<SnapshotFetchResult, SynchronizerError> {
375    if component_ids.is_empty() {
376        return Ok(SnapshotFetchResult {
377            components: HashMap::new(),
378            contract_ids: HashSet::new(),
379            dci_update: DCIUpdate::default(),
380            snapshot: Snapshot::default(),
381            snapshot_block: params.block_number,
382        });
383    }
384
385    let request = crate::rpc::ProtocolComponentsParams::new(params.chain, &params.protocol_system)
386        .with_component_ids(component_ids);
387    let components: HashMap<String, ProtocolComponent> = rpc_client
388        .get_protocol_components(request)
389        .await?
390        .into_data()
391        .into_iter()
392        .map(|pc| (pc.id.clone(), pc))
393        .collect();
394
395    let contract_ids: HashSet<Bytes> = components
396        .values()
397        .flat_map(|c| c.contract_addresses.iter().cloned())
398        .collect();
399
400    let snapshot_block = params.block_number;
401    let (snapshot, dci_update, contract_ids) =
402        fetch_snapshot(&rpc_client, components.clone(), contract_ids, &params).await?;
403
404    Ok(SnapshotFetchResult { components, contract_ids, dci_update, snapshot, snapshot_block })
405}
406
407impl<R, D> ProtocolStateSynchronizer<R, D>
408where
409    // TODO: Consider moving these constraints directly to the
410    // client...
411    R: RPCClient + Clone + Send + Sync + 'static,
412    D: DeltasClient + Clone + Send + Sync + 'static,
413{
414    /// Creates a new state synchronizer.
415    #[allow(clippy::too_many_arguments)]
416    pub fn new(
417        extractor_id: ExtractorIdentity,
418        retrieve_balances: bool,
419        component_filter: ComponentFilter,
420        max_retries: u64,
421        retry_cooldown: Duration,
422        include_snapshots: bool,
423        include_tvl: bool,
424        compression: bool,
425        rpc_client: R,
426        deltas_client: D,
427        timeout: u64,
428    ) -> Self {
429        Self {
430            extractor_id: extractor_id.clone(),
431            retrieve_balances,
432            rpc_client: rpc_client.clone(),
433            include_snapshots,
434            deltas_client,
435            component_tracker: ComponentTracker::new(
436                extractor_id.chain,
437                extractor_id.name.as_str(),
438                component_filter,
439                rpc_client,
440            ),
441            max_retries,
442            retry_cooldown,
443            last_synced_block: None,
444            timeout,
445            include_tvl,
446            compression,
447            partial_blocks: false,
448            uses_dci: false,
449            snapshot_jitter: DEFAULT_SNAPSHOT_JITTER,
450            snapshot_tasks: Vec::new(),
451            buffered_deltas: Vec::new(),
452            snapshot_queue: HashMap::new(),
453        }
454    }
455
456    /// Sets whether this protocol uses Dynamic Contract Indexing (DCI).
457    /// When true, entrypoints will be fetched during snapshot retrieval.
458    pub fn with_dci(mut self, uses_dci: bool) -> Self {
459        self.uses_dci = uses_dci;
460        self
461    }
462
463    /// Enables receiving partial block updates.
464    pub fn with_partial_blocks(mut self, partial_blocks: bool) -> Self {
465        self.partial_blocks = partial_blocks;
466        self
467    }
468
469    /// Sets the maximum random delay before a restarted synchronizer requests a snapshot.
470    ///
471    /// After a restart that needs a snapshot, the synchronizer waits a random time in
472    /// `[0, jitter]` before it requests the snapshot. The first start has no delay.
473    /// A `jitter` below [`MIN_SNAPSHOT_JITTER`] is raised to it.
474    pub fn with_snapshot_jitter(mut self, jitter: Duration) -> Self {
475        if jitter < MIN_SNAPSHOT_JITTER {
476            warn!(
477                extractor_id=%self.extractor_id,
478                requested_ms=jitter.as_millis(),
479                min_ms=MIN_SNAPSHOT_JITTER.as_millis(),
480                "Snapshot jitter is below the minimum; using the minimum"
481            );
482        }
483        self.snapshot_jitter = jitter.max(MIN_SNAPSHOT_JITTER);
484        self
485    }
486
487    /// Main method that does all the work.
488    ///
489    /// ## Return Value
490    ///
491    /// Returns a `Result` where:
492    /// - `Ok(())` - Synchronization completed successfully (usually due to close signal)
493    /// - `Err((error, None))` - Error occurred AND close signal was received (don't retry)
494    /// - `Err((error, Some(end_rx)))` - Error occurred but close signal was NOT received (can
495    ///   retry)
496    ///
497    /// The returned `end_rx` (if any) should be reused for retry attempts since the close
498    /// signal may still arrive and we want to remain cancellable across retries.
499    ///
500    /// If the first block needs a snapshot, the synchronizer discards deltas for
501    /// `snapshot_delay` and requests the snapshot at the first block after the delay.
502    #[instrument(skip(self, block_tx, end_rx), fields(extractor_id = %self.extractor_id))]
503    async fn state_sync(
504        &mut self,
505        block_tx: &mut Sender<SyncResult<StateSyncMessage<BlockHeader>>>,
506        mut end_rx: oneshot::Receiver<()>,
507        snapshot_delay: Duration,
508    ) -> Result<(), Box<SyncError>> {
509        // initialisation
510        let subscription_options = SubscriptionOptions::new()
511            .with_state(self.include_snapshots)
512            .with_compression(self.compression)
513            .with_partial_blocks(self.partial_blocks);
514        let (subscription_id, mut msg_rx) = match self
515            .deltas_client
516            .subscribe(self.extractor_id.clone(), subscription_options)
517            .await
518        {
519            Ok(result) => result,
520            Err(e) => return Err(Box::new((e.into(), Some(end_rx)))),
521        };
522
523        let result = async {
524            info!("Waiting for deltas...");
525            // Track the last seen block number such that we know when we get the first partial
526            let mut last_block_number: Option<u64> = None;
527
528            // Outer loop: find a suitable first block and fetch its snapshot. Retries within the
529            // same subscription when the snapshot endpoint rejects the block as too old — this
530            // happens after a server restart whose persisted state is outside the plan retention
531            // window. Consuming the stale delta and waiting for the next one lets the server catch
532            // up without tearing down the WS subscription and rebuilding state from scratch.
533            const MAX_STALE_RETRIES: u32 = 5;
534            let mut stale_retries: u32 = 0;
535            // Messages keep arriving during the delay, so we discard them instead of sleeping:
536            // a sleep would overflow the subscription buffer on fast chains.
537            let mut snapshot_not_before: Option<Instant> = None;
538            let (msg, header) = 'init: loop {
539                let mut warned_waiting_for_new_block = false;
540                let mut warned_skipping_synced = false;
541                let mut first_msg = loop {
542                    let msg = select! {
543                        deltas_result = timeout(Duration::from_secs(self.timeout), msg_rx.recv()) => {
544                            deltas_result
545                                .map_err(|_| {
546                                    SynchronizerError::Timeout(format!(
547                                        "First deltas took longer than {t}s to arrive",
548                                        t = self.timeout
549                                    ))
550                                })?
551                                .ok_or_else(|| {
552                                    SynchronizerError::ConnectionError(
553                                        "Deltas channel closed before first message".to_string(),
554                                    )
555                                })?
556                        },
557                        _ = &mut end_rx => {
558                            info!("Received close signal while waiting for first deltas");
559                            return Ok(());
560                        }
561                    };
562
563                    let incoming: BlockHeader = (&msg).into();
564
565                    // Determine if this message is a candidate for starting synchronization.
566                    // In partial mode, we wait for a new block to start (block number increases).
567                    // In non-partial mode, all messages are candidates.
568                    let is_new_block_candidate = if self.partial_blocks {
569                        match msg.partial_block_index {
570                            None => {
571                                // If we get a full block, it is a candidate
572                                last_block_number = Some(incoming.number);
573                                true
574                            }
575                            Some(current_partial_idx) => {
576                                let is_new_block = last_block_number
577                                    .map(|prev_block| incoming.number > prev_block)
578                                    .unwrap_or(false);
579
580                                if !warned_waiting_for_new_block {
581                                    info!(
582                                        extractor=%self.extractor_id,
583                                        block=incoming.number,
584                                        partial_idx=current_partial_idx,
585                                        "Syncing. Waiting for new block to start"
586                                    );
587                                    warned_waiting_for_new_block = true;
588                                }
589                                last_block_number = Some(incoming.number);
590                                is_new_block
591                            }
592                        }
593                    } else {
594                        true // Non-partial mode: all messages are candidates
595                    };
596
597                    if !is_new_block_candidate {
598                        continue;
599                    }
600
601                    // Check if we've already synced this block (applies to both modes)
602                    if let Some(current) = &self.last_synced_block {
603                        if current.number >= incoming.number && !self.is_next_expected(&incoming) {
604                            if !warned_skipping_synced {
605                                info!(extractor=%self.extractor_id, from=incoming.number, to=current.number, "Syncing. Skipping already synced block");
606                                warned_skipping_synced = true;
607                            }
608                            continue;
609                        }
610                    }
611                    match snapshot_not_before {
612                        Some(not_before) if Instant::now() < not_before => continue,
613                        None if !snapshot_delay.is_zero() && !self.is_next_expected(&incoming) => {
614                            info!(
615                                extractor=%self.extractor_id,
616                                delay_ms=snapshot_delay.as_millis(),
617                                "Syncing. Delaying snapshot request"
618                            );
619                            snapshot_not_before = Some(Instant::now() + snapshot_delay);
620                            continue;
621                        }
622                        _ => {}
623                    }
624                    break msg;
625                };
626
627                self.filter_deltas(&mut first_msg);
628
629                // initial snapshot
630                info!(height = first_msg.get_block().number, "First deltas received");
631                let header: BlockHeader = (&first_msg).into();
632                let deltas_msg = StateSyncMessage {
633                    header: header.clone(),
634                    snapshots: Default::default(),
635                    deltas: Some(first_msg),
636                    removed_components: Default::default(),
637                };
638
639                // If possible skip retrieving snapshots
640                if !self.is_next_expected(&header) {
641                    info!("Retrieving snapshot");
642                    // With partial blocks, the server only has full blocks in its buffer; pass the
643                    // previous block's header so we request state at N-1, then merge with deltas.
644                    let snapshot_header = if self.partial_blocks && header.number > 0 {
645                        BlockHeader {
646                            number: header.number - 1,
647                            hash: header.parent_hash.clone(),
648                            ..Default::default()
649                        }
650                    } else {
651                        BlockHeader { revert: false, ..header.clone() }
652                    };
653                    let component_ids =
654                        self.component_tracker.get_tracked_component_ids();
655                    let init_snapshot = if !self.include_snapshots ||
656                        component_ids.is_empty()
657                    {
658                        Snapshot::default()
659                    } else {
660                        // Fetch initial snapshots
661                        let components: HashMap<_, _> = self
662                            .component_tracker
663                            .components
664                            .iter()
665                            .filter(|(id, _)| component_ids.contains(id))
666                            .map(|(k, v)| (k.clone(), v.clone()))
667                            .collect();
668                        let contract_ids: HashSet<Bytes> = self
669                            .component_tracker
670                            .get_contracts_by_component(&component_ids)
671                            .into_iter()
672                            .collect();
673                        let fetch_params = FetchSnapshotParams {
674                            chain: self.extractor_id.chain,
675                            protocol_system: self.extractor_id.name.clone(),
676                            block_number: snapshot_header.number,
677                            uses_dci: self.uses_dci,
678                            retrieve_balances: self.retrieve_balances,
679                            include_tvl: self.include_tvl,
680                        };
681                        match fetch_snapshot(
682                            &self.rpc_client,
683                            components,
684                            contract_ids,
685                            &fetch_params,
686                        )
687                        .await
688                        {
689                            Ok((snap, dci_update, _)) => {
690                                self.component_tracker
691                                    .process_entrypoints(&dci_update);
692                                snap
693                            }
694                            Err(SynchronizerError::RPCError(
695                                crate::rpc::RPCError::StaleBlock(reason),
696                            )) => {
697                                stale_retries += 1;
698                                if stale_retries > MAX_STALE_RETRIES {
699                                    return Err(SynchronizerError::RPCError(
700                                        crate::rpc::RPCError::StaleBlock(reason),
701                                    ));
702                                }
703                                // The server's persisted state for this block is outside
704                                // the plan retention window. Discard this delta and wait
705                                // for a fresher block from the same subscription rather
706                                // than restarting from scratch.
707                                warn!(
708                                    block = header.number,
709                                    stale_retries,
710                                    %reason,
711                                    "Snapshot block is outside server retention \
712                                     window; waiting for a more recent block"
713                                );
714                                continue 'init;
715                            }
716                            Err(e) => return Err(e),
717                        }
718                    };
719                    let n_components = self.component_tracker.components.len();
720                    let n_snapshots = init_snapshot.states.len();
721                    info!(
722                        n_components,
723                        n_snapshots,
724                        "Initial snapshot retrieved, starting delta message feed"
725                    );
726                    let snapshot_msg = StateSyncMessage {
727                        header: snapshot_header,
728                        snapshots: init_snapshot,
729                        deltas: None,
730                        removed_components: HashMap::new(),
731                    };
732                    break 'init (snapshot_msg.merge(deltas_msg), header);
733                } else {
734                    break 'init (deltas_msg, header);
735                }
736            };
737
738            block_tx.send(Ok(msg)).await?;
739            self.last_synced_block = Some(header);
740            loop {
741                select! {
742                    deltas_opt = msg_rx.recv() => {
743                        if let Some(mut deltas) = deltas_opt {
744                            let header: BlockHeader = (&deltas).into();
745                            debug!(block_number=?header.number, "Received delta message");
746
747                            // Buffer unfiltered delta while any snapshot task is in-flight.
748                            if !self.snapshot_tasks.is_empty() {
749                                self.buffered_deltas.push(deltas.clone());
750                            }
751
752                            let background_snapshots = self.drain_completed_snapshots();
753
754                            // Trim buffered_deltas: discard blocks no longer needed by any pending task.
755                            if self.snapshot_tasks.is_empty() {
756                                self.buffered_deltas.clear();
757                            } else {
758                                let oldest_pending_block = self
759                                    .snapshot_tasks
760                                    .iter()
761                                    .map(|p| p.snapshot_block)
762                                    .min()
763                                    .unwrap_or(u64::MAX);
764                                self.buffered_deltas
765                                    .retain(|d| d.block.number > oldest_pending_block);
766                            }
767
768                            let (snapshots, removed_components) = {
769                                let (to_add, to_remove) =
770                                    self.component_tracker.filter_updated_components(&deltas);
771
772                                // Harvest transient retries now so they feed into truly_new.
773                                // TVL changes are not re-emitted, so without explicit re-queuing
774                                // a transiently failed component would never be retried.
775                                // Remove from the map first so the truly_new filter below treats
776                                // them the same as brand-new components.
777                                let retry_ids: Vec<String> = self
778                                    .snapshot_queue
779                                    .iter()
780                                    .filter(|(_, s)| matches!(s, SnapshotStatus::RetryNext))
781                                    .map(|(id, _)| id.clone())
782                                    .collect();
783                                for id in &retry_ids {
784                                    self.snapshot_queue.remove(id);
785                                }
786
787                                // Components not yet tracked and not in the staged state machine
788                                // (not in-flight, not deferred, not blacklisted). Merges
789                                // delta-triggered new components with transient retries; `seen`
790                                // deduplicates the two sources.
791                                let truly_new: Vec<String> = {
792                                    let mut seen = HashSet::new();
793                                    to_add
794                                        .iter()
795                                        .chain(retry_ids.iter())
796                                        .filter(|id| {
797                                            !self.component_tracker
798                                                .components
799                                                .contains_key(id.as_str())
800                                                && !self
801                                                    .snapshot_queue
802                                                    .contains_key(id.as_str())
803                                                && seen.insert(id.as_str())
804                                        })
805                                        .cloned()
806                                        .collect()
807                                };
808
809                                if self.partial_blocks {
810                                    let is_new_block = self
811                                        .last_synced_block
812                                        .as_ref()
813                                        .map(|b| header.number > b.number)
814                                        .unwrap_or(true);
815
816                                    let has_deferred = self
817                                        .snapshot_queue
818                                        .values()
819                                        .any(|s| matches!(s, SnapshotStatus::Deferred));
820                                    if is_new_block && has_deferred && header.number > 0 {
821                                        // Block number incremented: the previous block is
822                                        // complete. Fire deferred components at that block's
823                                        // height.
824                                        let to_fire: Vec<String> = self
825                                            .snapshot_queue
826                                            .iter()
827                                            .filter(|(_, s)| matches!(s, SnapshotStatus::Deferred))
828                                            .map(|(id, _)| id.clone())
829                                            .collect();
830                                        for id in &to_fire {
831                                            self.snapshot_queue.remove(id);
832                                        }
833                                        let snapshot_header = BlockHeader {
834                                            number: header.number - 1,
835                                            hash: header.parent_hash.clone(),
836                                            ..Default::default()
837                                        };
838                                        debug!(
839                                            components = ?to_fire,
840                                            extractor = %self.extractor_id.name,
841                                            snapshot_block = header.number - 1,
842                                            "snapshot_deferred_to_background"
843                                        );
844                                        self.spawn_snapshot_task(
845                                            to_fire,
846                                            snapshot_header,
847                                            &deltas,
848                                        );
849                                    }
850
851                                    // Accumulate truly_new into the deferred set for the current
852                                    // block; they will be fired when the next block arrives.
853                                    for id in truly_new {
854                                        self.snapshot_queue
855                                            .insert(id, SnapshotStatus::Deferred);
856                                    }
857                                } else if !truly_new.is_empty() {
858                                    debug!(
859                                        components = ?truly_new,
860                                        extractor = %self.extractor_id.name,
861                                        block_number = ?header.number,
862                                        "snapshot_deferred_to_background"
863                                    );
864                                    let snapshot_header =
865                                        BlockHeader { revert: false, ..header.clone() };
866                                    self.spawn_snapshot_task(truly_new, snapshot_header, &deltas);
867                                }
868
869                                let snapshots = background_snapshots;
870
871                                let removed_components = if !to_remove.is_empty() {
872                                    self.component_tracker.stop_tracking(&to_remove)
873                                } else {
874                                    Default::default()
875                                };
876
877                                (snapshots, removed_components)
878                            };
879
880                            // Update entrypoints on the tracker (affects which contracts are tracked for DCI).
881                            self.component_tracker.process_entrypoints(&deltas.dci_update);
882
883                            // Filter deltas by currently tracked components / contracts.
884                            self.filter_deltas(&mut deltas);
885                            let n_changes = deltas.n_changes();
886
887                            let next = StateSyncMessage {
888                                header: header.clone(),
889                                snapshots,
890                                deltas: Some(deltas),
891                                removed_components,
892                            };
893                            block_tx.send(Ok(next)).await?;
894                            self.last_synced_block = Some(header.clone());
895
896                            debug!(block_number=?header.number, n_changes, "Finished processing delta message");
897                        } else {
898                            return Err(SynchronizerError::ConnectionError("Deltas channel closed".to_string()));
899                        }
900                    },
901                    _ = &mut end_rx => {
902                        info!("Received close signal during state_sync");
903                        return Ok(());
904                    }
905                }
906            }
907        }.await;
908
909        // This cleanup code now runs regardless of how the function exits (error or channel close)
910        warn!(last_synced_block = ?&self.last_synced_block, "Deltas processing ended.");
911        //Ignore error
912        let _ = self
913            .deltas_client
914            .unsubscribe(subscription_id)
915            .await
916            .map_err(|err| {
917                warn!(err=?err, "Unsubscribing from deltas on cleanup failed!");
918            });
919
920        // Handle the result: if it succeeded, we're done. If it errored, we need to determine
921        // whether the end_rx was consumed (close signal received) or not
922        match result {
923            Ok(()) => Ok(()), // Success, likely due to close signal
924            Err(e) => {
925                // The error came from the inner async block. Since the async block
926                // can receive close signals (which would return Ok), any error means
927                // the close signal was NOT received, so we can return the end_rx for retry
928                Err(Box::new((e, Some(end_rx))))
929            }
930        }
931    }
932
933    /// Applies `self.buffered_deltas` to `snapshot`, updating attributes, balances, and contract
934    /// storage for deltas strictly after `snapshot_block`.
935    fn apply_deltas_to_snapshot(
936        &self,
937        snapshot: &mut Snapshot,
938        snapshot_block: u64,
939        contract_ids: &HashSet<Bytes>,
940    ) {
941        for delta in &self.buffered_deltas {
942            if delta.block.number <= snapshot_block {
943                continue;
944            }
945            for (component_id, state_delta) in &delta.state_deltas {
946                if let Some(cws) = snapshot.states.get_mut(component_id) {
947                    cws.state.attributes.extend(
948                        state_delta
949                            .updated_attributes
950                            .iter()
951                            .map(|(k, v)| (k.clone(), v.clone())),
952                    );
953                    for key in &state_delta.deleted_attributes {
954                        cws.state.attributes.remove(key);
955                    }
956                }
957            }
958            for (component_id, token_balances) in &delta.component_balances {
959                if let Some(cws) = snapshot.states.get_mut(component_id) {
960                    for (token, bal) in token_balances {
961                        cws.state
962                            .balances
963                            .insert(token.clone(), bal.balance.clone());
964                    }
965                }
966            }
967            for (address, account_delta) in &delta.account_deltas {
968                if contract_ids.contains(address) {
969                    if let Some(account) = snapshot.vm_storage.get_mut(address) {
970                        account.slots.extend(
971                            account_delta
972                                .slots
973                                .iter()
974                                .filter_map(|(k, v)| {
975                                    v.as_ref()
976                                        .map(|v| (k.clone(), v.clone()))
977                                }),
978                        );
979                        if let Some(balance) = &account_delta.balance {
980                            account.native_balance = balance.clone();
981                        }
982                        if let Some(code) = account_delta.code() {
983                            account.code = code.clone();
984                        }
985                    }
986                }
987            }
988        }
989    }
990
991    /// Spawns a background snapshot task for `component_ids` at `snapshot_header`. If no other
992    /// task is already in-flight, starts buffering deltas from `current_delta` so the snapshot
993    /// can be brought up to date when the task drains.
994    fn spawn_snapshot_task(
995        &mut self,
996        component_ids: Vec<String>,
997        snapshot_header: BlockHeader,
998        current_delta: &BlockAggregatedChanges,
999    ) {
1000        let snapshot_block = snapshot_header.number;
1001
1002        if self.snapshot_tasks.is_empty() {
1003            self.buffered_deltas
1004                .push(current_delta.clone());
1005        }
1006
1007        let (tx, rx) = oneshot::channel();
1008        let rpc = self.rpc_client.clone();
1009        let bg_params = FetchSnapshotParams {
1010            chain: self.extractor_id.chain,
1011            protocol_system: self.extractor_id.name.clone(),
1012            block_number: snapshot_block,
1013            uses_dci: self.uses_dci,
1014            retrieve_balances: self.retrieve_balances,
1015            include_tvl: self.include_tvl,
1016        };
1017        let ids = component_ids.clone();
1018        tokio::spawn(async move {
1019            let _ = tx.send(fetch_snapshot_background(rpc, ids, bg_params).await);
1020        });
1021        for id in &component_ids {
1022            self.snapshot_queue
1023                .insert(id.clone(), SnapshotStatus::InFlight);
1024        }
1025        self.snapshot_tasks
1026            .push(SnapshotTask { component_ids, snapshot_block, receiver: rx });
1027    }
1028
1029    /// Drains any background snapshot tasks that have completed. Returns a `Snapshot` containing
1030    /// all ready results, with buffered deltas applied to bring each snapshot up to date.
1031    fn drain_completed_snapshots(&mut self) -> Snapshot {
1032        let mut result = Snapshot::default();
1033        let pending = std::mem::take(&mut self.snapshot_tasks);
1034
1035        for mut p in pending {
1036            match p.receiver.try_recv() {
1037                Ok(Ok(fetch_result)) => {
1038                    debug!(
1039                        components = ?p.component_ids,
1040                        extractor = %self.extractor_id.name,
1041                        "snapshot_background_ready"
1042                    );
1043                    for id in &p.component_ids {
1044                        self.snapshot_queue.remove(id);
1045                    }
1046                    let new_component_ids: Vec<String> = fetch_result
1047                        .components
1048                        .keys()
1049                        .cloned()
1050                        .collect();
1051                    self.component_tracker
1052                        .components
1053                        .extend(fetch_result.components);
1054                    self.component_tracker
1055                        .process_entrypoints(&fetch_result.dci_update);
1056                    self.component_tracker
1057                        .update_contracts(new_component_ids);
1058                    let mut snapshot = fetch_result.snapshot;
1059                    self.apply_deltas_to_snapshot(
1060                        &mut snapshot,
1061                        fetch_result.snapshot_block,
1062                        &fetch_result.contract_ids,
1063                    );
1064                    result.extend(snapshot);
1065                }
1066                Ok(Err(e)) => {
1067                    if e.is_transient() {
1068                        warn!(
1069                            components = ?p.component_ids,
1070                            extractor = %self.extractor_id.name,
1071                            err = %e,
1072                            "Background snapshot fetch failed transiently; will retry next block"
1073                        );
1074                        for id in &p.component_ids {
1075                            self.snapshot_queue
1076                                .insert(id.clone(), SnapshotStatus::RetryNext);
1077                        }
1078                    } else {
1079                        warn!(
1080                            components = ?p.component_ids,
1081                            extractor = %self.extractor_id.name,
1082                            err = %e,
1083                            "Background snapshot fetch failed permanently; \
1084                             components blacklisted until restart"
1085                        );
1086                        for id in &p.component_ids {
1087                            self.snapshot_queue
1088                                .insert(id.clone(), SnapshotStatus::Blacklisted);
1089                        }
1090                    }
1091                }
1092                Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
1093                    self.snapshot_tasks.push(p);
1094                }
1095                Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
1096                    warn!(
1097                        components = ?p.component_ids,
1098                        extractor = %self.extractor_id.name,
1099                        "Background snapshot task dropped before sending result"
1100                    );
1101                    for id in &p.component_ids {
1102                        self.snapshot_queue.remove(id);
1103                    }
1104                }
1105            }
1106        }
1107
1108        result
1109    }
1110
1111    fn is_next_expected(&self, incoming: &BlockHeader) -> bool {
1112        if let Some(block) = self.last_synced_block.as_ref() {
1113            return incoming.parent_hash == block.hash;
1114        }
1115        false
1116    }
1117    fn filter_deltas(&self, deltas: &mut BlockAggregatedChanges) {
1118        deltas.filter_by_component(|id| {
1119            self.component_tracker
1120                .components
1121                .contains_key(id)
1122        });
1123        deltas.filter_by_contract(|id| {
1124            self.component_tracker
1125                .contracts
1126                .contains(id)
1127        });
1128    }
1129}
1130
1131#[async_trait]
1132impl<R, D> StateSynchronizer for ProtocolStateSynchronizer<R, D>
1133where
1134    R: RPCClient + Clone + Send + Sync + 'static,
1135    D: DeltasClient + Clone + Send + Sync + 'static,
1136{
1137    async fn initialize(&mut self) -> SyncResult<()> {
1138        info!("Retrieving relevant protocol components");
1139        self.component_tracker
1140            .initialise_components()
1141            .await?;
1142        info!(
1143            n_components = self.component_tracker.components.len(),
1144            n_contracts = self.component_tracker.contracts.len(),
1145            extractor = %self.extractor_id,
1146            "Finished retrieving components",
1147        );
1148
1149        Ok(())
1150    }
1151
1152    async fn start(
1153        mut self,
1154    ) -> (SynchronizerTaskHandle, Receiver<SyncResult<StateSyncMessage<BlockHeader>>>) {
1155        let (mut tx, rx) = channel(15);
1156        let (end_tx, end_rx) = oneshot::channel::<()>();
1157
1158        let jh = tokio::spawn(async move {
1159            let mut retry_count = 0;
1160            let mut current_end_rx = end_rx;
1161            let mut final_error = None;
1162
1163            while retry_count < self.max_retries {
1164                info!(extractor_id=%&self.extractor_id, retry_count, "(Re)starting synchronization loop");
1165
1166                let prev_block = self
1167                    .last_synced_block
1168                    .as_ref()
1169                    .map(|h| h.number);
1170                // Restarts of many clients happen at once after a server or extractor restart;
1171                // a random delay spreads their snapshot requests. A stream that requests no
1172                // snapshot puts no load to spread and would only lose deltas.
1173                let requests_snapshot = self.include_snapshots &&
1174                    !self
1175                        .component_tracker
1176                        .components
1177                        .is_empty();
1178                let snapshot_delay = if prev_block.is_some() && requests_snapshot {
1179                    rand::thread_rng().gen_range(Duration::ZERO..=self.snapshot_jitter)
1180                } else {
1181                    Duration::ZERO
1182                };
1183                let res = self
1184                    .state_sync(&mut tx, current_end_rx, snapshot_delay)
1185                    .await;
1186                let made_progress = self
1187                    .last_synced_block
1188                    .as_ref()
1189                    .map(|h| h.number) >
1190                    prev_block;
1191                match res {
1192                    Ok(()) => {
1193                        info!(
1194                            extractor_id=%&self.extractor_id,
1195                            retry_count,
1196                            "State synchronization exited cleanly"
1197                        );
1198                        return;
1199                    }
1200                    Err(e) => {
1201                        let (e, maybe_end_rx) = *e;
1202                        warn!(
1203                            extractor_id=%&self.extractor_id,
1204                            retry_count,
1205                            error=%e,
1206                            "State synchronization errored!"
1207                        );
1208
1209                        // If we have the end_rx back, we can retry
1210                        if let Some(recovered_end_rx) = maybe_end_rx {
1211                            current_end_rx = recovered_end_rx;
1212
1213                            if let SynchronizerError::ConnectionClosed = e {
1214                                // break synchronization loop if websocket client is dead
1215                                error!(
1216                                    "Websocket connection closed. State synchronization exiting."
1217                                );
1218                                let _ = tx.send(Err(e)).await;
1219                                return;
1220                            } else {
1221                                // Store error in case this is our last retry
1222                                final_error = Some(e);
1223                            }
1224                        } else {
1225                            // Close signal was received, exit cleanly
1226                            info!(extractor_id=%&self.extractor_id, "Received close signal, exiting.");
1227                            return;
1228                        }
1229                    }
1230                }
1231                // A run that processed blocks is a healthy run — reset the counter so
1232                // transient failures after a long successful period get a fresh retry budget,
1233                // and restart at once: the resubscribe waits for the websocket anyway.
1234                if made_progress {
1235                    retry_count = 0;
1236                } else {
1237                    retry_count += 1;
1238                    sleep(self.retry_cooldown).await;
1239                }
1240            }
1241            if let Some(e) = final_error {
1242                warn!(extractor_id=%&self.extractor_id, retry_count, error=%e, "Max retries exceeded");
1243                let _ = tx.send(Err(e)).await;
1244            }
1245        });
1246
1247        let handle = SynchronizerTaskHandle::new(jh, end_tx);
1248        (handle, rx)
1249    }
1250}
1251
1252#[cfg(test)]
1253mod test {
1254    //! Test suite for ProtocolStateSynchronizer shutdown and cleanup behavior.
1255    //!
1256    //! ## Test Coverage Strategy:
1257    //!
1258    //! ### Shutdown & Close Signal Tests:
1259    //! - `test_public_close_api_functionality` - Tests public API (start/close lifecycle)
1260    //! - `test_close_signal_while_waiting_for_first_deltas` - Close during initial wait
1261    //! - `test_close_signal_during_main_processing_loop` - Close during main processing
1262    //!
1263    //! ### Cleanup & Error Handling Tests:
1264    //! - `test_cleanup_runs_when_state_sync_processing_errors` - Cleanup on processing errors
1265    //!
1266    //! ### Coverage Summary:
1267    //! These tests ensure cleanup code (shared state reset + unsubscribe) runs on ALL exit paths:
1268    //! ✓ Close signal before first deltas   ✓ Close signal during processing
1269    //! ✓ Processing errors                  ✓ Channel closure
1270    //! ✓ Public API close operations        ✓ Normal completion
1271
1272    use std::{collections::HashSet, sync::Arc};
1273
1274    use tycho_common::models::{
1275        blockchain::{
1276            AddressStorageLocation, Block, BlockAggregatedChanges, DCIUpdate, EntryPoint,
1277            EntryPointWithTracingParams, RPCTracerParams, TracingParams, TracingResult,
1278        },
1279        protocol::{ProtocolComponent, ProtocolComponentState},
1280        token::Token,
1281        Chain,
1282    };
1283    use uuid::Uuid;
1284
1285    use super::*;
1286    use crate::{
1287        deltas::MockDeltasClient,
1288        rpc::{MockRPCClient, Page},
1289        DeltasError, RPCError,
1290    };
1291
1292    // Required for mock client to implement clone
1293    struct ArcRPCClient<T>(Arc<T>);
1294
1295    // Default derive(Clone) does require T to be Clone as well.
1296    impl<T> Clone for ArcRPCClient<T> {
1297        fn clone(&self) -> Self {
1298            ArcRPCClient(self.0.clone())
1299        }
1300    }
1301
1302    #[async_trait]
1303    impl<T> RPCClient for ArcRPCClient<T>
1304    where
1305        T: RPCClient + Sync + Send + 'static,
1306    {
1307        async fn get_tokens(
1308            &self,
1309            params: crate::rpc::TokensParams,
1310        ) -> Result<crate::rpc::Page<Vec<Token>>, RPCError> {
1311            self.0.get_tokens(params).await
1312        }
1313
1314        async fn get_contract_state(
1315            &self,
1316            params: crate::rpc::ContractStateParams,
1317        ) -> Result<crate::rpc::Page<Vec<Account>>, RPCError> {
1318            self.0.get_contract_state(params).await
1319        }
1320
1321        async fn get_protocol_components(
1322            &self,
1323            params: crate::rpc::ProtocolComponentsParams,
1324        ) -> Result<crate::rpc::Page<Vec<ProtocolComponent>>, RPCError> {
1325            self.0
1326                .get_protocol_components(params)
1327                .await
1328        }
1329
1330        async fn get_protocol_states(
1331            &self,
1332            params: crate::rpc::ProtocolStatesParams,
1333        ) -> Result<crate::rpc::Page<Vec<ProtocolComponentState>>, RPCError> {
1334            self.0.get_protocol_states(params).await
1335        }
1336
1337        async fn get_protocol_systems(
1338            &self,
1339            params: crate::rpc::ProtocolSystemsParams,
1340        ) -> Result<crate::rpc::Page<crate::rpc::ProtocolSystems>, RPCError> {
1341            self.0
1342                .get_protocol_systems(params)
1343                .await
1344        }
1345
1346        async fn get_component_tvl(
1347            &self,
1348            params: crate::rpc::ComponentTvlParams,
1349        ) -> Result<crate::rpc::Page<HashMap<String, f64>>, RPCError> {
1350            self.0.get_component_tvl(params).await
1351        }
1352
1353        async fn get_traced_entry_points(
1354            &self,
1355            params: crate::rpc::TracedEntryPointsParams,
1356        ) -> Result<
1357            crate::rpc::Page<HashMap<String, Vec<(EntryPointWithTracingParams, TracingResult)>>>,
1358            RPCError,
1359        > {
1360            self.0
1361                .get_traced_entry_points(params)
1362                .await
1363        }
1364
1365        async fn get_snapshots<'a>(
1366            &self,
1367            request: &SnapshotParameters<'a>,
1368            chunk_size: Option<usize>,
1369            concurrency: usize,
1370        ) -> Result<Snapshot, RPCError> {
1371            self.0
1372                .get_snapshots(request, chunk_size, concurrency)
1373                .await
1374        }
1375
1376        fn compression(&self) -> bool {
1377            self.0.compression()
1378        }
1379    }
1380
1381    // Required for mock client to implement clone
1382    struct ArcDeltasClient<T>(Arc<T>);
1383
1384    // Default derive(Clone) does require T to be Clone as well.
1385    impl<T> Clone for ArcDeltasClient<T> {
1386        fn clone(&self) -> Self {
1387            ArcDeltasClient(self.0.clone())
1388        }
1389    }
1390
1391    #[async_trait]
1392    impl<T> DeltasClient for ArcDeltasClient<T>
1393    where
1394        T: DeltasClient + Sync + Send + 'static,
1395    {
1396        async fn subscribe(
1397            &self,
1398            extractor_id: tycho_common::models::ExtractorIdentity,
1399            options: SubscriptionOptions,
1400        ) -> Result<(Uuid, Receiver<BlockAggregatedChanges>), DeltasError> {
1401            self.0
1402                .subscribe(extractor_id, options)
1403                .await
1404        }
1405
1406        async fn unsubscribe(&self, subscription_id: Uuid) -> Result<(), DeltasError> {
1407            self.0
1408                .unsubscribe(subscription_id)
1409                .await
1410        }
1411
1412        async fn connect(&self) -> Result<JoinHandle<Result<(), DeltasError>>, DeltasError> {
1413            self.0.connect().await
1414        }
1415
1416        async fn close(&self) -> Result<(), DeltasError> {
1417            self.0.close().await
1418        }
1419    }
1420
1421    fn with_mocked_clients(
1422        native: bool,
1423        include_tvl: bool,
1424        rpc_client: Option<MockRPCClient>,
1425        deltas_client: Option<MockDeltasClient>,
1426    ) -> ProtocolStateSynchronizer<ArcRPCClient<MockRPCClient>, ArcDeltasClient<MockDeltasClient>>
1427    {
1428        let rpc_client = ArcRPCClient(Arc::new(rpc_client.unwrap_or_default()));
1429        let deltas_client = ArcDeltasClient(Arc::new(deltas_client.unwrap_or_default()));
1430
1431        ProtocolStateSynchronizer::new(
1432            ExtractorIdentity::new(Chain::Ethereum, "uniswap-v2"),
1433            native,
1434            ComponentFilter::with_tvl_range(50.0, 50.0),
1435            1,
1436            Duration::from_secs(0),
1437            true,
1438            include_tvl,
1439            true, // Does not matter as we mock the client that never compresses
1440            rpc_client,
1441            deltas_client,
1442            10_u64,
1443        )
1444    }
1445
1446    fn state_snapshot_native() -> Vec<ProtocolComponentState> {
1447        vec![ProtocolComponentState {
1448            component_id: "Component1".to_string(),
1449            attributes: HashMap::new(),
1450            balances: HashMap::new(),
1451        }]
1452    }
1453
1454    fn make_mock_client() -> MockRPCClient {
1455        let mut m = MockRPCClient::new();
1456        m.expect_compression()
1457            .return_const(false);
1458        m
1459    }
1460
1461    #[test_log::test(tokio::test)]
1462    async fn test_get_snapshots_native() {
1463        let header = BlockHeader::default();
1464        let mut rpc = make_mock_client();
1465        let component = ProtocolComponent { id: "Component1".to_string(), ..Default::default() };
1466
1467        let component_clone = component.clone();
1468        rpc.expect_get_snapshots()
1469            .returning(move |_request, _chunk_size, _concurrency| {
1470                Ok(Snapshot {
1471                    states: state_snapshot_native()
1472                        .into_iter()
1473                        .map(|state| {
1474                            (
1475                                state.component_id.clone(),
1476                                ComponentWithState {
1477                                    state,
1478                                    component: component_clone.clone(),
1479                                    entrypoints: vec![],
1480                                    component_tvl: None,
1481                                },
1482                            )
1483                        })
1484                        .collect(),
1485                    vm_storage: HashMap::new(),
1486                })
1487            });
1488
1489        let mut state_sync = with_mocked_clients(true, false, Some(rpc), None);
1490        state_sync
1491            .component_tracker
1492            .components
1493            .insert("Component1".to_string(), component.clone());
1494        let components_arg = ["Component1".to_string()];
1495        let exp = StateSyncMessage {
1496            header: header.clone(),
1497            snapshots: Snapshot {
1498                states: state_snapshot_native()
1499                    .into_iter()
1500                    .map(|state| {
1501                        (
1502                            state.component_id.clone(),
1503                            ComponentWithState {
1504                                state,
1505                                component: component.clone(),
1506                                entrypoints: vec![],
1507                                component_tvl: None,
1508                            },
1509                        )
1510                    })
1511                    .collect(),
1512                vm_storage: HashMap::new(),
1513            },
1514            deltas: None,
1515            removed_components: Default::default(),
1516        };
1517
1518        let req_ids: Vec<String> = components_arg.to_vec();
1519        let components: HashMap<_, _> = state_sync
1520            .component_tracker
1521            .components
1522            .iter()
1523            .filter(|(id, _)| req_ids.contains(id))
1524            .map(|(k, v)| (k.clone(), v.clone()))
1525            .collect();
1526        let contract_ids: HashSet<Bytes> = state_sync
1527            .component_tracker
1528            .get_contracts_by_component(&req_ids)
1529            .into_iter()
1530            .collect();
1531        let params = FetchSnapshotParams {
1532            chain: Chain::Ethereum,
1533            protocol_system: "uniswap-v2".to_string(),
1534            block_number: header.number,
1535            uses_dci: false,
1536            retrieve_balances: true,
1537            include_tvl: false,
1538        };
1539        let (snapshot, _, _) =
1540            fetch_snapshot(&state_sync.rpc_client, components, contract_ids, &params)
1541                .await
1542                .expect("Retrieving snapshot failed");
1543        let snap = StateSyncMessage {
1544            header: header.clone(),
1545            snapshots: snapshot,
1546            deltas: None,
1547            removed_components: Default::default(),
1548        };
1549
1550        assert_eq!(snap, exp);
1551    }
1552
1553    #[test_log::test(tokio::test)]
1554    async fn test_get_snapshots_native_with_tvl() {
1555        let header = BlockHeader::default();
1556        let mut rpc = make_mock_client();
1557        let component = ProtocolComponent { id: "Component1".to_string(), ..Default::default() };
1558
1559        let component_clone = component.clone();
1560        rpc.expect_get_snapshots()
1561            .returning(move |_request, _chunk_size, _concurrency| {
1562                Ok(Snapshot {
1563                    states: state_snapshot_native()
1564                        .into_iter()
1565                        .map(|state| {
1566                            (
1567                                state.component_id.clone(),
1568                                ComponentWithState {
1569                                    state,
1570                                    component: component_clone.clone(),
1571                                    component_tvl: Some(100.0),
1572                                    entrypoints: vec![],
1573                                },
1574                            )
1575                        })
1576                        .collect(),
1577                    vm_storage: HashMap::new(),
1578                })
1579            });
1580
1581        let mut state_sync = with_mocked_clients(true, true, Some(rpc), None);
1582        state_sync
1583            .component_tracker
1584            .components
1585            .insert("Component1".to_string(), component.clone());
1586        let components_arg = ["Component1".to_string()];
1587        let exp = StateSyncMessage {
1588            header: header.clone(),
1589            snapshots: Snapshot {
1590                states: state_snapshot_native()
1591                    .into_iter()
1592                    .map(|state| {
1593                        (
1594                            state.component_id.clone(),
1595                            ComponentWithState {
1596                                state,
1597                                component: component.clone(),
1598                                component_tvl: Some(100.0),
1599                                entrypoints: vec![],
1600                            },
1601                        )
1602                    })
1603                    .collect(),
1604                vm_storage: HashMap::new(),
1605            },
1606            deltas: None,
1607            removed_components: Default::default(),
1608        };
1609
1610        let req_ids: Vec<String> = components_arg.to_vec();
1611        let components: HashMap<_, _> = state_sync
1612            .component_tracker
1613            .components
1614            .iter()
1615            .filter(|(id, _)| req_ids.contains(id))
1616            .map(|(k, v)| (k.clone(), v.clone()))
1617            .collect();
1618        let contract_ids: HashSet<Bytes> = state_sync
1619            .component_tracker
1620            .get_contracts_by_component(&req_ids)
1621            .into_iter()
1622            .collect();
1623        let params = FetchSnapshotParams {
1624            chain: Chain::Ethereum,
1625            protocol_system: "uniswap-v2".to_string(),
1626            block_number: header.number,
1627            uses_dci: false,
1628            retrieve_balances: true,
1629            include_tvl: true,
1630        };
1631        let (snapshot, _, _) =
1632            fetch_snapshot(&state_sync.rpc_client, components, contract_ids, &params)
1633                .await
1634                .expect("Retrieving snapshot failed");
1635        let snap = StateSyncMessage {
1636            header: header.clone(),
1637            snapshots: snapshot,
1638            deltas: None,
1639            removed_components: Default::default(),
1640        };
1641
1642        assert_eq!(snap, exp);
1643    }
1644
1645    fn state_snapshot_vm() -> Vec<Account> {
1646        vec![
1647            Account::new(
1648                Chain::default(),
1649                Bytes::from("0x0badc0ffee"),
1650                String::new(),
1651                HashMap::new(),
1652                Bytes::default(),
1653                HashMap::new(),
1654                Bytes::default(),
1655                Bytes::default(),
1656                Bytes::default(),
1657                Bytes::default(),
1658                None,
1659            ),
1660            Account::new(
1661                Chain::default(),
1662                Bytes::from("0xbabe42"),
1663                String::new(),
1664                HashMap::new(),
1665                Bytes::default(),
1666                HashMap::new(),
1667                Bytes::default(),
1668                Bytes::default(),
1669                Bytes::default(),
1670                Bytes::default(),
1671                None,
1672            ),
1673        ]
1674    }
1675
1676    fn traced_entry_point_response(
1677    ) -> HashMap<String, Vec<(EntryPointWithTracingParams, TracingResult)>> {
1678        HashMap::from([(
1679            "Component1".to_string(),
1680            vec![(
1681                EntryPointWithTracingParams {
1682                    entry_point: EntryPoint {
1683                        external_id: "entrypoint_a".to_string(),
1684                        target: Bytes::from("0x0badc0ffee"),
1685                        signature: "sig()".to_string(),
1686                    },
1687                    params: TracingParams::RPCTracer(RPCTracerParams {
1688                        caller: Some(Bytes::from("0x0badc0ffee")),
1689                        calldata: Bytes::from("0x0badc0ffee"),
1690                        state_overrides: None,
1691                        prune_addresses: None,
1692                    }),
1693                },
1694                TracingResult {
1695                    retriggers: HashSet::from([(
1696                        Bytes::from("0x0badc0ffee"),
1697                        AddressStorageLocation::new(Bytes::from("0x0badc0ffee"), 12),
1698                    )]),
1699                    accessed_slots: HashMap::from([(
1700                        Bytes::from("0x0badc0ffee"),
1701                        HashSet::from([Bytes::from("0xbadbeef0")]),
1702                    )]),
1703                },
1704            )],
1705        )])
1706    }
1707
1708    #[test_log::test(tokio::test)]
1709    async fn test_get_snapshots_vm() {
1710        let header = BlockHeader::default();
1711        let mut rpc = make_mock_client();
1712
1713        let traced_ep_response = traced_entry_point_response();
1714        rpc.expect_get_snapshots()
1715            .returning(move |_request, _chunk_size, _concurrency| {
1716                let vm_storage_accounts = state_snapshot_vm();
1717                Ok(Snapshot {
1718                    states: [(
1719                        "Component1".to_string(),
1720                        ComponentWithState {
1721                            state: ProtocolComponentState {
1722                                component_id: "Component1".to_string(),
1723                                attributes: HashMap::new(),
1724                                balances: HashMap::new(),
1725                            },
1726                            component: ProtocolComponent {
1727                                id: "Component1".to_string(),
1728                                contract_addresses: vec![
1729                                    Bytes::from("0x0badc0ffee"),
1730                                    Bytes::from("0xbabe42"),
1731                                ],
1732                                ..Default::default()
1733                            },
1734                            component_tvl: None,
1735                            entrypoints: traced_ep_response
1736                                .get("Component1")
1737                                .cloned()
1738                                .unwrap_or_default(),
1739                        },
1740                    )]
1741                    .into_iter()
1742                    .collect(),
1743                    vm_storage: vm_storage_accounts
1744                        .into_iter()
1745                        .map(|account| (account.address.clone(), account))
1746                        .collect(),
1747                })
1748            });
1749
1750        let mut state_sync = with_mocked_clients(false, false, Some(rpc), None);
1751        let component = ProtocolComponent {
1752            id: "Component1".to_string(),
1753            contract_addresses: vec![Bytes::from("0x0badc0ffee"), Bytes::from("0xbabe42")],
1754            ..Default::default()
1755        };
1756        state_sync
1757            .component_tracker
1758            .components
1759            .insert("Component1".to_string(), component.clone());
1760        let components_arg = ["Component1".to_string()];
1761        let exp = StateSyncMessage {
1762            header: header.clone(),
1763            snapshots: Snapshot {
1764                states: [(
1765                    component.id.clone(),
1766                    ComponentWithState {
1767                        state: ProtocolComponentState {
1768                            component_id: "Component1".to_string(),
1769                            attributes: HashMap::new(),
1770                            balances: HashMap::new(),
1771                        },
1772                        component: component.clone(),
1773                        component_tvl: None,
1774                        entrypoints: traced_entry_point_response()
1775                            .remove("Component1")
1776                            .unwrap_or_default(),
1777                    },
1778                )]
1779                .into_iter()
1780                .collect(),
1781                vm_storage: state_snapshot_vm()
1782                    .into_iter()
1783                    .map(|account| (account.address.clone(), account))
1784                    .collect(),
1785            },
1786            deltas: None,
1787            removed_components: Default::default(),
1788        };
1789
1790        let req_ids: Vec<String> = components_arg.to_vec();
1791        let components: HashMap<_, _> = state_sync
1792            .component_tracker
1793            .components
1794            .iter()
1795            .filter(|(id, _)| req_ids.contains(id))
1796            .map(|(k, v)| (k.clone(), v.clone()))
1797            .collect();
1798        let contract_ids: HashSet<Bytes> = state_sync
1799            .component_tracker
1800            .get_contracts_by_component(&req_ids)
1801            .into_iter()
1802            .collect();
1803        let params = FetchSnapshotParams {
1804            chain: Chain::Ethereum,
1805            protocol_system: "uniswap-v2".to_string(),
1806            block_number: header.number,
1807            uses_dci: false,
1808            retrieve_balances: false,
1809            include_tvl: false,
1810        };
1811        let (snapshot, _, _) =
1812            fetch_snapshot(&state_sync.rpc_client, components, contract_ids, &params)
1813                .await
1814                .expect("Retrieving snapshot failed");
1815        let snap = StateSyncMessage {
1816            header: header.clone(),
1817            snapshots: snapshot,
1818            deltas: None,
1819            removed_components: Default::default(),
1820        };
1821
1822        assert_eq!(snap, exp);
1823    }
1824
1825    #[test_log::test(tokio::test)]
1826    async fn test_get_snapshots_vm_with_tvl() {
1827        let header = BlockHeader::default();
1828        let mut rpc = make_mock_client();
1829        let component = ProtocolComponent {
1830            id: "Component1".to_string(),
1831            contract_addresses: vec![Bytes::from("0x0badc0ffee"), Bytes::from("0xbabe42")],
1832            ..Default::default()
1833        };
1834
1835        let component_clone = component.clone();
1836        rpc.expect_get_snapshots()
1837            .returning(move |_request, _chunk_size, _concurrency| {
1838                let vm_storage_accounts = state_snapshot_vm();
1839                Ok(Snapshot {
1840                    states: [(
1841                        "Component1".to_string(),
1842                        ComponentWithState {
1843                            state: ProtocolComponentState {
1844                                component_id: "Component1".to_string(),
1845                                attributes: HashMap::new(),
1846                                balances: HashMap::new(),
1847                            },
1848                            component: component_clone.clone(),
1849                            component_tvl: Some(100.0),
1850                            entrypoints: vec![],
1851                        },
1852                    )]
1853                    .into_iter()
1854                    .collect(),
1855                    vm_storage: vm_storage_accounts
1856                        .into_iter()
1857                        .map(|account| (account.address.clone(), account))
1858                        .collect(),
1859                })
1860            });
1861
1862        let mut state_sync = with_mocked_clients(false, true, Some(rpc), None);
1863        state_sync
1864            .component_tracker
1865            .components
1866            .insert("Component1".to_string(), component.clone());
1867        let components_arg = ["Component1".to_string()];
1868        let exp = StateSyncMessage {
1869            header: header.clone(),
1870            snapshots: Snapshot {
1871                states: [(
1872                    component.id.clone(),
1873                    ComponentWithState {
1874                        state: ProtocolComponentState {
1875                            component_id: "Component1".to_string(),
1876                            attributes: HashMap::new(),
1877                            balances: HashMap::new(),
1878                        },
1879                        component: component.clone(),
1880                        component_tvl: Some(100.0),
1881                        entrypoints: vec![],
1882                    },
1883                )]
1884                .into_iter()
1885                .collect(),
1886                vm_storage: state_snapshot_vm()
1887                    .into_iter()
1888                    .map(|account| (account.address.clone(), account))
1889                    .collect(),
1890            },
1891            deltas: None,
1892            removed_components: Default::default(),
1893        };
1894
1895        let req_ids: Vec<String> = components_arg.to_vec();
1896        let components: HashMap<_, _> = state_sync
1897            .component_tracker
1898            .components
1899            .iter()
1900            .filter(|(id, _)| req_ids.contains(id))
1901            .map(|(k, v)| (k.clone(), v.clone()))
1902            .collect();
1903        let contract_ids: HashSet<Bytes> = state_sync
1904            .component_tracker
1905            .get_contracts_by_component(&req_ids)
1906            .into_iter()
1907            .collect();
1908        let params = FetchSnapshotParams {
1909            chain: Chain::Ethereum,
1910            protocol_system: "uniswap-v2".to_string(),
1911            block_number: header.number,
1912            uses_dci: false,
1913            retrieve_balances: false,
1914            include_tvl: true,
1915        };
1916        let (snapshot, _, _) =
1917            fetch_snapshot(&state_sync.rpc_client, components, contract_ids, &params)
1918                .await
1919                .expect("Retrieving snapshot failed");
1920        let snap = StateSyncMessage {
1921            header: header.clone(),
1922            snapshots: snapshot,
1923            deltas: None,
1924            removed_components: Default::default(),
1925        };
1926
1927        assert_eq!(snap, exp);
1928    }
1929
1930    /// Test that get_snapshots only fetches snapshots for requested components,
1931    /// not all tracked components. This prevents returning full snapshots repeatedly
1932    /// when only a subset of components need updates.
1933    #[test_log::test(tokio::test)]
1934    async fn test_get_snapshots_filters_to_requested_components_only() {
1935        let header = BlockHeader::default();
1936        let mut rpc = make_mock_client();
1937
1938        // Create three components
1939        let component1 = ProtocolComponent { id: "Component1".to_string(), ..Default::default() };
1940        let component2 = ProtocolComponent { id: "Component2".to_string(), ..Default::default() };
1941        let component3 = ProtocolComponent { id: "Component3".to_string(), ..Default::default() };
1942
1943        let component2_clone = component2.clone();
1944
1945        // Mock the RPC call and verify it only receives Component2
1946        rpc.expect_get_snapshots()
1947            .withf(
1948                |request: &SnapshotParameters,
1949                 _chunk_size: &Option<usize>,
1950                 _concurrency: &usize| {
1951                    // Verify that the request contains ONLY Component2, not all tracked components
1952                    request.components.len() == 1 &&
1953                        request
1954                            .components
1955                            .contains_key("Component2")
1956                },
1957            )
1958            .times(1)
1959            .returning(move |_request, _chunk_size, _concurrency| {
1960                Ok(Snapshot {
1961                    states: [(
1962                        "Component2".to_string(),
1963                        ComponentWithState {
1964                            state: ProtocolComponentState {
1965                                component_id: "Component2".to_string(),
1966                                attributes: HashMap::new(),
1967                                balances: HashMap::new(),
1968                            },
1969                            component: component2_clone.clone(),
1970                            entrypoints: vec![],
1971                            component_tvl: None,
1972                        },
1973                    )]
1974                    .into_iter()
1975                    .collect(),
1976                    vm_storage: HashMap::new(),
1977                })
1978            });
1979
1980        let mut state_sync = with_mocked_clients(true, false, Some(rpc), None);
1981
1982        // Track all three components
1983        state_sync
1984            .component_tracker
1985            .components
1986            .insert("Component1".to_string(), component1.clone());
1987        state_sync
1988            .component_tracker
1989            .components
1990            .insert("Component2".to_string(), component2.clone());
1991        state_sync
1992            .component_tracker
1993            .components
1994            .insert("Component3".to_string(), component3.clone());
1995
1996        // Request snapshot for ONLY Component2
1997        let components_arg = ["Component2".to_string()];
1998        let req_ids: Vec<String> = components_arg.to_vec();
1999        let components: HashMap<_, _> = state_sync
2000            .component_tracker
2001            .components
2002            .iter()
2003            .filter(|(id, _)| req_ids.contains(id))
2004            .map(|(k, v)| (k.clone(), v.clone()))
2005            .collect();
2006        let contract_ids: HashSet<Bytes> = state_sync
2007            .component_tracker
2008            .get_contracts_by_component(&req_ids)
2009            .into_iter()
2010            .collect();
2011        let params = FetchSnapshotParams {
2012            chain: Chain::Ethereum,
2013            protocol_system: "uniswap-v2".to_string(),
2014            block_number: header.number,
2015            uses_dci: false,
2016            retrieve_balances: true,
2017            include_tvl: false,
2018        };
2019        let (snapshot, _, _) =
2020            fetch_snapshot(&state_sync.rpc_client, components, contract_ids, &params)
2021                .await
2022                .expect("Retrieving snapshot failed");
2023
2024        // Verify we only got Component2 back
2025        assert_eq!(snapshot.states.len(), 1);
2026        assert!(snapshot
2027            .states
2028            .contains_key("Component2"));
2029        assert!(!snapshot
2030            .states
2031            .contains_key("Component1"));
2032        assert!(!snapshot
2033            .states
2034            .contains_key("Component3"));
2035    }
2036
2037    fn mock_clients_for_state_sync(
2038        bg_done: Option<Arc<tokio::sync::Notify>>,
2039    ) -> (MockRPCClient, MockDeltasClient, Sender<BlockAggregatedChanges>) {
2040        let mut rpc_client = make_mock_client();
2041        // Mocks for the start_tracking call, these need to come first because they are more
2042        // specific, see: https://docs.rs/mockall/latest/mockall/#matching-multiple-calls
2043        rpc_client
2044            .expect_get_protocol_components()
2045            .withf(|params: &crate::rpc::ProtocolComponentsParams| {
2046                params
2047                    .component_ids()
2048                    .is_some_and(|ids| ids.contains(&"Component3".to_string()))
2049            })
2050            .returning(|_| {
2051                // return Component3
2052                Ok(Page::new(
2053                    vec![
2054                        // this component shall have a tvl update above threshold
2055                        ProtocolComponent { id: "Component3".to_string(), ..Default::default() },
2056                    ],
2057                    1,
2058                    0,
2059                    100,
2060                ))
2061            });
2062        // Mock get_snapshots for Component3
2063        rpc_client
2064            .expect_get_snapshots()
2065            .withf(
2066                |request: &SnapshotParameters,
2067                 _chunk_size: &Option<usize>,
2068                 _concurrency: &usize| {
2069                    request
2070                        .components
2071                        .contains_key("Component3")
2072                },
2073            )
2074            .returning(move |_request, _chunk_size, _concurrency| {
2075                let snap = Ok(Snapshot {
2076                    states: [(
2077                        "Component3".to_string(),
2078                        ComponentWithState {
2079                            state: ProtocolComponentState::new(
2080                                "Component3",
2081                                Default::default(),
2082                                Default::default(),
2083                            ),
2084                            component: ProtocolComponent {
2085                                id: "Component3".to_string(),
2086                                ..Default::default()
2087                            },
2088                            component_tvl: Some(1000.0),
2089                            entrypoints: vec![],
2090                        },
2091                    )]
2092                    .into_iter()
2093                    .collect(),
2094                    vm_storage: HashMap::new(),
2095                });
2096                if let Some(n) = &bg_done {
2097                    n.notify_one();
2098                }
2099                snap
2100            });
2101
2102        // mock calls for the initial state snapshots
2103        rpc_client
2104            .expect_get_protocol_components()
2105            .returning(|_| {
2106                // Initial sync of components
2107                Ok(Page::new(
2108                    vec![
2109                        // this component shall have a tvl update above threshold
2110                        ProtocolComponent { id: "Component1".to_string(), ..Default::default() },
2111                        // this component shall have a tvl update below threshold.
2112                        ProtocolComponent { id: "Component2".to_string(), ..Default::default() },
2113                        // a third component will have a tvl update above threshold
2114                    ],
2115                    2,
2116                    0,
2117                    100,
2118                ))
2119            });
2120
2121        rpc_client
2122            .expect_get_snapshots()
2123            .returning(|_request, _chunk_size, _concurrency| {
2124                Ok(Snapshot {
2125                    states: [
2126                        (
2127                            "Component1".to_string(),
2128                            ComponentWithState {
2129                                state: ProtocolComponentState::new(
2130                                    "Component1",
2131                                    Default::default(),
2132                                    Default::default(),
2133                                ),
2134                                component: ProtocolComponent {
2135                                    id: "Component1".to_string(),
2136                                    ..Default::default()
2137                                },
2138                                component_tvl: Some(100.0),
2139                                entrypoints: vec![],
2140                            },
2141                        ),
2142                        (
2143                            "Component2".to_string(),
2144                            ComponentWithState {
2145                                state: ProtocolComponentState::new(
2146                                    "Component2",
2147                                    Default::default(),
2148                                    Default::default(),
2149                                ),
2150                                component: ProtocolComponent {
2151                                    id: "Component2".to_string(),
2152                                    ..Default::default()
2153                                },
2154                                component_tvl: Some(0.0),
2155                                entrypoints: vec![],
2156                            },
2157                        ),
2158                    ]
2159                    .into_iter()
2160                    .collect(),
2161                    vm_storage: HashMap::new(),
2162                })
2163            });
2164
2165        // Mock get_traced_entry_points for Ethereum chain
2166        rpc_client
2167            .expect_get_traced_entry_points()
2168            .returning(|_| Ok(Page::new(HashMap::new(), 0, 0, 0)));
2169
2170        // Mock deltas client and messages
2171        let mut deltas_client = MockDeltasClient::new();
2172        let (tx, rx) = channel(1);
2173        deltas_client
2174            .expect_subscribe()
2175            .return_once(move |_, _| {
2176                // Return subscriber id and a channel
2177                Ok((Uuid::default(), rx))
2178            });
2179
2180        // Expect unsubscribe call during cleanup
2181        deltas_client
2182            .expect_unsubscribe()
2183            .return_once(|_| Ok(()));
2184
2185        (rpc_client, deltas_client, tx)
2186    }
2187
2188    /// Test strategy
2189    ///
2190    /// - initial snapshot retrieval returns component1 and component2 as snapshots
2191    /// - block 1: DCI update for Component1; no new components
2192    /// - block 2: Component3 TVL crosses threshold → background snapshot task spawned; snapshot
2193    ///   appears in block 3 after the background task drains
2194    /// - block 3: empty block; drain produces Component3 snapshot
2195    #[test_log::test(tokio::test)]
2196    async fn test_state_sync() {
2197        let bg_done = Arc::new(tokio::sync::Notify::new());
2198        let (rpc_client, deltas_client, tx) = mock_clients_for_state_sync(Some(bg_done.clone()));
2199        let deltas = [
2200            BlockAggregatedChanges {
2201                extractor: "uniswap-v2".to_string(),
2202                chain: Chain::Ethereum,
2203                block: Block {
2204                    number: 1,
2205                    hash: Bytes::from("0x01"),
2206                    parent_hash: Bytes::from("0x00"),
2207                    chain: Chain::Ethereum,
2208                    ts: Default::default(),
2209                },
2210                revert: false,
2211                dci_update: DCIUpdate {
2212                    new_entrypoints: HashMap::from([(
2213                        "Component1".to_string(),
2214                        HashSet::from([EntryPoint {
2215                            external_id: "entrypoint_a".to_string(),
2216                            target: Bytes::from("0x0badc0ffee"),
2217                            signature: "sig()".to_string(),
2218                        }]),
2219                    )]),
2220                    new_entrypoint_params: HashMap::from([(
2221                        "entrypoint_a".to_string(),
2222                        HashSet::from([(
2223                            TracingParams::RPCTracer(RPCTracerParams {
2224                                caller: Some(Bytes::from("0x0badc0ffee")),
2225                                calldata: Bytes::from("0x0badc0ffee"),
2226                                state_overrides: None,
2227                                prune_addresses: None,
2228                            }),
2229                            "Component1".to_string(),
2230                        )]),
2231                    )]),
2232                    trace_results: HashMap::from([(
2233                        "entrypoint_a".to_string(),
2234                        TracingResult {
2235                            retriggers: HashSet::from([(
2236                                Bytes::from("0x0badc0ffee"),
2237                                AddressStorageLocation::new(Bytes::from("0x0badc0ffee"), 12),
2238                            )]),
2239                            accessed_slots: HashMap::from([(
2240                                Bytes::from("0x0badc0ffee"),
2241                                HashSet::from([Bytes::from("0xbadbeef0")]),
2242                            )]),
2243                        },
2244                    )]),
2245                },
2246                ..Default::default()
2247            },
2248            BlockAggregatedChanges {
2249                extractor: "uniswap-v2".to_string(),
2250                chain: Chain::Ethereum,
2251                block: Block {
2252                    number: 2,
2253                    hash: Bytes::from("0x02"),
2254                    parent_hash: Bytes::from("0x01"),
2255                    chain: Chain::Ethereum,
2256                    ts: Default::default(),
2257                },
2258                revert: false,
2259                component_tvl: [
2260                    ("Component1".to_string(), 100.0),
2261                    ("Component2".to_string(), 0.0),
2262                    ("Component3".to_string(), 1000.0),
2263                ]
2264                .into_iter()
2265                .collect(),
2266                ..Default::default()
2267            },
2268            // Block 3: empty block; the background task for Component3 should have completed,
2269            // so drain_completed_snapshots returns the Component3 snapshot.
2270            BlockAggregatedChanges {
2271                extractor: "uniswap-v2".to_string(),
2272                chain: Chain::Ethereum,
2273                block: Block {
2274                    number: 3,
2275                    hash: Bytes::from("0x03"),
2276                    parent_hash: Bytes::from("0x02"),
2277                    chain: Chain::Ethereum,
2278                    ts: Default::default(),
2279                },
2280                revert: false,
2281                ..Default::default()
2282            },
2283        ];
2284        let mut state_sync = with_mocked_clients(true, true, Some(rpc_client), Some(deltas_client));
2285        state_sync
2286            .initialize()
2287            .await
2288            .expect("Init failed");
2289
2290        // Test starts here
2291        let (handle, mut rx) = state_sync.start().await;
2292        let (jh, close_tx) = handle.split();
2293        tx.send(deltas[0].clone())
2294            .await
2295            .expect("deltas channel msg 0 closed!");
2296        let first_msg = timeout(Duration::from_millis(200), rx.recv())
2297            .await
2298            .expect("waiting for first state msg timed out!")
2299            .expect("state sync block sender closed!");
2300        tx.send(deltas[1].clone())
2301            .await
2302            .expect("deltas channel msg 1 closed!");
2303        let second_msg = timeout(Duration::from_millis(200), rx.recv())
2304            .await
2305            .expect("waiting for second state msg timed out!")
2306            .expect("state sync block sender closed!");
2307        // Wait for the background snapshot task to complete before sending block 3.
2308        bg_done.notified().await;
2309        tx.send(deltas[2].clone())
2310            .await
2311            .expect("deltas channel msg 2 closed!");
2312        let third_msg = timeout(Duration::from_millis(200), rx.recv())
2313            .await
2314            .expect("waiting for third state msg timed out!")
2315            .expect("state sync block sender closed!");
2316        let _ = close_tx.send(());
2317        jh.await
2318            .expect("state sync task panicked!");
2319
2320        // assertions
2321        let exp1 = StateSyncMessage {
2322            header: BlockHeader {
2323                number: 1,
2324                hash: Bytes::from("0x01"),
2325                parent_hash: Bytes::from("0x00"),
2326                revert: false,
2327                ..Default::default()
2328            },
2329            snapshots: Snapshot {
2330                states: [
2331                    (
2332                        "Component1".to_string(),
2333                        ComponentWithState {
2334                            state: ProtocolComponentState::new(
2335                                "Component1",
2336                                Default::default(),
2337                                Default::default(),
2338                            ),
2339                            component: ProtocolComponent {
2340                                id: "Component1".to_string(),
2341                                ..Default::default()
2342                            },
2343                            component_tvl: Some(100.0),
2344                            entrypoints: vec![],
2345                        },
2346                    ),
2347                    (
2348                        "Component2".to_string(),
2349                        ComponentWithState {
2350                            state: ProtocolComponentState::new(
2351                                "Component2",
2352                                Default::default(),
2353                                Default::default(),
2354                            ),
2355                            component: ProtocolComponent {
2356                                id: "Component2".to_string(),
2357                                ..Default::default()
2358                            },
2359                            component_tvl: Some(0.0),
2360                            entrypoints: vec![],
2361                        },
2362                    ),
2363                ]
2364                .into_iter()
2365                .collect(),
2366                vm_storage: HashMap::new(),
2367            },
2368            deltas: Some(deltas[0].clone()),
2369            removed_components: Default::default(),
2370        };
2371
2372        // Block 2: Component3 snapshot task is spawned in the background. Component3 is not
2373        // yet tracked, so it is filtered from component_tvl. Snapshot is empty.
2374        let exp2 = StateSyncMessage {
2375            header: BlockHeader {
2376                number: 2,
2377                hash: Bytes::from("0x02"),
2378                parent_hash: Bytes::from("0x01"),
2379                revert: false,
2380                ..Default::default()
2381            },
2382            snapshots: Snapshot::default(),
2383            deltas: Some(BlockAggregatedChanges {
2384                extractor: "uniswap-v2".to_string(),
2385                chain: Chain::Ethereum,
2386                block: Block {
2387                    number: 2,
2388                    hash: Bytes::from("0x02"),
2389                    parent_hash: Bytes::from("0x01"),
2390                    chain: Chain::Ethereum,
2391                    ts: Default::default(),
2392                },
2393                revert: false,
2394                component_tvl: [
2395                    // Component2 removed (tvl=0), Component3 not yet tracked → filtered out.
2396                    ("Component1".to_string(), 100.0),
2397                ]
2398                .into_iter()
2399                .collect(),
2400                ..Default::default()
2401            }),
2402            // "Component2" was removed, because its tvl changed to 0.
2403            removed_components: [(
2404                "Component2".to_string(),
2405                ProtocolComponent { id: "Component2".to_string(), ..Default::default() },
2406            )]
2407            .into_iter()
2408            .collect(),
2409        };
2410
2411        // Block 3: background task has completed; Component3 snapshot is drained and included.
2412        let exp3 = StateSyncMessage {
2413            header: BlockHeader {
2414                number: 3,
2415                hash: Bytes::from("0x03"),
2416                parent_hash: Bytes::from("0x02"),
2417                revert: false,
2418                ..Default::default()
2419            },
2420            snapshots: Snapshot {
2421                states: [(
2422                    "Component3".to_string(),
2423                    ComponentWithState {
2424                        state: ProtocolComponentState::new(
2425                            "Component3",
2426                            Default::default(),
2427                            Default::default(),
2428                        ),
2429                        component: ProtocolComponent {
2430                            id: "Component3".to_string(),
2431                            ..Default::default()
2432                        },
2433                        component_tvl: Some(1000.0),
2434                        entrypoints: vec![],
2435                    },
2436                )]
2437                .into_iter()
2438                .collect(),
2439                vm_storage: HashMap::new(),
2440            },
2441            deltas: Some(deltas[2].clone()),
2442            removed_components: Default::default(),
2443        };
2444        assert_eq!(first_msg.unwrap(), exp1);
2445        assert_eq!(second_msg.unwrap(), exp2);
2446        assert_eq!(third_msg.unwrap(), exp3);
2447    }
2448
2449    #[test_log::test(tokio::test)]
2450    async fn test_state_sync_with_tvl_range() {
2451        let remove_tvl_threshold = 5.0;
2452        let add_tvl_threshold = 7.0;
2453        let bg_done = Arc::new(tokio::sync::Notify::new());
2454
2455        let mut rpc_client = make_mock_client();
2456        let mut deltas_client = MockDeltasClient::new();
2457
2458        rpc_client
2459            .expect_get_protocol_components()
2460            .withf(|params: &crate::rpc::ProtocolComponentsParams| {
2461                params
2462                    .component_ids()
2463                    .is_some_and(|ids| ids.contains(&"Component3".to_string()))
2464            })
2465            .returning(|_| {
2466                Ok(Page::new(
2467                    vec![ProtocolComponent { id: "Component3".to_string(), ..Default::default() }],
2468                    1,
2469                    0,
2470                    100,
2471                ))
2472            });
2473        // Mock get_snapshots for Component3
2474        let bg_done_clone = bg_done.clone();
2475        rpc_client
2476            .expect_get_snapshots()
2477            .withf(
2478                |request: &SnapshotParameters,
2479                 _chunk_size: &Option<usize>,
2480                 _concurrency: &usize| {
2481                    request
2482                        .components
2483                        .contains_key("Component3")
2484                },
2485            )
2486            .returning(move |_request, _chunk_size, _concurrency| {
2487                let snap = Ok(Snapshot {
2488                    states: [(
2489                        "Component3".to_string(),
2490                        ComponentWithState {
2491                            state: ProtocolComponentState::new(
2492                                "Component3",
2493                                Default::default(),
2494                                Default::default(),
2495                            ),
2496                            component: ProtocolComponent {
2497                                id: "Component3".to_string(),
2498                                ..Default::default()
2499                            },
2500                            component_tvl: Some(10.0),
2501                            entrypoints: vec![],
2502                        },
2503                    )]
2504                    .into_iter()
2505                    .collect(),
2506                    vm_storage: HashMap::new(),
2507                });
2508                bg_done_clone.notify_one();
2509                snap
2510            });
2511
2512        // Mock for the initial snapshot retrieval
2513        rpc_client
2514            .expect_get_protocol_components()
2515            .returning(|_| {
2516                Ok(Page::new(
2517                    vec![
2518                        ProtocolComponent { id: "Component1".to_string(), ..Default::default() },
2519                        ProtocolComponent { id: "Component2".to_string(), ..Default::default() },
2520                    ],
2521                    2,
2522                    0,
2523                    100,
2524                ))
2525            });
2526
2527        // Mock get_snapshots for initial snapshot
2528        rpc_client
2529            .expect_get_snapshots()
2530            .returning(|_request, _chunk_size, _concurrency| {
2531                Ok(Snapshot {
2532                    states: [
2533                        (
2534                            "Component1".to_string(),
2535                            ComponentWithState {
2536                                state: ProtocolComponentState::new(
2537                                    "Component1",
2538                                    Default::default(),
2539                                    Default::default(),
2540                                ),
2541                                component: ProtocolComponent {
2542                                    id: "Component1".to_string(),
2543                                    ..Default::default()
2544                                },
2545                                component_tvl: Some(6.0),
2546                                entrypoints: vec![],
2547                            },
2548                        ),
2549                        (
2550                            "Component2".to_string(),
2551                            ComponentWithState {
2552                                state: ProtocolComponentState::new(
2553                                    "Component2",
2554                                    Default::default(),
2555                                    Default::default(),
2556                                ),
2557                                component: ProtocolComponent {
2558                                    id: "Component2".to_string(),
2559                                    ..Default::default()
2560                                },
2561                                component_tvl: Some(2.0),
2562                                entrypoints: vec![],
2563                            },
2564                        ),
2565                    ]
2566                    .into_iter()
2567                    .collect(),
2568                    vm_storage: HashMap::new(),
2569                })
2570            });
2571
2572        // Mock get_traced_entry_points for Ethereum chain
2573        rpc_client
2574            .expect_get_traced_entry_points()
2575            .returning(|_| Ok(Page::new(HashMap::new(), 0, 0, 0)));
2576
2577        let (tx, rx) = channel(1);
2578        deltas_client
2579            .expect_subscribe()
2580            .return_once(move |_, _| Ok((Uuid::default(), rx)));
2581
2582        // Expect unsubscribe call during cleanup
2583        deltas_client
2584            .expect_unsubscribe()
2585            .return_once(|_| Ok(()));
2586
2587        let mut state_sync = ProtocolStateSynchronizer::new(
2588            ExtractorIdentity::new(Chain::Ethereum, "uniswap-v2"),
2589            true,
2590            ComponentFilter::with_tvl_range(remove_tvl_threshold, add_tvl_threshold),
2591            1,
2592            Duration::from_secs(0),
2593            true,
2594            true,
2595            true,
2596            ArcRPCClient(Arc::new(rpc_client)),
2597            ArcDeltasClient(Arc::new(deltas_client)),
2598            10_u64,
2599        );
2600        state_sync
2601            .initialize()
2602            .await
2603            .expect("Init failed");
2604
2605        // Simulate the incoming BlockAggregatedChanges
2606        let deltas = [
2607            BlockAggregatedChanges {
2608                extractor: "uniswap-v2".to_string(),
2609                chain: Chain::Ethereum,
2610                block: Block {
2611                    number: 1,
2612                    hash: Bytes::from("0x01"),
2613                    parent_hash: Bytes::from("0x00"),
2614                    chain: Chain::Ethereum,
2615                    ts: Default::default(),
2616                },
2617                revert: false,
2618                ..Default::default()
2619            },
2620            BlockAggregatedChanges {
2621                extractor: "uniswap-v2".to_string(),
2622                chain: Chain::Ethereum,
2623                block: Block {
2624                    number: 2,
2625                    hash: Bytes::from("0x02"),
2626                    parent_hash: Bytes::from("0x01"),
2627                    chain: Chain::Ethereum,
2628                    ts: Default::default(),
2629                },
2630                revert: false,
2631                component_tvl: [
2632                    ("Component1".to_string(), 6.0), // Within range, should not trigger changes
2633                    ("Component2".to_string(), 2.0), // Below lower threshold, should be removed
2634                    ("Component3".to_string(), 10.0), // Above upper threshold, should be added
2635                ]
2636                .into_iter()
2637                .collect(),
2638                ..Default::default()
2639            },
2640            // Block 3: empty; background task for Component3 should have completed.
2641            BlockAggregatedChanges {
2642                extractor: "uniswap-v2".to_string(),
2643                chain: Chain::Ethereum,
2644                block: Block {
2645                    number: 3,
2646                    hash: Bytes::from("0x03"),
2647                    parent_hash: Bytes::from("0x02"),
2648                    chain: Chain::Ethereum,
2649                    ts: Default::default(),
2650                },
2651                revert: false,
2652                ..Default::default()
2653            },
2654        ];
2655
2656        let (handle, mut rx) = state_sync.start().await;
2657        let (jh, close_tx) = handle.split();
2658
2659        // Simulate sending delta messages
2660        tx.send(deltas[0].clone())
2661            .await
2662            .expect("deltas channel msg 0 closed!");
2663
2664        // Expecting to receive the initial state message
2665        let _ = timeout(Duration::from_millis(200), rx.recv())
2666            .await
2667            .expect("waiting for first state msg timed out!")
2668            .expect("state sync block sender closed!");
2669
2670        // Send the second message, which should trigger TVL-based changes.
2671        // Component3 snapshot is deferred to background; not in this block's message.
2672        tx.send(deltas[1].clone())
2673            .await
2674            .expect("deltas channel msg 1 closed!");
2675        let second_msg = timeout(Duration::from_millis(200), rx.recv())
2676            .await
2677            .expect("waiting for second state msg timed out!")
2678            .expect("state sync block sender closed!")
2679            .expect("no error");
2680
2681        // Wait for the background snapshot task to complete before sending block 3.
2682        bg_done.notified().await;
2683
2684        tx.send(deltas[2].clone())
2685            .await
2686            .expect("deltas channel msg 2 closed!");
2687        let third_msg = timeout(Duration::from_millis(200), rx.recv())
2688            .await
2689            .expect("waiting for third state msg timed out!")
2690            .expect("state sync block sender closed!")
2691            .expect("no error");
2692
2693        let _ = close_tx.send(());
2694        jh.await
2695            .expect("state sync task panicked!");
2696
2697        // Block 2: Component3 task spawned; snapshot is empty, Component3 filtered from deltas.
2698        let expected_second_msg = StateSyncMessage {
2699            header: BlockHeader {
2700                number: 2,
2701                hash: Bytes::from("0x02"),
2702                parent_hash: Bytes::from("0x01"),
2703                revert: false,
2704                ..Default::default()
2705            },
2706            snapshots: Snapshot::default(),
2707            deltas: Some(BlockAggregatedChanges {
2708                extractor: "uniswap-v2".to_string(),
2709                chain: Chain::Ethereum,
2710                block: Block {
2711                    number: 2,
2712                    hash: Bytes::from("0x02"),
2713                    parent_hash: Bytes::from("0x01"),
2714                    chain: Chain::Ethereum,
2715                    ts: Default::default(),
2716                },
2717                revert: false,
2718                component_tvl: [
2719                    ("Component1".to_string(), 6.0), /* Within range, should not trigger changes
2720                                                      * Component3 not yet tracked → filtered
2721                                                      * out */
2722                ]
2723                .into_iter()
2724                .collect(),
2725                ..Default::default()
2726            }),
2727            removed_components: [(
2728                "Component2".to_string(),
2729                ProtocolComponent { id: "Component2".to_string(), ..Default::default() },
2730            )]
2731            .into_iter()
2732            .collect(),
2733        };
2734
2735        // Block 3: background task drained; Component3 snapshot present.
2736        let expected_third_msg = StateSyncMessage {
2737            header: BlockHeader {
2738                number: 3,
2739                hash: Bytes::from("0x03"),
2740                parent_hash: Bytes::from("0x02"),
2741                revert: false,
2742                ..Default::default()
2743            },
2744            snapshots: Snapshot {
2745                states: [(
2746                    "Component3".to_string(),
2747                    ComponentWithState {
2748                        state: ProtocolComponentState::new(
2749                            "Component3",
2750                            Default::default(),
2751                            Default::default(),
2752                        ),
2753                        component: ProtocolComponent {
2754                            id: "Component3".to_string(),
2755                            ..Default::default()
2756                        },
2757                        component_tvl: Some(10.0),
2758                        entrypoints: vec![],
2759                    },
2760                )]
2761                .into_iter()
2762                .collect(),
2763                vm_storage: HashMap::new(),
2764            },
2765            deltas: Some(deltas[2].clone()),
2766            removed_components: Default::default(),
2767        };
2768
2769        assert_eq!(second_msg, expected_second_msg);
2770        assert_eq!(third_msg, expected_third_msg);
2771    }
2772
2773    #[test_log::test(tokio::test)]
2774    async fn test_public_close_api_functionality() {
2775        // Tests the public close() API through the StateSynchronizer trait:
2776        // - close() fails before start() is called
2777        // - close() succeeds while synchronizer is running
2778        // - close() fails after already closed
2779        // This tests the full start/close lifecycle via the public API
2780
2781        let mut rpc_client = make_mock_client();
2782        let mut deltas_client = MockDeltasClient::new();
2783
2784        // Mock the initial components call
2785        rpc_client
2786            .expect_get_protocol_components()
2787            .returning(|_| Ok(Page::new(vec![], 0, 0, 0)));
2788
2789        // Set up deltas client that will wait for messages (blocking in state_sync)
2790        let (_tx, rx) = channel(1);
2791        deltas_client
2792            .expect_subscribe()
2793            .return_once(move |_, _| Ok((Uuid::default(), rx)));
2794
2795        // Expect unsubscribe call during cleanup
2796        deltas_client
2797            .expect_unsubscribe()
2798            .return_once(|_| Ok(()));
2799
2800        let mut state_sync = ProtocolStateSynchronizer::new(
2801            ExtractorIdentity::new(Chain::Ethereum, "test-protocol"),
2802            true,
2803            ComponentFilter::with_tvl_range(0.0, 0.0),
2804            5, // Enough retries
2805            Duration::from_secs(0),
2806            true,
2807            false,
2808            true,
2809            ArcRPCClient(Arc::new(rpc_client)),
2810            ArcDeltasClient(Arc::new(deltas_client)),
2811            10000_u64, // Long timeout so task doesn't exit on its own
2812        );
2813
2814        state_sync
2815            .initialize()
2816            .await
2817            .expect("Init should succeed");
2818
2819        // Start the synchronizer and test the new split-based close mechanism
2820        let (handle, _rx) = state_sync.start().await;
2821        let (jh, close_tx) = handle.split();
2822
2823        // Give it time to start up and enter state_sync
2824        tokio::time::sleep(Duration::from_millis(100)).await;
2825
2826        // Send close signal should succeed
2827        close_tx
2828            .send(())
2829            .expect("Should be able to send close signal");
2830        // Task should stop cleanly
2831        jh.await.expect("Task should not panic");
2832    }
2833
2834    #[test_log::test(tokio::test)]
2835    async fn test_cleanup_runs_when_state_sync_processing_errors() {
2836        // Tests that cleanup code runs when state_sync() errors during delta processing.
2837        // Specifically tests: RPC errors during snapshot retrieval cause proper cleanup.
2838        // Verifies: shared.last_synced_block reset + subscription unsubscribe on errors
2839
2840        let mut rpc_client = make_mock_client();
2841        let mut deltas_client = MockDeltasClient::new();
2842
2843        // Mock the initial components call
2844        rpc_client
2845            .expect_get_protocol_components()
2846            .returning(|_| Ok(Page::new(vec![], 0, 0, 0)));
2847
2848        // Mock to fail during snapshot retrieval (this will cause an error during processing)
2849        rpc_client
2850            .expect_get_protocol_states()
2851            .returning(|_| {
2852                Err(RPCError::ParseResponse("Test error during snapshot retrieval".to_string()))
2853            });
2854
2855        // Set up deltas client to send one message that will trigger snapshot retrieval
2856        let (tx, rx) = channel(10);
2857        deltas_client
2858            .expect_subscribe()
2859            .return_once(move |_, _| {
2860                // Send a delta message that will require a snapshot
2861                let delta = BlockAggregatedChanges {
2862                    extractor: "test".to_string(),
2863                    chain: Chain::Ethereum,
2864                    block: Block {
2865                        hash: Bytes::from("0x0123"),
2866                        number: 1,
2867                        parent_hash: Bytes::from("0x0000"),
2868                        chain: Chain::Ethereum,
2869                        ts: chrono::DateTime::from_timestamp(1234567890, 0)
2870                            .unwrap()
2871                            .naive_utc(),
2872                    },
2873                    revert: false,
2874                    // Add a new component to trigger snapshot request
2875                    new_protocol_components: [(
2876                        "new_component".to_string(),
2877                        ProtocolComponent { id: "new_component".to_string(), ..Default::default() },
2878                    )]
2879                    .into_iter()
2880                    .collect(),
2881                    component_tvl: [("new_component".to_string(), 100.0)]
2882                        .into_iter()
2883                        .collect(),
2884                    ..Default::default()
2885                };
2886
2887                tokio::spawn(async move {
2888                    let _ = tx.send(delta).await;
2889                    // Close the channel after sending one message
2890                });
2891
2892                Ok((Uuid::default(), rx))
2893            });
2894
2895        // Expect unsubscribe call during cleanup
2896        deltas_client
2897            .expect_unsubscribe()
2898            .return_once(|_| Ok(()));
2899
2900        let mut state_sync = ProtocolStateSynchronizer::new(
2901            ExtractorIdentity::new(Chain::Ethereum, "test-protocol"),
2902            true,
2903            ComponentFilter::with_tvl_range(0.0, 1000.0), // Include the component
2904            1,
2905            Duration::from_secs(0),
2906            true,
2907            false,
2908            true,
2909            ArcRPCClient(Arc::new(rpc_client)),
2910            ArcDeltasClient(Arc::new(deltas_client)),
2911            5000_u64,
2912        );
2913
2914        state_sync
2915            .initialize()
2916            .await
2917            .expect("Init should succeed");
2918
2919        // Before calling state_sync, set a value in last_synced_block
2920        state_sync.last_synced_block = Some(BlockHeader {
2921            hash: Bytes::from("0x0badc0ffee"),
2922            number: 42,
2923            parent_hash: Bytes::from("0xbadbeef0"),
2924            revert: false,
2925            timestamp: 123456789,
2926            partial_block_index: None,
2927        });
2928
2929        // Create a channel for state_sync to send messages to
2930        let (mut block_tx, _block_rx) = channel(10);
2931
2932        // Call state_sync directly - this should error during processing
2933        let (_end_tx, end_rx) = oneshot::channel::<()>();
2934        let result = state_sync
2935            .state_sync(&mut block_tx, end_rx, Duration::ZERO)
2936            .await;
2937        // Verify that state_sync returned an error
2938        assert!(result.is_err(), "state_sync should have errored during processing");
2939
2940        // Note: We can't verify internal state cleanup since state_sync consumes self,
2941        // but the cleanup logic is still tested by the fact that the method returns properly.
2942    }
2943
2944    #[test_log::test(tokio::test)]
2945    async fn test_close_signal_while_waiting_for_first_deltas() {
2946        // Tests close signal handling during the initial "waiting for deltas" phase.
2947        // This is the earliest possible close scenario - before any deltas are received.
2948        // Verifies: close signal received while waiting for first message triggers cleanup
2949        let mut rpc_client = make_mock_client();
2950        let mut deltas_client = MockDeltasClient::new();
2951
2952        rpc_client
2953            .expect_get_protocol_components()
2954            .returning(|_| Ok(Page::new(vec![], 0, 0, 0)));
2955
2956        let (_tx, rx) = channel(1);
2957        deltas_client
2958            .expect_subscribe()
2959            .return_once(move |_, _| Ok((Uuid::default(), rx)));
2960
2961        deltas_client
2962            .expect_unsubscribe()
2963            .return_once(|_| Ok(()));
2964
2965        let mut state_sync = ProtocolStateSynchronizer::new(
2966            ExtractorIdentity::new(Chain::Ethereum, "test-protocol"),
2967            true,
2968            ComponentFilter::with_tvl_range(0.0, 0.0),
2969            1,
2970            Duration::from_secs(0),
2971            true,
2972            true,
2973            false,
2974            ArcRPCClient(Arc::new(rpc_client)),
2975            ArcDeltasClient(Arc::new(deltas_client)),
2976            10000_u64,
2977        );
2978
2979        state_sync
2980            .initialize()
2981            .await
2982            .expect("Init should succeed");
2983
2984        let (mut block_tx, _block_rx) = channel(10);
2985        let (end_tx, end_rx) = oneshot::channel::<()>();
2986
2987        // Start state_sync in a task
2988        let state_sync_handle = tokio::spawn(async move {
2989            state_sync
2990                .state_sync(&mut block_tx, end_rx, Duration::ZERO)
2991                .await
2992        });
2993
2994        // Give it a moment to start
2995        tokio::time::sleep(Duration::from_millis(100)).await;
2996
2997        // Send close signal
2998        let _ = end_tx.send(());
2999
3000        // state_sync should exit cleanly
3001        let result = state_sync_handle
3002            .await
3003            .expect("Task should not panic");
3004        assert!(result.is_ok(), "state_sync should exit cleanly when closed: {result:?}");
3005
3006        println!("SUCCESS: Close signal handled correctly while waiting for first deltas");
3007    }
3008
3009    #[test_log::test(tokio::test)]
3010    async fn test_close_signal_during_main_processing_loop() {
3011        // Tests close signal handling during the main delta processing loop.
3012        // This tests the scenario where first message is processed successfully,
3013        // then close signal is received while waiting for subsequent deltas.
3014        // Verifies: close signal in main loop (after initialization) triggers cleanup
3015
3016        let mut rpc_client = make_mock_client();
3017        let mut deltas_client = MockDeltasClient::new();
3018
3019        // Mock the initial components call
3020        rpc_client
3021            .expect_get_protocol_components()
3022            .returning(|_| Ok(Page::new(vec![], 0, 0, 0)));
3023
3024        // Mock the snapshot retrieval that happens after first message
3025        rpc_client
3026            .expect_get_protocol_states()
3027            .returning(|_| Ok(Page::new(vec![], 0, 0, 0)));
3028
3029        rpc_client
3030            .expect_get_component_tvl()
3031            .returning(|_| Ok(Page::new(HashMap::new(), 0, 0, 0)));
3032
3033        rpc_client
3034            .expect_get_traced_entry_points()
3035            .returning(|_| Ok(Page::new(HashMap::new(), 0, 0, 0)));
3036
3037        // Set up deltas client to send one message, then keep channel open
3038        let (tx, rx) = channel(10);
3039        deltas_client
3040            .expect_subscribe()
3041            .return_once(move |_, _| {
3042                // Send first message immediately
3043                let first_delta = BlockAggregatedChanges {
3044                    extractor: "test".to_string(),
3045                    chain: Chain::Ethereum,
3046                    block: Block {
3047                        hash: Bytes::from("0x0123"),
3048                        number: 1,
3049                        parent_hash: Bytes::from("0x0000"),
3050                        chain: Chain::Ethereum,
3051                        ts: chrono::DateTime::from_timestamp(1234567890, 0)
3052                            .unwrap()
3053                            .naive_utc(),
3054                    },
3055                    revert: false,
3056                    ..Default::default()
3057                };
3058
3059                tokio::spawn(async move {
3060                    let _ = tx.send(first_delta).await;
3061                    // Keep the sender alive but don't send more messages
3062                    // This will make the recv() block waiting for the next message
3063                    tokio::time::sleep(Duration::from_secs(30)).await;
3064                });
3065
3066                Ok((Uuid::default(), rx))
3067            });
3068
3069        deltas_client
3070            .expect_unsubscribe()
3071            .return_once(|_| Ok(()));
3072
3073        let mut state_sync = ProtocolStateSynchronizer::new(
3074            ExtractorIdentity::new(Chain::Ethereum, "test-protocol"),
3075            true,
3076            ComponentFilter::with_tvl_range(0.0, 1000.0),
3077            1,
3078            Duration::from_secs(0),
3079            true,
3080            false,
3081            true,
3082            ArcRPCClient(Arc::new(rpc_client)),
3083            ArcDeltasClient(Arc::new(deltas_client)),
3084            10000_u64,
3085        );
3086
3087        state_sync
3088            .initialize()
3089            .await
3090            .expect("Init should succeed");
3091
3092        let (mut block_tx, mut block_rx) = channel(10);
3093        let (end_tx, end_rx) = oneshot::channel::<()>();
3094
3095        // Start state_sync in a task
3096        let state_sync_handle = tokio::spawn(async move {
3097            state_sync
3098                .state_sync(&mut block_tx, end_rx, Duration::ZERO)
3099                .await
3100        });
3101
3102        // Wait for the first message to be processed (snapshot sent)
3103        let first_snapshot = block_rx
3104            .recv()
3105            .await
3106            .expect("Should receive first snapshot")
3107            .expect("Synchronizer error");
3108        assert!(
3109            !first_snapshot
3110                .snapshots
3111                .states
3112                .is_empty() ||
3113                first_snapshot.deltas.is_some()
3114        );
3115        // Now send close signal - this should be handled in the main processing loop
3116        let _ = end_tx.send(());
3117
3118        // state_sync should exit cleanly after receiving close signal in main loop
3119        let result = state_sync_handle
3120            .await
3121            .expect("Task should not panic");
3122        assert!(
3123            result.is_ok(),
3124            "state_sync should exit cleanly when closed after first message: {result:?}"
3125        );
3126    }
3127
3128    #[test_log::test(tokio::test)]
3129    async fn test_max_retries_exceeded_error_propagation() {
3130        // Test that when max_retries is exceeded, the final error is sent through the channel
3131        // to the receiver and the synchronizer task exits cleanly
3132
3133        let mut rpc_client = make_mock_client();
3134        let mut deltas_client = MockDeltasClient::new();
3135
3136        // Mock the initial components call to succeed
3137        rpc_client
3138            .expect_get_protocol_components()
3139            .returning(|_| Ok(Page::new(vec![], 0, 0, 0)));
3140
3141        // Set up deltas client to consistently fail after subscription
3142        // This will cause connection errors and trigger retries
3143        deltas_client
3144            .expect_subscribe()
3145            .returning(|_, _| {
3146                // Return a connection error to trigger retries
3147                Err(DeltasError::NotConnected)
3148            });
3149
3150        // Expect multiple unsubscribe calls during retries
3151        deltas_client
3152            .expect_unsubscribe()
3153            .returning(|_| Ok(()))
3154            .times(0..=5);
3155
3156        // Create synchronizer with only 2 retries and short cooldown
3157        let mut state_sync = ProtocolStateSynchronizer::new(
3158            ExtractorIdentity::new(Chain::Ethereum, "test-protocol"),
3159            true,
3160            ComponentFilter::with_tvl_range(0.0, 1000.0),
3161            2,                         // max_retries = 2
3162            Duration::from_millis(10), // short retry cooldown
3163            true,
3164            false,
3165            true,
3166            ArcRPCClient(Arc::new(rpc_client)),
3167            ArcDeltasClient(Arc::new(deltas_client)),
3168            1000_u64,
3169        );
3170
3171        state_sync
3172            .initialize()
3173            .await
3174            .expect("Init should succeed");
3175
3176        // Start the synchronizer - it should fail to subscribe and retry
3177        let (handle, mut rx) = state_sync.start().await;
3178        let (jh, _close_tx) = handle.split();
3179
3180        let res = tokio::time::timeout(Duration::from_millis(100), rx.recv())
3181            .await
3182            .expect("responsds in time")
3183            .expect("channel open");
3184
3185        // Verify the error is a ConnectionClosed error (converted from DeltasError::NotConnected)
3186        if let Err(err) = res {
3187            assert!(
3188                matches!(err, SynchronizerError::ConnectionClosed),
3189                "Expected ConnectionClosed error, got: {:?}",
3190                err
3191            );
3192        } else {
3193            panic!("Expected an error")
3194        }
3195
3196        // The task should complete (not hang) after max retries
3197        let task_result = tokio::time::timeout(Duration::from_secs(2), jh).await;
3198        assert!(task_result.is_ok(), "Synchronizer task should complete after max retries");
3199    }
3200
3201    #[test_log::test(tokio::test)]
3202    async fn test_restart_after_healthy_run_skips_cooldown() {
3203        let mut rpc_client = make_mock_client();
3204        rpc_client
3205            .expect_get_protocol_components()
3206            .returning(|_| Ok(Page::new(vec![], 0, 0, 0)));
3207
3208        let mut deltas_client = MockDeltasClient::new();
3209        let (resubscribed_tx, resubscribed_rx) = oneshot::channel();
3210        let mut resubscribed_tx = Some(resubscribed_tx);
3211        let mut open_senders = Vec::new();
3212        let mut calls = 0;
3213        deltas_client
3214            .expect_subscribe()
3215            .returning(move |_, _| {
3216                calls += 1;
3217                let (tx, rx) = channel(10);
3218                if calls == 1 {
3219                    // Deliver one block, then close the channel like a websocket reset does.
3220                    let block = Block { number: 1, ..Default::default() };
3221                    tx.try_send(BlockAggregatedChanges { block, ..Default::default() })
3222                        .unwrap();
3223                } else {
3224                    if let Some(notify) = resubscribed_tx.take() {
3225                        let _ = notify.send(std::time::Instant::now());
3226                    }
3227                    open_senders.push(tx);
3228                }
3229                Ok((Uuid::default(), rx))
3230            });
3231        deltas_client
3232            .expect_unsubscribe()
3233            .returning(|_| Ok(()));
3234
3235        let cooldown = Duration::from_secs(5);
3236        let mut state_sync = ProtocolStateSynchronizer::new(
3237            ExtractorIdentity::new(Chain::Ethereum, "test-protocol"),
3238            true,
3239            ComponentFilter::with_tvl_range(0.0, 1000.0),
3240            3,
3241            cooldown,
3242            false,
3243            false,
3244            true,
3245            ArcRPCClient(Arc::new(rpc_client)),
3246            ArcDeltasClient(Arc::new(deltas_client)),
3247            10_u64,
3248        );
3249        state_sync
3250            .initialize()
3251            .await
3252            .expect("Init should succeed");
3253
3254        let started = std::time::Instant::now();
3255        let (handle, _rx) = state_sync.start().await;
3256        let resubscribed_at = timeout(cooldown * 2, resubscribed_rx)
3257            .await
3258            .expect("synchronizer should resubscribe")
3259            .unwrap();
3260        let (_jh, close_tx) = handle.split();
3261        let _ = close_tx.send(());
3262
3263        assert!(resubscribed_at - started < Duration::from_secs(1));
3264    }
3265
3266    #[test_log::test(tokio::test)]
3267    async fn test_is_next_expected() {
3268        // Test the is_next_expected function to ensure it correctly identifies
3269        // when an incoming block is the expected next block in the chain
3270
3271        let mut state_sync = with_mocked_clients(true, false, None, None);
3272
3273        // Test 1: No previous block - should return false
3274        let incoming_header = BlockHeader {
3275            number: 100,
3276            hash: Bytes::from("0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"),
3277            parent_hash: Bytes::from(
3278                "0x0000000000000000000000000000000000000000000000000000000000000000",
3279            ),
3280            revert: false,
3281            timestamp: 123456789,
3282            partial_block_index: None,
3283        };
3284        assert!(
3285            !state_sync.is_next_expected(&incoming_header),
3286            "Should return false when no previous block is set"
3287        );
3288
3289        // Test 2: Set a previous block and test with matching parent hash
3290        let previous_header = BlockHeader {
3291            number: 99,
3292            hash: Bytes::from("0x0000000000000000000000000000000000000000000000000000000000000000"),
3293            parent_hash: Bytes::from(
3294                "0xabcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
3295            ),
3296            revert: false,
3297            timestamp: 123456788,
3298            partial_block_index: None,
3299        };
3300        state_sync.last_synced_block = Some(previous_header.clone());
3301
3302        assert!(
3303            state_sync.is_next_expected(&incoming_header),
3304            "Should return true when incoming parent_hash matches previous hash"
3305        );
3306
3307        // Test 3: Test with non-matching parent hash
3308        let non_matching_header = BlockHeader {
3309            number: 100,
3310            hash: Bytes::from("0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"),
3311            parent_hash: Bytes::from(
3312                "0x1111111111111111111111111111111111111111111111111111111111111111",
3313            ), // Wrong parent hash
3314            revert: false,
3315            timestamp: 123456789,
3316            partial_block_index: None,
3317        };
3318        assert!(
3319            !state_sync.is_next_expected(&non_matching_header),
3320            "Should return false when incoming parent_hash doesn't match previous hash"
3321        );
3322    }
3323
3324    #[test_log::test(tokio::test)]
3325    async fn test_synchronizer_restart_skip_snapshot_on_expected_block() {
3326        // Test that on synchronizer restart with the next expected block,
3327        // get_snapshot is not called and only deltas are sent
3328
3329        let mut rpc_client = make_mock_client();
3330        let mut deltas_client = MockDeltasClient::new();
3331
3332        // Mock the initial components call
3333        rpc_client
3334            .expect_get_protocol_components()
3335            .returning(|_| {
3336                Ok(Page::new(
3337                    vec![ProtocolComponent { id: "Component1".to_string(), ..Default::default() }],
3338                    1,
3339                    0,
3340                    100,
3341                ))
3342            });
3343
3344        // Set up deltas client to send a message that is the next expected block
3345        let (tx, rx) = channel(10);
3346        deltas_client
3347            .expect_subscribe()
3348            .return_once(move |_, _| {
3349                let expected_next_delta = BlockAggregatedChanges {
3350                    extractor: "uniswap-v2".to_string(),
3351                    chain: Chain::Ethereum,
3352                    block: Block {
3353                        hash: Bytes::from(
3354                            "0x0000000000000000000000000000000000000000000000000000000000000002",
3355                        ), // This will be the next expected block
3356                        number: 2,
3357                        parent_hash: Bytes::from(
3358                            "0x0000000000000000000000000000000000000000000000000000000000000001",
3359                        ), // This matches our last synced block hash
3360                        chain: Chain::Ethereum,
3361                        ts: chrono::DateTime::from_timestamp(1234567890, 0)
3362                            .unwrap()
3363                            .naive_utc(),
3364                    },
3365                    revert: false,
3366                    ..Default::default()
3367                };
3368
3369                tokio::spawn(async move {
3370                    let _ = tx.send(expected_next_delta).await;
3371                });
3372
3373                Ok((Uuid::default(), rx))
3374            });
3375
3376        deltas_client
3377            .expect_unsubscribe()
3378            .return_once(|_| Ok(()));
3379
3380        let mut state_sync = ProtocolStateSynchronizer::new(
3381            ExtractorIdentity::new(Chain::Ethereum, "uniswap-v2"),
3382            true,
3383            ComponentFilter::with_tvl_range(0.0, 1000.0),
3384            1,
3385            Duration::from_secs(0),
3386            true, // include_snapshots = true
3387            false,
3388            true,
3389            ArcRPCClient(Arc::new(rpc_client)),
3390            ArcDeltasClient(Arc::new(deltas_client)),
3391            10000_u64,
3392        );
3393
3394        // Initialize and set up the last synced block to simulate a restart scenario
3395        state_sync
3396            .initialize()
3397            .await
3398            .expect("Init should succeed");
3399
3400        // Set last_synced_block to simulate that we've previously synced block 1
3401        state_sync.last_synced_block = Some(BlockHeader {
3402            number: 1,
3403            hash: Bytes::from("0x0000000000000000000000000000000000000000000000000000000000000001"), /* This matches the parent_hash in our delta */
3404            parent_hash: Bytes::from(
3405                "0x0000000000000000000000000000000000000000000000000000000000000000",
3406            ),
3407            revert: false,
3408            timestamp: 123456789,
3409            partial_block_index: None,
3410        });
3411
3412        let (mut block_tx, mut block_rx) = channel(10);
3413        let (end_tx, end_rx) = oneshot::channel::<()>();
3414
3415        // Start state_sync
3416        let state_sync_handle = tokio::spawn(async move {
3417            state_sync
3418                .state_sync(&mut block_tx, end_rx, Duration::ZERO)
3419                .await
3420        });
3421
3422        // Wait for the message - it should be a delta-only message (no snapshots)
3423        let result_msg = timeout(Duration::from_millis(200), block_rx.recv())
3424            .await
3425            .expect("Should receive message within timeout")
3426            .expect("Channel should be open")
3427            .expect("Should not be an error");
3428
3429        // Send close signal
3430        let _ = end_tx.send(());
3431
3432        // Wait for state_sync to finish
3433        let _ = state_sync_handle
3434            .await
3435            .expect("Task should not panic");
3436
3437        // Verify the message contains deltas but no snapshots
3438        // (because we skipped snapshot retrieval)
3439        assert!(result_msg.deltas.is_some(), "Should contain deltas");
3440        assert!(
3441            result_msg.snapshots.states.is_empty(),
3442            "Should not contain snapshots when next expected block is received"
3443        );
3444
3445        // Verify the block details match our expected next block
3446        if let Some(deltas) = &result_msg.deltas {
3447            assert_eq!(deltas.block.number, 2);
3448            assert_eq!(
3449                deltas.block.hash,
3450                Bytes::from("0x0000000000000000000000000000000000000000000000000000000000000002")
3451            );
3452            assert_eq!(
3453                deltas.block.parent_hash,
3454                Bytes::from("0x0000000000000000000000000000000000000000000000000000000000000001")
3455            );
3456        }
3457    }
3458
3459    #[test_log::test(tokio::test)]
3460    async fn test_skip_previously_processed_messages() {
3461        // Test that the synchronizer skips messages for blocks that have already been processed
3462        // This simulates a service restart scenario where old messages are re-emitted
3463
3464        let mut rpc_client = make_mock_client();
3465        let mut deltas_client = MockDeltasClient::new();
3466
3467        // Mock the initial components call
3468        rpc_client
3469            .expect_get_protocol_components()
3470            .returning(|_| {
3471                Ok(Page::new(
3472                    vec![ProtocolComponent { id: "Component1".to_string(), ..Default::default() }],
3473                    1,
3474                    0,
3475                    100,
3476                ))
3477            });
3478
3479        // Mock snapshot calls for when we process the expected next block (block 6)
3480        rpc_client
3481            .expect_get_protocol_states()
3482            .returning(|_| {
3483                Ok(Page::new(
3484                    vec![ProtocolComponentState::new(
3485                        "Component1",
3486                        Default::default(),
3487                        Default::default(),
3488                    )],
3489                    1,
3490                    0,
3491                    100,
3492                ))
3493            });
3494
3495        rpc_client
3496            .expect_get_component_tvl()
3497            .returning(|_| {
3498                Ok(Page::new(
3499                    [("Component1".to_string(), 100.0)]
3500                        .into_iter()
3501                        .collect(),
3502                    1,
3503                    0,
3504                    100,
3505                ))
3506            });
3507
3508        rpc_client
3509            .expect_get_traced_entry_points()
3510            .returning(|_| Ok(Page::new(HashMap::new(), 0, 0, 0)));
3511
3512        // Set up deltas client to send old messages first, then the expected next block
3513        let (tx, rx) = channel(10);
3514        deltas_client
3515            .expect_subscribe()
3516            .return_once(move |_, _| {
3517                // Send messages for blocks 3, 4, 5 (already processed), then block 6 (expected)
3518                let old_messages = vec![
3519                    BlockAggregatedChanges {
3520                        extractor: "uniswap-v2".to_string(),
3521                        chain: Chain::Ethereum,
3522                        block: Block {
3523                            hash: Bytes::from("0x0000000000000000000000000000000000000000000000000000000000000003"),
3524                            number: 3,
3525                            parent_hash: Bytes::from("0x0000000000000000000000000000000000000000000000000000000000000002"),
3526                            chain: Chain::Ethereum,
3527                            ts: chrono::DateTime::from_timestamp(1234567890, 0).unwrap().naive_utc(),
3528                        },
3529                        revert: false,
3530                        ..Default::default()
3531                    },
3532                    BlockAggregatedChanges {
3533                        extractor: "uniswap-v2".to_string(),
3534                        chain: Chain::Ethereum,
3535                        block: Block {
3536                            hash: Bytes::from("0x0000000000000000000000000000000000000000000000000000000000000004"),
3537                            number: 4,
3538                            parent_hash: Bytes::from("0x0000000000000000000000000000000000000000000000000000000000000003"),
3539                            chain: Chain::Ethereum,
3540                            ts: chrono::DateTime::from_timestamp(1234567891, 0).unwrap().naive_utc(),
3541                        },
3542                        revert: false,
3543                        ..Default::default()
3544                    },
3545                    BlockAggregatedChanges {
3546                        extractor: "uniswap-v2".to_string(),
3547                        chain: Chain::Ethereum,
3548                        block: Block {
3549                            hash: Bytes::from("0x0000000000000000000000000000000000000000000000000000000000000005"),
3550                            number: 5,
3551                            parent_hash: Bytes::from("0x0000000000000000000000000000000000000000000000000000000000000004"),
3552                            chain: Chain::Ethereum,
3553                            ts: chrono::DateTime::from_timestamp(1234567892, 0).unwrap().naive_utc(),
3554                        },
3555                        revert: false,
3556                        ..Default::default()
3557                    },
3558                    // This is the expected next block (block 6)
3559                    BlockAggregatedChanges {
3560                        extractor: "uniswap-v2".to_string(),
3561                        chain: Chain::Ethereum,
3562                        block: Block {
3563                            hash: Bytes::from("0x0000000000000000000000000000000000000000000000000000000000000006"),
3564                            number: 6,
3565                            parent_hash: Bytes::from("0x0000000000000000000000000000000000000000000000000000000000000005"),
3566                            chain: Chain::Ethereum,
3567                            ts: chrono::DateTime::from_timestamp(1234567893, 0).unwrap().naive_utc(),
3568                        },
3569                        revert: false,
3570                        ..Default::default()
3571                    },
3572                ];
3573
3574                tokio::spawn(async move {
3575                    for message in old_messages {
3576                        let _ = tx.send(message).await;
3577                        tokio::time::sleep(Duration::from_millis(10)).await;
3578                    }
3579                });
3580
3581                Ok((Uuid::default(), rx))
3582            });
3583
3584        deltas_client
3585            .expect_unsubscribe()
3586            .return_once(|_| Ok(()));
3587
3588        let mut state_sync = ProtocolStateSynchronizer::new(
3589            ExtractorIdentity::new(Chain::Ethereum, "uniswap-v2"),
3590            true,
3591            ComponentFilter::with_tvl_range(0.0, 1000.0),
3592            1,
3593            Duration::from_secs(0),
3594            true,
3595            true,
3596            true,
3597            ArcRPCClient(Arc::new(rpc_client)),
3598            ArcDeltasClient(Arc::new(deltas_client)),
3599            10000_u64,
3600        );
3601
3602        // Initialize and set last_synced_block to simulate we've already processed block 5
3603        state_sync
3604            .initialize()
3605            .await
3606            .expect("Init should succeed");
3607
3608        state_sync.last_synced_block = Some(BlockHeader {
3609            number: 5,
3610            hash: Bytes::from("0x0000000000000000000000000000000000000000000000000000000000000005"),
3611            parent_hash: Bytes::from(
3612                "0x0000000000000000000000000000000000000000000000000000000000000004",
3613            ),
3614            revert: false,
3615            timestamp: 1234567892,
3616            partial_block_index: None,
3617        });
3618
3619        let (mut block_tx, mut block_rx) = channel(10);
3620        let (end_tx, end_rx) = oneshot::channel::<()>();
3621
3622        // Start state_sync
3623        let state_sync_handle = tokio::spawn(async move {
3624            state_sync
3625                .state_sync(&mut block_tx, end_rx, Duration::ZERO)
3626                .await
3627        });
3628
3629        // Wait for the message - it should only be for block 6 (skipping blocks 3, 4, 5)
3630        let result_msg = timeout(Duration::from_millis(500), block_rx.recv())
3631            .await
3632            .expect("Should receive message within timeout")
3633            .expect("Channel should be open")
3634            .expect("Should not be an error");
3635
3636        // Send close signal
3637        let _ = end_tx.send(());
3638
3639        // Wait for state_sync to finish
3640        let _ = state_sync_handle
3641            .await
3642            .expect("Task should not panic");
3643
3644        // Verify we only got the message for block 6 (the expected next block)
3645        assert!(result_msg.deltas.is_some(), "Should contain deltas");
3646        if let Some(deltas) = &result_msg.deltas {
3647            assert_eq!(
3648                deltas.block.number, 6,
3649                "Should only process block 6, skipping earlier blocks"
3650            );
3651            assert_eq!(
3652                deltas.block.hash,
3653                Bytes::from("0x0000000000000000000000000000000000000000000000000000000000000006")
3654            );
3655        }
3656
3657        // Verify that no additional messages are received immediately
3658        // (since the old blocks 3, 4, 5 were skipped and only block 6 was processed)
3659        match timeout(Duration::from_millis(50), block_rx.recv()).await {
3660            Err(_) => {
3661                // Timeout is expected - no more messages should come
3662            }
3663            Ok(Some(Err(_))) => {
3664                // Error received is also acceptable (connection closed)
3665            }
3666            Ok(Some(Ok(_))) => {
3667                panic!("Should not receive additional messages - old blocks should be skipped");
3668            }
3669            Ok(None) => {
3670                // Channel closed is also acceptable
3671            }
3672        }
3673    }
3674
3675    fn header_of(changes: &BlockAggregatedChanges) -> BlockHeader {
3676        changes.into()
3677    }
3678
3679    /// Mocks one tracked component and records the block of every snapshot request.
3680    fn mock_rpc_recording_snapshot_blocks() -> (MockRPCClient, Arc<std::sync::Mutex<Vec<u64>>>) {
3681        let mut rpc_client = make_mock_client();
3682        rpc_client
3683            .expect_get_protocol_components()
3684            .returning(|_| {
3685                Ok(Page::new(
3686                    vec![ProtocolComponent { id: "Component1".to_string(), ..Default::default() }],
3687                    1,
3688                    0,
3689                    100,
3690                ))
3691            });
3692        let snapshot_blocks = Arc::new(std::sync::Mutex::new(Vec::new()));
3693        let recorded = snapshot_blocks.clone();
3694        rpc_client
3695            .expect_get_snapshots()
3696            .returning(move |request, _chunk_size, _concurrency| {
3697                recorded
3698                    .lock()
3699                    .unwrap()
3700                    .push(request.block_number);
3701                Ok(Snapshot::default())
3702            });
3703        (rpc_client, snapshot_blocks)
3704    }
3705
3706    /// Returns a deltas client whose subscription emits `blocks` 50 ms apart.
3707    fn mock_deltas_emitting(blocks: Vec<u64>) -> MockDeltasClient {
3708        let mut deltas_client = MockDeltasClient::new();
3709        let (tx, rx) = channel(128);
3710        deltas_client
3711            .expect_subscribe()
3712            .return_once(move |_, _| {
3713                tokio::spawn(async move {
3714                    for block in blocks {
3715                        let _ = tx
3716                            .send(make_block_changes(block, None))
3717                            .await;
3718                        sleep(Duration::from_millis(50)).await;
3719                    }
3720                });
3721                Ok((Uuid::default(), rx))
3722            });
3723        deltas_client
3724            .expect_unsubscribe()
3725            .return_once(|_| Ok(()));
3726        deltas_client
3727    }
3728
3729    #[test_log::test(tokio::test(start_paused = true))]
3730    async fn test_snapshot_delay_discards_deltas_until_it_ends() {
3731        let (rpc_client, snapshot_blocks) = mock_rpc_recording_snapshot_blocks();
3732        let deltas_client = mock_deltas_emitting((10..=20).collect());
3733        let mut state_sync =
3734            with_mocked_clients(true, false, Some(rpc_client), Some(deltas_client));
3735        state_sync
3736            .initialize()
3737            .await
3738            .expect("Init should succeed");
3739        state_sync.last_synced_block = Some(header_of(&make_block_changes(5, None)));
3740
3741        let (mut block_tx, mut block_rx) = channel(10);
3742        let (end_tx, end_rx) = oneshot::channel::<()>();
3743        let handle = tokio::spawn(async move {
3744            state_sync
3745                .state_sync(&mut block_tx, end_rx, Duration::from_millis(300))
3746                .await
3747        });
3748
3749        let first = timeout(Duration::from_secs(1), block_rx.recv())
3750            .await
3751            .expect("Should receive first message")
3752            .expect("Channel should be open")
3753            .expect("Should not be an error");
3754        let second = timeout(Duration::from_secs(1), block_rx.recv())
3755            .await
3756            .expect("Should receive second message")
3757            .expect("Channel should be open")
3758            .expect("Should not be an error");
3759        let _ = end_tx.send(());
3760        let _ = handle
3761            .await
3762            .expect("Task should not panic");
3763
3764        // Blocks 10-15 arrive within the 300 ms delay, 50 ms apart.
3765        let snapshot_blocks = snapshot_blocks.lock().unwrap().clone();
3766        assert_eq!(snapshot_blocks.len(), 1, "Should request one snapshot");
3767        assert!(
3768            snapshot_blocks[0] >= 16,
3769            "Snapshot at block {} is inside the delay",
3770            snapshot_blocks[0]
3771        );
3772        assert_eq!(first.header.number, snapshot_blocks[0]);
3773        assert_eq!(second.header.number, snapshot_blocks[0] + 1);
3774    }
3775
3776    #[test_log::test(tokio::test(start_paused = true))]
3777    async fn test_snapshot_delay_skipped_when_next_block_expected() {
3778        let mut rpc_client = make_mock_client();
3779        rpc_client
3780            .expect_get_protocol_components()
3781            .returning(|_| {
3782                Ok(Page::new(
3783                    vec![ProtocolComponent { id: "Component1".to_string(), ..Default::default() }],
3784                    1,
3785                    0,
3786                    100,
3787                ))
3788            });
3789        rpc_client
3790            .expect_get_snapshots()
3791            .never();
3792        let deltas_client = mock_deltas_emitting(vec![10, 11]);
3793        let mut state_sync =
3794            with_mocked_clients(true, false, Some(rpc_client), Some(deltas_client));
3795        state_sync
3796            .initialize()
3797            .await
3798            .expect("Init should succeed");
3799        state_sync.last_synced_block = Some(header_of(&make_block_changes(9, None)));
3800
3801        let (mut block_tx, mut block_rx) = channel(10);
3802        let (end_tx, end_rx) = oneshot::channel::<()>();
3803        let handle = tokio::spawn(async move {
3804            state_sync
3805                .state_sync(&mut block_tx, end_rx, Duration::from_millis(300))
3806                .await
3807        });
3808
3809        let msg = timeout(Duration::from_millis(10), block_rx.recv())
3810            .await
3811            .expect("Should receive the next expected block at once")
3812            .expect("Channel should be open")
3813            .expect("Should not be an error");
3814        let _ = end_tx.send(());
3815        let _ = handle
3816            .await
3817            .expect("Task should not panic");
3818
3819        assert_eq!(msg.header.number, 10);
3820        assert!(msg.snapshots.states.is_empty());
3821    }
3822
3823    #[test_log::test(tokio::test(start_paused = true))]
3824    async fn test_first_start_has_no_snapshot_delay() {
3825        let (rpc_client, snapshot_blocks) = mock_rpc_recording_snapshot_blocks();
3826        let deltas_client = mock_deltas_emitting(vec![1]);
3827        let mut state_sync =
3828            with_mocked_clients(true, false, Some(rpc_client), Some(deltas_client));
3829        assert_eq!(state_sync.snapshot_jitter, DEFAULT_SNAPSHOT_JITTER);
3830        state_sync
3831            .initialize()
3832            .await
3833            .expect("Init should succeed");
3834
3835        let (handle, mut block_rx) = state_sync.start().await;
3836        let (jh, close_tx) = handle.split();
3837
3838        let msg = timeout(Duration::from_millis(10), block_rx.recv())
3839            .await
3840            .expect("Should receive the first block at once")
3841            .expect("Channel should be open")
3842            .expect("Should not be an error");
3843        let _ = close_tx.send(());
3844        jh.await.expect("Task should not panic");
3845
3846        assert_eq!(msg.header.number, 1);
3847        assert_eq!(*snapshot_blocks.lock().unwrap(), vec![1]);
3848    }
3849
3850    #[test]
3851    fn test_snapshot_jitter_has_a_minimum() {
3852        let state_sync = with_mocked_clients(true, false, None, None);
3853        let state_sync = state_sync.with_snapshot_jitter(Duration::ZERO);
3854        assert_eq!(state_sync.snapshot_jitter, MIN_SNAPSHOT_JITTER);
3855
3856        let state_sync = state_sync.with_snapshot_jitter(Duration::from_secs(10));
3857        assert_eq!(state_sync.snapshot_jitter, Duration::from_secs(10));
3858    }
3859
3860    #[test_log::test(tokio::test(start_paused = true))]
3861    async fn test_retry_before_first_block_has_no_snapshot_delay() {
3862        let (rpc_client, snapshot_blocks) = mock_rpc_recording_snapshot_blocks();
3863        let mut deltas_client = MockDeltasClient::new();
3864        deltas_client
3865            .expect_subscribe()
3866            .times(1)
3867            .returning(|_, _| Err(DeltasError::ServerError("unavailable".to_string())));
3868        let (tx, rx) = channel(128);
3869        deltas_client
3870            .expect_subscribe()
3871            .return_once(move |_, _| {
3872                tokio::spawn(async move {
3873                    for block in 1..=5 {
3874                        let _ = tx
3875                            .send(make_block_changes(block, None))
3876                            .await;
3877                        sleep(Duration::from_millis(50)).await;
3878                    }
3879                });
3880                Ok((Uuid::default(), rx))
3881            });
3882        deltas_client
3883            .expect_unsubscribe()
3884            .return_once(|_| Ok(()));
3885        let mut state_sync =
3886            with_mocked_clients(true, false, Some(rpc_client), Some(deltas_client));
3887        state_sync.max_retries = 2;
3888        state_sync
3889            .initialize()
3890            .await
3891            .expect("Init should succeed");
3892
3893        let (handle, mut block_rx) = state_sync.start().await;
3894        let (jh, close_tx) = handle.split();
3895
3896        let msg = timeout(Duration::from_secs(1), block_rx.recv())
3897            .await
3898            .expect("Should receive the first block")
3899            .expect("Channel should be open")
3900            .expect("Should not be an error");
3901        let _ = close_tx.send(());
3902        jh.await.expect("Task should not panic");
3903
3904        assert_eq!(msg.header.number, 1);
3905        assert_eq!(*snapshot_blocks.lock().unwrap(), vec![1]);
3906    }
3907
3908    /// Starts `state_sync` as a restart after block 5 and returns the first block it emits.
3909    async fn first_block_after_restart(
3910        mut state_sync: ProtocolStateSynchronizer<
3911            ArcRPCClient<MockRPCClient>,
3912            ArcDeltasClient<MockDeltasClient>,
3913        >,
3914    ) -> u64 {
3915        state_sync
3916            .initialize()
3917            .await
3918            .expect("Init should succeed");
3919        state_sync.last_synced_block = Some(header_of(&make_block_changes(5, None)));
3920
3921        let (handle, mut block_rx) = state_sync.start().await;
3922        let (jh, close_tx) = handle.split();
3923        let msg = timeout(Duration::from_millis(10), block_rx.recv())
3924            .await
3925            .expect("Should receive the first block at once")
3926            .expect("Channel should be open")
3927            .expect("Should not be an error");
3928        let _ = close_tx.send(());
3929        jh.await.expect("Task should not panic");
3930        msg.header.number
3931    }
3932
3933    #[test_log::test(tokio::test(start_paused = true))]
3934    async fn test_no_state_restart_has_no_snapshot_delay() {
3935        let mut rpc_client = make_mock_client();
3936        rpc_client
3937            .expect_get_protocol_components()
3938            .returning(|_| {
3939                Ok(Page::new(
3940                    vec![ProtocolComponent { id: "Component1".to_string(), ..Default::default() }],
3941                    1,
3942                    0,
3943                    100,
3944                ))
3945            });
3946        rpc_client
3947            .expect_get_snapshots()
3948            .never();
3949        let deltas_client = mock_deltas_emitting((10..=14).collect());
3950        let mut state_sync =
3951            with_mocked_clients(true, false, Some(rpc_client), Some(deltas_client));
3952        state_sync.include_snapshots = false;
3953
3954        assert_eq!(first_block_after_restart(state_sync).await, 10);
3955    }
3956
3957    #[test_log::test(tokio::test(start_paused = true))]
3958    async fn test_restart_without_components_has_no_snapshot_delay() {
3959        let mut rpc_client = make_mock_client();
3960        rpc_client
3961            .expect_get_protocol_components()
3962            .returning(|_| Ok(Page::new(vec![], 0, 0, 100)));
3963        rpc_client
3964            .expect_get_snapshots()
3965            .never();
3966        let deltas_client = mock_deltas_emitting((10..=14).collect());
3967        let state_sync = with_mocked_clients(true, false, Some(rpc_client), Some(deltas_client));
3968
3969        assert_eq!(first_block_after_restart(state_sync).await, 10);
3970    }
3971
3972    fn make_block_changes(block_num: u64, partial_idx: Option<u32>) -> BlockAggregatedChanges {
3973        // Use vec to create Bytes from block number
3974        let hash = Bytes::from(vec![block_num as u8; 32]);
3975        let parent_hash = Bytes::from(vec![block_num.saturating_sub(1) as u8; 32]);
3976        BlockAggregatedChanges {
3977            extractor: "uniswap-v2".to_string(),
3978            chain: Chain::Ethereum,
3979            block: Block {
3980                number: block_num,
3981                hash,
3982                parent_hash,
3983                chain: Chain::Ethereum,
3984                ts: Default::default(),
3985            },
3986            revert: false,
3987            partial_block_index: partial_idx,
3988            ..Default::default()
3989        }
3990    }
3991
3992    /// Test that full block as first message in partial mode is accepted
3993    #[test_log::test(tokio::test)]
3994    async fn test_partial_mode_accepts_full_block_as_first_message() {
3995        let (rpc_client, deltas_client, tx) = mock_clients_for_state_sync(None);
3996        let mut state_sync = with_mocked_clients(true, true, Some(rpc_client), Some(deltas_client))
3997            .with_partial_blocks(true);
3998        state_sync
3999            .initialize()
4000            .await
4001            .expect("Init failed");
4002
4003        let (handle, mut block_rx) = state_sync.start().await;
4004        let (jh, close_tx) = handle.split();
4005
4006        // Send full block as first message - should be accepted
4007        tx.send(make_block_changes(1, None))
4008            .await
4009            .unwrap();
4010
4011        // Should receive the full block immediately
4012        let msg = timeout(Duration::from_millis(100), block_rx.recv())
4013            .await
4014            .expect("Should receive message")
4015            .expect("Channel open")
4016            .expect("No error");
4017
4018        assert_eq!(msg.header.number, 1, "Should use block 1 (full block)");
4019        assert_eq!(msg.header.partial_block_index, None, "Should be a full block");
4020
4021        let _ = close_tx.send(());
4022        jh.await.expect("Task should not panic");
4023    }
4024
4025    /// Test that block number increase is detected as new block
4026    #[test_log::test(tokio::test)]
4027    async fn test_partial_mode_detects_block_number_increase() {
4028        let (rpc_client, deltas_client, tx) = mock_clients_for_state_sync(None);
4029        let mut state_sync = with_mocked_clients(true, true, Some(rpc_client), Some(deltas_client))
4030            .with_partial_blocks(true);
4031        state_sync
4032            .initialize()
4033            .await
4034            .expect("Init failed");
4035
4036        let (handle, mut block_rx) = state_sync.start().await;
4037        let (jh, close_tx) = handle.split();
4038
4039        // Send partial messages for block 1 (will be skipped - waiting for new block)
4040        tx.send(make_block_changes(1, Some(0)))
4041            .await
4042            .unwrap();
4043        tx.send(make_block_changes(1, Some(3)))
4044            .await
4045            .unwrap();
4046
4047        // Verify no message received yet
4048        match timeout(Duration::from_millis(50), block_rx.recv()).await {
4049            Err(_) => { /* Expected: timeout, no message yet */ }
4050            Ok(_) => panic!("Should not receive message while waiting for new block"),
4051        }
4052
4053        // Send partial for block 2 with HIGHER index (5 > 3) - should still be detected
4054        // because block number increased
4055        tx.send(make_block_changes(2, Some(5)))
4056            .await
4057            .unwrap();
4058
4059        // Should receive the message for block 2
4060        let msg = timeout(Duration::from_millis(100), block_rx.recv())
4061            .await
4062            .expect("Should receive message")
4063            .expect("Channel open")
4064            .expect("No error");
4065
4066        assert_eq!(msg.header.number, 2, "Should use block 2 (block number increased)");
4067        assert_eq!(msg.header.partial_block_index, Some(5));
4068
4069        let _ = close_tx.send(());
4070        jh.await.expect("Task should not panic");
4071    }
4072
4073    /// Test that partial mode skips new blocks that are already synced
4074    #[test_log::test(tokio::test)]
4075    async fn test_partial_mode_skips_already_synced_blocks() {
4076        let (rpc_client, deltas_client, tx) = mock_clients_for_state_sync(None);
4077        let mut state_sync = with_mocked_clients(true, true, Some(rpc_client), Some(deltas_client))
4078            .with_partial_blocks(true);
4079        state_sync
4080            .initialize()
4081            .await
4082            .expect("Init failed");
4083
4084        // Set last_synced_block to block 5 - we've already synced up to here
4085        state_sync.last_synced_block = Some(BlockHeader {
4086            number: 5,
4087            hash: Bytes::from("0x05"),
4088            parent_hash: Bytes::from("0x04"),
4089            revert: false,
4090            timestamp: 0,
4091            partial_block_index: None,
4092        });
4093        // This test checks block selection, not the restart delay.
4094        state_sync.snapshot_jitter = Duration::ZERO;
4095
4096        let (handle, mut block_rx) = state_sync.start().await;
4097        let (jh, close_tx) = handle.split();
4098
4099        // Send partial for block 3 to establish baseline
4100        tx.send(make_block_changes(3, Some(2)))
4101            .await
4102            .unwrap();
4103
4104        // Send "new block" for block 4 (partial index decreased) - but block 4 < last_synced (5)
4105        tx.send(make_block_changes(4, Some(0)))
4106            .await
4107            .unwrap();
4108
4109        // Should be skipped because block 4 is already synced
4110        match timeout(Duration::from_millis(50), block_rx.recv()).await {
4111            Err(_) => { /* Expected: skipped because already synced */ }
4112            Ok(_) => panic!("Should skip block 4 because it's already synced"),
4113        }
4114
4115        // Now send new block for block 6 (after last_synced)
4116        // First establish new partial index
4117        tx.send(make_block_changes(5, Some(3)))
4118            .await
4119            .unwrap();
4120        // Then trigger new block detection
4121        tx.send(make_block_changes(6, Some(0)))
4122            .await
4123            .unwrap();
4124
4125        let msg = timeout(Duration::from_millis(100), block_rx.recv())
4126            .await
4127            .expect("Should receive message")
4128            .expect("Channel open")
4129            .expect("No error");
4130
4131        assert_eq!(msg.header.number, 6, "Should use block 6 (after last synced)");
4132
4133        let _ = close_tx.send(());
4134        jh.await.expect("Task should not panic");
4135    }
4136
4137    #[test_log::test(tokio::test)]
4138    async fn test_get_snapshots_skips_entrypoints_when_not_dci() {
4139        let header = BlockHeader::default();
4140        let mut rpc = make_mock_client();
4141        let component = ProtocolComponent { id: "Component1".to_string(), ..Default::default() };
4142
4143        let component_clone = component.clone();
4144        rpc.expect_get_snapshots()
4145            .returning(move |_request, _chunk_size, _concurrency| {
4146                Ok(Snapshot {
4147                    states: [(
4148                        "Component1".to_string(),
4149                        ComponentWithState {
4150                            state: ProtocolComponentState::new(
4151                                "Component1",
4152                                Default::default(),
4153                                Default::default(),
4154                            ),
4155                            component: component_clone.clone(),
4156                            entrypoints: vec![],
4157                            component_tvl: None,
4158                        },
4159                    )]
4160                    .into_iter()
4161                    .collect(),
4162                    vm_storage: HashMap::new(),
4163                })
4164            });
4165
4166        // get_traced_entry_points should NOT be called for a non-DCI protocol
4167        rpc.expect_get_traced_entry_points()
4168            .never();
4169
4170        let mut state_sync = with_mocked_clients(true, false, Some(rpc), None);
4171        // uses_dci defaults to false, no .with_dci() call needed
4172        state_sync
4173            .component_tracker
4174            .components
4175            .insert("Component1".to_string(), component);
4176
4177        let components_arg = ["Component1".to_string()];
4178        let req_ids: Vec<String> = components_arg.to_vec();
4179        let components: HashMap<_, _> = state_sync
4180            .component_tracker
4181            .components
4182            .iter()
4183            .filter(|(id, _)| req_ids.contains(id))
4184            .map(|(k, v)| (k.clone(), v.clone()))
4185            .collect();
4186        let contract_ids: HashSet<Bytes> = state_sync
4187            .component_tracker
4188            .get_contracts_by_component(&req_ids)
4189            .into_iter()
4190            .collect();
4191        let params = FetchSnapshotParams {
4192            chain: Chain::Ethereum,
4193            protocol_system: "uniswap-v2".to_string(),
4194            block_number: header.number,
4195            uses_dci: false,
4196            retrieve_balances: true,
4197            include_tvl: false,
4198        };
4199        let (snapshot, _, _) =
4200            fetch_snapshot(&state_sync.rpc_client, components, contract_ids, &params)
4201                .await
4202                .expect("Retrieving snapshot failed");
4203
4204        assert!(snapshot
4205            .states
4206            .contains_key("Component1"));
4207    }
4208
4209    #[test_log::test(tokio::test)]
4210    async fn test_get_snapshots_fetches_entrypoints_when_dci() {
4211        let header = BlockHeader::default();
4212        let mut rpc = make_mock_client();
4213        let component = ProtocolComponent { id: "Component1".to_string(), ..Default::default() };
4214
4215        let component_clone = component.clone();
4216        rpc.expect_get_snapshots()
4217            .returning(move |_request, _chunk_size, _concurrency| {
4218                Ok(Snapshot {
4219                    states: [(
4220                        "Component1".to_string(),
4221                        ComponentWithState {
4222                            state: ProtocolComponentState::new(
4223                                "Component1",
4224                                Default::default(),
4225                                Default::default(),
4226                            ),
4227                            component: component_clone.clone(),
4228                            entrypoints: vec![],
4229                            component_tvl: None,
4230                        },
4231                    )]
4232                    .into_iter()
4233                    .collect(),
4234                    vm_storage: HashMap::new(),
4235                })
4236            });
4237
4238        // get_traced_entry_points SHOULD be called for a DCI protocol
4239        rpc.expect_get_traced_entry_points()
4240            .times(1)
4241            .returning(|_| Ok(Page::new(HashMap::new(), 0, 0, 0)));
4242
4243        let mut state_sync = with_mocked_clients(true, false, Some(rpc), None).with_dci(true);
4244        state_sync
4245            .component_tracker
4246            .components
4247            .insert("Component1".to_string(), component);
4248
4249        let components_arg = ["Component1".to_string()];
4250        let req_ids: Vec<String> = components_arg.to_vec();
4251        let components: HashMap<_, _> = state_sync
4252            .component_tracker
4253            .components
4254            .iter()
4255            .filter(|(id, _)| req_ids.contains(id))
4256            .map(|(k, v)| (k.clone(), v.clone()))
4257            .collect();
4258        let contract_ids: HashSet<Bytes> = state_sync
4259            .component_tracker
4260            .get_contracts_by_component(&req_ids)
4261            .into_iter()
4262            .collect();
4263        let params = FetchSnapshotParams {
4264            chain: Chain::Ethereum,
4265            protocol_system: "uniswap-v2".to_string(),
4266            block_number: header.number,
4267            uses_dci: true,
4268            retrieve_balances: true,
4269            include_tvl: false,
4270        };
4271        let (snapshot, _, _) =
4272            fetch_snapshot(&state_sync.rpc_client, components, contract_ids, &params)
4273                .await
4274                .expect("Retrieving snapshot failed");
4275
4276        assert!(snapshot
4277            .states
4278            .contains_key("Component1"));
4279    }
4280
4281    /// Test that in partial-blocks mode, new components are deferred until the block number
4282    /// increments (confirming the previous block is complete), then fired as a background task at
4283    /// the previous block's height. The snapshots appear in the first message of the block AFTER
4284    /// the one where the task was fired.
4285    ///
4286    /// Timeline:
4287    /// - Block 1 (full): initial sync
4288    /// - Block 2 partial: BrandNew + Preexisting added to deferred set (no task yet)
4289    /// - Block 3 partial: block number increments → task fired at snapshot_block=2; msg3 empty
4290    /// - Block 4 partial: task has completed → drain returns both snapshots in msg4
4291    #[test_log::test(tokio::test)]
4292    async fn test_partial_mode_defers_brand_new_component_snapshot_to_next_block() {
4293        use std::time::Duration;
4294
4295        use tokio::{sync::mpsc::channel, time::timeout};
4296
4297        let bg_done = Arc::new(tokio::sync::Notify::new());
4298        let mut rpc_client = make_mock_client();
4299        // get_protocol_components for BrandNew + Preexisting (background task fires at block 3)
4300        rpc_client
4301            .expect_get_protocol_components()
4302            .withf(|params: &crate::rpc::ProtocolComponentsParams| {
4303                params
4304                    .component_ids()
4305                    .is_some_and(|ids| ids.contains(&"BrandNew".to_string()))
4306            })
4307            .returning(|_| {
4308                Ok(Page::new(
4309                    vec![
4310                        ProtocolComponent { id: "BrandNew".to_string(), ..Default::default() },
4311                        ProtocolComponent { id: "Preexisting".to_string(), ..Default::default() },
4312                    ],
4313                    2,
4314                    0,
4315                    100,
4316                ))
4317            });
4318        // get_protocol_components for initial sync
4319        rpc_client
4320            .expect_get_protocol_components()
4321            .returning(|_| {
4322                Ok(Page::new(
4323                    vec![
4324                        ProtocolComponent { id: "Component1".to_string(), ..Default::default() },
4325                        ProtocolComponent { id: "Component2".to_string(), ..Default::default() },
4326                    ],
4327                    2,
4328                    0,
4329                    100,
4330                ))
4331            });
4332        // Background task fires when block 3 arrives: snapshot at block 2 (3 - 1).
4333        let bg_done_clone = bg_done.clone();
4334        rpc_client
4335            .expect_get_snapshots()
4336            .withf(
4337                |request: &SnapshotParameters,
4338                 _chunk_size: &Option<usize>,
4339                 _concurrency: &usize| {
4340                    request.block_number == 2 &&
4341                        (request
4342                            .components
4343                            .contains_key("BrandNew") ||
4344                            request
4345                                .components
4346                                .contains_key("Preexisting"))
4347                },
4348            )
4349            .returning(move |_request, _chunk_size, _concurrency| {
4350                let snap = Ok(Snapshot {
4351                    states: [
4352                        (
4353                            "BrandNew".to_string(),
4354                            ComponentWithState {
4355                                state: ProtocolComponentState::new(
4356                                    "BrandNew",
4357                                    Default::default(),
4358                                    Default::default(),
4359                                ),
4360                                component: ProtocolComponent {
4361                                    id: "BrandNew".to_string(),
4362                                    ..Default::default()
4363                                },
4364                                component_tvl: Some(100.0),
4365                                entrypoints: vec![],
4366                            },
4367                        ),
4368                        (
4369                            "Preexisting".to_string(),
4370                            ComponentWithState {
4371                                state: ProtocolComponentState::new(
4372                                    "Preexisting",
4373                                    Default::default(),
4374                                    Default::default(),
4375                                ),
4376                                component: ProtocolComponent {
4377                                    id: "Preexisting".to_string(),
4378                                    ..Default::default()
4379                                },
4380                                component_tvl: Some(75.0),
4381                                entrypoints: vec![],
4382                            },
4383                        ),
4384                    ]
4385                    .into_iter()
4386                    .collect(),
4387                    vm_storage: HashMap::new(),
4388                });
4389                bg_done_clone.notify_one();
4390                snap
4391            });
4392        // get_snapshots for initial sync (block 0, Component1+Component2)
4393        rpc_client
4394            .expect_get_snapshots()
4395            .returning(|_request, _chunk_size, _concurrency| {
4396                Ok(Snapshot {
4397                    states: [
4398                        (
4399                            "Component1".to_string(),
4400                            ComponentWithState {
4401                                state: ProtocolComponentState::new(
4402                                    "Component1",
4403                                    Default::default(),
4404                                    Default::default(),
4405                                ),
4406                                component: ProtocolComponent {
4407                                    id: "Component1".to_string(),
4408                                    ..Default::default()
4409                                },
4410                                component_tvl: Some(100.0),
4411                                entrypoints: vec![],
4412                            },
4413                        ),
4414                        (
4415                            "Component2".to_string(),
4416                            ComponentWithState {
4417                                state: ProtocolComponentState::new(
4418                                    "Component2",
4419                                    Default::default(),
4420                                    Default::default(),
4421                                ),
4422                                component: ProtocolComponent {
4423                                    id: "Component2".to_string(),
4424                                    ..Default::default()
4425                                },
4426                                component_tvl: Some(0.0),
4427                                entrypoints: vec![],
4428                            },
4429                        ),
4430                    ]
4431                    .into_iter()
4432                    .collect(),
4433                    vm_storage: HashMap::new(),
4434                })
4435            });
4436        rpc_client
4437            .expect_get_traced_entry_points()
4438            .returning(|_| Ok(Page::new(HashMap::new(), 0, 0, 0)));
4439
4440        let mut deltas_client = MockDeltasClient::new();
4441        let (tx, rx) = channel(4);
4442        deltas_client
4443            .expect_subscribe()
4444            .return_once(move |_, _| Ok((Uuid::default(), rx)));
4445        deltas_client
4446            .expect_unsubscribe()
4447            .return_once(|_| Ok(()));
4448
4449        let mut state_sync = with_mocked_clients(true, true, Some(rpc_client), Some(deltas_client))
4450            .with_partial_blocks(true);
4451        state_sync
4452            .initialize()
4453            .await
4454            .expect("Init failed");
4455
4456        let (handle, mut block_rx) = state_sync.start().await;
4457        let (jh, close_tx) = handle.split();
4458
4459        // Block 1 (full): used for initial sync merge
4460        tx.send(make_block_changes(1, None))
4461            .await
4462            .unwrap();
4463        let _msg1 = timeout(Duration::from_millis(200), block_rx.recv())
4464            .await
4465            .expect("Should receive initial + block 1")
4466            .expect("Channel open")
4467            .expect("No error");
4468
4469        // Block 2 partial: BrandNew and Preexisting both appear. Neither task is fired yet —
4470        // both are added to deferred_snapshot_components.
4471        let mut block2 = make_block_changes(2, Some(2));
4472        block2.new_protocol_components = HashMap::from([(
4473            "BrandNew".to_string(),
4474            ProtocolComponent { id: "BrandNew".to_string(), ..Default::default() },
4475        )]);
4476        block2.component_tvl =
4477            HashMap::from([("BrandNew".to_string(), 100.0), ("Preexisting".to_string(), 75.0)]);
4478        tx.send(block2).await.unwrap();
4479        let msg2 = timeout(Duration::from_millis(200), block_rx.recv())
4480            .await
4481            .expect("Should receive block 2")
4482            .expect("Channel open")
4483            .expect("No error");
4484
4485        assert!(
4486            !msg2
4487                .snapshots
4488                .states
4489                .contains_key("Preexisting"),
4490            "Preexisting should still be deferred in block 2, not yet snapshotted; got: {:?}",
4491            msg2.snapshots
4492                .states
4493                .keys()
4494                .collect::<Vec<_>>()
4495        );
4496        assert!(
4497            !msg2
4498                .snapshots
4499                .states
4500                .contains_key("BrandNew"),
4501            "BrandNew should still be deferred in block 2, not yet snapshotted"
4502        );
4503
4504        // Block 3 partial: block number increments → deferred components fire as background task
4505        // at snapshot_block=2. msg3 has no snapshots (task just spawned).
4506        tx.send(make_block_changes(3, Some(1)))
4507            .await
4508            .unwrap();
4509        let msg3 = timeout(Duration::from_millis(200), block_rx.recv())
4510            .await
4511            .expect("Should receive block 3")
4512            .expect("Channel open")
4513            .expect("No error");
4514
4515        assert_eq!(msg3.header.number, 3);
4516        assert_eq!(msg3.header.partial_block_index, Some(1));
4517        assert!(
4518            !msg3
4519                .snapshots
4520                .states
4521                .contains_key("BrandNew"),
4522            "BrandNew task just fired; snapshot not yet available in msg3"
4523        );
4524        assert!(
4525            !msg3
4526                .snapshots
4527                .states
4528                .contains_key("Preexisting"),
4529            "Preexisting task just fired; snapshot not yet available in msg3"
4530        );
4531
4532        // Wait for the background snapshot task to complete before the next block arrives.
4533        bg_done.notified().await;
4534
4535        // Block 4 partial: drain finds the completed task → both snapshots present in msg4.
4536        tx.send(make_block_changes(4, Some(0)))
4537            .await
4538            .unwrap();
4539        let msg4 = timeout(Duration::from_millis(200), block_rx.recv())
4540            .await
4541            .expect("Should receive block 4")
4542            .expect("Channel open")
4543            .expect("No error");
4544
4545        assert_eq!(msg4.header.number, 4);
4546        assert_eq!(msg4.header.partial_block_index, Some(0));
4547        assert!(
4548            msg4.snapshots
4549                .states
4550                .contains_key("BrandNew"),
4551            "BrandNew snapshot should be in msg4 after background task drains; got keys: {:?}",
4552            msg4.snapshots
4553                .states
4554                .keys()
4555                .collect::<Vec<_>>()
4556        );
4557        assert!(
4558            msg4.snapshots
4559                .states
4560                .contains_key("Preexisting"),
4561            "Preexisting snapshot should be in msg4 after background task drains; got keys: {:?}",
4562            msg4.snapshots
4563                .states
4564                .keys()
4565                .collect::<Vec<_>>()
4566        );
4567
4568        let _ = close_tx.send(());
4569        jh.await.expect("Task should not panic");
4570    }
4571
4572    /// Directly exercises all four mutation paths of `apply_deltas_to_snapshot`:
4573    /// attribute update, attribute deletion, balance merge, and VM slot/balance/code overwrite.
4574    /// Also verifies that deltas at or before `snapshot_block` are skipped.
4575    #[test]
4576    fn test_apply_deltas_to_snapshot() {
4577        use tycho_common::models::{
4578            contract::{Account, AccountDelta},
4579            protocol::{ComponentBalance, ProtocolComponentStateDelta},
4580            ChangeType,
4581        };
4582
4583        let contract_addr = Bytes::from("0xc0ffee");
4584        let token_addr = Bytes::from("0xdeadbeef");
4585
4586        // Build the snapshot at block 5: one component with attributes and balances,
4587        // one VM contract with slots and native balance.
4588        let mut snapshot = Snapshot {
4589            states: [(
4590                "comp1".to_string(),
4591                ComponentWithState {
4592                    state: ProtocolComponentState::new(
4593                        "comp1",
4594                        [
4595                            ("keep".to_string(), Bytes::from("0x01")),
4596                            ("delete_me".to_string(), Bytes::from("0x02")),
4597                        ]
4598                        .into_iter()
4599                        .collect(),
4600                        [(token_addr.clone(), Bytes::from("0x64"))]
4601                            .into_iter()
4602                            .collect(),
4603                    ),
4604                    component: ProtocolComponent::default(),
4605                    component_tvl: None,
4606                    entrypoints: vec![],
4607                },
4608            )]
4609            .into_iter()
4610            .collect(),
4611            vm_storage: [(
4612                contract_addr.clone(),
4613                Account {
4614                    chain: Chain::Ethereum,
4615                    address: contract_addr.clone(),
4616                    title: String::new(),
4617                    slots: [(Bytes::from("0x01"), Bytes::from("0xaa"))]
4618                        .into_iter()
4619                        .collect(),
4620                    native_balance: Bytes::from("0x10"),
4621                    token_balances: HashMap::new(),
4622                    code: Bytes::from("0x0a0b"),
4623                    code_hash: Default::default(),
4624                    balance_modify_tx: Default::default(),
4625                    code_modify_tx: Default::default(),
4626                    creation_tx: None,
4627                },
4628            )]
4629            .into_iter()
4630            .collect(),
4631        };
4632
4633        // Two buffered deltas: block 5 (at snapshot_block, must be skipped) and block 6
4634        // (after snapshot_block, must be applied).
4635        let skipped_delta = BlockAggregatedChanges {
4636            block: Block { number: 5, ..Default::default() },
4637            state_deltas: [(
4638                "comp1".to_string(),
4639                ProtocolComponentStateDelta::new(
4640                    "comp1",
4641                    [("keep".to_string(), Bytes::from("0xff"))]
4642                        .into_iter()
4643                        .collect(),
4644                    HashSet::new(),
4645                ),
4646            )]
4647            .into_iter()
4648            .collect(),
4649            ..Default::default()
4650        };
4651        let applied_delta = BlockAggregatedChanges {
4652            block: Block { number: 6, ..Default::default() },
4653            state_deltas: [(
4654                "comp1".to_string(),
4655                ProtocolComponentStateDelta::new(
4656                    "comp1",
4657                    [("keep".to_string(), Bytes::from("0x99"))]
4658                        .into_iter()
4659                        .collect(),
4660                    ["delete_me".to_string()]
4661                        .into_iter()
4662                        .collect(),
4663                ),
4664            )]
4665            .into_iter()
4666            .collect(),
4667            component_balances: [(
4668                "comp1".to_string(),
4669                [(
4670                    token_addr.clone(),
4671                    ComponentBalance::new(
4672                        token_addr.clone(),
4673                        Bytes::from("0xc8"),
4674                        200.0,
4675                        Default::default(),
4676                        "comp1",
4677                    ),
4678                )]
4679                .into_iter()
4680                .collect(),
4681            )]
4682            .into_iter()
4683            .collect(),
4684            account_deltas: [(
4685                contract_addr.clone(),
4686                AccountDelta::new(
4687                    Chain::Ethereum,
4688                    contract_addr.clone(),
4689                    [
4690                        (Bytes::from("0x01"), Some(Bytes::from("0xbb"))),
4691                        (Bytes::from("0x02"), Some(Bytes::from("0xcc"))),
4692                    ]
4693                    .into_iter()
4694                    .collect(),
4695                    Some(Bytes::from("0x20")),
4696                    Some(Bytes::from("0x0c0d")),
4697                    ChangeType::Update,
4698                ),
4699            )]
4700            .into_iter()
4701            .collect(),
4702            ..Default::default()
4703        };
4704
4705        let mut sync = with_mocked_clients(true, false, None, None);
4706        sync.buffered_deltas = vec![skipped_delta, applied_delta];
4707
4708        let contract_ids: HashSet<Bytes> = [contract_addr.clone()]
4709            .into_iter()
4710            .collect();
4711        sync.apply_deltas_to_snapshot(&mut snapshot, 5, &contract_ids);
4712
4713        let comp = &snapshot.states["comp1"].state;
4714
4715        // Attribute update applied
4716        assert_eq!(comp.attributes["keep"], Bytes::from("0x99"));
4717        // Attribute deletion applied
4718        assert!(!comp
4719            .attributes
4720            .contains_key("delete_me"));
4721        // Balance merge applied
4722        assert_eq!(comp.balances[&token_addr], Bytes::from("0xc8"));
4723
4724        let account = &snapshot.vm_storage[&contract_addr];
4725        // Existing slot overwritten, new slot added
4726        assert_eq!(account.slots[&Bytes::from("0x01")], Bytes::from("0xbb"));
4727        assert_eq!(account.slots[&Bytes::from("0x02")], Bytes::from("0xcc"));
4728        // Native balance updated
4729        assert_eq!(account.native_balance, Bytes::from("0x20"));
4730        // Code updated
4731        assert_eq!(account.code, Bytes::from("0x0c0d"));
4732    }
4733}