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