Skip to main content

forest/chain_sync/
validation.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use crate::blocks::{BLOCK_MESSAGE_LIMIT, Block, FullTipset, GossipBlock, Tipset, TxMeta};
7use crate::chain::ChainStore;
8use crate::message::SignedMessage;
9use crate::shim::clock::ChainEpoch;
10use crate::shim::message::Message;
11use crate::utils::{cid::CidCborExt, db::CborStoreExt};
12use cid::Cid;
13use fil_actors_shared::fvm_ipld_amt::{Amtv0 as Amt, Error as IpldAmtError};
14use fvm_ipld_blockstore::Blockstore;
15use fvm_ipld_encoding::Error as EncodingError;
16use thiserror::Error;
17
18use crate::chain_sync::bad_block_cache::{BadBlockCache, SeenBlockCache};
19
20const MAX_HEIGHT_DRIFT: ChainEpoch = 5;
21
22/// Compute the maximum allowed epoch given the current time (seconds since
23/// UNIX epoch). Returns `None` if inputs are nonsensical (clock before
24/// genesis, zero block delay).
25fn max_allowed_epoch(
26    now_secs: u64,
27    genesis_timestamp: u64,
28    block_delay: u32,
29) -> Option<ChainEpoch> {
30    let elapsed = now_secs.checked_sub(genesis_timestamp)?;
31    let delay = u64::from(block_delay);
32    if delay == 0 {
33        return None;
34    }
35    let epoch = ChainEpoch::try_from(elapsed / delay).unwrap_or(ChainEpoch::MAX);
36    Some(epoch.saturating_add(MAX_HEIGHT_DRIFT))
37}
38
39fn now_secs() -> u64 {
40    SystemTime::now()
41        .duration_since(UNIX_EPOCH)
42        .unwrap_or_default()
43        .as_secs()
44}
45
46#[derive(Debug, Error)]
47pub enum TipsetValidationError {
48    #[error("Tipset has no blocks")]
49    NoBlocks,
50    #[error("Tipset has an epoch that is too large")]
51    EpochTooLarge,
52    #[error("Tipset has an insufficient weight")]
53    InsufficientWeight,
54    #[error("Tipset block = [CID = {0}] is invalid")]
55    InvalidBlock(Cid),
56    #[error("Tipset headers are invalid")]
57    InvalidRoots,
58    #[error("Tipset IPLD error: {0}")]
59    IpldAmt(String),
60    #[error("Block store error while validating tipset: {0}")]
61    Blockstore(String),
62    #[error("Encoding error while validating tipset: {0}")]
63    Encoding(EncodingError),
64}
65
66impl From<EncodingError> for TipsetValidationError {
67    fn from(err: EncodingError) -> Self {
68        TipsetValidationError::Encoding(err)
69    }
70}
71
72impl From<IpldAmtError> for TipsetValidationError {
73    fn from(err: IpldAmtError) -> Self {
74        TipsetValidationError::IpldAmt(err.to_string())
75    }
76}
77
78pub struct TipsetValidator<'a>(pub &'a FullTipset);
79
80impl TipsetValidator<'_> {
81    pub fn validate(
82        &self,
83        chainstore: &ChainStore,
84        bad_block_cache: Option<&BadBlockCache>,
85        genesis_tipset: &Tipset,
86        block_delay: u32,
87    ) -> Result<(), TipsetValidationError> {
88        // No empty blocks
89        if self.0.blocks().is_empty() {
90            return Err(TipsetValidationError::NoBlocks);
91        }
92
93        // Tipset epoch must not be behind current max
94        self.validate_epoch(genesis_tipset, block_delay)?;
95
96        // Validate each block in the tipset by:
97        // 1. Calculating the message root using all of the messages to ensure it
98        // matches the mst root in the block header 2. Ensuring it has not
99        // previously been seen in the bad blocks cache
100        for block in self.0.blocks() {
101            Self::validate_msg_root(chainstore.db(), block)?;
102            if let Some(bad_block_cache) = bad_block_cache
103                && bad_block_cache.get(block.cid()).is_some()
104            {
105                return Err(TipsetValidationError::InvalidBlock(*block.cid()));
106            }
107        }
108
109        Ok(())
110    }
111
112    pub fn validate_epoch(
113        &self,
114        genesis_tipset: &Tipset,
115        block_delay: u32,
116    ) -> Result<(), TipsetValidationError> {
117        let max = max_allowed_epoch(now_secs(), genesis_tipset.min_timestamp(), block_delay)
118            .unwrap_or(ChainEpoch::MAX);
119        if self.0.epoch() > max {
120            Err(TipsetValidationError::EpochTooLarge)
121        } else {
122            Ok(())
123        }
124    }
125
126    pub fn validate_msg_root<DB: Blockstore>(
127        blockstore: &DB,
128        block: &Block,
129    ) -> Result<(), TipsetValidationError> {
130        let msg_root = Self::compute_msg_root(blockstore, block.bls_msgs(), block.secp_msgs())?;
131        if block.header().messages != msg_root {
132            Err(TipsetValidationError::InvalidRoots)
133        } else {
134            Ok(())
135        }
136    }
137
138    pub fn compute_msg_root<DB: Blockstore>(
139        blockstore: &DB,
140        bls_msgs: &[Message],
141        secp_msgs: &[SignedMessage],
142    ) -> Result<Cid, TipsetValidationError> {
143        // Generate message CIDs
144        let bls_cids = bls_msgs
145            .iter()
146            .map(Cid::from_cbor_blake2b256)
147            .collect::<Result<Vec<Cid>, fvm_ipld_encoding::Error>>()?;
148        let secp_cids = secp_msgs
149            .iter()
150            .map(Cid::from_cbor_blake2b256)
151            .collect::<Result<Vec<Cid>, fvm_ipld_encoding::Error>>()?;
152
153        // Generate Amt and batch set message values
154        let bls_message_root = Amt::new_from_iter(blockstore, bls_cids)?;
155        let secp_message_root = Amt::new_from_iter(blockstore, secp_cids)?;
156        let meta = TxMeta {
157            bls_message_root,
158            secp_message_root,
159        };
160
161        // Store message roots and receive meta_root CID
162        blockstore
163            .put_cbor_default(&meta)
164            .map_err(|e| TipsetValidationError::Blockstore(e.to_string()))
165    }
166}
167
168#[derive(Debug, Error)]
169pub enum GossipBlockRejectReason {
170    #[error("block epoch {0} is too far in the future")]
171    EpochTooFarAhead(ChainEpoch),
172    #[error("block epoch {epoch} is beyond finality (finalized: {finalized_epoch})")]
173    EpochBeyondFinality {
174        epoch: ChainEpoch,
175        finalized_epoch: ChainEpoch,
176    },
177    #[error("block epoch {0} is negative")]
178    NegativeEpoch(ChainEpoch),
179    #[error("block timestamp {timestamp} inconsistent with epoch {epoch} (expected {expected})")]
180    TimestampMismatch {
181        timestamp: u64,
182        epoch: ChainEpoch,
183        expected: u64,
184    },
185    #[error("block has no signature")]
186    MissingSignature,
187    #[error("block has no election proof")]
188    MissingElectionProof,
189    #[error("block election proof has win_count {0} < 1")]
190    InvalidWinCount(i64),
191    #[error("block has {0} messages, exceeding limit of {BLOCK_MESSAGE_LIMIT}")]
192    TooManyMessages(usize),
193    #[error("block CID {0} is in bad block cache")]
194    BadBlock(Cid),
195    #[error("duplicate block CID {0}")]
196    DuplicateBlock(Cid),
197}
198
199impl GossipBlockRejectReason {
200    pub fn label(&self) -> &'static str {
201        match self {
202            Self::EpochTooFarAhead(_) => "epoch_too_far_ahead",
203            Self::EpochBeyondFinality { .. } => "epoch_beyond_finality",
204            Self::NegativeEpoch(_) => "negative_epoch",
205            Self::TimestampMismatch { .. } => "timestamp_mismatch",
206            Self::MissingSignature => "missing_signature",
207            Self::MissingElectionProof => "missing_election_proof",
208            Self::InvalidWinCount(_) => "invalid_win_count",
209            Self::TooManyMessages(_) => "too_many_messages",
210            Self::BadBlock(_) => "bad_block",
211            Self::DuplicateBlock(_) => "duplicate_block",
212        }
213    }
214}
215
216/// Pre-validation of gossip blocks to avoid expensive `get_full_tipset`
217/// network round-trips and DB writes for obviously invalid blocks.
218/// Only uses data already present in the gossip message (header + CIDs).
219pub struct GossipBlockValidator<'a> {
220    block: &'a GossipBlock,
221}
222
223impl<'a> GossipBlockValidator<'a> {
224    pub fn new(block: &'a GossipBlock) -> Self {
225        Self { block }
226    }
227
228    /// Run all pre-fetch validation checks.
229    /// Checks are ordered cheapest/most-likely-to-reject first.
230    pub fn validate_pre_fetch(
231        &self,
232        genesis_tipset: &Tipset,
233        block_delay: u32,
234        finalized_epoch: ChainEpoch,
235        bad_block_cache: Option<&BadBlockCache>,
236        seen_block_cache: &SeenBlockCache,
237    ) -> Result<(), GossipBlockRejectReason> {
238        let cid = *self.block.header.cid();
239        Self::check_bad_block_cache(cid, bad_block_cache)?;
240        self.validate_epoch_range(genesis_tipset, block_delay, finalized_epoch)?;
241        self.validate_timestamp(genesis_tipset, block_delay)?;
242        self.validate_election_proof()?;
243        self.validate_signature_present()?;
244        self.validate_message_count()?;
245        // Insert into seen cache only after all checks pass, so transiently
246        // rejected blocks (e.g., slightly-future epoch) aren't suppressed later.
247        Self::check_duplicate(cid, seen_block_cache)?;
248        Ok(())
249    }
250
251    fn check_duplicate(
252        cid: Cid,
253        seen_block_cache: &SeenBlockCache,
254    ) -> Result<(), GossipBlockRejectReason> {
255        if seen_block_cache.test_and_insert(&cid) {
256            return Err(GossipBlockRejectReason::DuplicateBlock(cid));
257        }
258        Ok(())
259    }
260
261    fn check_bad_block_cache(
262        cid: Cid,
263        bad_block_cache: Option<&BadBlockCache>,
264    ) -> Result<(), GossipBlockRejectReason> {
265        if let Some(cache) = bad_block_cache
266            && cache.get(&cid).is_some()
267        {
268            return Err(GossipBlockRejectReason::BadBlock(cid));
269        }
270        Ok(())
271    }
272
273    fn validate_epoch_range(
274        &self,
275        genesis_tipset: &Tipset,
276        block_delay: u32,
277        finalized_epoch: ChainEpoch,
278    ) -> Result<(), GossipBlockRejectReason> {
279        let epoch = self.block.header.epoch;
280        if epoch < 0 {
281            return Err(GossipBlockRejectReason::NegativeEpoch(epoch));
282        }
283        let max = max_allowed_epoch(now_secs(), genesis_tipset.min_timestamp(), block_delay)
284            .unwrap_or(ChainEpoch::MAX);
285        if epoch > max {
286            return Err(GossipBlockRejectReason::EpochTooFarAhead(epoch));
287        }
288        if epoch < finalized_epoch {
289            return Err(GossipBlockRejectReason::EpochBeyondFinality {
290                epoch,
291                finalized_epoch,
292            });
293        }
294        Ok(())
295    }
296
297    /// Verify that block timestamp is consistent with its epoch:
298    /// `timestamp == genesis_timestamp + epoch * block_delay`
299    fn validate_timestamp(
300        &self,
301        genesis_tipset: &Tipset,
302        block_delay: u32,
303    ) -> Result<(), GossipBlockRejectReason> {
304        let epoch = self.block.header.epoch;
305        let timestamp = self.block.header.timestamp;
306        // epoch is validated non-negative by validate_epoch_range before this
307        // Saturating would let a block claiming `u64::MAX` match the saturated expectation.
308        let expected = (epoch as u64)
309            .checked_mul(u64::from(block_delay))
310            .and_then(|elapsed| genesis_tipset.min_timestamp().checked_add(elapsed))
311            .ok_or(GossipBlockRejectReason::EpochTooFarAhead(epoch))?;
312        if timestamp != expected {
313            return Err(GossipBlockRejectReason::TimestampMismatch {
314                timestamp,
315                epoch,
316                expected,
317            });
318        }
319        Ok(())
320    }
321
322    fn validate_election_proof(&self) -> Result<(), GossipBlockRejectReason> {
323        match &self.block.header.election_proof {
324            None => Err(GossipBlockRejectReason::MissingElectionProof),
325            Some(proof) if proof.win_count < 1 => {
326                Err(GossipBlockRejectReason::InvalidWinCount(proof.win_count))
327            }
328            _ => Ok(()),
329        }
330    }
331
332    fn validate_signature_present(&self) -> Result<(), GossipBlockRejectReason> {
333        if self.block.header.signature.is_none() {
334            return Err(GossipBlockRejectReason::MissingSignature);
335        }
336        Ok(())
337    }
338
339    fn validate_message_count(&self) -> Result<(), GossipBlockRejectReason> {
340        let count = self.block.bls_messages.len() + self.block.secpk_messages.len();
341        if count > BLOCK_MESSAGE_LIMIT {
342            return Err(GossipBlockRejectReason::TooManyMessages(count));
343        }
344        Ok(())
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use std::convert::TryFrom;
351
352    use crate::blocks::{CachingBlockHeader, ElectionProof, GossipBlock, RawBlockHeader, Tipset};
353    use crate::chain_sync::bad_block_cache::{BadBlockCache, SeenBlockCache};
354    use crate::db::MemoryDB;
355    use crate::message::SignedMessage;
356    use crate::shim::crypto::{Signature, SignatureType};
357    use crate::shim::message::Message;
358    use crate::test_utils::construct_messages;
359    use crate::utils::encoding::from_slice_with_fallback;
360    use base64::{Engine, prelude::BASE64_STANDARD};
361    use cid::Cid;
362
363    use super::{GossipBlockRejectReason, GossipBlockValidator, TipsetValidator};
364
365    #[test]
366    fn compute_msg_meta_given_msgs_test() {
367        let blockstore = MemoryDB::default();
368
369        let (bls, secp) = construct_messages();
370
371        let expected_root =
372            Cid::try_from("bafy2bzaceasssikoiintnok7f3sgnekfifarzobyr3r4f25sgxmn23q4c35ic")
373                .unwrap();
374
375        let root = TipsetValidator::compute_msg_root(&blockstore, &[bls], &[secp])
376            .expect("Computing message root should succeed");
377        assert_eq!(root, expected_root);
378    }
379
380    #[test]
381    fn empty_msg_meta_vector() {
382        let blockstore = MemoryDB::default();
383        let usm: Vec<Message> =
384            from_slice_with_fallback(&BASE64_STANDARD.decode("gA==").unwrap()).unwrap();
385        let sm: Vec<SignedMessage> =
386            from_slice_with_fallback(&BASE64_STANDARD.decode("gA==").unwrap()).unwrap();
387
388        assert_eq!(
389            TipsetValidator::compute_msg_root(&blockstore, &usm, &sm)
390                .expect("Computing message root should succeed")
391                .to_string(),
392            "bafy2bzacecmda75ovposbdateg7eyhwij65zklgyijgcjwynlklmqazpwlhba"
393        );
394    }
395
396    #[test]
397    fn max_allowed_epoch_basic() {
398        // genesis at t=1000, now at t=1300, block_delay=30
399        // elapsed=300, 300/30=10, +5 drift = 15
400        assert_eq!(super::max_allowed_epoch(1300, 1000, 30), Some(15));
401    }
402
403    #[test]
404    fn max_allowed_epoch_at_genesis() {
405        // now == genesis → epoch 0 + drift
406        assert_eq!(super::max_allowed_epoch(1000, 1000, 30), Some(5));
407    }
408
409    #[test]
410    fn max_allowed_epoch_clock_before_genesis() {
411        // clock is behind genesis — should not panic, returns None
412        assert_eq!(super::max_allowed_epoch(500, 1000, 30), None);
413    }
414
415    #[test]
416    fn max_allowed_epoch_zero_block_delay() {
417        // zero block delay would divide by zero — returns None
418        assert_eq!(super::max_allowed_epoch(2000, 1000, 0), None);
419    }
420
421    fn make_gossip_block_with(f: impl FnOnce(&mut RawBlockHeader)) -> GossipBlock {
422        let mut raw = RawBlockHeader {
423            election_proof: Some(ElectionProof {
424                win_count: 1,
425                vrfproof: Default::default(),
426            }),
427            signature: Some(Signature {
428                sig_type: SignatureType::Bls,
429                bytes: vec![0u8; 96],
430            }),
431            ..Default::default()
432        };
433        f(&mut raw);
434        GossipBlock {
435            header: CachingBlockHeader::from(raw),
436            bls_messages: vec![],
437            secpk_messages: vec![],
438        }
439    }
440
441    fn make_valid_gossip_block() -> GossipBlock {
442        make_gossip_block_with(|_| {})
443    }
444
445    fn make_genesis() -> Tipset {
446        Tipset::from(CachingBlockHeader::default())
447    }
448
449    #[test]
450    fn gossip_block_validator_accepts_valid_block() {
451        let block = make_valid_gossip_block();
452        let genesis = make_genesis();
453        let seen = SeenBlockCache::default();
454
455        let result = GossipBlockValidator::new(&block).validate_pre_fetch(
456            &genesis, 30,   // block_delay
457            0,    // finalized_epoch
458            None, // no bad block cache
459            &seen,
460        );
461        assert!(result.is_ok());
462    }
463
464    #[test]
465    fn gossip_block_validator_rejects_duplicate() {
466        let block = make_valid_gossip_block();
467        let genesis = make_genesis();
468        let seen = SeenBlockCache::default();
469
470        assert!(
471            GossipBlockValidator::new(&block)
472                .validate_pre_fetch(&genesis, 30, 0, None, &seen)
473                .is_ok()
474        );
475
476        let err = GossipBlockValidator::new(&block)
477            .validate_pre_fetch(&genesis, 30, 0, None, &seen)
478            .unwrap_err();
479        assert!(matches!(err, GossipBlockRejectReason::DuplicateBlock(_)));
480    }
481
482    #[test]
483    fn gossip_block_validator_rejects_bad_block() {
484        let block = make_valid_gossip_block();
485        let genesis = make_genesis();
486        let seen = SeenBlockCache::default();
487        let bad_cache = BadBlockCache::default();
488        bad_cache.push(*block.header.cid());
489
490        let err = GossipBlockValidator::new(&block)
491            .validate_pre_fetch(&genesis, 30, 0, Some(&bad_cache), &seen)
492            .unwrap_err();
493        assert!(matches!(err, GossipBlockRejectReason::BadBlock(_)));
494    }
495
496    #[test]
497    fn gossip_block_validator_rejects_epoch_too_far_ahead() {
498        let block = make_gossip_block_with(|h| h.epoch = i64::MAX);
499        let genesis = make_genesis();
500        let seen = SeenBlockCache::default();
501
502        let err = GossipBlockValidator::new(&block)
503            .validate_pre_fetch(&genesis, 30, 0, None, &seen)
504            .unwrap_err();
505        assert!(matches!(err, GossipBlockRejectReason::EpochTooFarAhead(_)));
506    }
507
508    #[test]
509    fn gossip_block_validator_rejects_epoch_beyond_finality() {
510        let block = make_valid_gossip_block(); // epoch = 0
511        let genesis = make_genesis();
512        let seen = SeenBlockCache::default();
513
514        let err = GossipBlockValidator::new(&block)
515            .validate_pre_fetch(&genesis, 30, 100, None, &seen)
516            .unwrap_err();
517        assert!(matches!(
518            err,
519            GossipBlockRejectReason::EpochBeyondFinality { .. }
520        ));
521    }
522
523    #[test]
524    fn gossip_block_validator_rejects_missing_election_proof() {
525        let block = make_gossip_block_with(|h| h.election_proof = None);
526        let genesis = make_genesis();
527        let seen = SeenBlockCache::default();
528
529        let err = GossipBlockValidator::new(&block)
530            .validate_pre_fetch(&genesis, 30, 0, None, &seen)
531            .unwrap_err();
532        assert!(matches!(err, GossipBlockRejectReason::MissingElectionProof));
533    }
534
535    #[test]
536    fn gossip_block_validator_rejects_zero_win_count() {
537        let block = make_gossip_block_with(|h| {
538            h.election_proof = Some(ElectionProof {
539                win_count: 0,
540                vrfproof: Default::default(),
541            })
542        });
543        let genesis = make_genesis();
544        let seen = SeenBlockCache::default();
545
546        let err = GossipBlockValidator::new(&block)
547            .validate_pre_fetch(&genesis, 30, 0, None, &seen)
548            .unwrap_err();
549        assert!(matches!(err, GossipBlockRejectReason::InvalidWinCount(0)));
550    }
551
552    #[test]
553    fn gossip_block_validator_rejects_missing_signature() {
554        let block = make_gossip_block_with(|h| h.signature = None);
555        let genesis = make_genesis();
556        let seen = SeenBlockCache::default();
557
558        let err = GossipBlockValidator::new(&block)
559            .validate_pre_fetch(&genesis, 30, 0, None, &seen)
560            .unwrap_err();
561        assert!(matches!(err, GossipBlockRejectReason::MissingSignature));
562    }
563
564    #[test]
565    fn gossip_block_validator_rejects_too_many_messages() {
566        let mut block = make_valid_gossip_block();
567        block.bls_messages = vec![Cid::default(); 10_001];
568        let genesis = make_genesis();
569        let seen = SeenBlockCache::default();
570
571        let err = GossipBlockValidator::new(&block)
572            .validate_pre_fetch(&genesis, 30, 0, None, &seen)
573            .unwrap_err();
574        assert!(matches!(err, GossipBlockRejectReason::TooManyMessages(_)));
575    }
576
577    #[test]
578    fn gossip_block_validator_rejects_negative_epoch() {
579        let block = make_gossip_block_with(|h| h.epoch = -1);
580        let genesis = make_genesis();
581        let seen = SeenBlockCache::default();
582
583        let err = GossipBlockValidator::new(&block)
584            .validate_pre_fetch(&genesis, 30, 0, None, &seen)
585            .unwrap_err();
586        assert!(matches!(err, GossipBlockRejectReason::NegativeEpoch(-1)));
587    }
588
589    #[test]
590    fn gossip_block_validator_rejects_timestamp_mismatch() {
591        // epoch=0, genesis timestamp=0, so expected timestamp = 0 + 0*30 = 0
592        // but we set timestamp=999
593        let block = make_gossip_block_with(|h| h.timestamp = 999);
594        let genesis = make_genesis();
595        let seen = SeenBlockCache::default();
596
597        let err = GossipBlockValidator::new(&block)
598            .validate_pre_fetch(&genesis, 30, 0, None, &seen)
599            .unwrap_err();
600        assert!(matches!(
601            err,
602            GossipBlockRejectReason::TimestampMismatch { .. }
603        ));
604    }
605
606    /// A genesis timestamp ahead of the local clock makes `max_allowed_epoch` fall back to
607    /// `ChainEpoch::MAX`, so the epoch range check no longer bounds what reaches the timestamp
608    /// arithmetic.
609    #[test]
610    fn timestamp_check_survives_extreme_epoch_when_clock_is_behind_genesis() {
611        let genesis = Tipset::from(CachingBlockHeader::new(RawBlockHeader {
612            timestamp: u64::MAX,
613            ..Default::default()
614        }));
615
616        // The second block claims the timestamp that saturating arithmetic would have computed
617        // as the expected one, so saturating would have accepted it.
618        for timestamp in [0, u64::MAX] {
619            let block = make_gossip_block_with(|h| {
620                h.epoch = i64::MAX;
621                h.timestamp = timestamp;
622            });
623            let err = GossipBlockValidator::new(&block)
624                .validate_pre_fetch(&genesis, 30, 0, None, &SeenBlockCache::default())
625                .unwrap_err();
626            assert!(
627                matches!(err, GossipBlockRejectReason::EpochTooFarAhead(_)),
628                "timestamp {timestamp}: {err}"
629            );
630        }
631    }
632
633    #[test]
634    fn rejected_block_not_cached_as_seen() {
635        // A block rejected for a transient reason (e.g., epoch too far ahead)
636        // must NOT be inserted into the seen cache. Otherwise, if the same
637        // block is received later when it becomes valid, it would be
638        // incorrectly suppressed as a duplicate.
639        let block = make_gossip_block_with(|h| h.epoch = i64::MAX);
640        let genesis = make_genesis();
641        let seen = SeenBlockCache::default();
642
643        // First attempt: rejected as too far ahead
644        let err = GossipBlockValidator::new(&block)
645            .validate_pre_fetch(&genesis, 30, 0, None, &seen)
646            .unwrap_err();
647        assert!(matches!(err, GossipBlockRejectReason::EpochTooFarAhead(_)));
648
649        // Second attempt: must still be EpochTooFarAhead, NOT DuplicateBlock
650        let err = GossipBlockValidator::new(&block)
651            .validate_pre_fetch(&genesis, 30, 0, None, &seen)
652            .unwrap_err();
653        assert!(matches!(err, GossipBlockRejectReason::EpochTooFarAhead(_)));
654    }
655
656    #[test]
657    fn seen_block_cache_deduplicates() {
658        let cache = SeenBlockCache::default();
659        let cid = Cid::default();
660
661        assert!(!cache.test_and_insert(&cid));
662        assert!(cache.test_and_insert(&cid));
663    }
664}