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}, downloading {to_download} tipsets"
412                        , heaviest_tipset.key()
413                    ),
414                    (true, false) => info!(
415                        "Catching up to HEAD: {heaviest_epoch}{} -> {expected_head}"
416                        , heaviest_tipset.key()
417                    ),
418                    (false, true) => {
419                        info!("Downloading {to_download} tipsets")
420                    }
421                    (false, false) => {}
422                }
423            }
424        }
425    });
426
427    set.join_all().await;
428    Ok(())
429}
430
431// Increment the gossipsub event metrics.
432fn inc_gossipsub_event_metrics(event: &NetworkEvent) {
433    let label = match event {
434        NetworkEvent::HelloRequestInbound => metrics::values::HELLO_REQUEST_INBOUND,
435        NetworkEvent::HelloResponseOutbound { .. } => metrics::values::HELLO_RESPONSE_OUTBOUND,
436        NetworkEvent::HelloRequestOutbound => metrics::values::HELLO_REQUEST_OUTBOUND,
437        NetworkEvent::HelloResponseInbound => metrics::values::HELLO_RESPONSE_INBOUND,
438        NetworkEvent::PeerConnected(_) => metrics::values::PEER_CONNECTED,
439        NetworkEvent::PeerDisconnected(_) => metrics::values::PEER_DISCONNECTED,
440        NetworkEvent::PubsubMessage { message } => match message {
441            PubsubMessage::Block(_) => metrics::values::PUBSUB_BLOCK,
442            PubsubMessage::Message(_) => metrics::values::PUBSUB_MESSAGE,
443        },
444        NetworkEvent::ChainExchangeRequestOutbound => {
445            metrics::values::CHAIN_EXCHANGE_REQUEST_OUTBOUND
446        }
447        NetworkEvent::ChainExchangeResponseInbound => {
448            metrics::values::CHAIN_EXCHANGE_RESPONSE_INBOUND
449        }
450        NetworkEvent::ChainExchangeRequestInbound => {
451            metrics::values::CHAIN_EXCHANGE_REQUEST_INBOUND
452        }
453        NetworkEvent::ChainExchangeResponseOutbound => {
454            metrics::values::CHAIN_EXCHANGE_RESPONSE_OUTBOUND
455        }
456    };
457
458    metrics::LIBP2P_MESSAGE_TOTAL.get_or_create(&label).inc();
459}
460
461// Keep our peer manager up to date.
462fn update_peer_info(
463    event: &NetworkEvent,
464    network: &SyncNetworkContext,
465    chain_store: ChainStore,
466    genesis: &Tipset,
467    cancellation_token: CancellationToken,
468) {
469    match event {
470        NetworkEvent::PeerConnected(peer_id) => {
471            let peer_id = *peer_id;
472            let genesis_cid = *genesis.block_headers().first().cid();
473            let network = network.shallow_clone();
474            // Spawn and immediately move on to the next event
475            tokio::task::spawn(async move {
476                cancellation_token
477                    .run_until_cancelled(handle_peer_connected_event(
478                        network,
479                        chain_store,
480                        peer_id,
481                        genesis_cid,
482                    ))
483                    .await
484            });
485        }
486        NetworkEvent::PeerDisconnected(peer_id) => {
487            handle_peer_disconnected_event(network, *peer_id);
488        }
489        _ => {}
490    }
491}
492
493async fn handle_peer_connected_event(
494    network: SyncNetworkContext,
495    chain_store: ChainStore,
496    peer_id: PeerId,
497    genesis_block_cid: Cid,
498) {
499    // Query the heaviest TipSet from the store
500    if network.peer_manager().is_peer_new(&peer_id) {
501        // Since the peer is new, send them a hello request
502        // Query the heaviest TipSet from the store
503        let heaviest = chain_store.heaviest_tipset();
504        let request = HelloRequest {
505            heaviest_tip_set: heaviest.cids(),
506            heaviest_tipset_height: heaviest.epoch(),
507            heaviest_tipset_weight: heaviest.weight().clone().into(),
508            genesis_cid: genesis_block_cid,
509        };
510        let (peer_id, moment_sent, response) = match network.hello_request(peer_id, request).await {
511            Ok(response) => response,
512            Err(e) => {
513                debug!("Hello request failed: {}", e);
514                return;
515            }
516        };
517        let dur = Instant::now().duration_since(moment_sent);
518
519        // Update the peer metadata based on the response
520        match response {
521            Some(_) => {
522                network.peer_manager().log_success(&peer_id, dur);
523            }
524            None => {
525                network.peer_manager().log_failure(&peer_id, dur);
526            }
527        }
528    }
529}
530
531fn handle_peer_disconnected_event(network: &SyncNetworkContext, peer_id: PeerId) {
532    network.peer_manager().remove_peer(&peer_id);
533    network.peer_manager().unmark_peer_bad(&peer_id);
534}
535
536pub async fn get_full_tipset(
537    network: &SyncNetworkContext,
538    chain_store: &ChainStore,
539    peer_id: Option<PeerId>,
540    tipset_keys: &TipsetKey,
541) -> anyhow::Result<FullTipset> {
542    // Attempt to load from the store
543    if let Ok(full_tipset) = load_full_tipset(chain_store, tipset_keys) {
544        return Ok(full_tipset);
545    }
546    // Load from the network
547    let tipset = network
548        .chain_exchange_full_tipset(peer_id, tipset_keys)
549        .await
550        .map_err(|e| anyhow::anyhow!(e))?;
551    tipset.persist(chain_store.db())?;
552
553    Ok(tipset)
554}
555
556async fn get_full_tipset_batch(
557    network: &SyncNetworkContext,
558    chain_store: &ChainStore,
559    peer_id: Option<PeerId>,
560    tipset_keys: &TipsetKey,
561) -> anyhow::Result<Vec<FullTipset>> {
562    // Attempt to load from the store
563    if let Ok(full_tipset) = load_full_tipset(chain_store, tipset_keys) {
564        return Ok(vec![full_tipset]);
565    }
566    // Load from the network
567    let tipsets = network
568        .chain_exchange_full_tipsets(peer_id, tipset_keys)
569        .await
570        .map_err(|e| anyhow::anyhow!(e))?;
571
572    for tipset in tipsets.iter() {
573        tipset.persist(chain_store.db())?;
574    }
575
576    Ok(tipsets)
577}
578
579pub fn load_full_tipset(
580    chain_store: &ChainStore,
581    tipset_keys: &TipsetKey,
582) -> anyhow::Result<FullTipset> {
583    // Retrieve tipset from store based on passed in TipsetKey
584    let ts = chain_store
585        .chain_index()
586        .load_required_tipset(tipset_keys)?;
587    let blocks: Vec<_> = ts
588        .block_headers()
589        .iter()
590        .map(|header| -> anyhow::Result<Block> {
591            let (bls_msgs, secp_msgs) = crate::chain::block_messages(chain_store.db(), header)?;
592            Ok(Block {
593                header: header.clone(),
594                bls_messages: bls_msgs,
595                secp_messages: secp_msgs,
596            })
597        })
598        .try_collect()?;
599    // Construct FullTipset
600    let fts = FullTipset::new(blocks)?;
601    Ok(fts)
602}
603
604enum SyncEvent {
605    NewFullTipsets(Vec<FullTipset>),
606    BadTipset(FullTipset),
607    ValidatedTipset {
608        tipset: FullTipset,
609        is_proposed_head: bool,
610    },
611}
612
613impl std::fmt::Display for SyncEvent {
614    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
615        fn tss_to_string(tss: &[FullTipset]) -> String {
616            format!(
617                "epoch: {}-{}",
618                tss.first().map(|ts| ts.epoch()).unwrap_or_default(),
619                tss.last().map(|ts| ts.epoch()).unwrap_or_default()
620            )
621        }
622
623        match self {
624            Self::NewFullTipsets(tss) => write!(f, "NewFullTipsets({})", tss_to_string(tss)),
625            Self::BadTipset(ts) => {
626                write!(f, "BadTipset(epoch: {}, key: {})", ts.epoch(), ts.key())
627            }
628            Self::ValidatedTipset {
629                tipset,
630                is_proposed_head,
631            } => write!(
632                f,
633                "ValidatedTipset(epoch: {}, key: {}, is_proposed_head: {is_proposed_head})",
634                tipset.epoch(),
635                tipset.key()
636            ),
637        }
638    }
639}
640
641#[derive(derive_more::Debug)]
642struct SyncStateMachine {
643    #[debug(skip)]
644    cs: ChainStore,
645    bad_block_cache: Option<BadBlockCache>,
646    // Map from TipsetKey to FullTipset
647    tipsets: HashMap<TipsetKey, FullTipset>,
648    stateless_mode: bool,
649    /// Broadcast channel for validated tipsets, used to notify other components of new validated tipsets.
650    validated_tipset_broadcast_tx: tokio::sync::broadcast::Sender<TipsetKey>,
651}
652
653impl SyncStateMachine {
654    pub fn new(
655        cs: ChainStore,
656        bad_block_cache: Option<BadBlockCache>,
657        stateless_mode: bool,
658    ) -> Self {
659        Self {
660            cs,
661            bad_block_cache,
662            tipsets: HashMap::default(),
663            stateless_mode,
664            validated_tipset_broadcast_tx: tokio::sync::broadcast::Sender::new(1024),
665        }
666    }
667
668    // Compute the list of chains from the tipsets map
669    fn chains(&self) -> Vec<Vec<FullTipset>> {
670        let mut chains = Vec::new();
671        let mut remaining_tipsets = self.tipsets.clone();
672
673        while let Some(heaviest) = remaining_tipsets
674            .values()
675            .max_by_key(|ts| ts.weight())
676            .cloned()
677        {
678            // Build chain starting from heaviest
679            let mut chain = Vec::new();
680            let mut current = Some(heaviest);
681
682            while let Some(tipset) = current.take() {
683                remaining_tipsets.remove(tipset.key());
684
685                // Find parent in tipsets map
686                current = self.tipsets.get(tipset.parents()).cloned();
687
688                chain.push(tipset);
689            }
690            chain.reverse();
691            chains.push(chain);
692        }
693
694        chains
695    }
696
697    fn is_parent_validated(&self, tipset: &FullTipset) -> bool {
698        let db = self.cs.db();
699        self.stateless_mode || db.has(tipset.parent_state()).unwrap_or(false)
700    }
701
702    fn is_ready_for_validation(&self, tipset: &FullTipset) -> bool {
703        if self.stateless_mode || tipset.key() == self.cs.genesis_tipset().key() {
704            // Skip validation in stateless mode and for genesis tipset
705            true
706        } else if let Ok(parent_ts) = load_full_tipset(&self.cs, tipset.parents()) {
707            let head_ts = self.cs.heaviest_tipset();
708            // Treat post-head-epoch tipsets as not validated to fix <https://github.com/ChainSafe/forest/issues/5677>
709            // basically, the follow task should always start from the current head which could be manually set
710            // to an old one. When a post-head-epoch tipset is considered validated, it could mess up the state machine
711            // in some edge cases and the node ends up being stuck with ever-empty sync task queue as reported
712            // in <https://github.com/ChainSafe/forest/issues/5679>.
713            if parent_ts.key() == head_ts.key() {
714                true
715            } else if parent_ts.epoch() >= head_ts.epoch() {
716                false
717            } else {
718                self.is_parent_validated(tipset)
719            }
720        } else {
721            false
722        }
723    }
724
725    fn add_full_tipset(&mut self, tipset: FullTipset) {
726        if let Err(why) = TipsetValidator(&tipset).validate(
727            &self.cs,
728            self.bad_block_cache.as_ref(),
729            &self.cs.genesis_tipset(),
730            self.cs.chain_config().block_delay_secs,
731        ) {
732            metrics::INVALID_TIPSET_TOTAL.inc();
733            trace!("Skipping invalid tipset: {}", why);
734            self.mark_bad_tipset(tipset);
735            return;
736        }
737
738        // Check if tipset is outside the chain finality window.
739        // Not using F3 finalized epoch as it could go above the chain head during catchup
740        if tipset.epoch() < self.cs.ec_calculator_finalized_epoch() {
741            self.mark_bad_tipset(tipset);
742            return;
743        }
744
745        // Check if tipset already exists
746        if self.tipsets.contains_key(tipset.key()) {
747            return;
748        }
749
750        // Skip if tipset is part of the current chain.
751        if let Ok(Some(ts)) = self.cs.chain_index().tipset_by_height_blocking(
752            tipset.epoch(),
753            self.cs.heaviest_tipset(),
754            ResolveNullTipset::TakeOlder,
755        ) && ts.key() == tipset.key()
756        {
757            return;
758        }
759
760        // Find any existing tipsets with same epoch and parents
761        let mut to_remove = Vec::new();
762        #[allow(clippy::mutable_key_type)]
763        let mut merged_blocks: HashSet<_> = tipset.blocks().iter().cloned().collect();
764
765        // Collect all parent references from existing tipsets
766        let parent_refs: HashSet<_> = self
767            .tipsets
768            .values()
769            .map(|ts| ts.parents().clone())
770            .collect();
771
772        for (key, existing_ts) in self.tipsets.iter() {
773            if existing_ts.epoch() == tipset.epoch() && existing_ts.parents() == tipset.parents() {
774                // Only mark for removal if not referenced as a parent
775                if !parent_refs.contains(key) {
776                    to_remove.push(key.clone());
777                }
778                // Add blocks from existing tipset - HashSet handles deduplication automatically
779                merged_blocks.extend(existing_ts.blocks().iter().cloned());
780            }
781        }
782
783        // Remove old tipsets that were merged and aren't referenced
784        for key in to_remove {
785            self.tipsets.remove(&key);
786        }
787
788        // Create and insert new merged tipset
789        if let Ok(merged_tipset) = FullTipset::new(merged_blocks) {
790            self.tipsets
791                .insert(merged_tipset.key().clone(), merged_tipset);
792        }
793
794        self.tipsets.shrink_to_fit();
795    }
796
797    // Mark blocks in tipset as bad.
798    // Mark all descendants of tipsets as bad.
799    // Remove all bad tipsets from the tipset map.
800    fn mark_bad_tipset(&mut self, tipset: FullTipset) {
801        let mut stack = vec![tipset];
802        while let Some(tipset) = stack.pop() {
803            self.tipsets.remove(tipset.key());
804            // Find all descendant tipsets (tipsets that have this tipset as a parent)
805            let mut to_remove = Vec::new();
806            let mut descendants = Vec::new();
807
808            for (key, ts) in self.tipsets.iter() {
809                if ts.parents() == tipset.key() {
810                    to_remove.push(key.clone());
811                    descendants.push(ts.clone());
812                }
813            }
814
815            // Remove bad tipsets from the map
816            for key in to_remove {
817                self.tipsets.remove(&key);
818            }
819
820            // Mark descendants as bad
821            stack.extend(descendants);
822        }
823    }
824
825    fn try_mark_tipset_as_validated(&mut self, tipset: FullTipset, is_proposed_head: bool) -> bool {
826        if !self.is_parent_validated(&tipset) {
827            tracing::error!(epoch = %tipset.epoch(), tsk = %tipset.key(), parent_state = %tipset.parent_state(), "Parent tipset must be validated");
828            return false;
829        }
830
831        self.tipsets.remove(tipset.key());
832        let tipset = tipset.into_tipset();
833        // cs.put_tipset requires state and doesn't work in stateless mode
834        if self.stateless_mode {
835            let epoch = tipset.epoch();
836            let terse_key = tipset.key().terse();
837            if self.cs.heaviest_tipset().weight() < tipset.weight() {
838                if let Err(e) = self.cs.set_heaviest_tipset(tipset) {
839                    error!("Error setting heaviest tipset: {}", e);
840                    return false;
841                } else {
842                    info!("Heaviest tipset: {} ({})", epoch, terse_key);
843                }
844            }
845        } else if is_proposed_head {
846            if let Err(e) = self.cs.maybe_update_pending_head(&tipset) {
847                error!("Error putting tipset: {e}");
848                return false;
849            }
850        } else if let Err(e) = self.cs.set_heaviest_tipset(tipset) {
851            error!("Error setting heaviest tipset: {e}");
852            return false;
853        }
854        true
855    }
856
857    pub fn update(&mut self, event: SyncEvent) {
858        tracing::trace!("update: {event}");
859        match event {
860            SyncEvent::NewFullTipsets(tipsets) => {
861                for tipset in tipsets {
862                    self.add_full_tipset(tipset);
863                }
864            }
865            SyncEvent::BadTipset(tipset) => self.mark_bad_tipset(tipset),
866            SyncEvent::ValidatedTipset {
867                tipset,
868                is_proposed_head,
869            } => {
870                if self.try_mark_tipset_as_validated(tipset, is_proposed_head)
871                    && crate::utils::broadcast::has_subscribers(&self.validated_tipset_broadcast_tx)
872                    // Sending the actual head key here as it could be expanded from the above tipset when `is_proposed_head` is `true`
873                    && let Err(e) = self.validated_tipset_broadcast_tx.send(self.cs.heaviest_tipset().key().clone())
874                {
875                    warn!("Failed to broadcast validated tipset: {e}");
876                }
877            }
878        }
879    }
880
881    pub fn tasks(&self) -> (Vec<SyncTask>, Vec<ForkSyncInfo>) {
882        // Get the node's current validated head epoch once, as it's the same for all forks.
883        let current_validated_epoch = self.cs.heaviest_tipset().epoch();
884        let now = Utc::now();
885
886        let mut active_sync_info = Vec::new();
887        let mut tasks = Vec::new();
888        for chain in self.chains() {
889            if let Some(first_ts) = chain.first() {
890                let last_ts = chain.last().expect("Infallible");
891                let stage: ForkSyncStage;
892                let start_time = Some(now);
893
894                if !self.is_ready_for_validation(first_ts) {
895                    stage = ForkSyncStage::FetchingHeaders;
896                    tasks.push(SyncTask::FetchTipset(
897                        first_ts.parents().clone(),
898                        first_ts.epoch(),
899                    ));
900                } else {
901                    stage = ForkSyncStage::ValidatingTipsets;
902                    tasks.push(SyncTask::ValidateTipset {
903                        tipset: first_ts.clone(),
904                        is_proposed_head: chain.len() == 1,
905                    });
906                }
907
908                let fork_info = ForkSyncInfo {
909                    target_tipset_key: last_ts.key().clone(),
910                    target_epoch: last_ts.epoch(),
911                    target_sync_epoch_start: first_ts.epoch(),
912                    stage,
913                    validated_chain_head_epoch: current_validated_epoch,
914                    start_time,
915                    last_updated: Some(now),
916                };
917
918                active_sync_info.push(fork_info);
919            }
920        }
921        (tasks, active_sync_info)
922    }
923
924    pub fn cleanup_dangling_forks(&mut self) {
925        let finalized_epoch = self.cs.ec_calculator_finalized_epoch();
926        for chain in self.chains() {
927            // Cleanup dangling fork when its target epoch is finalized
928            if let Some(target) = chain.last()
929                && target.epoch() < finalized_epoch
930            {
931                chain.iter().for_each(|ts| {
932                    self.tipsets.remove(ts.key());
933                });
934                tracing::info!(
935                    "Cleaned up dangling fork from epoch {} to {}",
936                    chain.first().map(|ts| ts.epoch()).unwrap_or_default(),
937                    chain.last().map(|ts| ts.epoch()).unwrap_or_default(),
938                );
939            }
940        }
941    }
942}
943
944#[derive(PartialEq, Eq, Hash, Clone, Debug)]
945enum SyncTask {
946    ValidateTipset {
947        tipset: FullTipset,
948        is_proposed_head: bool,
949    },
950    FetchTipset(TipsetKey, ChainEpoch),
951}
952
953impl std::fmt::Display for SyncTask {
954    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
955        match self {
956            SyncTask::ValidateTipset {
957                tipset,
958                is_proposed_head,
959            } => write!(
960                f,
961                "ValidateTipset(epoch: {}, is_proposed_head: {is_proposed_head})",
962                tipset.epoch()
963            ),
964            SyncTask::FetchTipset(key, epoch) => {
965                let s = key.to_string();
966                write!(
967                    f,
968                    "FetchTipset({}, epoch: {})",
969                    &s[s.len().saturating_sub(8)..],
970                    epoch
971                )
972            }
973        }
974    }
975}
976
977impl SyncTask {
978    async fn execute(
979        self,
980        network: SyncNetworkContext,
981        state_manager: StateManager,
982        stateless_mode: bool,
983        bad_block_cache: Option<BadBlockCache>,
984    ) -> Option<SyncEvent> {
985        tracing::trace!("SyncTask::execute {self}");
986        match self {
987            SyncTask::ValidateTipset {
988                tipset,
989                is_proposed_head,
990            } if stateless_mode => Some(SyncEvent::ValidatedTipset {
991                tipset,
992                is_proposed_head,
993            }),
994            SyncTask::ValidateTipset {
995                tipset,
996                is_proposed_head,
997            } => match validate_tipset(&state_manager, tipset.clone(), bad_block_cache).await {
998                Ok(()) => Some(SyncEvent::ValidatedTipset {
999                    tipset,
1000                    is_proposed_head,
1001                }),
1002                // If temporal drift error, don't mark as bad, just skip validation and try again
1003                // later. This mirrors internal logic where temporal drift doesn't mark a block as
1004                // bad permanently, since it could be valid later on. If not done, a single
1005                // time-traveling block could cause the node to be stuck without making progress.
1006                Err(e) if matches!(e, TipsetSyncerError::TimeTravellingBlock { .. }) => {
1007                    warn!("Time travelling block detected, skipping tipset for now: {e}");
1008                    None
1009                }
1010                Err(e) => {
1011                    warn!("Error validating tipset: {e}");
1012                    Some(SyncEvent::BadTipset(tipset))
1013                }
1014            },
1015            SyncTask::FetchTipset(key, epoch) => {
1016                match get_full_tipset_batch(&network, state_manager.chain_store(), None, &key).await
1017                {
1018                    Ok(parents) => Some(SyncEvent::NewFullTipsets(parents)),
1019                    Err(e) => {
1020                        // It's not a massive error; could be a transient network issue or a fork.
1021                        tracing::debug!(%key, %epoch, "failed to fetch tipset: {e:#}");
1022                        None
1023                    }
1024                }
1025            }
1026        }
1027    }
1028}
1029
1030#[derive(Debug)]
1031struct SyncTasks(Arc<Mutex<HashSet<SyncTask>>>);
1032
1033#[derive(Debug)]
1034struct SyncStateMachineWrapper(Arc<Mutex<SyncStateMachine>>);
1035
1036mod metrics_collection {
1037    use super::*;
1038    use prometheus_client::{
1039        collector::Collector,
1040        encoding::{DescriptorEncoder, EncodeMetric},
1041        metrics::gauge::Gauge,
1042        registry::Unit,
1043    };
1044
1045    impl Collector for SyncTasks {
1046        fn encode(&self, mut encoder: DescriptorEncoder) -> Result<(), std::fmt::Error> {
1047            {
1048                let size_in_bytes = {
1049                    let g: Gauge = Default::default();
1050                    g.set(self.0.lock().allocation_size() as i64);
1051                    g
1052                };
1053                let size_metric_encoder = encoder.encode_descriptor(
1054                    "chain_follower_tasks_size",
1055                    "Size of the chain follower tasks in bytes",
1056                    Some(&Unit::Bytes),
1057                    size_in_bytes.metric_type(),
1058                )?;
1059                size_in_bytes.encode(size_metric_encoder)?;
1060            }
1061            {
1062                let len = {
1063                    let g: Gauge = Default::default();
1064                    g.set(self.0.lock().len() as i64);
1065                    g
1066                };
1067                let size_metric_encoder = encoder.encode_descriptor(
1068                    "chain_follower_tasks_len",
1069                    "Length of the chain follower tasks",
1070                    None,
1071                    len.metric_type(),
1072                )?;
1073                len.encode(size_metric_encoder)?;
1074            }
1075            {
1076                let cap = {
1077                    let g: Gauge = Default::default();
1078                    g.set(self.0.lock().capacity() as i64);
1079                    g
1080                };
1081                let size_metric_encoder = encoder.encode_descriptor(
1082                    "chain_follower_tasks_cap",
1083                    "Capacity of the chain follower tasks",
1084                    None,
1085                    cap.metric_type(),
1086                )?;
1087                cap.encode(size_metric_encoder)?;
1088            }
1089
1090            Ok(())
1091        }
1092    }
1093
1094    impl Collector for SyncStateMachineWrapper {
1095        fn encode(&self, mut encoder: DescriptorEncoder) -> Result<(), std::fmt::Error> {
1096            {
1097                let size_in_bytes = {
1098                    let g: Gauge = Default::default();
1099                    g.set(self.0.lock().tipsets.allocation_size() as i64);
1100                    g
1101                };
1102                let size_metric_encoder = encoder.encode_descriptor(
1103                    "chain_follower_tipsets_size",
1104                    "Size of the chain follower tipsets in bytes",
1105                    Some(&Unit::Bytes),
1106                    size_in_bytes.metric_type(),
1107                )?;
1108                size_in_bytes.encode(size_metric_encoder)?;
1109            }
1110            {
1111                let len = {
1112                    let g: Gauge = Default::default();
1113                    g.set(self.0.lock().tipsets.len() as i64);
1114                    g
1115                };
1116                let size_metric_encoder = encoder.encode_descriptor(
1117                    "chain_follower_tipsets_len",
1118                    "Length of the chain follower tipsets",
1119                    None,
1120                    len.metric_type(),
1121                )?;
1122                len.encode(size_metric_encoder)?;
1123            }
1124            {
1125                let cap = {
1126                    let g: Gauge = Default::default();
1127                    g.set(self.0.lock().tipsets.capacity() as i64);
1128                    g
1129                };
1130                let size_metric_encoder = encoder.encode_descriptor(
1131                    "chain_follower_tipsets_cap",
1132                    "Capacity of the chain follower tipsets",
1133                    None,
1134                    cap.metric_type(),
1135                )?;
1136                cap.encode(size_metric_encoder)?;
1137            }
1138
1139            Ok(())
1140        }
1141    }
1142}
1143
1144#[cfg(test)]
1145mod tests {
1146    use super::*;
1147    use crate::blocks::{Chain4U, HeaderBuilder, chain4u};
1148    use crate::db::MemoryDB;
1149    use crate::utils::db::CborStoreExt as _;
1150    use num_bigint::BigInt;
1151    use num_traits::ToPrimitive;
1152    use std::sync::Arc;
1153    use tracing::level_filters::LevelFilter;
1154    use tracing_subscriber::EnvFilter;
1155
1156    fn setup() -> (ChainStore, Chain4U<Arc<MemoryDB>>) {
1157        // Initialize test logger
1158        let _ = tracing_subscriber::fmt()
1159            .without_time()
1160            .with_env_filter(
1161                EnvFilter::builder()
1162                    .with_default_directive(LevelFilter::DEBUG.into())
1163                    .from_env()
1164                    .unwrap(),
1165            )
1166            .try_init();
1167
1168        let db = Arc::new(MemoryDB::default());
1169
1170        // Create a chain of 5 tipsets using Chain4U
1171        let c4u = Chain4U::with_blockstore(db.clone());
1172        chain4u! {
1173            in c4u;
1174            [genesis_header = dummy_node(&db, 0)]
1175        };
1176
1177        let cs = ChainStore::new(db, Default::default(), genesis_header).unwrap();
1178
1179        cs.set_heaviest_tipset(cs.genesis_tipset()).unwrap();
1180
1181        (cs, c4u)
1182    }
1183
1184    fn dummy_state(db: impl Blockstore, i: ChainEpoch) -> Cid {
1185        db.put_cbor_default(&i).unwrap()
1186    }
1187
1188    fn dummy_node(db: impl Blockstore, i: ChainEpoch) -> HeaderBuilder {
1189        HeaderBuilder {
1190            state_root: dummy_state(db, i).into(),
1191            weight: BigInt::from(i).into(),
1192            epoch: i.into(),
1193            ..Default::default()
1194        }
1195    }
1196
1197    #[test]
1198    fn test_state_machine_validation_order() {
1199        let (cs, c4u) = setup();
1200        let db = cs.db_owned();
1201
1202        chain4u! {
1203            from [genesis_header] in c4u;
1204            [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)]
1205        };
1206
1207        // Create the state machine
1208        let mut state_machine = SyncStateMachine::new(cs.shallow_clone(), Default::default(), true);
1209
1210        // Insert tipsets in random order
1211        let tipsets = vec![e, b, d, c, a];
1212
1213        // Convert each block into a FullTipset and add it to the state machine
1214        for block in tipsets {
1215            let full_tipset = FullTipset::new(vec![Block {
1216                header: block.clone().into(),
1217                bls_messages: vec![],
1218                secp_messages: vec![],
1219            }])
1220            .unwrap();
1221            state_machine.update(SyncEvent::NewFullTipsets(vec![full_tipset]));
1222        }
1223
1224        // Record validation order by processing all validation tasks in each iteration
1225        let mut validation_tasks = Vec::new();
1226        loop {
1227            let (tasks, _) = state_machine.tasks();
1228
1229            // Find all validation tasks
1230            let validation_tipsets: Vec<_> = tasks
1231                .into_iter()
1232                .filter_map(|task| {
1233                    if let SyncTask::ValidateTipset {
1234                        tipset,
1235                        is_proposed_head,
1236                    } = task
1237                    {
1238                        Some((tipset, is_proposed_head))
1239                    } else {
1240                        None
1241                    }
1242                })
1243                .collect();
1244
1245            if validation_tipsets.is_empty() {
1246                break;
1247            }
1248
1249            // Record and mark all tipsets as validated
1250            for (ts, is_proposed_head) in validation_tipsets {
1251                validation_tasks.push(ts.epoch());
1252                db.put_cbor_default(&ts.epoch()).unwrap();
1253                state_machine.try_mark_tipset_as_validated(ts, is_proposed_head);
1254            }
1255        }
1256
1257        // We expect validation tasks for epochs 1 through 5 in order
1258        assert_eq!(validation_tasks, vec![1, 2, 3, 4, 5]);
1259    }
1260
1261    #[test]
1262    fn test_sync_state_machine_chain_fragments() {
1263        let (cs, c4u) = setup();
1264        let db = cs.db();
1265
1266        // Create a forked chain
1267        // genesis -> a -> b
1268        //            \--> c
1269        chain4u! {
1270            in c4u;
1271            [a = dummy_node(db, 1)] -> [b = dummy_node(db, 2)]
1272        };
1273        chain4u! {
1274            from [a] in c4u;
1275            [c = dummy_node(db, 3)]
1276        };
1277
1278        // Create the state machine
1279        let mut state_machine = SyncStateMachine::new(cs, Default::default(), false);
1280
1281        // Convert each block into a FullTipset and add it to the state machine
1282        for block in [a, b, c] {
1283            let full_tipset = FullTipset::new(vec![Block {
1284                header: block.clone().into(),
1285                bls_messages: vec![],
1286                secp_messages: vec![],
1287            }])
1288            .unwrap();
1289            state_machine.update(SyncEvent::NewFullTipsets(vec![full_tipset]));
1290        }
1291
1292        let chains = state_machine
1293            .chains()
1294            .into_iter()
1295            .map(|v| {
1296                v.into_iter()
1297                    .map(|ts| ts.weight().to_i64().unwrap_or(0))
1298                    .collect_vec()
1299            })
1300            .collect_vec();
1301
1302        // Both chains should start at the same tipset
1303        assert_eq!(chains, vec![vec![1, 3], vec![1, 2]]);
1304    }
1305}