From 6d5c40fad38b76714b29edb5b83ba5bc4d73d988 Mon Sep 17 00:00:00 2001 From: EclesioMeloJunior Date: Tue, 4 Aug 2026 08:13:40 -0400 Subject: [PATCH 01/13] wip: validate header drand --- src/blocks/header.rs | 44 +++++++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/src/blocks/header.rs b/src/blocks/header.rs index d2a6e41430e..b9c436049d4 100644 --- a/src/blocks/header.rs +++ b/src/blocks/header.rs @@ -154,31 +154,32 @@ impl RawBlockHeader { } // We skip verifying the genesis entry when randomness is "chained". - if curr_beacon.network().is_chained() && prev_entry.round() == 0 { + if curr_beacon.network().is_chained() { // This basically means that the drand entry of the first non-genesis tipset isn't verified IF we are starting on Drand mainnet (the "chained" drand) // Networks that start on drand quicknet, or other unchained randomness sources, will still verify it - return Ok(()); + if prev_entry.round() == 0 { + return Ok(()); + } } - let last = match self.beacon_entries.last() { - Some(last) => last, - None => { - return Err(Error::Validation( - "Block must include at least 1 beacon entry".into(), - )); + if curr_beacon.network().is_unchained() { + for (idx, beacon_entry) in self.beacon_entries.iter().enumerate() { + let lookup_epoch = parent_epoch + (idx + 1) as i64; + let expected_round = curr_beacon.max_beacon_round_for_epoch(network_version, lookup_epoch); + if beacon_entry.round() != expected_round { + return Err(Error::Validation( + format!("expected max round for epoch {} to be {}, got: {}", + lookup_epoch, + expected_round, + beacon_entry.round(), + ) + .into(), + )); + } } - }; - - if last.round() != max_round { - return Err(Error::Validation( - format!( - "expected final beacon entry in block to be at round {}, got: {}", - max_round, - last.round() - ) - .into(), - )); } + + if !curr_beacon .verify_entries(&self.beacon_entries, prev_entry) @@ -432,4 +433,9 @@ mod tests { *FILECOIN_GENESIS_CID ); } + + #[test] + fn test_validate_block_drand_when_prev_epoch_gaps() { + + } } From 480a36bea051d9e75ffe8801700aed09ad8e27f9 Mon Sep 17 00:00:00 2001 From: EclesioMeloJunior Date: Tue, 4 Aug 2026 11:25:34 -0400 Subject: [PATCH 02/13] feat: `validate_block_drand` validates all covered epochs and its beacon entries --- src/beacon/mod.rs | 6 ++- src/beacon/tests/drand.rs | 2 +- src/blocks/header.rs | 103 +++++++++++++++++++++++++++++++++----- 3 files changed, 95 insertions(+), 16 deletions(-) diff --git a/src/beacon/mod.rs b/src/beacon/mod.rs index f9c59337020..8718f77407f 100644 --- a/src/beacon/mod.rs +++ b/src/beacon/mod.rs @@ -10,6 +10,8 @@ pub use drand::*; #[cfg(test)] pub mod mock_beacon; #[cfg(test)] -mod tests { - mod drand; +pub mod tests { + // `pub` so that helpers such as `drand::new_beacon_quicknet` can be shared with + // tests in other modules. + pub mod drand; } diff --git a/src/beacon/tests/drand.rs b/src/beacon/tests/drand.rs index 350b8d2399f..3138e4832c2 100644 --- a/src/beacon/tests/drand.rs +++ b/src/beacon/tests/drand.rs @@ -39,7 +39,7 @@ fn new_beacon_mainnet() -> DrandBeacon { ) } -fn new_beacon_quicknet() -> DrandBeacon { +pub fn new_beacon_quicknet() -> DrandBeacon { DrandBeacon::new( 1598306400, 30, diff --git a/src/blocks/header.rs b/src/blocks/header.rs index b9c436049d4..2cc6a612ecd 100644 --- a/src/blocks/header.rs +++ b/src/blocks/header.rs @@ -154,21 +154,64 @@ impl RawBlockHeader { } // We skip verifying the genesis entry when randomness is "chained". - if curr_beacon.network().is_chained() { + if curr_beacon.network().is_chained() && prev_entry.round() == 0 { // This basically means that the drand entry of the first non-genesis tipset isn't verified IF we are starting on Drand mainnet (the "chained" drand) // Networks that start on drand quicknet, or other unchained randomness sources, will still verify it - if prev_entry.round() == 0 { - return Ok(()); + return Ok(()); + } + + let last = match self.beacon_entries.last() { + Some(last) => last, + None => { + return Err(Error::Validation( + "Block must include at least 1 beacon entry".into(), + )); } + }; + + if last.round() != max_round { + return Err(Error::Validation( + format!( + "expected final beacon entry in block to be at round {}, got: {}", + max_round, + last.round() + ) + .into(), + )); } + // An unchained beacon carries no link between consecutive entries, so nothing + // but these checks ties them to the epochs they are supposed to cover. A block + // covers every epoch since its parent - normally just its own, but null rounds + // can happen and it must carry exactly one entry per covered epoch, in + // ascending order. if curr_beacon.network().is_unchained() { - for (idx, beacon_entry) in self.beacon_entries.iter().enumerate() { - let lookup_epoch = parent_epoch + (idx + 1) as i64; - let expected_round = curr_beacon.max_beacon_round_for_epoch(network_version, lookup_epoch); + let covered_epochs = self.epoch - parent_epoch; + let found_entries = i64::try_from(self.beacon_entries.len()) + .map_err(|_| Error::Validation("too many beacon entries".into()))?; + if found_entries != covered_epochs { + return Err(Error::Validation( + format!( + "expected {covered_epochs} beacon entries for epochs {}..={}, got: {found_entries}", + parent_epoch + 1, + self.epoch, + ) + .into(), + )); + } + + // Lengths match, so `zip` pairs every entry with the epoch it must cover. + for (beacon_entry, lookup_epoch) in self + .beacon_entries + .iter() + .zip((parent_epoch + 1)..=self.epoch) + { + let expected_round = + curr_beacon.max_beacon_round_for_epoch(network_version, lookup_epoch); if beacon_entry.round() != expected_round { return Err(Error::Validation( - format!("expected max round for epoch {} to be {}, got: {}", + format!( + "expected max round for epoch {} to be {}, got: {}", lookup_epoch, expected_round, beacon_entry.round(), @@ -178,8 +221,6 @@ impl RawBlockHeader { } } } - - if !curr_beacon .verify_entries(&self.beacon_entries, prev_entry) @@ -353,7 +394,10 @@ impl<'de> Deserialize<'de> for CachingBlockHeader { #[cfg(test)] mod tests { use super::*; - use crate::beacon::{BeaconEntry, BeaconPoint, BeaconSchedule, mock_beacon::MockBeacon}; + use crate::beacon::{ + BeaconEntry, BeaconPoint, BeaconSchedule, mock_beacon::MockBeacon, + tests::drand::new_beacon_quicknet, + }; use crate::blocks::{CachingBlockHeader, Error}; use crate::shim::clock::ChainEpoch; use crate::shim::{address::Address, version::NetworkVersion}; @@ -434,8 +478,41 @@ mod tests { ); } - #[test] - fn test_validate_block_drand_when_prev_epoch_gaps() { - + #[tokio::test] + async fn validate_block_drand_accepts_correct_entries_quicknet() { + // (parent epoch, its beacon round, block epoch, rounds the header must carry) + let cases = [ + // no gaps, expected only a single entry + (6216199, 30662992, 6216200, vec![30663002]), + // one gap (6216199) so the block 6216200 should have its and the 6216199 beacon entries + (6216198, 30662982, 6216200, vec![30662992, 30663002]), + ]; + + let schedule = BeaconSchedule(vec![BeaconPoint::new(0, new_beacon_quicknet())]); + + for (parent_epoch, prev_round, epoch, rounds) in cases { + let (_, curr_beacon) = schedule.beacon_for_epoch(epoch).unwrap(); + + // fetch the real entries so BLS verifies correctly + let mut beacon_entries = Vec::with_capacity(rounds.len()); + for round in rounds { + beacon_entries.push(curr_beacon.entry(round).await.unwrap()); + } + + // Unchained verification never reads `prev_entry`, only its round is used + // to decide whether drand has ticked since the parent. + let prev_entry = BeaconEntry::new(prev_round, vec![]); + + let header = RawBlockHeader { + miner_address: Address::new_id(0), + epoch, + beacon_entries, + ..Default::default() + }; + + header + .validate_block_drand(NetworkVersion::V22, &schedule, parent_epoch, &prev_entry) + .unwrap_or_else(|e| panic!("epoch {epoch}, parent {parent_epoch}: {e}")); + } } } From ca5328167d3c24bbe3f87d13de656fa9668d01e9 Mon Sep 17 00:00:00 2001 From: EclesioMeloJunior Date: Tue, 4 Aug 2026 14:35:12 -0400 Subject: [PATCH 03/13] chore: update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f41141b4dc1..afd0a8e8b6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,8 @@ ### Fixed +- [#7412](https://github.com/ChainSafe/forest/issues/7412): Block validation on quicknet now rejects headers whose beacon entries do not cover every epoch since the parent tipset + ## Forest v0.35.0 "Shravan" Non-mandatory release for all node operators. It includes some fixes and improvements, notably around state-related RPC. Note that this release contains breaking changes, so please read the changelog carefully before upgrading. From 8e84f8c0c99824ab21bbb6d1b4e920ed145cf7c1 Mon Sep 17 00:00:00 2001 From: EclesioMeloJunior Date: Tue, 4 Aug 2026 15:18:25 -0400 Subject: [PATCH 04/13] chore: fix CHANGELOG.md issue ref --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afd0a8e8b6a..5062a620a8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,7 @@ ### Fixed -- [#7412](https://github.com/ChainSafe/forest/issues/7412): Block validation on quicknet now rejects headers whose beacon entries do not cover every epoch since the parent tipset +- [#7413](https://github.com/ChainSafe/forest/issues/7413): Block validation on quicknet now rejects headers whose beacon entries do not cover every epoch since the parent tipset ## Forest v0.35.0 "Shravan" From 0096657517f609d29c182778c1657e4e18df569f Mon Sep 17 00:00:00 2001 From: EclesioMeloJunior Date: Wed, 5 Aug 2026 09:06:34 -0400 Subject: [PATCH 05/13] chore: use `izip!`, introduce 2 invalid cases to the tests --- src/blocks/header.rs | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/src/blocks/header.rs b/src/blocks/header.rs index 2cc6a612ecd..7b71e3ccf65 100644 --- a/src/blocks/header.rs +++ b/src/blocks/header.rs @@ -20,6 +20,7 @@ use fvm_ipld_blockstore::Blockstore; use fvm_ipld_encoding::CborStore as _; use fvm_ipld_encoding::tuple::*; use get_size2::GetSize; +use itertools::izip; use multihash_derive::MultihashDigest as _; use num::BigInt; use serde::{Deserialize, Serialize}; @@ -201,10 +202,8 @@ impl RawBlockHeader { } // Lengths match, so `zip` pairs every entry with the epoch it must cover. - for (beacon_entry, lookup_epoch) in self - .beacon_entries - .iter() - .zip((parent_epoch + 1)..=self.epoch) + for (beacon_entry, lookup_epoch) in izip!( + self.beacon_entries.iter(), (parent_epoch+1)..=self.epoch) { let expected_round = curr_beacon.max_beacon_round_for_epoch(network_version, lookup_epoch); @@ -479,18 +478,19 @@ mod tests { } #[tokio::test] - async fn validate_block_drand_accepts_correct_entries_quicknet() { - // (parent epoch, its beacon round, block epoch, rounds the header must carry) + async fn validate_beacon_entries_on_quicknet() { + // (acse name, parent epoch, its beacon round, block epoch, rounds the header must carry, expect fail) let cases = [ - // no gaps, expected only a single entry - (6216199, 30662992, 6216200, vec![30663002]), - // one gap (6216199) so the block 6216200 should have its and the 6216199 beacon entries - (6216198, 30662982, 6216200, vec![30662992, 30663002]), + ("no null round", 6216199, 30662992, 6216200, vec![30663002], true), + ("null round, both entries", 6216198, 30662982, 6216200, vec![30662992, 30663002], true), + ("null round, invalid entry", 6216198, 30662982, 6216200, vec![30662990, 30663002], false), + ("null round, missing the null epoch's entry", 6216198, 30662982, 6216200, vec![30663002], false), + ("null round, missing the block's own entry", 6216198, 30662982, 6216200, vec![30662992], false), ]; let schedule = BeaconSchedule(vec![BeaconPoint::new(0, new_beacon_quicknet())]); - for (parent_epoch, prev_round, epoch, rounds) in cases { + for (case, parent_epoch, prev_round, epoch, rounds, success) in cases { let (_, curr_beacon) = schedule.beacon_for_epoch(epoch).unwrap(); // fetch the real entries so BLS verifies correctly @@ -510,9 +510,15 @@ mod tests { ..Default::default() }; - header - .validate_block_drand(NetworkVersion::V22, &schedule, parent_epoch, &prev_entry) - .unwrap_or_else(|e| panic!("epoch {epoch}, parent {parent_epoch}: {e}")); + let result = header + .validate_block_drand(NetworkVersion::V22, &schedule, parent_epoch, &prev_entry); + + assert_eq!( + result.is_ok(), + success, + "{case} (epoch {epoch}, parent {parent_epoch}): {result:?}" + ); } } + } From 89ac8fa3c8249817078eafe5cb6a9fce108637c1 Mon Sep 17 00:00:00 2001 From: EclesioMeloJunior Date: Wed, 5 Aug 2026 09:12:30 -0400 Subject: [PATCH 06/13] chore: include lotus reference for unchained validation --- src/blocks/header.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/blocks/header.rs b/src/blocks/header.rs index 7b71e3ccf65..2a362c507ca 100644 --- a/src/blocks/header.rs +++ b/src/blocks/header.rs @@ -186,6 +186,7 @@ impl RawBlockHeader { // covers every epoch since its parent - normally just its own, but null rounds // can happen and it must carry exactly one entry per covered epoch, in // ascending order. + // ref: https://github.com/filecoin-project/lotus/blob/27abf0f16a7f2a83305910f3c2a1844764d20b75/chain/beacon/beacon.go#L95 if curr_beacon.network().is_unchained() { let covered_epochs = self.epoch - parent_epoch; let found_entries = i64::try_from(self.beacon_entries.len()) From 8e4fb8a2d070cd18ebf9d880033a7e051c198816 Mon Sep 17 00:00:00 2001 From: EclesioMeloJunior Date: Wed, 5 Aug 2026 09:22:21 -0400 Subject: [PATCH 07/13] chore: remove unneeded `..=self.epoch` --- src/blocks/header.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blocks/header.rs b/src/blocks/header.rs index 2a362c507ca..51f13f13717 100644 --- a/src/blocks/header.rs +++ b/src/blocks/header.rs @@ -204,7 +204,7 @@ impl RawBlockHeader { // Lengths match, so `zip` pairs every entry with the epoch it must cover. for (beacon_entry, lookup_epoch) in izip!( - self.beacon_entries.iter(), (parent_epoch+1)..=self.epoch) + self.beacon_entries.iter(), (parent_epoch+1)..) { let expected_round = curr_beacon.max_beacon_round_for_epoch(network_version, lookup_epoch); From c087b026a5c6de264998d8fe259d87a505e134be Mon Sep 17 00:00:00 2001 From: EclesioMeloJunior Date: Wed, 5 Aug 2026 10:30:40 -0400 Subject: [PATCH 08/13] chore: solve lint --- src/blocks/header.rs | 58 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/src/blocks/header.rs b/src/blocks/header.rs index 51f13f13717..a9b921901b7 100644 --- a/src/blocks/header.rs +++ b/src/blocks/header.rs @@ -203,8 +203,8 @@ impl RawBlockHeader { } // Lengths match, so `zip` pairs every entry with the epoch it must cover. - for (beacon_entry, lookup_epoch) in izip!( - self.beacon_entries.iter(), (parent_epoch+1)..) + for (beacon_entry, lookup_epoch) in + izip!(self.beacon_entries.iter(), (parent_epoch + 1)..) { let expected_round = curr_beacon.max_beacon_round_for_epoch(network_version, lookup_epoch); @@ -482,11 +482,46 @@ mod tests { async fn validate_beacon_entries_on_quicknet() { // (acse name, parent epoch, its beacon round, block epoch, rounds the header must carry, expect fail) let cases = [ - ("no null round", 6216199, 30662992, 6216200, vec![30663002], true), - ("null round, both entries", 6216198, 30662982, 6216200, vec![30662992, 30663002], true), - ("null round, invalid entry", 6216198, 30662982, 6216200, vec![30662990, 30663002], false), - ("null round, missing the null epoch's entry", 6216198, 30662982, 6216200, vec![30663002], false), - ("null round, missing the block's own entry", 6216198, 30662982, 6216200, vec![30662992], false), + ( + "no null round", + 6216199, + 30662992, + 6216200, + vec![30663002], + true, + ), + ( + "null round, both entries", + 6216198, + 30662982, + 6216200, + vec![30662992, 30663002], + true, + ), + ( + "null round, invalid entry", + 6216198, + 30662982, + 6216200, + vec![30662990, 30663002], + false, + ), + ( + "null round, missing the null epoch's entry", + 6216198, + 30662982, + 6216200, + vec![30663002], + false, + ), + ( + "null round, missing the block's own entry", + 6216198, + 30662982, + 6216200, + vec![30662992], + false, + ), ]; let schedule = BeaconSchedule(vec![BeaconPoint::new(0, new_beacon_quicknet())]); @@ -511,8 +546,12 @@ mod tests { ..Default::default() }; - let result = header - .validate_block_drand(NetworkVersion::V22, &schedule, parent_epoch, &prev_entry); + let result = header.validate_block_drand( + NetworkVersion::V22, + &schedule, + parent_epoch, + &prev_entry, + ); assert_eq!( result.is_ok(), @@ -521,5 +560,4 @@ mod tests { ); } } - } From a64e36634e10ba66e4f3107aba75fd21a5b11841 Mon Sep 17 00:00:00 2001 From: EclesioMeloJunior Date: Wed, 5 Aug 2026 11:28:14 -0400 Subject: [PATCH 09/13] chore: remove uneeded length check, and bound zipping to `..=self.epoch` --- src/blocks/header.rs | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/blocks/header.rs b/src/blocks/header.rs index a9b921901b7..2894b9a46a4 100644 --- a/src/blocks/header.rs +++ b/src/blocks/header.rs @@ -181,6 +181,7 @@ impl RawBlockHeader { )); } + // An unchained beacon carries no link between consecutive entries, so nothing // but these checks ties them to the epochs they are supposed to cover. A block // covers every epoch since its parent - normally just its own, but null rounds @@ -188,23 +189,10 @@ impl RawBlockHeader { // ascending order. // ref: https://github.com/filecoin-project/lotus/blob/27abf0f16a7f2a83305910f3c2a1844764d20b75/chain/beacon/beacon.go#L95 if curr_beacon.network().is_unchained() { - let covered_epochs = self.epoch - parent_epoch; - let found_entries = i64::try_from(self.beacon_entries.len()) - .map_err(|_| Error::Validation("too many beacon entries".into()))?; - if found_entries != covered_epochs { - return Err(Error::Validation( - format!( - "expected {covered_epochs} beacon entries for epochs {}..={}, got: {found_entries}", - parent_epoch + 1, - self.epoch, - ) - .into(), - )); - } - - // Lengths match, so `zip` pairs every entry with the epoch it must cover. + // we already made sure that the last beacon entry matches the current block epoch + // now this loop covers a possible gap between parent block and the current block for (beacon_entry, lookup_epoch) in - izip!(self.beacon_entries.iter(), (parent_epoch + 1)..) + izip!(self.beacon_entries.iter(), (parent_epoch + 1)..=self.epoch) { let expected_round = curr_beacon.max_beacon_round_for_epoch(network_version, lookup_epoch); @@ -480,7 +468,7 @@ mod tests { #[tokio::test] async fn validate_beacon_entries_on_quicknet() { - // (acse name, parent epoch, its beacon round, block epoch, rounds the header must carry, expect fail) + // (case name, parent epoch, its beacon round, block epoch, rounds the header must carry, expect fail) let cases = [ ( "no null round", From c3dbe8b62ff53823845fe9a43b321d44819eaca1 Mon Sep 17 00:00:00 2001 From: EclesioMeloJunior Date: Wed, 5 Aug 2026 14:37:33 -0400 Subject: [PATCH 10/13] chore: use `rstest` and stack the tests cases and define `BeaconSchedule` as a fixture --- src/blocks/header.rs | 166 +++++++++++++++++++++++-------------------- 1 file changed, 89 insertions(+), 77 deletions(-) diff --git a/src/blocks/header.rs b/src/blocks/header.rs index 2894b9a46a4..cae456734a3 100644 --- a/src/blocks/header.rs +++ b/src/blocks/header.rs @@ -181,7 +181,6 @@ impl RawBlockHeader { )); } - // An unchained beacon carries no link between consecutive entries, so nothing // but these checks ties them to the epochs they are supposed to cover. A block // covers every epoch since its parent - normally just its own, but null rounds @@ -394,6 +393,7 @@ mod tests { use crate::utils::multihash::MultihashCode; use cid::Cid; use fvm_ipld_encoding::{DAG_CBOR, to_vec}; + use rstest::{fixture, rstest}; impl quickcheck::Arbitrary for CachingBlockHeader { fn arbitrary(g: &mut quickcheck::Gen) -> Self { @@ -466,86 +466,98 @@ mod tests { ); } - #[tokio::test] - async fn validate_beacon_entries_on_quicknet() { - // (case name, parent epoch, its beacon round, block epoch, rounds the header must carry, expect fail) - let cases = [ - ( - "no null round", - 6216199, - 30662992, - 6216200, - vec![30663002], - true, - ), - ( - "null round, both entries", - 6216198, - 30662982, - 6216200, - vec![30662992, 30663002], - true, - ), - ( - "null round, invalid entry", - 6216198, - 30662982, - 6216200, - vec![30662990, 30663002], - false, - ), - ( - "null round, missing the null epoch's entry", - 6216198, - 30662982, - 6216200, - vec![30663002], - false, - ), - ( - "null round, missing the block's own entry", - 6216198, - 30662982, - 6216200, - vec![30662992], - false, - ), - ]; + #[fixture] + #[once] + fn schedule() -> BeaconSchedule { + BeaconSchedule(vec![BeaconPoint::new(0, new_beacon_quicknet())]) + } - let schedule = BeaconSchedule(vec![BeaconPoint::new(0, new_beacon_quicknet())]); + /// A single case for [`validate_beacon_entries_on_quicknet`]. + #[derive(Debug)] + struct BeaconEntriesCase { + parent_epoch: ChainEpoch, + prev_round: u64, + epoch: ChainEpoch, + rounds: Vec, + accepted: bool, + } - for (case, parent_epoch, prev_round, epoch, rounds, success) in cases { - let (_, curr_beacon) = schedule.beacon_for_epoch(epoch).unwrap(); + #[rstest] + // no gaps, a single entry is all the block owes + #[case::no_null_round(BeaconEntriesCase { + parent_epoch: 6216199, + prev_round: 30662992, + epoch: 6216200, + rounds: vec![30663002], + accepted: true, + })] + // one gap (6216199), so the block carries its own entry and 6216199's + #[case::null_round_both_entries(BeaconEntriesCase { + parent_epoch: 6216198, + prev_round: 30662982, + epoch: 6216200, + rounds: vec![30662992, 30663002], + accepted: true, + })] + // the first entry is not the round that epoch 6216199 maps to + #[case::null_round_invalid_entry(BeaconEntriesCase { + parent_epoch: 6216198, + prev_round: 30662982, + epoch: 6216200, + rounds: vec![30662990, 30663002], + accepted: false, + })] + // the null epoch's entry is missing: too few entries for the covered range + #[case::null_round_missing_null_entry(BeaconEntriesCase { + parent_epoch: 6216198, + prev_round: 30662982, + epoch: 6216200, + rounds: vec![30663002], + accepted: false, + })] + // the block's own entry is missing, so the last entry isn't at `max_round` + #[case::null_round_missing_own_entry(BeaconEntriesCase { + parent_epoch: 6216198, + prev_round: 30662982, + epoch: 6216200, + rounds: vec![30662992], + accepted: false, + })] + #[tokio::test] + async fn validate_beacon_entries_on_quicknet( + schedule: &BeaconSchedule, + #[case] case: BeaconEntriesCase, + ) { + let BeaconEntriesCase { + parent_epoch, + prev_round, + epoch, + rounds, + accepted, + } = case; + + let (_, curr_beacon) = schedule.beacon_for_epoch(epoch).unwrap(); + + let mut beacon_entries = Vec::with_capacity(rounds.len()); + for round in rounds { + beacon_entries.push(curr_beacon.entry(round).await.unwrap()); + } - // fetch the real entries so BLS verifies correctly - let mut beacon_entries = Vec::with_capacity(rounds.len()); - for round in rounds { - beacon_entries.push(curr_beacon.entry(round).await.unwrap()); - } + let prev_entry = BeaconEntry::new(prev_round, vec![]); + let header = RawBlockHeader { + miner_address: Address::new_id(0), + epoch, + beacon_entries, + ..Default::default() + }; - // Unchained verification never reads `prev_entry`, only its round is used - // to decide whether drand has ticked since the parent. - let prev_entry = BeaconEntry::new(prev_round, vec![]); + let result = + header.validate_block_drand(NetworkVersion::V22, schedule, parent_epoch, &prev_entry); - let header = RawBlockHeader { - miner_address: Address::new_id(0), - epoch, - beacon_entries, - ..Default::default() - }; - - let result = header.validate_block_drand( - NetworkVersion::V22, - &schedule, - parent_epoch, - &prev_entry, - ); - - assert_eq!( - result.is_ok(), - success, - "{case} (epoch {epoch}, parent {parent_epoch}): {result:?}" - ); - } + assert_eq!( + result.is_ok(), + accepted, + "epoch {epoch}, parent {parent_epoch}: {result:?}" + ); } } From 6c46c3b43929584d51e1dab816c004400019d735 Mon Sep 17 00:00:00 2001 From: EclesioMeloJunior Date: Thu, 6 Aug 2026 11:17:53 -0400 Subject: [PATCH 11/13] chore: unbound `izip!` --- src/blocks/header.rs | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/blocks/header.rs b/src/blocks/header.rs index cae456734a3..5ca3d29fecc 100644 --- a/src/blocks/header.rs +++ b/src/blocks/header.rs @@ -188,10 +188,13 @@ impl RawBlockHeader { // ascending order. // ref: https://github.com/filecoin-project/lotus/blob/27abf0f16a7f2a83305910f3c2a1844764d20b75/chain/beacon/beacon.go#L95 if curr_beacon.network().is_unchained() { - // we already made sure that the last beacon entry matches the current block epoch - // now this loop covers a possible gap between parent block and the current block + // We already made sure that the last beacon entry matches the current block + // epoch, and this loop covers a possible gap between the parent block and + // the current one. The epoch range is deliberately unbounded so that every + // entry is checked: an entry past `self.epoch` has no epoch to cover and is + // rejected against the round of the epoch that would follow this block. for (beacon_entry, lookup_epoch) in - izip!(self.beacon_entries.iter(), (parent_epoch + 1)..=self.epoch) + izip!(self.beacon_entries.iter(), (parent_epoch + 1)..) { let expected_round = curr_beacon.max_beacon_round_for_epoch(network_version, lookup_epoch); @@ -523,6 +526,21 @@ mod tests { rounds: vec![30662992], accepted: false, })] + // a duplicated final entry: one more entry than the covered range + #[case::extra_trailing_entry(BeaconEntriesCase { + parent_epoch: 6216199, + prev_round: 30662992, + epoch: 6216200, + rounds: vec![30663002, 30663002], + accepted: false, + })] + #[case::null_round_extra_trailing_entry(BeaconEntriesCase { + parent_epoch: 6216198, + prev_round: 30662982, + epoch: 6216200, + rounds: vec![30662992, 30663002, 30663002], + accepted: false, + })] #[tokio::test] async fn validate_beacon_entries_on_quicknet( schedule: &BeaconSchedule, From 421319054f28c3d9482621cb2ad4c419f2674264 Mon Sep 17 00:00:00 2001 From: EclesioMeloJunior Date: Thu, 6 Aug 2026 14:38:02 -0400 Subject: [PATCH 12/13] chore: address comments --- src/blocks/header.rs | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/src/blocks/header.rs b/src/blocks/header.rs index 5ca3d29fecc..d967cfee7d3 100644 --- a/src/blocks/header.rs +++ b/src/blocks/header.rs @@ -20,7 +20,6 @@ use fvm_ipld_blockstore::Blockstore; use fvm_ipld_encoding::CborStore as _; use fvm_ipld_encoding::tuple::*; use get_size2::GetSize; -use itertools::izip; use multihash_derive::MultihashDigest as _; use num::BigInt; use serde::{Deserialize, Serialize}; @@ -181,21 +180,11 @@ impl RawBlockHeader { )); } - // An unchained beacon carries no link between consecutive entries, so nothing - // but these checks ties them to the epochs they are supposed to cover. A block - // covers every epoch since its parent - normally just its own, but null rounds - // can happen and it must carry exactly one entry per covered epoch, in - // ascending order. // ref: https://github.com/filecoin-project/lotus/blob/27abf0f16a7f2a83305910f3c2a1844764d20b75/chain/beacon/beacon.go#L95 if curr_beacon.network().is_unchained() { - // We already made sure that the last beacon entry matches the current block - // epoch, and this loop covers a possible gap between the parent block and - // the current one. The epoch range is deliberately unbounded so that every - // entry is checked: an entry past `self.epoch` has no epoch to cover and is - // rejected against the round of the epoch that would follow this block. - for (beacon_entry, lookup_epoch) in - izip!(self.beacon_entries.iter(), (parent_epoch + 1)..) - { + for (idx, beacon_entry) in self.beacon_entries.iter().enumerate() { + let lookup_epoch = parent_epoch + 1 + idx as i64; + let expected_round = curr_beacon.max_beacon_round_for_epoch(network_version, lookup_epoch); if beacon_entry.round() != expected_round { @@ -475,7 +464,6 @@ mod tests { BeaconSchedule(vec![BeaconPoint::new(0, new_beacon_quicknet())]) } - /// A single case for [`validate_beacon_entries_on_quicknet`]. #[derive(Debug)] struct BeaconEntriesCase { parent_epoch: ChainEpoch, @@ -486,7 +474,6 @@ mod tests { } #[rstest] - // no gaps, a single entry is all the block owes #[case::no_null_round(BeaconEntriesCase { parent_epoch: 6216199, prev_round: 30662992, @@ -494,7 +481,6 @@ mod tests { rounds: vec![30663002], accepted: true, })] - // one gap (6216199), so the block carries its own entry and 6216199's #[case::null_round_both_entries(BeaconEntriesCase { parent_epoch: 6216198, prev_round: 30662982, @@ -502,7 +488,6 @@ mod tests { rounds: vec![30662992, 30663002], accepted: true, })] - // the first entry is not the round that epoch 6216199 maps to #[case::null_round_invalid_entry(BeaconEntriesCase { parent_epoch: 6216198, prev_round: 30662982, @@ -510,7 +495,6 @@ mod tests { rounds: vec![30662990, 30663002], accepted: false, })] - // the null epoch's entry is missing: too few entries for the covered range #[case::null_round_missing_null_entry(BeaconEntriesCase { parent_epoch: 6216198, prev_round: 30662982, @@ -518,7 +502,6 @@ mod tests { rounds: vec![30663002], accepted: false, })] - // the block's own entry is missing, so the last entry isn't at `max_round` #[case::null_round_missing_own_entry(BeaconEntriesCase { parent_epoch: 6216198, prev_round: 30662982, @@ -526,7 +509,6 @@ mod tests { rounds: vec![30662992], accepted: false, })] - // a duplicated final entry: one more entry than the covered range #[case::extra_trailing_entry(BeaconEntriesCase { parent_epoch: 6216199, prev_round: 30662992, From 86b53b169838698a41c13b2ab2d544517cf10fa7 Mon Sep 17 00:00:00 2001 From: Hubert Bugaj Date: Fri, 7 Aug 2026 12:44:57 +0200 Subject: [PATCH 13/13] bump (gh outage)