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