Skip to main content

forest/chain_sync/
tipset_syncer.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4use crate::chain_sync::BadBlockCache;
5use crate::db::DbImpl;
6use crate::networks::Height;
7use crate::prelude::*;
8use crate::shim::clock::ALLOWABLE_CLOCK_DRIFT;
9use crate::shim::crypto::SignatureType;
10use crate::shim::message::Message;
11use crate::shim::{
12    address::Address, crypto::verify_bls_aggregate, econ::BLOCK_GAS_LIMIT,
13    gas::price_list_by_network_version, state_tree::StateTree,
14};
15use crate::state_manager::ExecutedTipset;
16use crate::state_manager::{Error as StateManagerError, StateManager, utils::is_valid_for_sending};
17use crate::{
18    blocks::{Block, CachingBlockHeader, Error as ForestBlockError, FullTipset, Tipset},
19    fil_cns::{self, FilecoinConsensus, FilecoinConsensusError},
20};
21use crate::{
22    chain::{ChainStore, Error as ChainStoreError},
23    metrics::HistogramTimerExt,
24};
25use crate::{
26    eth::is_valid_eth_tx_for_sending,
27    message::{MessageRead as _, valid_for_block_inclusion},
28};
29use ahash::HashMap;
30use futures::TryFutureExt;
31use fvm_ipld_encoding::to_vec;
32use nunny::Vec as NonEmpty;
33use thiserror::Error;
34use tokio::task::JoinSet;
35use tracing::{trace, warn};
36
37use crate::chain_sync::{consensus::collect_errs, metrics, validation::TipsetValidator};
38
39#[derive(Debug, Error)]
40pub enum TipsetSyncerError {
41    #[error("Block must have a signature")]
42    BlockWithoutSignature,
43    #[error("Block without BLS aggregate signature")]
44    BlockWithoutBlsAggregate,
45    #[error("Block received from the future: now = {0}, block = {1}")]
46    TimeTravellingBlock(u64, u64),
47    #[error("Validation error: {0}")]
48    Validation(String),
49    /// Locally computed parent state or receipt root does not match the block header.
50    /// Distinct from [`Self::Validation`] so the chain follower can repair locally
51    /// corrupted inputs (e.g. a stale tipset lookup entry) before treating the block as bad.
52    #[error("Parent chain state mismatch: {0}")]
53    ParentChainStateMismatch(String),
54    #[error("Processing error: {0}")]
55    Calculation(String),
56    #[error("Chain store error: {0}")]
57    ChainStore(#[from] ChainStoreError),
58    #[error("StateManager error: {0}")]
59    StateManager(#[from] StateManagerError),
60    #[error("Block error: {0}")]
61    BlockError(#[from] ForestBlockError),
62    #[error("Querying tipsets from the network failed: {0}")]
63    NetworkTipsetQueryFailed(String),
64    #[error("BLS aggregate signature {0} was invalid for msgs {1}")]
65    BlsAggregateSignatureInvalid(String, String),
66    #[error("Message signature invalid: {0}")]
67    MessageSignatureInvalid(String),
68    #[error("Block message root does not match: expected {0}, computed {1}")]
69    BlockMessageRootInvalid(String, String),
70    #[error("Computing message root failed: {0}")]
71    ComputingMessageRoot(String),
72    #[error("Resolving address from message failed: {0}")]
73    ResolvingAddressFromMessage(String),
74    #[error("Loading tipset parent from the store failed: {0}")]
75    TipsetParentNotFound(ChainStoreError),
76    #[error("Consensus error: {0}")]
77    ConsensusError(FilecoinConsensusError),
78}
79
80impl From<tokio::task::JoinError> for TipsetSyncerError {
81    fn from(err: tokio::task::JoinError) -> Self {
82        TipsetSyncerError::NetworkTipsetQueryFailed(format!("{err}"))
83    }
84}
85
86impl TipsetSyncerError {
87    /// Concatenate all validation error messages into one comma separated
88    /// version.
89    fn concat(errs: NonEmpty<TipsetSyncerError>) -> Self {
90        let msg = errs.iter().map(|e| e.to_string()).collect_vec().join(", ");
91
92        if errs
93            .iter()
94            .any(|e| matches!(e, TipsetSyncerError::ParentChainStateMismatch(_)))
95        {
96            TipsetSyncerError::ParentChainStateMismatch(msg)
97        } else {
98            TipsetSyncerError::Validation(msg)
99        }
100    }
101}
102
103/// Validates full blocks in the tipset in parallel (since the messages are not
104/// executed), adding the successful ones to the tipset tracker, and the failed
105/// ones to the bad block cache, depending on strategy. Any bad block fails
106/// validation.
107pub async fn validate_tipset(
108    state_manager: &StateManager,
109    full_tipset: FullTipset,
110    bad_block_cache: Option<BadBlockCache>,
111) -> Result<(), TipsetSyncerError> {
112    if full_tipset
113        .key()
114        .eq(state_manager.chain_store().genesis_tipset().key())
115    {
116        trace!("Skipping genesis tipset validation");
117        return Ok(());
118    }
119
120    let timer = metrics::TIPSET_PROCESSING_TIME.start_timer();
121
122    let epoch = full_tipset.epoch();
123    let parent_state = *full_tipset.parent_state();
124    let tipset_key = full_tipset.key();
125    trace!("Tipset keys: {tipset_key}");
126    let blocks = full_tipset.into_blocks();
127    let mut validations = JoinSet::new();
128    for b in blocks {
129        validations.spawn(validate_block(state_manager.shallow_clone(), Arc::new(b)));
130    }
131
132    while let Some(result) = validations.join_next().await {
133        match result? {
134            Ok(block) => {
135                state_manager
136                    .chain_store()
137                    .add_to_tipset_tracker(block.header());
138            }
139            Err((cid, why)) => {
140                warn!(
141                    "Validating block [CID = {cid}, PARENT_STATE = {parent_state}] in EPOCH = {epoch} failed: {why}",
142                );
143                match &why {
144                    TipsetSyncerError::TimeTravellingBlock(_, _) => {
145                        // Do not mark a block as bad for temporary errors.
146                        // See <https://github.com/filecoin-project/lotus/blob/v1.34.1/chain/sync.go#L602> in Lotus
147                    }
148                    _ => {
149                        // Do not mark block as bad if the parent state tree does not exist
150                        if StateTree::new_from_root(state_manager.db(), &parent_state).is_ok()
151                            && let Some(bad_block_cache) = bad_block_cache
152                        {
153                            bad_block_cache.push(cid);
154                        }
155                    }
156                };
157                return Err(why);
158            }
159        }
160    }
161    drop(timer);
162    Ok(())
163}
164
165/// Validate the block according to the rules specific to the consensus being
166/// used, and the common rules that pertain to the assumptions of the
167/// `ChainSync` protocol.
168///
169/// Returns the validated block if `Ok`.
170/// Returns the block CID (for marking bad) and `Error` if invalid (`Err`).
171///
172/// Common validation includes:
173/// * Sanity checks
174/// * Clock drifts
175/// * Signatures
176/// * Message inclusion (fees, sequences)
177/// * Parent related fields: base fee, weight, the state root
178/// * NB: This is where the messages in the *parent* tipset are executed.
179///
180/// Consensus specific validation should include:
181/// * Checking that the messages in the block correspond to the agreed upon
182///   total ordering
183/// * That the block is a deterministic derivative of the underlying consensus
184async fn validate_block(
185    state_manager: StateManager,
186    block: Arc<Block>,
187) -> Result<Arc<Block>, (Cid, TipsetSyncerError)> {
188    let consensus = FilecoinConsensus::new(state_manager.beacon_schedule().clone());
189    trace!(
190        "Validating block: epoch = {}, weight = {}, key = {}",
191        block.header().epoch,
192        block.header().weight,
193        block.header().cid(),
194    );
195    let chain_store = state_manager.chain_store().shallow_clone();
196    let block_cid = block.cid();
197
198    // Check block validation cache in store
199    let is_validated = chain_store.is_block_validated(block_cid);
200    if is_validated {
201        return Ok(block);
202    }
203
204    let _timer = metrics::BLOCK_VALIDATION_TIME.start_timer();
205
206    let header = block.header();
207
208    // Check to ensure all optional values exist
209    block_sanity_checks(header).map_err(|e| (*block_cid, e))?;
210    block_timestamp_checks(header).map_err(|e| (*block_cid, e))?;
211
212    let base_tipset = chain_store
213        .chain_index()
214        .load_required_tipset(&header.parents)
215        // The parent tipset will always be there when calling validate_block
216        // as part of the sync_tipset_range flow because all of the headers in the range
217        // have been committed to the store. When validate_block is called from sync_tipset
218        // this guarantee does not exist, so we create a specific error to inform the caller
219        // not to add this block to the bad blocks cache.
220        .map_err(|why| (*block_cid, TipsetSyncerError::TipsetParentNotFound(why)))?;
221
222    // Retrieve lookback tipset for validation
223    let lookback_state = ChainStore::get_lookback_tipset_for_round(
224        state_manager.chain_store().chain_index().shallow_clone(),
225        state_manager.chain_config().shallow_clone(),
226        base_tipset.shallow_clone(),
227        block.header().epoch,
228    )
229    .await
230    .map_err(|e| (*block_cid, e.into()))
231    .map(|(_, s)| Arc::new(s))?;
232
233    // Work address needed for async validations, so necessary
234    // to do sync to avoid duplication
235    let work_addr = state_manager
236        .get_miner_work_addr(*lookback_state, &header.miner_address)
237        .map_err(|e| (*block_cid, e.into()))?;
238
239    // Async validations
240    let mut validations = JoinSet::new();
241
242    // Check block messages
243    validations.spawn(check_block_messages(
244        state_manager.shallow_clone(),
245        block.shallow_clone(),
246        base_tipset.shallow_clone(),
247    ));
248
249    // Base fee check
250    validations.spawn_blocking({
251        let smoke_height = state_manager.chain_config().epoch(Height::Smoke);
252        let firehorse_height = state_manager.chain_config().epoch(Height::FireHorse);
253        let base_tipset = base_tipset.shallow_clone();
254        let block_store = state_manager.db_owned();
255        let block = block.shallow_clone();
256        move || {
257            let base_fee = crate::chain::compute_base_fee(
258                &block_store,
259                &base_tipset,
260                smoke_height,
261                firehorse_height,
262            )
263            .map_err(|e| {
264                TipsetSyncerError::Validation(format!("Could not compute base fee: {e}"))
265            })?;
266            let parent_base_fee = &block.header.parent_base_fee;
267            if &base_fee != parent_base_fee {
268                return Err(TipsetSyncerError::Validation(format!(
269                    "base fee doesn't match: {parent_base_fee} (header), {base_fee} (computed)"
270                )));
271            }
272            Ok(())
273        }
274    });
275
276    // Parent weight calculation check
277    validations.spawn_blocking({
278        let block_store = state_manager.db_owned();
279        let base_tipset = base_tipset.shallow_clone();
280        let weight = header.weight.clone();
281        move || {
282            let calc_weight = fil_cns::weight(&block_store, &base_tipset).map_err(|e| {
283                TipsetSyncerError::Calculation(format!("Error calculating weight: {e:#}"))
284            })?;
285            if weight != calc_weight {
286                return Err(TipsetSyncerError::Validation(format!(
287                    "Parent weight doesn't match: {weight} (header), {calc_weight} (computed)"
288                )));
289            }
290            Ok(())
291        }
292    });
293
294    // State root and receipt root validations
295    validations.spawn({
296        let state_manager = state_manager.shallow_clone();
297        let block = block.shallow_clone();
298        async move {
299            let header = block.header();
300            let ExecutedTipset {
301                state_root,
302                receipt_root,
303                ..
304            } = state_manager
305                .load_executed_tipset(&base_tipset)
306                .await
307                .map_err(|e| {
308                    TipsetSyncerError::Calculation(format!("Failed to calculate state: {e:#}"))
309                })?;
310
311            if state_root != header.state_root {
312                return Err(TipsetSyncerError::ParentChainStateMismatch(format!(
313                    "Parent state root did not match computed state: {} (header), {} (computed)",
314                    header.state_root, state_root,
315                )));
316            }
317
318            if receipt_root != header.message_receipts {
319                return Err(TipsetSyncerError::ParentChainStateMismatch(format!(
320                    "Parent receipt root did not match computed root: {} (header), {} (computed)",
321                    header.message_receipts, receipt_root
322                )));
323            }
324            Ok(())
325        }
326    });
327
328    // Block signature check
329    validations.spawn_blocking({
330        let block = block.shallow_clone();
331        move || {
332            block.header().verify_signature_against(&work_addr)?;
333            Ok(())
334        }
335    });
336
337    validations.spawn({
338        let block = block.shallow_clone();
339        async move {
340            consensus
341                .validate_block(state_manager, block)
342                .map_err(|errs| {
343                    // NOTE: Concatenating errors here means the wrapper type of error
344                    // never surfaces, yet we always pay the cost of the generic argument.
345                    // But there's no reason `validate_block` couldn't return a list of all
346                    // errors instead of a single one that has all the error messages,
347                    // removing the caller's ability to distinguish between them.
348
349                    TipsetSyncerError::concat(
350                        errs.into_iter_ne()
351                            .map(TipsetSyncerError::ConsensusError)
352                            .collect_vec(),
353                    )
354                })
355                .await
356        }
357    });
358
359    // Collect the errors from the async validations
360    if let Err(errs) = collect_errs(validations).await {
361        return Err((*block_cid, TipsetSyncerError::concat(errs)));
362    }
363
364    chain_store.mark_block_as_validated(block_cid);
365
366    Ok(block)
367}
368
369/// Validate messages in a full block, relative to the parent tipset.
370///
371/// This includes:
372/// * signature checks
373/// * gas limits, and prices
374/// * account nonce values
375/// * the message root in the header
376///
377/// NB: This loads/computes the state resulting from the execution of the parent
378/// tipset.
379async fn check_block_messages(
380    state_manager: StateManager,
381    block: Arc<Block>,
382    base_tipset: Tipset,
383) -> Result<(), TipsetSyncerError> {
384    let network_version = state_manager
385        .chain_config()
386        .network_version(block.header.epoch);
387    let eth_chain_id = state_manager.chain_config().eth_chain_id;
388
389    if let Some(sig) = &block.header().bls_aggregate {
390        // Do the initial loop here
391        // check block message and signatures in them
392        let mut pub_keys = Vec::with_capacity(block.bls_msgs().len());
393        let mut cids = Vec::with_capacity(block.bls_msgs().len());
394        let db = state_manager.db();
395        for m in block.bls_msgs() {
396            let pk = StateManager::get_bls_public_key(db, m.from, *base_tipset.parent_state())?;
397            pub_keys.push(pk);
398            cids.push(m.cid().to_bytes());
399        }
400
401        if !verify_bls_aggregate(
402            &cids.iter().map(|x| x.as_slice()).collect_vec(),
403            &pub_keys,
404            sig,
405        ) {
406            return Err(TipsetSyncerError::BlsAggregateSignatureInvalid(
407                format!("{sig:?}"),
408                format!("{cids:?}"),
409            ));
410        }
411    } else {
412        return Err(TipsetSyncerError::BlockWithoutBlsAggregate);
413    }
414
415    let price_list = price_list_by_network_version(network_version);
416    let mut sum_gas_limit = 0;
417
418    // Check messages for validity
419    let mut check_msg = |msg: &Message,
420                         account_sequences: &mut HashMap<Address, u64>,
421                         tree: &StateTree<DbImpl>|
422     -> anyhow::Result<()> {
423        // Phase 1: Syntactic validation
424        let min_gas = price_list.on_chain_message(to_vec(msg).unwrap().len());
425        valid_for_block_inclusion(msg, min_gas.total(), network_version)
426            .map_err(|e| anyhow::anyhow!("{}", e))?;
427        sum_gas_limit += msg.gas_limit;
428        if sum_gas_limit > BLOCK_GAS_LIMIT {
429            anyhow::bail!("block gas limit exceeded");
430        }
431
432        // Phase 2: (Partial) Semantic validation
433        // Send exists and is an account actor, and sequence is correct
434        let sequence: u64 = match account_sequences.get(&msg.from()) {
435            Some(sequence) => *sequence,
436            None => {
437                let actor = tree.get_actor(&msg.from)?.ok_or_else(|| {
438                    anyhow::anyhow!(
439                        "Failed to retrieve nonce for addr: Actor does not exist in state"
440                    )
441                })?;
442                let network_version = state_manager
443                    .chain_config()
444                    .network_version(block.header.epoch);
445                if !is_valid_for_sending(network_version, &actor) {
446                    anyhow::bail!("not valid for sending!");
447                }
448                actor.sequence
449            }
450        };
451
452        // Sequence equality check
453        if sequence != msg.sequence {
454            anyhow::bail!(
455                "Message has incorrect sequence (exp: {} got: {})",
456                sequence,
457                msg.sequence
458            );
459        }
460        account_sequences.insert(msg.from(), sequence + 1);
461        Ok(())
462    };
463
464    let mut account_sequences: HashMap<Address, u64> = HashMap::default();
465    let ExecutedTipset { state_root, .. } = state_manager
466        .load_executed_tipset(&base_tipset)
467        .await
468        .map_err(|e| TipsetSyncerError::Calculation(format!("Could not update state: {e:#}")))?;
469    let tree = StateTree::new_from_root(state_manager.db(), &state_root).map_err(|e| {
470        TipsetSyncerError::Calculation(format!(
471            "Could not load from new state root in state manager: {e:#}"
472        ))
473    })?;
474
475    // Check validity for BLS messages
476    for (i, msg) in block.bls_msgs().iter().enumerate() {
477        check_msg(msg, &mut account_sequences, &tree).map_err(|e| {
478            TipsetSyncerError::Validation(format!(
479                "Block had invalid BLS message at index {i}: {e:#}"
480            ))
481        })?;
482    }
483
484    // Check validity for SECP messages
485    for (i, msg) in block.secp_msgs().iter().enumerate() {
486        if msg.signature().signature_type() == SignatureType::Delegated
487            && !is_valid_eth_tx_for_sending(eth_chain_id, network_version, msg)
488        {
489            return Err(TipsetSyncerError::Validation(
490                "Network version must be at least NV23 for legacy Ethereum transactions".to_owned(),
491            ));
492        }
493        check_msg(msg.message(), &mut account_sequences, &tree).map_err(|e| {
494            TipsetSyncerError::Validation(format!(
495                "block had an invalid secp message at index {i}: {e:#}"
496            ))
497        })?;
498        // Resolve key address for signature verification
499        let key_addr = state_manager
500            .resolve_to_deterministic_address(msg.from(), &base_tipset)
501            .await
502            .map_err(|e| TipsetSyncerError::ResolvingAddressFromMessage(e.to_string()))?;
503        // SecP256K1 Signature validation
504        msg.signature
505            .authenticate_msg(eth_chain_id, msg, &key_addr)
506            .map_err(|e| TipsetSyncerError::MessageSignatureInvalid(e.to_string()))?;
507    }
508
509    // Validate message root from header matches message root
510    let msg_root =
511        TipsetValidator::compute_msg_root(state_manager.db(), block.bls_msgs(), block.secp_msgs())
512            .map_err(|err| TipsetSyncerError::ComputingMessageRoot(err.to_string()))?;
513    if block.header().messages != msg_root {
514        return Err(TipsetSyncerError::BlockMessageRootInvalid(
515            format!("{:?}", block.header().messages),
516            format!("{msg_root:?}"),
517        ));
518    }
519
520    Ok(())
521}
522
523/// Checks optional values in header.
524///
525/// It only looks for fields which are common to all consensus types.
526fn block_sanity_checks(header: &CachingBlockHeader) -> Result<(), TipsetSyncerError> {
527    if header.signature.is_none() {
528        return Err(TipsetSyncerError::BlockWithoutSignature);
529    }
530    if header.bls_aggregate.is_none() {
531        return Err(TipsetSyncerError::BlockWithoutBlsAggregate);
532    }
533    Ok(())
534}
535
536/// Check the clock drift.
537fn block_timestamp_checks(header: &CachingBlockHeader) -> Result<(), TipsetSyncerError> {
538    let time_now = chrono::Utc::now().timestamp() as u64;
539    if header.timestamp > time_now.saturating_add(ALLOWABLE_CLOCK_DRIFT) {
540        return Err(TipsetSyncerError::TimeTravellingBlock(
541            time_now,
542            header.timestamp,
543        ));
544    } else if header.timestamp > time_now {
545        warn!(
546            "Got block from the future, but within clock drift threshold, {} > {}",
547            header.timestamp, time_now
548        );
549    }
550    Ok(())
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    #[test]
558    fn concat_preserves_parent_chain_state_mismatch() {
559        let concatenated = TipsetSyncerError::concat(nunny::vec![
560            TipsetSyncerError::Validation("a".into()),
561            TipsetSyncerError::ParentChainStateMismatch("b".into()),
562        ]);
563        assert!(matches!(
564            concatenated,
565            TipsetSyncerError::ParentChainStateMismatch(_)
566        ));
567
568        let concatenated =
569            TipsetSyncerError::concat(nunny::vec![TipsetSyncerError::Validation("a".into())]);
570        assert!(matches!(concatenated, TipsetSyncerError::Validation(_)));
571    }
572}