Skip to main content

forest/chain_sync/
chain_follower.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3//! This module contains the logic for driving Forest forward in the Filecoin
4//! blockchain.
5//!
6//! Forest keeps track of the current heaviest tipset, and receives information
7//! about new blocks and tipsets from peers as well as connected miners. The
8//! state machine has the following rules:
9//! - A tipset is invalid if its parent is invalid.
10//! - If a tipset's parent isn't in our database, request it from the network.
11//! - If a tipset's parent has been validated, validate the tipset.
12//! - If a tipset is 1 day older than the heaviest tipset, the tipset is
13//!   invalid. This prevents Forest from following forks that will never be
14//!   accepted.
15//!
16//! The state machine does not do any network requests or validation. Those are
17//! handled by an external actor.
18
19use super::network_context::SyncNetworkContext;
20use crate::{
21    blocks::{Block, FullTipset, Tipset, TipsetKey},
22    chain::{ChainStore, index::ResolveNullTipset},
23    chain_sync::{
24        ForkSyncInfo, ForkSyncStage, SyncStatus, SyncStatusReport, TipsetValidator,
25        bad_block_cache::{BadBlockCache, SeenBlockCache},
26        metrics,
27        tipset_syncer::{TipsetSyncerError, validate_tipset},
28        validation::GossipBlockValidator,
29    },
30    libp2p::{NetworkEvent, PubsubMessage, hello::HelloRequest},
31    message_pool::MessagePool,
32    networks::calculate_expected_epoch,
33    prelude::*,
34    shim::clock::ChainEpoch,
35    state_manager::StateManager,
36    utils::misc::env::env_or_default_logged,
37};
38use arc_swap::ArcSwap;
39use chrono::Utc;
40use hashbrown::{HashMap, HashSet};
41use libp2p::PeerId;
42use nonzero_ext::nonzero;
43use parking_lot::Mutex;
44use std::{
45    borrow::Cow,
46    sync::LazyLock,
47    time::{Duration, Instant},
48};
49use tokio::{
50    sync::{Notify, OwnedSemaphorePermit, Semaphore},
51    task::JoinSet,
52};
53use tokio_util::sync::CancellationToken;
54
55pub struct ChainFollower {
56    /// Tasks
57    tasks: Arc<Mutex<HashSet<SyncTask>>>,
58
59    /// State machine
60    state_machine: Arc<Mutex<SyncStateMachine>>,
61
62    /// Syncing status of the chain
63    pub sync_status: SyncStatus,
64
65    /// manages retrieving and updates state objects
66    pub state_manager: StateManager,
67
68    /// Context to be able to send requests to P2P network
69    pub network: SyncNetworkContext,
70
71    /// Genesis tipset
72    genesis: Tipset,
73
74    /// Bad blocks cache, updates based on invalid state transitions.
75    /// Will mark any invalid blocks and all children as bad in this bounded
76    /// cache
77    pub bad_blocks: Option<BadBlockCache>,
78
79    /// Incoming network events to be handled by synchronizer
80    net_handler: Arc<flume::Receiver<NetworkEvent>>,
81
82    /// Tipset channel sender
83    pub tipset_sender: flume::Sender<FullTipset>,
84
85    /// Tipset channel receiver
86    tipset_receiver: Arc<flume::Receiver<FullTipset>>,
87
88    /// When `stateless_mode` is true, forest connects to the P2P network but
89    /// does not execute any state transitions. This drastically reduces the
90    /// memory and disk footprint of Forest but also means that Forest will not
91    /// be able to validate the correctness of the chain.
92    stateless_mode: bool,
93
94    /// Message pool
95    mem_pool: MessagePool<ChainStore>,
96}
97
98impl ShallowClone for ChainFollower {
99    fn shallow_clone(&self) -> Self {
100        Self {
101            tasks: self.tasks.shallow_clone(),
102            state_machine: self.state_machine.shallow_clone(),
103            sync_status: self.sync_status.shallow_clone(),
104            state_manager: self.state_manager.shallow_clone(),
105            network: self.network.shallow_clone(),
106            genesis: self.genesis.shallow_clone(),
107            bad_blocks: self.bad_blocks.shallow_clone(),
108            net_handler: self.net_handler.shallow_clone(),
109            tipset_sender: self.tipset_sender.clone(),
110            tipset_receiver: self.tipset_receiver.shallow_clone(),
111            stateless_mode: self.stateless_mode,
112            mem_pool: self.mem_pool.shallow_clone(),
113        }
114    }
115}
116
117impl ChainFollower {
118    pub fn new(
119        state_manager: StateManager,
120        network: SyncNetworkContext,
121        genesis: Tipset,
122        net_handler: flume::Receiver<NetworkEvent>,
123        stateless_mode: bool,
124        mem_pool: MessagePool<ChainStore>,
125    ) -> Self {
126        crate::def_is_env_truthy!(cache_disabled, "FOREST_DISABLE_BAD_BLOCK_CACHE");
127        let (tipset_sender, tipset_receiver) = flume::bounded(20);
128        let tasks: Arc<Mutex<HashSet<SyncTask>>> = Arc::new(Mutex::new(HashSet::default()));
129        let bad_blocks = if cache_disabled() {
130            tracing::warn!("bad block cache is disabled by `FOREST_DISABLE_BAD_BLOCK_CACHE`");
131            None
132        } else {
133            Some(Default::default())
134        };
135        let state_machine = Arc::new(Mutex::new(SyncStateMachine::new(
136            state_manager.chain_store().shallow_clone(),
137            bad_blocks.shallow_clone(),
138            stateless_mode,
139        )));
140
141        crate::metrics::register_collector(Box::new(SyncTasks(tasks.shallow_clone())));
142        crate::metrics::register_collector(Box::new(SyncStateMachineWrapper(
143            state_machine.shallow_clone(),
144        )));
145
146        Self {
147            tasks,
148            state_machine,
149            sync_status: Arc::new(ArcSwap::from_pointee(SyncStatusReport::init())),
150            state_manager,
151            network,
152            genesis,
153            bad_blocks,
154            net_handler: net_handler.into(),
155            tipset_sender,
156            tipset_receiver: tipset_receiver.into(),
157            stateless_mode,
158            mem_pool,
159        }
160    }
161
162    /// Reset inner states
163    pub fn reset(&self) {
164        let start = Instant::now();
165        self.tasks.lock().clear();
166        self.state_manager.chain_store().validated_blocks.clear();
167        self.state_machine.lock().tipsets.clear();
168        if let Some(bad_blocks) = &self.bad_blocks {
169            bad_blocks.clear();
170        }
171        tracing::info!(
172            "chain follower reset, took {}",
173            humantime::format_duration(start.elapsed())
174        );
175    }
176
177    pub async fn run(&self) -> anyhow::Result<()> {
178        chain_follower(
179            &self.tasks,
180            &self.state_machine,
181            &self.state_manager,
182            self.bad_blocks.shallow_clone(),
183            self.net_handler.shallow_clone(),
184            self.tipset_sender.clone(),
185            self.tipset_receiver.shallow_clone(),
186            &self.network,
187            &self.mem_pool,
188            &self.sync_status,
189            &self.genesis,
190            self.stateless_mode,
191        )
192        .await
193    }
194
195    /// Subscribe to validated tipsets.
196    pub fn subscribe_validated_tipset(&self) -> tokio::sync::broadcast::Receiver<TipsetKey> {
197        self.state_machine
198            .lock()
199            .validated_tipset_broadcast_tx
200            .subscribe()
201    }
202
203    /// Subscribe-only handle to per-block validation outcomes (see [`BlockValidationOutcome`]).
204    pub fn block_validation_subscriber(&self) -> BlockValidationSubscriber {
205        BlockValidationSubscriber(self.state_machine.lock().block_validation_tx.clone())
206    }
207}
208
209#[allow(clippy::too_many_arguments)]
210// We receive new full tipsets from the p2p swarm, and from miners that use Forest as their frontend.
211async fn chain_follower(
212    tasks: &Arc<Mutex<HashSet<SyncTask>>>,
213    state_machine: &Arc<Mutex<SyncStateMachine>>,
214    state_manager: &StateManager,
215    bad_block_cache: Option<BadBlockCache>,
216    network_rx: Arc<flume::Receiver<NetworkEvent>>,
217    tipset_sender: flume::Sender<FullTipset>,
218    tipset_receiver: Arc<flume::Receiver<FullTipset>>,
219    network: &SyncNetworkContext,
220    mem_pool: &MessagePool<ChainStore>,
221    sync_status: &SyncStatus,
222    genesis: &Tipset,
223    stateless_mode: bool,
224) -> anyhow::Result<()> {
225    let state_changed = Arc::new(Notify::new());
226
227    let seen_block_cache = SeenBlockCache::default();
228
229    let hello_fetch_limiter = Arc::new(Semaphore::new(*MAX_CONCURRENT_HELLO_TRIGGERED_FETCHES));
230
231    let mut set = JoinSet::new();
232    let cancellation_token = CancellationToken::new();
233    let _cancellation_token_drop_guard = cancellation_token.drop_guard_ref();
234
235    // Increment metrics, update peer information, and forward tipsets to the state machine.
236    set.spawn({
237        let state_manager = state_manager.shallow_clone();
238        let network = network.shallow_clone();
239        let mem_pool = mem_pool.shallow_clone();
240        let genesis = genesis.shallow_clone();
241        let bad_block_cache = bad_block_cache.shallow_clone();
242        let seen_block_cache = seen_block_cache.shallow_clone();
243        let cancellation_token = cancellation_token.clone();
244        let hello_fetch_limiter = hello_fetch_limiter.shallow_clone();
245        let tipset_sender = tipset_sender.clone();
246        async move {
247            while let Ok(event) = network_rx.recv_async().await {
248                inc_gossipsub_event_metrics(&event);
249
250                update_peer_info(
251                    &event,
252                    &network,
253                    state_manager.chain_store().shallow_clone(),
254                    &genesis,
255                    cancellation_token.clone(),
256                );
257
258                // Fetches are spawned, never awaited here: an unresponsive peer
259                // would otherwise stall the loop and back up the unbounded event queue.
260                match event {
261                    NetworkEvent::HelloResponseOutbound { request, source } => {
262                        let Ok(permit) = hello_fetch_limiter.shallow_clone().try_acquire_owned() else {
263                            debug!(%source, "dropping hello-triggered tipset fetch: too many in flight");
264                            continue;
265                        };
266                        spawn_tipset_fetch(
267                            network.shallow_clone(),
268                            state_manager.chain_store().shallow_clone(),
269                            Some(source),
270                            TipsetKey::from(request.heaviest_tip_set),
271                            tipset_sender.clone(),
272                            cancellation_token.clone(),
273                            Some(permit),
274                        );
275                    }
276                    NetworkEvent::PubsubMessage { message } => match message {
277                        PubsubMessage::Block(b) => {
278                            let cs = state_manager.chain_store();
279                            let cfg = cs.chain_config();
280                            if let Err(reason) = GossipBlockValidator::new(&b).validate_pre_fetch(
281                                &genesis,
282                                cfg.block_delay_secs,
283                                cs.ec_calculator_finalized_epoch(), // Not using F3 finalized epoch as it could go above the chain head during catchup
284                                bad_block_cache.as_ref(),
285                                &seen_block_cache,
286                            ) {
287                                metrics::GOSSIP_BLOCK_REJECTED_TOTAL
288                                    .get_or_create(&metrics::GossipRejectReasonLabel {
289                                        reason: reason.label(),
290                                    })
291                                    .inc();
292                                debug!("Rejected gossip block {}: {reason}", b.header.cid());
293                                continue;
294                            }
295                            let key = TipsetKey::from(nunny::vec![*b.header.cid()]);
296                            // Not rate-limited: gossip blocks are signed and peer-scored.
297                            spawn_tipset_fetch(
298                                network.shallow_clone(),
299                                cs.shallow_clone(),
300                                None,
301                                key,
302                                tipset_sender.clone(),
303                                cancellation_token.clone(),
304                                None,
305                            );
306                        }
307                        PubsubMessage::Message(m) => {
308                            if let Err(why) = mem_pool.add(m).await {
309                                debug!("Received invalid GossipSub message: {}", why);
310                            }
311                        }
312                    },
313                    _ => {}
314                }
315            }
316        }
317    });
318
319    // Forward tipsets from miners into the state machine.
320    set.spawn({
321        let state_changed = state_changed.clone();
322        let state_machine = state_machine.clone();
323
324        async move {
325            while let Ok(tipset) = tipset_receiver.recv_async().await {
326                state_machine
327                    .lock()
328                    .update(SyncEvent::NewFullTipsets(vec![tipset]));
329                state_changed.notify_one();
330            }
331        }
332    });
333
334    // When the state machine is updated, we need to update the sync status and spawn tasks
335    set.spawn({
336        let state_manager = state_manager.shallow_clone();
337        let state_machine = state_machine.shallow_clone();
338        let network = network.shallow_clone();
339        let sync_status = sync_status.shallow_clone();
340        let state_changed = state_changed.shallow_clone();
341        let tasks = tasks.shallow_clone();
342        let bad_block_cache = bad_block_cache.shallow_clone();
343        let cancellation_token = cancellation_token.clone();
344        async move {
345            const FORK_CLEANUP_INTERVAL: Duration = Duration::from_mins(1);
346            let mut last_fork_cleanup = Instant::now();
347            loop {
348                state_changed.notified().await;
349
350                let mut tasks_set = tasks.lock();
351                if last_fork_cleanup + FORK_CLEANUP_INTERVAL < Instant::now() {
352                    state_machine.lock().cleanup_dangling_forks();
353                    last_fork_cleanup = Instant::now();
354                }
355                let (task_vec, current_active_forks) = state_machine.lock().tasks();
356
357                // Update the sync states
358                {
359                    let old_status_report = sync_status.load().shallow_clone();
360                    let new_status_report = old_status_report.update(
361                        &state_manager,
362                        current_active_forks,
363                        stateless_mode,
364                    );
365                    sync_status.store(new_status_report.into());
366                }
367
368                for task in task_vec {
369                    // insert task into tasks. If task is already in tasks, skip. If it is not, spawn it.
370                    let new = tasks_set.insert(task.clone());
371                    if new {
372                        let action = task.clone().execute(
373                            network.shallow_clone(),
374                            state_manager.shallow_clone(),
375                            stateless_mode,
376                            bad_block_cache.shallow_clone(),
377                        );
378                        tokio::spawn({
379                            let tasks = tasks.shallow_clone();
380                            let state_machine = state_machine.shallow_clone();
381                            let state_changed = state_changed.shallow_clone();
382                            let cancellation_token = cancellation_token.clone();
383                            async move {
384                                cancellation_token
385                                    .run_until_cancelled(async move {
386                                        if let Some(event) = action.await {
387                                            state_machine.lock().update(event);
388                                            state_changed.notify_one();
389                                        }
390                                        let mut tasks = tasks.lock();
391                                        tasks.remove(&task);
392                                        tasks.shrink_to_fit();
393                                    })
394                                    .await
395                            }
396                        });
397                    }
398                }
399            }
400        }
401    });
402
403    // Periodically report progress while catching up to HEAD. Once we're in
404    // steady-state (i.e. caught up to HEAD), and there are
405    // no active forks, this will not report anything.
406    set.spawn({
407        let state_manager = state_manager.shallow_clone();
408        let sync_status = sync_status.shallow_clone();
409        async move {
410            loop {
411                tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;
412                let heaviest_tipset = state_manager.chain_store().heaviest_tipset();
413                let heaviest_epoch = heaviest_tipset.epoch();
414                let expected_head = calculate_expected_epoch(
415                    Utc::now().timestamp() as u64,
416                    state_manager.chain_store().genesis_block_header().timestamp,
417                    state_manager.chain_config().block_delay_secs,
418                );
419                let diff = expected_head - heaviest_epoch;
420
421                // Only print 'Catching up to HEAD' if we're more than 10 epochs
422                // behind. Otherwise it can be too spammy.
423                if diff <= 10 {
424                    continue;
425                }
426
427                // The event loop refreshes this report on every state change,
428                // so it is never staler than the state machine itself.
429                let report = sync_status.load();
430                let forks = &report.active_forks;
431                // Once the nearest fork is validating, the remaining fetch
432                // gaps belong to freshly gossiped forks near the clock head
433                // and their min would mislead.
434                let status: Cow<'static, str> = if forks
435                    .iter()
436                    .any(|fork| fork.stage == ForkSyncStage::ValidatingTipsets)
437                {
438                    ", validating tipsets".into()
439                } else if forks
440                    .iter()
441                    .any(|fork| fork.stage == ForkSyncStage::FetchingHeaders)
442                {
443                    // The fork closest to the validated head connects first, so
444                    // its gap approximates the header fetches left before
445                    // validation can start.
446                    let remaining = forks
447                        .iter()
448                        .filter(|fork| fork.stage == ForkSyncStage::FetchingHeaders)
449                        .map(|fork| fork.target_sync_epoch_start - fork.validated_chain_head_epoch)
450                        .filter(|gap| *gap > 0)
451                        .min();
452                    match remaining {
453                        Some(remaining) => format!(", fetching headers (~{remaining})").into(),
454                        // All fetches are at or below the validated head:
455                        // fork-point searches, fetching a competing branch's
456                        // ancestry down to where it forked off our chain.
457                        None => ", fetching headers".into(),
458                    }
459                } else {
460                    // waiting for peers to (re)seed tipsets (startup, no usable peers, or the buffer
461                    // was just cleared after a failed validation).
462                    ", waiting for tipsets".into()
463                };
464                info!(
465                    "Catching up to HEAD: {heaviest_epoch}{} -> {expected_head} (diff: {diff}){status}",
466                    heaviest_tipset.key()
467                );
468            }
469        }
470    });
471
472    set.join_all().await;
473    Ok(())
474}
475
476// Increment the gossipsub event metrics.
477fn inc_gossipsub_event_metrics(event: &NetworkEvent) {
478    let label = match event {
479        NetworkEvent::HelloRequestInbound => metrics::values::HELLO_REQUEST_INBOUND,
480        NetworkEvent::HelloResponseOutbound { .. } => metrics::values::HELLO_RESPONSE_OUTBOUND,
481        NetworkEvent::HelloRequestOutbound => metrics::values::HELLO_REQUEST_OUTBOUND,
482        NetworkEvent::HelloResponseInbound => metrics::values::HELLO_RESPONSE_INBOUND,
483        NetworkEvent::PeerConnected(_) => metrics::values::PEER_CONNECTED,
484        NetworkEvent::PeerDisconnected(_) => metrics::values::PEER_DISCONNECTED,
485        NetworkEvent::PubsubMessage { message } => match message {
486            PubsubMessage::Block(_) => metrics::values::PUBSUB_BLOCK,
487            PubsubMessage::Message(_) => metrics::values::PUBSUB_MESSAGE,
488        },
489        NetworkEvent::ChainExchangeRequestOutbound => {
490            metrics::values::CHAIN_EXCHANGE_REQUEST_OUTBOUND
491        }
492        NetworkEvent::ChainExchangeResponseInbound => {
493            metrics::values::CHAIN_EXCHANGE_RESPONSE_INBOUND
494        }
495        NetworkEvent::ChainExchangeRequestInbound => {
496            metrics::values::CHAIN_EXCHANGE_REQUEST_INBOUND
497        }
498        NetworkEvent::ChainExchangeResponseOutbound => {
499            metrics::values::CHAIN_EXCHANGE_RESPONSE_OUTBOUND
500        }
501    };
502
503    metrics::LIBP2P_MESSAGE_TOTAL.get_or_create(&label).inc();
504}
505
506// Keep our peer manager up to date.
507fn update_peer_info(
508    event: &NetworkEvent,
509    network: &SyncNetworkContext,
510    chain_store: ChainStore,
511    genesis: &Tipset,
512    cancellation_token: CancellationToken,
513) {
514    match event {
515        NetworkEvent::PeerConnected(peer_id) => {
516            let peer_id = *peer_id;
517            let genesis_cid = *genesis.block_headers().first().cid();
518            let network = network.shallow_clone();
519            // Spawn and immediately move on to the next event
520            tokio::task::spawn(async move {
521                cancellation_token
522                    .run_until_cancelled(handle_peer_connected_event(
523                        network,
524                        chain_store,
525                        peer_id,
526                        genesis_cid,
527                    ))
528                    .await
529            });
530        }
531        NetworkEvent::PeerDisconnected(peer_id) => {
532            handle_peer_disconnected_event(network, *peer_id);
533        }
534        _ => {}
535    }
536}
537
538async fn handle_peer_connected_event(
539    network: SyncNetworkContext,
540    chain_store: ChainStore,
541    peer_id: PeerId,
542    genesis_block_cid: Cid,
543) {
544    // Query the heaviest TipSet from the store
545    if network.peer_manager().is_peer_new(&peer_id) {
546        // Since the peer is new, send them a hello request
547        // Query the heaviest TipSet from the store
548        let heaviest = chain_store.heaviest_tipset();
549        let request = HelloRequest {
550            heaviest_tip_set: heaviest.cids(),
551            heaviest_tipset_height: heaviest.epoch(),
552            heaviest_tipset_weight: heaviest.weight().clone().into(),
553            genesis_cid: genesis_block_cid,
554        };
555        let (peer_id, moment_sent, response) = match network.hello_request(peer_id, request).await {
556            Ok(response) => response,
557            Err(e) => {
558                debug!("Hello request failed: {}", e);
559                return;
560            }
561        };
562        let dur = Instant::now().duration_since(moment_sent);
563
564        // Update the peer metadata based on the response
565        match response {
566            Some(_) => {
567                network.peer_manager().log_success(&peer_id, dur);
568            }
569            None => {
570                network.peer_manager().log_failure(&peer_id, dur);
571            }
572        }
573    }
574}
575
576fn handle_peer_disconnected_event(network: &SyncNetworkContext, peer_id: PeerId) {
577    network.peer_manager().remove_peer(&peer_id);
578    network.peer_manager().unmark_peer_bad(&peer_id);
579}
580
581/// Concurrency cap for tipset fetches triggered by an inbound `hello`.
582///
583/// A `hello` is unauthenticated, and its sender controls both the tipset we
584/// fetch and, by stalling the chain-exchange, how long the fetch blocks.
585/// Uncapped, a flood spawns fetches without bound, each pinning a blocking-pool
586/// thread for the chain-exchange timeout (up to 60 seconds). Excess is dropped, not queued.
587static MAX_CONCURRENT_HELLO_TRIGGERED_FETCHES: LazyLock<usize> = LazyLock::new(|| {
588    // `.min` keeps an oversized override from panicking `Semaphore::new`.
589    env_or_default_logged(
590        "FOREST_MAX_CONCURRENT_HELLO_TRIGGERED_FETCHES",
591        nonzero!(16_usize),
592    )
593    .get()
594    .min(Semaphore::MAX_PERMITS)
595});
596
597/// Fetches a tipset off the event loop, forwarding a success into `tipset_sender`
598/// (the same channel miner tipsets use). Any `permit` is held for the fetch.
599fn spawn_tipset_fetch(
600    network: SyncNetworkContext,
601    chain_store: ChainStore,
602    peer_id: Option<PeerId>,
603    tipset_keys: TipsetKey,
604    tipset_sender: flume::Sender<FullTipset>,
605    cancellation_token: CancellationToken,
606    permit: Option<OwnedSemaphorePermit>,
607) {
608    tokio::spawn(async move {
609        let _permit = permit;
610        // Fetch and forward both run inside the cancellation scope so a shutdown
611        // aborts a send blocked on a full channel, releasing the permit promptly.
612        cancellation_token
613            .run_until_cancelled(async {
614                match get_full_tipset(&network, &chain_store, peer_id, &tipset_keys).await {
615                    Ok(tipset) => {
616                        let _ = tipset_sender.send_async(tipset).await;
617                    }
618                    Err(e) => debug!("Querying full tipset failed: {e}"),
619                }
620            })
621            .await;
622    });
623}
624
625pub async fn get_full_tipset(
626    network: &SyncNetworkContext,
627    chain_store: &ChainStore,
628    peer_id: Option<PeerId>,
629    tipset_keys: &TipsetKey,
630) -> anyhow::Result<FullTipset> {
631    // Attempt to load from the store
632    if let Ok(full_tipset) = load_full_tipset(chain_store, tipset_keys) {
633        return Ok(full_tipset);
634    }
635    // Load from the network
636    let tipset = network
637        .chain_exchange_full_tipset(peer_id, tipset_keys)
638        .await
639        .map_err(|e| anyhow::anyhow!(e))?;
640    tipset.persist(chain_store.db())?;
641
642    Ok(tipset)
643}
644
645async fn get_full_tipset_batch(
646    network: &SyncNetworkContext,
647    chain_store: &ChainStore,
648    peer_id: Option<PeerId>,
649    tipset_keys: &TipsetKey,
650) -> anyhow::Result<Vec<FullTipset>> {
651    // Attempt to load from the store
652    if let Ok(full_tipset) = load_full_tipset(chain_store, tipset_keys) {
653        return Ok(vec![full_tipset]);
654    }
655    // Load from the network
656    let tipsets = network
657        .chain_exchange_full_tipsets(peer_id, tipset_keys)
658        .await
659        .map_err(|e| anyhow::anyhow!(e))?;
660
661    for tipset in tipsets.iter() {
662        tipset.persist(chain_store.db())?;
663    }
664
665    Ok(tipsets)
666}
667
668pub fn load_full_tipset(
669    chain_store: &ChainStore,
670    tipset_keys: &TipsetKey,
671) -> anyhow::Result<FullTipset> {
672    // Retrieve tipset from store based on passed in TipsetKey
673    let ts = chain_store
674        .chain_index()
675        .load_required_tipset(tipset_keys)?;
676    let blocks: Vec<_> = ts
677        .block_headers()
678        .iter()
679        .map(|header| -> anyhow::Result<Block> {
680            let (bls_msgs, secp_msgs) = crate::chain::block_messages(chain_store.db(), header)?;
681            Ok(Block {
682                header: header.clone(),
683                bls_messages: bls_msgs,
684                secp_messages: secp_msgs,
685            })
686        })
687        .try_collect()?;
688    // Construct FullTipset
689    let fts = FullTipset::new(blocks)?;
690    Ok(fts)
691}
692
693/// Per-block validation outcome from the sync state machine. `Filecoin.SyncSubmitBlock`
694/// awaits its block's outcome instead of inferring it from head movement.
695#[derive(Clone, Copy, Debug, PartialEq, Eq)]
696pub enum BlockValidationOutcome {
697    Applied,
698    Rejected,
699}
700
701/// Subscribe-only handle to the per-block validation outcome broadcast (keyed by block CID).
702/// `Default` yields a handle wired to no follower, for contexts that never submit blocks (tests,
703/// the offline RPC server).
704#[derive(Clone)]
705pub struct BlockValidationSubscriber(tokio::sync::broadcast::Sender<(Cid, BlockValidationOutcome)>);
706
707impl Default for BlockValidationSubscriber {
708    fn default() -> Self {
709        Self(tokio::sync::broadcast::Sender::new(1))
710    }
711}
712
713impl BlockValidationSubscriber {
714    /// Returns a receiver delivering `(Cid, BlockValidationOutcome)` for each block the follower
715    /// validates or rejects, following Tokio broadcast semantics: only outcomes broadcast after
716    /// this call are delivered, and a slow reader may observe `RecvError::Lagged`.
717    pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<(Cid, BlockValidationOutcome)> {
718        self.0.subscribe()
719    }
720}
721
722enum SyncEvent {
723    NewFullTipsets(Vec<FullTipset>),
724    BadTipset(FullTipset),
725    ValidatedTipset {
726        tipset: FullTipset,
727        is_proposed_head: bool,
728    },
729}
730
731impl std::fmt::Display for SyncEvent {
732    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
733        fn tss_to_string(tss: &[FullTipset]) -> String {
734            format!(
735                "epoch: {}-{}",
736                tss.first().map(|ts| ts.epoch()).unwrap_or_default(),
737                tss.last().map(|ts| ts.epoch()).unwrap_or_default()
738            )
739        }
740
741        match self {
742            Self::NewFullTipsets(tss) => write!(f, "NewFullTipsets({})", tss_to_string(tss)),
743            Self::BadTipset(ts) => {
744                write!(f, "BadTipset(epoch: {}, key: {})", ts.epoch(), ts.key())
745            }
746            Self::ValidatedTipset {
747                tipset,
748                is_proposed_head,
749            } => write!(
750                f,
751                "ValidatedTipset(epoch: {}, key: {}, is_proposed_head: {is_proposed_head})",
752                tipset.epoch(),
753                tipset.key()
754            ),
755        }
756    }
757}
758
759#[derive(derive_more::Debug)]
760struct SyncStateMachine {
761    #[debug(skip)]
762    cs: ChainStore,
763    bad_block_cache: Option<BadBlockCache>,
764    // Map from TipsetKey to FullTipset
765    tipsets: HashMap<TipsetKey, FullTipset>,
766    stateless_mode: bool,
767    /// Broadcast channel for validated tipsets, used to notify other components of new validated tipsets.
768    validated_tipset_broadcast_tx: tokio::sync::broadcast::Sender<TipsetKey>,
769    /// Broadcast of each block's validation outcome, keyed by block CID.
770    block_validation_tx: tokio::sync::broadcast::Sender<(Cid, BlockValidationOutcome)>,
771}
772
773impl SyncStateMachine {
774    pub fn new(
775        cs: ChainStore,
776        bad_block_cache: Option<BadBlockCache>,
777        stateless_mode: bool,
778    ) -> Self {
779        Self {
780            cs,
781            bad_block_cache,
782            tipsets: HashMap::default(),
783            stateless_mode,
784            validated_tipset_broadcast_tx: tokio::sync::broadcast::Sender::new(1024),
785            block_validation_tx: tokio::sync::broadcast::Sender::new(1024),
786        }
787    }
788
789    /// Report `Rejected` for the blocks the validator actually flagged bad. Keyed on
790    /// `bad_block_cache` membership rather than the whole tipset, so a valid block merged into
791    /// the same tipset as a bad sibling (`validate_tipset` fails the tipset on one bad block) is
792    /// not wrongly rejected.
793    fn notify_rejected(&self, tipset: &FullTipset) {
794        if crate::utils::broadcast::has_subscribers(&self.block_validation_tx) {
795            for cid in tipset.key().to_cids() {
796                if self
797                    .bad_block_cache
798                    .as_ref()
799                    .is_some_and(|c| c.get(&cid).is_some())
800                {
801                    let _ = self
802                        .block_validation_tx
803                        .send((cid, BlockValidationOutcome::Rejected));
804                }
805            }
806        }
807    }
808
809    // Compute the list of chains from the tipsets map
810    fn chains(&self) -> Vec<Vec<FullTipset>> {
811        let mut chains = Vec::new();
812        let mut remaining_tipsets = self.tipsets.clone();
813
814        while let Some(heaviest) = remaining_tipsets
815            .values()
816            .max_by_key(|ts| ts.weight())
817            .cloned()
818        {
819            // Build chain starting from heaviest
820            let mut chain = Vec::new();
821            let mut current = Some(heaviest);
822
823            while let Some(tipset) = current.take() {
824                remaining_tipsets.remove(tipset.key());
825
826                // Find parent in tipsets map
827                current = self.tipsets.get(tipset.parents()).cloned();
828
829                chain.push(tipset);
830            }
831            chain.reverse();
832            chains.push(chain);
833        }
834
835        chains
836    }
837
838    fn is_parent_validated(&self, tipset: &FullTipset) -> bool {
839        let db = self.cs.db();
840        self.stateless_mode || db.has(tipset.parent_state()).unwrap_or(false)
841    }
842
843    fn is_ready_for_validation(&self, tipset: &FullTipset) -> bool {
844        if self.stateless_mode || tipset.key() == self.cs.genesis_tipset().key() {
845            // Skip validation in stateless mode and for genesis tipset
846            true
847        } else if let Ok(parent_ts) = load_full_tipset(&self.cs, tipset.parents()) {
848            let head_ts = self.cs.heaviest_tipset();
849            // Treat post-head-epoch tipsets as not validated to fix <https://github.com/ChainSafe/forest/issues/5677>
850            // basically, the follow task should always start from the current head which could be manually set
851            // to an old one. When a post-head-epoch tipset is considered validated, it could mess up the state machine
852            // in some edge cases and the node ends up being stuck with ever-empty sync task queue as reported
853            // in <https://github.com/ChainSafe/forest/issues/5679>.
854            if parent_ts.key() == head_ts.key() {
855                true
856            } else if parent_ts.epoch() >= head_ts.epoch() {
857                false
858            } else {
859                self.is_parent_validated(tipset)
860            }
861        } else {
862            false
863        }
864    }
865
866    fn add_full_tipset(&mut self, tipset: FullTipset) {
867        if let Err(why) = TipsetValidator(&tipset).validate(
868            &self.cs,
869            self.bad_block_cache.as_ref(),
870            &self.cs.genesis_tipset(),
871            self.cs.chain_config().block_delay_secs,
872        ) {
873            metrics::INVALID_TIPSET_TOTAL.inc();
874            trace!("Skipping invalid tipset: {}", why);
875            self.mark_bad_tipset(tipset);
876            return;
877        }
878
879        // Check if tipset is outside the chain finality window.
880        // Not using F3 finalized epoch as it could go above the chain head during catchup
881        if tipset.epoch() < self.cs.ec_calculator_finalized_epoch() {
882            self.mark_bad_tipset(tipset);
883            return;
884        }
885
886        // Check if tipset already exists
887        if self.tipsets.contains_key(tipset.key()) {
888            return;
889        }
890
891        // Skip if tipset is part of the current chain.
892        if let Ok(Some(ts)) = self.cs.chain_index().tipset_by_height_blocking(
893            tipset.epoch(),
894            self.cs.heaviest_tipset(),
895            ResolveNullTipset::TakeOlder,
896        ) && ts.key() == tipset.key()
897        {
898            return;
899        }
900
901        // Find any existing tipsets with same epoch and parents
902        let mut to_remove = Vec::new();
903        #[allow(clippy::mutable_key_type)]
904        let mut merged_blocks: HashSet<_> = tipset.blocks().iter().cloned().collect();
905
906        // Collect all parent references from existing tipsets
907        let parent_refs: HashSet<_> = self
908            .tipsets
909            .values()
910            .map(|ts| ts.parents().clone())
911            .collect();
912
913        for (key, existing_ts) in self.tipsets.iter() {
914            if existing_ts.epoch() == tipset.epoch() && existing_ts.parents() == tipset.parents() {
915                // Only mark for removal if not referenced as a parent
916                if !parent_refs.contains(key) {
917                    to_remove.push(key.clone());
918                }
919                // Add blocks from existing tipset - HashSet handles deduplication automatically
920                merged_blocks.extend(existing_ts.blocks().iter().cloned());
921            }
922        }
923
924        // Remove old tipsets that were merged and aren't referenced
925        for key in to_remove {
926            self.tipsets.remove(&key);
927        }
928
929        // Create and insert new merged tipset
930        if let Ok(merged_tipset) = FullTipset::new(merged_blocks) {
931            self.tipsets
932                .insert(merged_tipset.key().clone(), merged_tipset);
933        }
934
935        self.tipsets.shrink_to_fit();
936    }
937
938    // Mark blocks in tipset as bad.
939    // Mark all descendants of tipsets as bad.
940    // Remove all bad tipsets from the tipset map.
941    fn mark_bad_tipset(&mut self, tipset: FullTipset) {
942        // Only the entered tipset can carry a submitted block; descendants are marked bad by
943        // cascade, so reporting the outcome once here avoids flooding the channel.
944        self.notify_rejected(&tipset);
945        let mut stack = vec![tipset];
946        while let Some(tipset) = stack.pop() {
947            self.tipsets.remove(tipset.key());
948            // Find all descendant tipsets (tipsets that have this tipset as a parent)
949            let mut to_remove = Vec::new();
950            let mut descendants = Vec::new();
951
952            for (key, ts) in self.tipsets.iter() {
953                if ts.parents() == tipset.key() {
954                    to_remove.push(key.clone());
955                    descendants.push(ts.clone());
956                }
957            }
958
959            // Remove bad tipsets from the map
960            for key in to_remove {
961                self.tipsets.remove(&key);
962            }
963
964            // Mark descendants as bad
965            stack.extend(descendants);
966        }
967    }
968
969    fn try_mark_tipset_as_validated(&mut self, tipset: FullTipset, is_proposed_head: bool) -> bool {
970        if !self.is_parent_validated(&tipset) {
971            tracing::error!(epoch = %tipset.epoch(), tsk = %tipset.key(), parent_state = %tipset.parent_state(), "Parent tipset must be validated");
972            return false;
973        }
974
975        self.tipsets.remove(tipset.key());
976        let tipset = tipset.into_tipset();
977        // cs.put_tipset requires state and doesn't work in stateless mode
978        if self.stateless_mode {
979            let epoch = tipset.epoch();
980            let terse_key = tipset.key().terse();
981            if self.cs.heaviest_tipset().weight() < tipset.weight() {
982                if let Err(e) = self.cs.set_heaviest_tipset(tipset) {
983                    error!("Error setting heaviest tipset: {}", e);
984                    return false;
985                } else {
986                    info!("Heaviest tipset: {} ({})", epoch, terse_key);
987                }
988            }
989        } else if is_proposed_head {
990            if let Err(e) = self.cs.maybe_update_pending_head(&tipset) {
991                error!("Error putting tipset: {e}");
992                return false;
993            }
994        } else if let Err(e) = self.cs.set_heaviest_tipset(tipset) {
995            error!("Error setting heaviest tipset: {e}");
996            return false;
997        }
998        true
999    }
1000
1001    pub fn update(&mut self, event: SyncEvent) {
1002        tracing::trace!("update: {event}");
1003        match event {
1004            SyncEvent::NewFullTipsets(tipsets) => {
1005                for tipset in tipsets {
1006                    self.add_full_tipset(tipset);
1007                }
1008            }
1009            SyncEvent::BadTipset(tipset) => self.mark_bad_tipset(tipset),
1010            SyncEvent::ValidatedTipset {
1011                tipset,
1012                is_proposed_head,
1013            } => {
1014                // Capture CIDs before `try_mark` consumes the tipset, but only when a submitter is
1015                // waiting, to avoid allocating on the steady-state sync path.
1016                let applied_cids =
1017                    crate::utils::broadcast::has_subscribers(&self.block_validation_tx)
1018                        .then(|| tipset.key().to_cids());
1019                if self.try_mark_tipset_as_validated(tipset, is_proposed_head) {
1020                    if let Some(cids) = applied_cids {
1021                        for cid in cids {
1022                            let _ = self
1023                                .block_validation_tx
1024                                .send((cid, BlockValidationOutcome::Applied));
1025                        }
1026                    }
1027                    if crate::utils::broadcast::has_subscribers(&self.validated_tipset_broadcast_tx)
1028                        // Sending the actual head key here as it could be expanded from the above tipset when `is_proposed_head` is `true`
1029                        && let Err(e) = self.validated_tipset_broadcast_tx.send(self.cs.heaviest_tipset().key().clone())
1030                    {
1031                        warn!("Failed to broadcast validated tipset: {e}");
1032                    }
1033                }
1034            }
1035        }
1036    }
1037
1038    pub fn tasks(&self) -> (Vec<SyncTask>, Vec<ForkSyncInfo>) {
1039        // Get the node's current validated head epoch once, as it's the same for all forks.
1040        let current_validated_epoch = self.cs.heaviest_tipset().epoch();
1041        let now = Utc::now();
1042
1043        let mut active_sync_info = Vec::new();
1044        let mut tasks = Vec::new();
1045        for chain in self.chains() {
1046            if let Some(first_ts) = chain.first() {
1047                let last_ts = chain.last().expect("Infallible");
1048                let stage: ForkSyncStage;
1049                let start_time = Some(now);
1050
1051                if !self.is_ready_for_validation(first_ts) {
1052                    stage = ForkSyncStage::FetchingHeaders;
1053                    tasks.push(SyncTask::FetchTipset(
1054                        first_ts.parents().clone(),
1055                        first_ts.epoch(),
1056                    ));
1057                } else {
1058                    stage = ForkSyncStage::ValidatingTipsets;
1059                    tasks.push(SyncTask::ValidateTipset {
1060                        tipset: first_ts.clone(),
1061                        is_proposed_head: chain.len() == 1,
1062                    });
1063                }
1064
1065                let fork_info = ForkSyncInfo {
1066                    target_tipset_key: last_ts.key().clone(),
1067                    target_epoch: last_ts.epoch(),
1068                    target_sync_epoch_start: first_ts.epoch(),
1069                    stage,
1070                    validated_chain_head_epoch: current_validated_epoch,
1071                    start_time,
1072                    last_updated: Some(now),
1073                };
1074
1075                active_sync_info.push(fork_info);
1076            }
1077        }
1078        (tasks, active_sync_info)
1079    }
1080
1081    pub fn cleanup_dangling_forks(&mut self) {
1082        let finalized_epoch = self.cs.ec_calculator_finalized_epoch();
1083        for chain in self.chains() {
1084            // Cleanup dangling fork when its target epoch is finalized
1085            if let Some(target) = chain.last()
1086                && target.epoch() < finalized_epoch
1087            {
1088                chain.iter().for_each(|ts| {
1089                    self.tipsets.remove(ts.key());
1090                });
1091                tracing::info!(
1092                    "Cleaned up dangling fork from epoch {} to {}",
1093                    chain.first().map(|ts| ts.epoch()).unwrap_or_default(),
1094                    chain.last().map(|ts| ts.epoch()).unwrap_or_default(),
1095                );
1096            }
1097        }
1098    }
1099}
1100
1101#[derive(PartialEq, Eq, Hash, Clone, Debug)]
1102enum SyncTask {
1103    ValidateTipset {
1104        tipset: FullTipset,
1105        is_proposed_head: bool,
1106    },
1107    FetchTipset(TipsetKey, ChainEpoch),
1108}
1109
1110impl std::fmt::Display for SyncTask {
1111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1112        match self {
1113            SyncTask::ValidateTipset {
1114                tipset,
1115                is_proposed_head,
1116            } => write!(
1117                f,
1118                "ValidateTipset(epoch: {}, is_proposed_head: {is_proposed_head})",
1119                tipset.epoch()
1120            ),
1121            SyncTask::FetchTipset(key, epoch) => {
1122                let s = key.to_string();
1123                write!(
1124                    f,
1125                    "FetchTipset({}, epoch: {})",
1126                    &s[s.len().saturating_sub(8)..],
1127                    epoch
1128                )
1129            }
1130        }
1131    }
1132}
1133
1134impl SyncTask {
1135    async fn execute(
1136        self,
1137        network: SyncNetworkContext,
1138        state_manager: StateManager,
1139        stateless_mode: bool,
1140        bad_block_cache: Option<BadBlockCache>,
1141    ) -> Option<SyncEvent> {
1142        tracing::trace!("SyncTask::execute {self}");
1143        match self {
1144            SyncTask::ValidateTipset {
1145                tipset,
1146                is_proposed_head,
1147            } if stateless_mode => Some(SyncEvent::ValidatedTipset {
1148                tipset,
1149                is_proposed_head,
1150            }),
1151            SyncTask::ValidateTipset {
1152                tipset,
1153                is_proposed_head,
1154            } => match validate_tipset(&state_manager, tipset.clone(), bad_block_cache).await {
1155                Ok(()) => Some(SyncEvent::ValidatedTipset {
1156                    tipset,
1157                    is_proposed_head,
1158                }),
1159                // If temporal drift error, don't mark as bad, just skip validation and try again
1160                // later. This mirrors internal logic where temporal drift doesn't mark a block as
1161                // bad permanently, since it could be valid later on. If not done, a single
1162                // time-traveling block could cause the node to be stuck without making progress.
1163                Err(e) if matches!(e, TipsetSyncerError::TimeTravellingBlock { .. }) => {
1164                    warn!("Time travelling block detected, skipping tipset for now: {e}");
1165                    None
1166                }
1167                // May stem from locally corrupted inputs rather than a bad block — repair
1168                // the lookup table and retry only if something was actually wrong.
1169                Err(e) if matches!(e, TipsetSyncerError::ParentChainStateMismatch(_)) => {
1170                    warn!("Error validating tipset: {e}");
1171                    let sm = state_manager.shallow_clone();
1172                    match tokio::task::spawn_blocking(move || sm.repair_tipset_lookup())
1173                        .await
1174                        .unwrap_or_else(|e| Err(e.into()))
1175                    {
1176                        // A verified-clean table means the mismatch is genuine.
1177                        Ok(0) => Some(SyncEvent::BadTipset(tipset)),
1178                        Ok(repaired) => {
1179                            warn!(
1180                                "Repaired {repaired} tipset lookup entries; retrying tipset validation at epoch {}",
1181                                tipset.epoch()
1182                            );
1183                            None
1184                        }
1185                        // The table could not be verified; retry later rather than risk
1186                        // marking a canonical tipset bad on incomplete knowledge.
1187                        Err(e) => {
1188                            warn!("Failed to repair tipset lookup table: {e:#}");
1189                            None
1190                        }
1191                    }
1192                }
1193                Err(e) => {
1194                    warn!("Error validating tipset: {e}");
1195                    Some(SyncEvent::BadTipset(tipset))
1196                }
1197            },
1198            SyncTask::FetchTipset(key, epoch) => {
1199                match get_full_tipset_batch(&network, state_manager.chain_store(), None, &key).await
1200                {
1201                    Ok(parents) => Some(SyncEvent::NewFullTipsets(parents)),
1202                    Err(e) => {
1203                        // It's not a massive error; could be a transient network issue or a fork.
1204                        tracing::debug!(%key, %epoch, "failed to fetch tipset: {e:#}");
1205                        None
1206                    }
1207                }
1208            }
1209        }
1210    }
1211}
1212
1213#[derive(Debug)]
1214struct SyncTasks(Arc<Mutex<HashSet<SyncTask>>>);
1215
1216#[derive(Debug)]
1217struct SyncStateMachineWrapper(Arc<Mutex<SyncStateMachine>>);
1218
1219mod metrics_collection {
1220    use super::*;
1221    use prometheus_client::{
1222        collector::Collector,
1223        encoding::{DescriptorEncoder, EncodeMetric},
1224        metrics::gauge::Gauge,
1225        registry::Unit,
1226    };
1227
1228    impl Collector for SyncTasks {
1229        fn encode(&self, mut encoder: DescriptorEncoder) -> Result<(), std::fmt::Error> {
1230            {
1231                let size_in_bytes = {
1232                    let g: Gauge = Default::default();
1233                    g.set(self.0.lock().allocation_size() as i64);
1234                    g
1235                };
1236                let size_metric_encoder = encoder.encode_descriptor(
1237                    "chain_follower_tasks_size",
1238                    "Size of the chain follower tasks in bytes",
1239                    Some(&Unit::Bytes),
1240                    size_in_bytes.metric_type(),
1241                )?;
1242                size_in_bytes.encode(size_metric_encoder)?;
1243            }
1244            {
1245                let len = {
1246                    let g: Gauge = Default::default();
1247                    g.set(self.0.lock().len() as i64);
1248                    g
1249                };
1250                let size_metric_encoder = encoder.encode_descriptor(
1251                    "chain_follower_tasks_len",
1252                    "Length of the chain follower tasks",
1253                    None,
1254                    len.metric_type(),
1255                )?;
1256                len.encode(size_metric_encoder)?;
1257            }
1258            {
1259                let cap = {
1260                    let g: Gauge = Default::default();
1261                    g.set(self.0.lock().capacity() as i64);
1262                    g
1263                };
1264                let size_metric_encoder = encoder.encode_descriptor(
1265                    "chain_follower_tasks_cap",
1266                    "Capacity of the chain follower tasks",
1267                    None,
1268                    cap.metric_type(),
1269                )?;
1270                cap.encode(size_metric_encoder)?;
1271            }
1272
1273            Ok(())
1274        }
1275    }
1276
1277    impl Collector for SyncStateMachineWrapper {
1278        fn encode(&self, mut encoder: DescriptorEncoder) -> Result<(), std::fmt::Error> {
1279            {
1280                let size_in_bytes = {
1281                    let g: Gauge = Default::default();
1282                    g.set(self.0.lock().tipsets.allocation_size() as i64);
1283                    g
1284                };
1285                let size_metric_encoder = encoder.encode_descriptor(
1286                    "chain_follower_tipsets_size",
1287                    "Size of the chain follower tipsets in bytes",
1288                    Some(&Unit::Bytes),
1289                    size_in_bytes.metric_type(),
1290                )?;
1291                size_in_bytes.encode(size_metric_encoder)?;
1292            }
1293            {
1294                let len = {
1295                    let g: Gauge = Default::default();
1296                    g.set(self.0.lock().tipsets.len() as i64);
1297                    g
1298                };
1299                let size_metric_encoder = encoder.encode_descriptor(
1300                    "chain_follower_tipsets_len",
1301                    "Length of the chain follower tipsets",
1302                    None,
1303                    len.metric_type(),
1304                )?;
1305                len.encode(size_metric_encoder)?;
1306            }
1307            {
1308                let cap = {
1309                    let g: Gauge = Default::default();
1310                    g.set(self.0.lock().tipsets.capacity() as i64);
1311                    g
1312                };
1313                let size_metric_encoder = encoder.encode_descriptor(
1314                    "chain_follower_tipsets_cap",
1315                    "Capacity of the chain follower tipsets",
1316                    None,
1317                    cap.metric_type(),
1318                )?;
1319                cap.encode(size_metric_encoder)?;
1320            }
1321
1322            Ok(())
1323        }
1324    }
1325}
1326
1327#[cfg(test)]
1328mod tests {
1329    use super::*;
1330    use crate::blocks::{CachingBlockHeader, Chain4U, HeaderBuilder, chain4u};
1331    use crate::db::MemoryDB;
1332    use crate::utils::db::CborStoreExt as _;
1333    use num_bigint::BigInt;
1334    use num_traits::ToPrimitive;
1335    use std::sync::Arc;
1336    use tracing::level_filters::LevelFilter;
1337    use tracing_subscriber::EnvFilter;
1338
1339    fn setup() -> (ChainStore, Chain4U<Arc<MemoryDB>>) {
1340        // Initialize test logger
1341        let _ = tracing_subscriber::fmt()
1342            .without_time()
1343            .with_env_filter(
1344                EnvFilter::builder()
1345                    .with_default_directive(LevelFilter::DEBUG.into())
1346                    .from_env()
1347                    .unwrap(),
1348            )
1349            .try_init();
1350
1351        let db = Arc::new(MemoryDB::default());
1352
1353        // Create a chain of 5 tipsets using Chain4U
1354        let c4u = Chain4U::with_blockstore(db.clone());
1355        chain4u! {
1356            in c4u;
1357            [genesis_header = dummy_node(&db, 0)]
1358        };
1359
1360        let cs = ChainStore::new(db, Default::default(), genesis_header).unwrap();
1361
1362        cs.set_heaviest_tipset(cs.genesis_tipset()).unwrap();
1363
1364        (cs, c4u)
1365    }
1366
1367    fn dummy_state(db: impl Blockstore, i: ChainEpoch) -> Cid {
1368        db.put_cbor_default(&i).unwrap()
1369    }
1370
1371    fn dummy_node(db: impl Blockstore, i: ChainEpoch) -> HeaderBuilder {
1372        HeaderBuilder {
1373            state_root: dummy_state(db, i).into(),
1374            weight: BigInt::from(i).into(),
1375            epoch: i.into(),
1376            ..Default::default()
1377        }
1378    }
1379
1380    #[test]
1381    fn test_state_machine_validation_order() {
1382        let (cs, c4u) = setup();
1383        let db = cs.db_owned();
1384
1385        chain4u! {
1386            from [genesis_header] in c4u;
1387            [a = dummy_node(&db, 1)] -> [b = dummy_node(&db, 2)] -> [c = dummy_node(&db, 3)] -> [d = dummy_node(&db, 4)] -> [e = dummy_node(&db, 5)]
1388        };
1389
1390        // Create the state machine
1391        let mut state_machine = SyncStateMachine::new(cs.shallow_clone(), Default::default(), true);
1392
1393        // Insert tipsets in random order
1394        let tipsets = vec![e, b, d, c, a];
1395
1396        // Convert each block into a FullTipset and add it to the state machine
1397        for block in tipsets {
1398            let full_tipset = FullTipset::new(vec![Block {
1399                header: block.clone().into(),
1400                bls_messages: vec![],
1401                secp_messages: vec![],
1402            }])
1403            .unwrap();
1404            state_machine.update(SyncEvent::NewFullTipsets(vec![full_tipset]));
1405        }
1406
1407        // Record validation order by processing all validation tasks in each iteration
1408        let mut validation_tasks = Vec::new();
1409        loop {
1410            let (tasks, _) = state_machine.tasks();
1411
1412            // Find all validation tasks
1413            let validation_tipsets: Vec<_> = tasks
1414                .into_iter()
1415                .filter_map(|task| {
1416                    if let SyncTask::ValidateTipset {
1417                        tipset,
1418                        is_proposed_head,
1419                    } = task
1420                    {
1421                        Some((tipset, is_proposed_head))
1422                    } else {
1423                        None
1424                    }
1425                })
1426                .collect();
1427
1428            if validation_tipsets.is_empty() {
1429                break;
1430            }
1431
1432            // Record and mark all tipsets as validated
1433            for (ts, is_proposed_head) in validation_tipsets {
1434                validation_tasks.push(ts.epoch());
1435                db.put_cbor_default(&ts.epoch()).unwrap();
1436                state_machine.try_mark_tipset_as_validated(ts, is_proposed_head);
1437            }
1438        }
1439
1440        // We expect validation tasks for epochs 1 through 5 in order
1441        assert_eq!(validation_tasks, vec![1, 2, 3, 4, 5]);
1442    }
1443
1444    #[test]
1445    fn test_sync_state_machine_chain_fragments() {
1446        let (cs, c4u) = setup();
1447        let db = cs.db();
1448
1449        // Create a forked chain
1450        // genesis -> a -> b
1451        //            \--> c
1452        chain4u! {
1453            in c4u;
1454            [a = dummy_node(db, 1)] -> [b = dummy_node(db, 2)]
1455        };
1456        chain4u! {
1457            from [a] in c4u;
1458            [c = dummy_node(db, 3)]
1459        };
1460
1461        // Create the state machine
1462        let mut state_machine = SyncStateMachine::new(cs, Default::default(), false);
1463
1464        // Convert each block into a FullTipset and add it to the state machine
1465        for block in [a, b, c] {
1466            let full_tipset = FullTipset::new(vec![Block {
1467                header: block.clone().into(),
1468                bls_messages: vec![],
1469                secp_messages: vec![],
1470            }])
1471            .unwrap();
1472            state_machine.update(SyncEvent::NewFullTipsets(vec![full_tipset]));
1473        }
1474
1475        let chains = state_machine
1476            .chains()
1477            .into_iter()
1478            .map(|v| {
1479                v.into_iter()
1480                    .map(|ts| ts.weight().to_i64().unwrap_or(0))
1481                    .collect_vec()
1482            })
1483            .collect_vec();
1484
1485        // Both chains should start at the same tipset
1486        assert_eq!(chains, vec![vec![1, 3], vec![1, 2]]);
1487    }
1488
1489    fn single_block_tipset(header: CachingBlockHeader) -> FullTipset {
1490        FullTipset::new(vec![Block {
1491            header,
1492            bls_messages: vec![],
1493            secp_messages: vec![],
1494        }])
1495        .unwrap()
1496    }
1497
1498    #[test]
1499    fn notify_rejected_reports_only_bad_block_cache_members() {
1500        let (cs, c4u) = setup();
1501        let db = cs.db_owned();
1502        chain4u! { from [genesis_header] in c4u; [a = dummy_node(&db, 1)] };
1503
1504        let bad_blocks = BadBlockCache::default();
1505        let state_machine =
1506            SyncStateMachine::new(cs.shallow_clone(), Some(bad_blocks.shallow_clone()), true);
1507        let mut rx = state_machine.block_validation_tx.subscribe();
1508
1509        let tipset = single_block_tipset(a.clone().into());
1510        let block_cid = *tipset.blocks().first().cid();
1511
1512        // Not flagged bad: a valid block sharing a tipset with a bad sibling must not be rejected.
1513        state_machine.notify_rejected(&tipset);
1514        assert!(rx.try_recv().is_err());
1515
1516        // Flagged bad by the validator: reported as rejected.
1517        bad_blocks.push(block_cid);
1518        state_machine.notify_rejected(&tipset);
1519        assert_eq!(
1520            rx.try_recv().unwrap(),
1521            (block_cid, BlockValidationOutcome::Rejected)
1522        );
1523    }
1524
1525    #[test]
1526    fn validated_tipset_reports_applied() {
1527        let (cs, c4u) = setup();
1528        let db = cs.db_owned();
1529        chain4u! { from [genesis_header] in c4u; [a = dummy_node(&db, 1)] };
1530
1531        let mut state_machine = SyncStateMachine::new(cs.shallow_clone(), Default::default(), true);
1532        let mut rx = state_machine.block_validation_tx.subscribe();
1533
1534        let tipset = single_block_tipset(a.clone().into());
1535        let block_cid = *tipset.blocks().first().cid();
1536
1537        state_machine.update(SyncEvent::ValidatedTipset {
1538            tipset,
1539            is_proposed_head: false,
1540        });
1541        assert_eq!(
1542            rx.try_recv().unwrap(),
1543            (block_cid, BlockValidationOutcome::Applied)
1544        );
1545    }
1546}