Skip to main content

forest/chain_sync/
chain_follower.rs

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