Skip to main content

espresso_types/v0/impls/
committee.rs

1use std::{
2    collections::{BTreeMap, HashMap},
3    ops::Bound,
4    sync::Arc,
5};
6
7use alloy::primitives::{Address, U256};
8use anyhow::{Context, bail};
9use async_lock::Mutex as AsyncMutex;
10use committable::Commitment;
11use hotshot_types::{
12    PeerConfig, PeerConnectInfo,
13    data::{BlockNumber, EpochNumber, ViewNumber},
14    drb::{
15        DrbResult,
16        election::{RandomizedCommittee, generate_stake_cdf, select_randomized_leader},
17    },
18    epoch_membership::EpochMembershipCoordinator,
19    signature_key::BLSPubKey,
20    stake_table::StakeTableEntry,
21    traits::{
22        election::{Membership, MembershipSnapshot, NonEpochMembershipSnapshot},
23        signature_key::{SignatureKey as _, StakeTableEntryType},
24    },
25    utils::{epoch_from_block_number, root_block_in_epoch, transition_block_for_epoch},
26};
27#[cfg(feature = "node")]
28use hotshot_types::{traits::block_contents::BlockHeader, utils::is_epoch_root};
29use indexmap::IndexMap;
30use parking_lot::RwLock;
31use thiserror::Error;
32use tracing::{debug, error, info, warn};
33#[cfg(feature = "node")]
34use versions::DRB_AND_HEADER_UPGRADE_VERSION;
35use versions::EPOCH_VERSION;
36
37use super::{
38    AuthenticatedValidatorMap, RegisteredValidatorMap, StakeTableHash, StakeTableState,
39    compute_block_reward,
40};
41use crate::{
42    Header, Leaf2, PubKey, SeqTypes,
43    traits::StateCatchup,
44    v0_3::{ASSUMED_BLOCK_TIME_SECONDS, AuthenticatedValidator, Fetcher, RewardAmount},
45};
46
47/// Type to describe DA and Stake memberships.
48#[derive(Clone, Debug)]
49pub struct EpochCommittees {
50    inner: Arc<RwLock<Inner>>,
51    fetcher: Arc<Fetcher>,
52    #[cfg_attr(not(feature = "node"), allow(dead_code))]
53    update_fixed_block_reward_lock: Arc<AsyncMutex<()>>,
54    epoch_height: BlockNumber,
55}
56
57#[derive(Debug)]
58struct Inner {
59    /// Captured pre-epoch view.
60    ///
61    /// Built once at constructor time. `add_da_committee` operates on the
62    /// per-epoch `da_committees` map and does not modify the pre-epoch view.
63    non_epoch_snapshot: NonEpochSnapshot,
64
65    /// Per-epoch snapshots.
66    ///
67    /// Each mutator rebuilds the snapshot for its affected epoch(s).
68    snapshots: BTreeMap<EpochNumber, EpochSnapshot>,
69
70    /// Holds the full validator candidate sets temporarily, until we store them.
71    #[cfg_attr(not(feature = "node"), allow(dead_code))]
72    all_validators: BTreeMap<EpochNumber, RegisteredValidatorMap>,
73
74    /// DA committees, indexed by the first epoch in which they apply.
75    ///
76    /// Kept separate from `snapshots` because the lookup is a range query.
77    da_committees: BTreeMap<EpochNumber, Arc<DaCommittee>>,
78
79    first_epoch: Option<EpochNumber>,
80
81    /// Fixed block reward (used only in V3).
82    ///
83    /// Starting from V4, block reward is dynamic
84    fixed_block_reward: Option<RewardAmount>,
85}
86
87#[derive(Clone, Debug)]
88struct DaCommittee {
89    committee: Vec<PeerConfig<SeqTypes>>,
90    indexed_committee: HashMap<PubKey, PeerConfig<SeqTypes>>,
91}
92
93/// Pre-epoch stake-table state.
94#[derive(Debug)]
95struct NonEpochCommittee {
96    /// The nodes eligible for leadership.
97    ///
98    /// NOTE: This is currently a hack because the DA leader needs to be the quorum
99    /// leader but without voting rights.
100    eligible_leaders: Vec<PeerConfig<SeqTypes>>,
101
102    /// Keys for nodes participating in the network
103    stake_table: Vec<PeerConfig<SeqTypes>>,
104
105    /// Stake entries indexed by public key, for efficient lookup.
106    indexed_stake_table: HashMap<PubKey, PeerConfig<SeqTypes>>,
107}
108
109/// Holds Stake table and da stake
110#[derive(Debug)]
111struct EpochCommittee {
112    /// The nodes eligible for leadership.
113    ///
114    /// NOTE: This is currently a hack because the DA leader needs to be the quorum
115    /// leader but without voting rights.
116    eligible_leaders: Vec<PeerConfig<SeqTypes>>,
117    /// Keys for nodes participating in the network
118    stake_table: IndexMap<PubKey, PeerConfig<SeqTypes>>,
119    validators: AuthenticatedValidatorMap,
120    address_mapping: HashMap<BLSPubKey, Address>,
121    block_reward: Option<RewardAmount>,
122    stake_table_hash: Option<StakeTableHash>,
123    header: Option<Header>,
124}
125
126impl EpochCommittee {
127    fn new(
128        validators: AuthenticatedValidatorMap,
129        block_reward: Option<RewardAmount>,
130        hash: Option<StakeTableHash>,
131        header: Option<Header>,
132    ) -> Self {
133        let mut address_mapping = HashMap::new();
134        let stake_table: IndexMap<PubKey, PeerConfig<SeqTypes>> = validators
135            .values()
136            .map(|v| {
137                let key = *v.stake_table_key();
138                address_mapping.insert(key, v.account);
139                (
140                    key,
141                    PeerConfig {
142                        stake_table_entry: BLSPubKey::stake_table_entry(&key, v.stake),
143                        state_ver_key: v.state_ver_key().clone(),
144                        connect_info: v.x25519_key.and_then(|p| {
145                            let a = v.p2p_addr.clone()?;
146                            Some(PeerConnectInfo {
147                                x25519_key: p,
148                                p2p_addr: a,
149                            })
150                        }),
151                    },
152                )
153            })
154            .collect();
155
156        let eligible_leaders: Vec<PeerConfig<SeqTypes>> =
157            stake_table.iter().map(|(_, l)| l.clone()).collect();
158
159        Self {
160            eligible_leaders,
161            stake_table,
162            validators,
163            address_mapping,
164            block_reward,
165            stake_table_hash: hash,
166            header,
167        }
168    }
169}
170
171impl EpochCommittees {
172    pub fn epoch_height(&self) -> BlockNumber {
173        self.epoch_height
174    }
175
176    pub fn first_epoch(&self) -> Option<EpochNumber> {
177        self.inner.read().first_epoch
178    }
179
180    pub fn fetcher(&self) -> &Fetcher {
181        &self.fetcher
182    }
183
184    pub fn fixed_block_reward(&self) -> Option<RewardAmount> {
185        self.inner.read().fixed_block_reward
186    }
187
188    /// Find the most recent stake-table entry for `key`.
189    ///
190    /// Scanns loaded epochs from highest to lowest and falling back to the
191    /// genesis bootstrap committee.
192    pub fn latest_peer_config(&self, key: &PubKey) -> Option<PeerConfig<SeqTypes>> {
193        let inner = self.inner.read();
194        for snap in inner.snapshots.values().rev() {
195            if let Some(cfg) = snap.inner.committee.stake_table.get(key) {
196                return Some(cfg.clone());
197            }
198        }
199        inner
200            .non_epoch_snapshot
201            .inner
202            .committee
203            .indexed_stake_table
204            .get(key)
205            .cloned()
206    }
207
208    /// Fetch the fixed block reward and update it if its None.
209    /// We used a fixed block reward for version v3
210    /// Version v4 uses the dynamic block reward
211    /// Assumes the stake table contract proxy address does not change
212    #[cfg(feature = "node")]
213    async fn fetch_and_update_fixed_block_reward(
214        &self,
215        epoch: EpochNumber,
216    ) -> anyhow::Result<RewardAmount> {
217        // Ensure there is only one `fetch_and_update_fixed_block_reward` at a time:
218        let _guard = self.update_fixed_block_reward_lock.lock().await;
219
220        // Clippy claims "temporary with significant `Drop` in `if let`
221        // scrutinee will live until the end of the `if let` expression",
222        // however this is incorrect. The 2024 edition changed the drop
223        // scope of `if-let` expressions:
224        //
225        // https://doc.rust-lang.org/edition-guide/rust-2024/temporary-if-let-scope.html
226        //
227        // The read guard is dropped before `else`.
228        #[allow(clippy::significant_drop_in_scrutinee)]
229        if let Some(reward) = self.inner.read().fixed_block_reward {
230            Ok(reward)
231        } else {
232            warn!(%epoch,
233                "Block reward is None. attempting to fetch it from L1",
234            );
235            let block_reward =
236                self.fetcher
237                    .fetch_fixed_block_reward()
238                    .await
239                    .inspect_err(|err| {
240                        error!(?epoch, ?err, "failed to fetch block_reward");
241                    })?;
242            self.inner.write().fixed_block_reward = Some(block_reward);
243            Ok(block_reward)
244        }
245    }
246
247    /// Calculates the dynamic block reward for a given block header within an epoch.
248    ///
249    /// The reward is based on a dynamic inflation rate computed from the current stake ratio (p),
250    /// where `p = total_stake / total_supply`. The inflation function R(p) is defined piecewise:
251    /// - If `p <= 0.01`: R(p) = 0.03 / sqrt(2 * 0.01)
252    /// - Else: R(p) = 0.03 / sqrt(2 * p)
253    async fn calculate_dynamic_block_reward(
254        &self,
255        epoch: EpochNumber,
256        header: &Header,
257        validators: &AuthenticatedValidatorMap,
258        coordinator: &EpochMembershipCoordinator<SeqTypes>,
259    ) -> anyhow::Result<Option<RewardAmount>> {
260        let epoch_height = self.epoch_height;
261        let current_epoch = epoch_from_block_number(header.height(), *epoch_height);
262        let previous_epoch = current_epoch
263            .checked_sub(1)
264            .context("underflow: cannot get previous epoch when current_epoch is 0")?;
265        debug!(?epoch, "previous_epoch={previous_epoch:?}");
266
267        let first_epoch = *self.first_epoch().context("first epoch is None")?;
268
269        // Return early if previous epoch is not the first two epochs
270        // and we don't have the stake table.
271        if previous_epoch > first_epoch + 1 && self.snapshot(previous_epoch.into()).is_none() {
272            warn!(?previous_epoch, "missing stake table for previous epoch");
273            return Ok(None);
274        }
275
276        let previous_reward_distributed = header
277            .total_reward_distributed()
278            .context("Invalid block header: missing total_reward_distributed field")?;
279
280        // Calculate total stake across all active validators
281        let total_stake: U256 = validators.values().map(|v| v.stake).sum();
282        let initial_supply = self.fetcher.initial_supply_or_fetch().await?;
283        let total_supply = initial_supply
284            .checked_add(previous_reward_distributed.0)
285            .context("initial_supply + previous_reward_distributed overflow")?;
286
287        // Calculate average block time over the last epoch
288        let curr_ts = header.timestamp_millis_internal();
289        debug!(?epoch, "curr_ts={curr_ts:?}");
290
291        // If the node starts from epoch version V4, there is no previous epoch root available.
292        // In this case, we assume a fixed average block time of 2000 milli seconds (2s)
293        // for the first epoch in which reward id distributed
294        let average_block_time_ms = if previous_epoch <= first_epoch + 1 {
295            ASSUMED_BLOCK_TIME_SECONDS as u64 * 1000 // 2 seconds in milliseconds
296        } else {
297            // We are calculating rewards for epoch `epoch`, so the current epoch should be `epoch - 2`.
298            // We need to calculate the average block time for the current epoch, so we need to know
299            // the previous epoch root which is stored with epoch `epoch - 1`, i.e. the next epoch.
300            let next_epoch = epoch
301                .checked_sub(1)
302                .context("underflow: cannot get next epoch when epoch is 0")?;
303            let prev_ts = match self.map_header(next_epoch, |h| h.timestamp_millis_internal()) {
304                Some(ts) => ts,
305                None => {
306                    info!(
307                        "Calculating rewards for epoch {}, we have no root leaf header for epoch \
308                         - 1. Fetching from peers",
309                        epoch
310                    );
311
312                    let root_height = header.height().checked_sub(*epoch_height).context(
313                        "Epoch height is greater than block height. cannot compute previous epoch \
314                         root height",
315                    )?;
316
317                    self.fetcher
318                        .peers
319                        .fetch_leaf(coordinator.clone(), root_height)
320                        .await
321                        .context("Epoch root leaf not found")?
322                        .block_header()
323                        .timestamp_millis_internal()
324                },
325            };
326
327            let time_diff = curr_ts.checked_sub(prev_ts).context(
328                "Current timestamp is earlier than previous. underflow in block time calculation",
329            )?;
330
331            time_diff
332                .checked_div(*epoch_height)
333                .context("Epoch height is zero. cannot compute average block time")?
334        };
335        info!(?epoch, %total_supply, %total_stake, %average_block_time_ms,
336                       "dynamic block reward parameters");
337
338        let block_reward =
339            compute_block_reward(epoch, total_supply, total_stake, average_block_time_ms)?;
340
341        Ok(Some(block_reward))
342    }
343
344    /// This function just returns the stored block reward in epoch committee
345    pub fn epoch_block_reward(&self, epoch: EpochNumber) -> Option<RewardAmount> {
346        self.inner
347            .read()
348            .epoch_committee(epoch)
349            .and_then(|committee| committee.block_reward)
350    }
351
352    /// Get the index of a validator's BLS key in the epoch's stake table.
353    /// Returns None if the validator is not in the stake table for this epoch.
354    ///
355    /// The index corresponds to the position in the `leader_counts` array in V6 headers.
356    pub fn get_validator_index(&self, epoch: EpochNumber, bls_key: &PubKey) -> Option<usize> {
357        self.inner
358            .read()
359            .epoch_committee(epoch)
360            .and_then(|committee| committee.stake_table.get_index_of(bls_key))
361    }
362
363    pub fn active_validators(&self, e: EpochNumber) -> anyhow::Result<AuthenticatedValidatorMap> {
364        self.inner.read().active_validators(e)
365    }
366
367    pub fn address(&self, e: EpochNumber, key: &BLSPubKey) -> anyhow::Result<Address> {
368        self.inner.read().address(e, key)
369    }
370
371    pub fn get_validator_config(
372        &self,
373        epoch: EpochNumber,
374        key: &BLSPubKey,
375    ) -> anyhow::Result<AuthenticatedValidator<BLSPubKey>> {
376        let inner = self.inner.read();
377        let address = inner.address(epoch, key)?;
378        let validators = inner.active_validators(epoch)?;
379        validators
380            .get(&address)
381            .context("validator not found")
382            .cloned()
383    }
384
385    // We need a constructor to match our concrete type.
386    pub fn new_stake<B: Into<BlockNumber>>(
387        // TODO remove `new` from trait and rename this to `new`.
388        // https://github.com/EspressoSystems/HotShot/commit/fcb7d54a4443e29d643b3bbc53761856aef4de8b
389        committee_members: Vec<PeerConfig<SeqTypes>>,
390        da_members: Vec<PeerConfig<SeqTypes>>,
391        fixed_block_reward: Option<RewardAmount>,
392        fetcher: Fetcher,
393        epoch_height: B,
394    ) -> Self {
395        // For each member, get the stake table entry
396        let stake_table: Vec<_> = committee_members
397            .iter()
398            .filter(|&peer_config| peer_config.stake_table_entry.stake() > U256::ZERO)
399            .cloned()
400            .collect();
401
402        let eligible_leaders = stake_table.clone();
403        // For each member, get the stake table entry
404        let da_members: Vec<_> = da_members
405            .iter()
406            .filter(|&peer_config| peer_config.stake_table_entry.stake() > U256::ZERO)
407            .cloned()
408            .collect();
409
410        // Index the stake table by public key
411        let indexed_stake_table: HashMap<PubKey, _> = stake_table
412            .iter()
413            .map(|peer_config| {
414                (
415                    PubKey::public_key(&peer_config.stake_table_entry),
416                    peer_config.clone(),
417                )
418            })
419            .collect();
420
421        // Index the stake table by public key
422        let indexed_da_members: HashMap<PubKey, _> = da_members
423            .iter()
424            .map(|peer_config| {
425                (
426                    PubKey::public_key(&peer_config.stake_table_entry),
427                    peer_config.clone(),
428                )
429            })
430            .collect();
431
432        let da_committee = Arc::new(DaCommittee {
433            committee: da_members,
434            indexed_committee: indexed_da_members,
435        });
436
437        let members = Arc::new(NonEpochCommittee {
438            eligible_leaders,
439            stake_table,
440            indexed_stake_table,
441        });
442
443        let non_epoch_snapshot = NonEpochSnapshot::new(members.clone(), da_committee.clone());
444
445        let epoch_committee = Arc::new(EpochCommittee {
446            eligible_leaders: members.eligible_leaders.clone(),
447            stake_table: members
448                .stake_table
449                .iter()
450                .map(|x| (PubKey::public_key(&x.stake_table_entry), x.clone()))
451                .collect(),
452            validators: Default::default(),
453            address_mapping: HashMap::new(),
454            block_reward: Default::default(),
455            stake_table_hash: None,
456            header: None,
457        });
458
459        let mut snapshots = BTreeMap::new();
460        snapshots.insert(
461            EpochNumber::genesis(),
462            EpochSnapshot::new(
463                EpochNumber::genesis(),
464                None,
465                epoch_committee.clone(),
466                None,
467                da_committee.clone(),
468            ),
469        );
470        // TODO: remove this, workaround for hotshot asking for stake tables from epoch 1
471        snapshots.insert(
472            EpochNumber::genesis() + 1u64,
473            EpochSnapshot::new(
474                EpochNumber::genesis() + 1u64,
475                None,
476                epoch_committee,
477                None,
478                da_committee,
479            ),
480        );
481
482        Self {
483            inner: Arc::new(RwLock::new(Inner {
484                non_epoch_snapshot,
485                da_committees: BTreeMap::new(),
486                snapshots,
487                all_validators: BTreeMap::new(),
488                first_epoch: None,
489                fixed_block_reward,
490            })),
491            fetcher: Arc::new(fetcher),
492            update_fixed_block_reward_lock: Arc::new(AsyncMutex::new(())),
493            epoch_height: epoch_height.into(),
494        }
495    }
496
497    #[cfg(feature = "node")]
498    pub async fn reload_stake(&mut self, limit: u64) {
499        match self.fetcher.fetch_fixed_block_reward().await {
500            Ok(block_reward) => {
501                info!("Fetched block reward: {block_reward}");
502                self.inner.write().fixed_block_reward = Some(block_reward);
503            },
504            Err(err) => {
505                warn!("Failed to fetch the block reward when reloading the stake tables: {err}");
506            },
507        }
508
509        // Load the 50 latest stored stake tables
510        let loaded_stake = match self
511            .fetcher
512            .persistence
513            .lock()
514            .await
515            .load_latest_stake(limit)
516            .await
517        {
518            Ok(Some(loaded)) => loaded,
519            Ok(None) => {
520                warn!("No stake table history found in persistence!");
521                return;
522            },
523            Err(e) => {
524                error!("Failed to load stake table history from persistence: {e}");
525                return;
526            },
527        };
528
529        for (epoch, (validators, block_reward), stake_table_hash) in loaded_stake {
530            let committee = EpochCommittee::new(validators, block_reward, stake_table_hash, None);
531            self.inner
532                .write()
533                .put_epoch_committee(epoch, Arc::new(committee));
534        }
535    }
536
537    /// Get root leaf header for a given epoch
538    fn map_header<E, F, R>(&self, epoch: E, f: F) -> Option<R>
539    where
540        E: Into<EpochNumber>,
541        F: FnMut(&Header) -> R,
542    {
543        self.inner
544            .read()
545            .epoch_committee(epoch.into())
546            .and_then(|committee| committee.header.as_ref().map(f))
547    }
548
549    fn randomized_committee(
550        &self,
551        epoch: EpochNumber,
552        drb: DrbResult,
553    ) -> Option<RandomizedCommittee<StakeTableEntry<PubKey>>> {
554        let inner = self.inner.read();
555        let Some(raw_stake_table) = inner.epoch_committee(epoch) else {
556            error!(
557                "randomized_committee({epoch}, {drb:?}) was called, but we do not yet have the \
558                 stake table for epoch {epoch}"
559            );
560            return None;
561        };
562
563        let leaders = raw_stake_table
564            .eligible_leaders
565            .clone()
566            .into_iter()
567            .map(|peer_config| peer_config.stake_table_entry)
568            .collect::<Vec<_>>();
569
570        Some(generate_stake_cdf(leaders, drb))
571    }
572}
573
574/// returns the block reward for the given epoch.
575///
576/// Reward depends on the epoch root header version:
577/// V3: Returns the fixed block reward as V3 only supports fixed reward
578/// >= V4 : Returns the dynamic block reward
579///
580/// It also attempts catchup for the root header if not present in the committee,
581/// and also for the stake table of the previous epoch
582/// before computing the dynamic block reward
583pub async fn fetch_and_calculate_block_reward(
584    coordinator: EpochMembershipCoordinator<SeqTypes>,
585    current_epoch: EpochNumber,
586) -> anyhow::Result<RewardAmount> {
587    let committee;
588    let first_epoch;
589    let fixed_block_reward;
590    {
591        let membership = coordinator.membership().inner.read();
592        fixed_block_reward = membership.fixed_block_reward;
593
594        committee = membership
595            .epoch_committee(current_epoch)
596            .context(format!("committee not found for epoch={current_epoch:?}"))?
597            .clone();
598
599        // Return early if committee has a reward already
600        if let Some(reward) = committee.block_reward {
601            return Ok(reward);
602        }
603
604        first_epoch = membership.first_epoch.context(format!(
605            "First epoch not initialized (current_epoch={current_epoch})"
606        ))?;
607    }
608
609    if *current_epoch <= *first_epoch + 1 {
610        bail!(
611            "epoch is in first two epochs: current_epoch={current_epoch}, \
612             first_epoch={first_epoch}"
613        );
614    }
615
616    let header = match committee.header.clone() {
617        Some(header) => header,
618        None => {
619            let root_epoch = current_epoch.checked_sub(2).context(format!(
620                "Epoch calculation underflow (current_epoch={current_epoch})"
621            ))?;
622
623            info!(?root_epoch, "catchup epoch root header");
624
625            let leaf = coordinator
626                .get_epoch_root(EpochNumber::new(root_epoch))
627                .await
628                .with_context(|| format!("Failed to get epoch root for root_epoch={root_epoch}"))?;
629            leaf.block_header().clone()
630        },
631    };
632
633    if header.version() <= EPOCH_VERSION {
634        return fixed_block_reward.context(format!(
635            "Fixed block reward not found for current_epoch={current_epoch}"
636        ));
637    }
638
639    let prev_epoch_u64 = current_epoch.checked_sub(1).context(format!(
640        "Underflow: cannot compute previous epoch when current_epoch={current_epoch}"
641    ))?;
642
643    let prev_epoch = EpochNumber::new(prev_epoch_u64);
644
645    // If the previous epoch is not in the first two epochs,
646    // there should be a stake table for it
647    if *prev_epoch > *first_epoch + 1
648        && let Err(err) = coordinator.stake_table_for_epoch(Some(prev_epoch))
649    {
650        info!("failed to get membership for epoch={prev_epoch:?}: {err:#}");
651
652        coordinator
653            .wait_for_catchup(prev_epoch)
654            .await
655            .context(format!("failed to catch up for epoch={prev_epoch}"))?;
656    }
657
658    coordinator
659        .membership()
660        .calculate_dynamic_block_reward(current_epoch, &header, &committee.validators, &coordinator)
661        .await
662        .with_context(|| {
663            format!("dynamic block reward calculation failed for epoch={current_epoch}")
664        })?
665        .with_context(|| format!("dynamic block reward returned None. epoch={current_epoch}"))
666}
667
668impl Membership<SeqTypes> for EpochCommittees {
669    type Error = EpochCommitteesError;
670    type Snapshot = EpochSnapshot;
671    type NonEpochSnapshot = NonEpochSnapshot;
672
673    fn snapshot(&self, epoch: EpochNumber) -> Option<Self::Snapshot> {
674        self.inner.read().snapshots.get(&epoch).cloned()
675    }
676
677    fn non_epoch_snapshot(&self) -> Self::NonEpochSnapshot {
678        self.inner.read().non_epoch_snapshot.clone()
679    }
680
681    /// Adds the epoch committee and block reward for a given epoch,
682    /// either by fetching from L1 or using local state if available.
683    /// It also calculates and stores the block reward based on header version.
684    #[cfg(not(feature = "node"))]
685    async fn add_epoch_root(
686        &self,
687        _block_header: Header,
688        _coordinator: &EpochMembershipCoordinator<SeqTypes>,
689    ) -> Result<(), Self::Error> {
690        // Fetching stake table events for the new epoch requires an L1 client.
691        unimplemented!("add_epoch_root requires the node feature");
692    }
693
694    /// Adds the epoch committee and block reward for a given epoch,
695    /// either by fetching from L1 or using local state if available.
696    /// It also calculates and stores the block reward based on header version.
697    #[cfg(feature = "node")]
698    async fn add_epoch_root(
699        &self,
700        block_header: Header,
701        coordinator: &EpochMembershipCoordinator<SeqTypes>,
702    ) -> Result<(), Self::Error> {
703        let block_number = block_header.block_number();
704
705        let epoch_height = *self.epoch_height;
706
707        let epoch = EpochNumber::new(epoch_from_block_number(block_number, epoch_height) + 2);
708
709        info!(?epoch, "adding epoch root. height={:?}", block_number);
710
711        if !is_epoch_root(block_number, epoch_height) {
712            error!(
713                "`add_epoch_root` was called with a block header that was not the root block for \
714                 an epoch. This should never happen. Header:\n\n{block_header:?}"
715            );
716            return Err(Self::Error::NoRootBlock(block_number.into()));
717        }
718
719        let version = block_header.version();
720        // Update the chain config if the block header contains a newer one.
721        self.fetcher
722            .update_chain_config(&block_header)
723            .await
724            .map_err(Self::Error::Fetcher)?;
725
726        let mut block_reward = None;
727        // Even if the current header is the root of the epoch which falls in the post upgrade
728        // we use the fixed block reward
729        if version == EPOCH_VERSION {
730            let reward = self
731                .fetch_and_update_fixed_block_reward(epoch)
732                .await
733                .map_err(Self::Error::Fetcher)?;
734            block_reward = Some(reward);
735        }
736
737        let epoch_committee = self.inner.read().epoch_committee(epoch).cloned();
738
739        // TODO: If the stake table is missing should it be fetched by an unbounded
740        // number of tasks?
741
742        // If the epoch committee:
743        // - exists and has a header stake table hash and block reward, return early.
744        // - exists without a reward, reuse validators and update reward.
745        // and fetch from L1 if the stake table hash is missing.
746        // - doesn't exist, fetch it from L1.
747        let (active_validators, all_validators, stake_table_hash) = match epoch_committee {
748            Some(committee)
749                if committee.block_reward.is_some()
750                    && committee.header.is_some()
751                    && committee.stake_table_hash.is_some() =>
752            {
753                info!(
754                    ?epoch,
755                    "committee already has block reward, header, and stake table hash; skipping \
756                     add_epoch_root"
757                );
758                return Ok(());
759            },
760
761            Some(committee) => {
762                if let Some(reward) = committee.block_reward {
763                    block_reward = Some(reward);
764                }
765
766                if let Some(hash) = committee.stake_table_hash {
767                    (committee.validators.clone(), Default::default(), Some(hash))
768                } else {
769                    // if stake table hash is missing then recalculate from events
770                    info!(
771                        "Stake table hash missing for epoch {epoch}. recalculating by fetching \
772                         from l1."
773                    );
774                    let set = self
775                        .fetcher
776                        .fetch(epoch, &block_header)
777                        .await
778                        .map_err(Self::Error::Fetcher)?;
779                    (
780                        set.active_validators,
781                        set.all_validators,
782                        set.stake_table_hash,
783                    )
784                }
785            },
786
787            None => {
788                info!("Stake table missing for epoch {epoch}. Fetching from L1.");
789                let set = self
790                    .fetcher
791                    .fetch(epoch, &block_header)
792                    .await
793                    .map_err(Self::Error::Fetcher)?;
794                (
795                    set.active_validators,
796                    set.all_validators,
797                    set.stake_table_hash,
798                )
799            },
800        };
801
802        // If we are past the DRB+Header upgrade point,
803        // and don't have block reward
804        // calculate the dynamic block reward based on validator info and block header.
805        if block_reward.is_none() && version >= DRB_AND_HEADER_UPGRADE_VERSION {
806            info!(?epoch, "calculating dynamic block reward");
807            let reward = self
808                .calculate_dynamic_block_reward(
809                    epoch,
810                    &block_header,
811                    &active_validators,
812                    coordinator,
813                )
814                .await
815                .map_err(Self::Error::Reward)?;
816
817            info!(?epoch, "calculated dynamic block reward = {reward:?}");
818            block_reward = reward;
819        }
820
821        let committee = EpochCommittee::new(
822            active_validators.clone(),
823            block_reward,
824            stake_table_hash,
825            Some(block_header.clone()),
826        );
827
828        let previous_epoch;
829        let previous_committee;
830        let previous_validators;
831        {
832            let mut inner = self.inner.write();
833            inner.put_epoch_committee(epoch, Arc::new(committee));
834            // previous_epoch is the epoch prior to `epoch`,
835            // or the epoch immediately succeeding the block header
836            previous_epoch = EpochNumber::new(epoch.saturating_sub(1));
837            previous_committee = inner.epoch_committee(previous_epoch).cloned();
838            // garbage collect the validator set
839            inner.all_validators = inner.all_validators.split_off(&previous_epoch);
840            // extract `all_validators` for the previous epoch
841            previous_validators = inner.all_validators.remove(&previous_epoch);
842            inner.all_validators.insert(epoch, all_validators.clone());
843        }
844
845        let persistence_lock = self.fetcher.persistence.lock().await;
846
847        let decided_hash = block_header.next_stake_table_hash();
848
849        // we store the information from the previous epoch's in-memory committeee
850        // if the decided stake_table_hash is consistent with what we get
851        //
852        // in principle this is unnecessary and we could've stored these right away,
853        // without offsetting the epoch. but the intention is to catch L1 provider issues
854        // if there is a mismatch
855        if let Some(previous_committee) = previous_committee {
856            if decided_hash.is_none() || decided_hash == previous_committee.stake_table_hash {
857                if let Err(e) = persistence_lock
858                    .store_stake(
859                        previous_epoch,
860                        previous_committee.validators.clone(),
861                        previous_committee.block_reward,
862                        previous_committee.stake_table_hash,
863                    )
864                    .await
865                {
866                    error!(
867                        ?e,
868                        ?previous_epoch,
869                        "`add_epoch_root`, error storing stake table"
870                    );
871                }
872
873                if let Some(previous_validators) = previous_validators
874                    && let Err(e) = persistence_lock
875                        .store_all_validators(previous_epoch, previous_validators)
876                        .await
877                {
878                    error!(?e, ?epoch, "`add_epoch_root`, error storing all validators");
879                }
880            } else {
881                panic!(
882                    "The decided block header's `next_stake_table_hash` does not match the hash \
883                     of the stake table we have. This is an unrecoverable error likely due to \
884                     issues with your L1 RPC provider. Decided:\n\n{:?}Actual:\n\n{:?}",
885                    decided_hash, previous_committee.stake_table_hash
886                );
887            }
888        }
889
890        Ok(())
891    }
892
893    async fn get_epoch_root(
894        &self,
895        epoch: EpochNumber,
896        coordinator: &EpochMembershipCoordinator<SeqTypes>,
897    ) -> Result<Leaf2, Self::Error> {
898        let block_height = root_block_in_epoch(*epoch, *self.epoch_height());
899        let peers = self.fetcher.peers.clone();
900
901        // the root block may not exist anywhere yet
902        // Each attempt tries all peers once
903        // `retry` only scales the per peer timeout
904        for retry in 0..3 {
905            match peers
906                .try_fetch_leaf(retry, coordinator.clone(), block_height)
907                .await
908            {
909                Ok(leaf) => return Ok(leaf),
910                Err(err) => {
911                    warn!(%epoch, block_height, retry, "failed to fetch epoch root leaf: {err:#}");
912                },
913            }
914        }
915        Err(Self::Error::Catchup(anyhow::anyhow!(
916            "failed to fetch epoch root leaf for epoch {epoch} (block {block_height}) from peers"
917        )))
918    }
919
920    async fn get_epoch_drb(
921        &self,
922        epoch: EpochNumber,
923        coordinator: &EpochMembershipCoordinator<SeqTypes>,
924    ) -> Result<DrbResult, Self::Error> {
925        let peers = self.fetcher.peers.clone();
926
927        // Try to retrieve the DRB result from an existing snapshot's randomized committee.
928        if let Some(snap) = self.snapshot(epoch)
929            && let Some(rand) = &snap.inner.randomized
930        {
931            return Ok(rand.drb_result());
932        }
933
934        // Otherwise, we try to fetch the epoch root leaf
935        let previous_epoch = match epoch.checked_sub(1) {
936            Some(epoch) => EpochNumber::new(epoch),
937            None => {
938                return self
939                    .snapshot(epoch)
940                    .and_then(|s| s.inner.randomized.as_ref().map(|r| r.drb_result()))
941                    .ok_or_else(|| {
942                        Self::Error::Message(format!(
943                            "Missing randomized committee for epoch {epoch}"
944                        ))
945                    });
946            },
947        };
948
949        let block_height = transition_block_for_epoch(*previous_epoch, *self.epoch_height());
950
951        debug!(
952            "Getting DRB for epoch {}, block height {}",
953            epoch, block_height
954        );
955        let drb_leaf = peers
956            .try_fetch_leaf(1, coordinator.clone(), block_height)
957            .await
958            .map_err(Self::Error::Catchup)?;
959
960        let Some(drb) = drb_leaf.next_drb_result else {
961            error!(
962                "We received a leaf that should contain a DRB result, but the DRB result is \
963                 missing: {:?}",
964                drb_leaf
965            );
966
967            return Err(Self::Error::Message(
968                "DRB leaf is missing the DRB result.".to_string(),
969            ));
970        };
971
972        Ok(drb)
973    }
974
975    fn add_drb_result(&self, epoch: EpochNumber, drb: DrbResult) {
976        info!("Adding DRB result {drb:?} to epoch {epoch}");
977        if let Some(committee) = self.randomized_committee(epoch, drb) {
978            self.inner
979                .write()
980                .put_randomized_committee(epoch, Arc::new(committee));
981        }
982    }
983
984    fn set_first_epoch(&self, epoch: EpochNumber, initial_drb_result: DrbResult) {
985        let rand_comm = Arc::new(
986            self.randomized_committee(EpochNumber::genesis(), initial_drb_result)
987                .expect("committee exist at genesis"),
988        );
989
990        let mut inner = self.inner.write();
991        inner.first_epoch = Some(epoch);
992
993        let epoch_committee = inner
994            .epoch_committee(EpochNumber::genesis())
995            .expect("committee exists at genesis")
996            .clone();
997
998        // Build snapshots for `epoch` and `epoch + 1` with the genesis
999        // stake table and the initial DRB result.
1000        inner.put_epoch_committee(epoch, epoch_committee.clone());
1001        inner.put_randomized_committee(epoch, rand_comm.clone());
1002        inner.put_epoch_committee(epoch + 1, epoch_committee);
1003        inner.put_randomized_committee(epoch + 1, rand_comm);
1004    }
1005
1006    fn first_epoch(&self) -> Option<EpochNumber> {
1007        self.inner.read().first_epoch
1008    }
1009
1010    fn highest_known_epoch(&self) -> Option<EpochNumber> {
1011        self.inner.read().snapshots.keys().max().copied()
1012    }
1013
1014    fn add_da_committee(&self, first_epoch: EpochNumber, committee: Vec<PeerConfig<SeqTypes>>) {
1015        let indexed_committee: HashMap<PubKey, _> = committee
1016            .iter()
1017            .map(|peer_config| {
1018                (
1019                    PubKey::public_key(&peer_config.stake_table_entry),
1020                    peer_config.clone(),
1021                )
1022            })
1023            .collect();
1024
1025        let da_committee = Arc::new(DaCommittee {
1026            committee,
1027            indexed_committee,
1028        });
1029
1030        let mut inner = self.inner.write();
1031        inner
1032            .da_committees
1033            .insert(first_epoch, da_committee.clone());
1034
1035        // The DA committee inserted at `first_epoch` applies to every epoch
1036        // up to (but not including) the next `da_committees` key. Snapshots
1037        // for those epochs were built with whatever DA was current at the
1038        // time and must be rebuilt so reads of `da_stake_table()` etc.
1039        // reflect the new committee.
1040        let upper = inner
1041            .da_committees
1042            .range((Bound::Excluded(first_epoch), Bound::Unbounded))
1043            .next()
1044            .map(|(k, _)| *k);
1045
1046        let range = if let Some(u) = upper {
1047            (Bound::Included(first_epoch), Bound::Excluded(u))
1048        } else {
1049            (Bound::Included(first_epoch), Bound::Unbounded)
1050        };
1051
1052        let affected: Vec<EpochNumber> = inner.snapshots.range(range).map(|(k, _)| *k).collect();
1053        let first_epoch_field = inner.first_epoch;
1054
1055        for epoch in affected {
1056            let Some(existing) = inner.snapshots.get(&epoch) else {
1057                continue;
1058            };
1059            let new_snapshot = EpochSnapshot::new(
1060                epoch,
1061                first_epoch_field,
1062                existing.inner.committee.clone(),
1063                existing.inner.randomized.clone(),
1064                da_committee.clone(),
1065            );
1066            inner.snapshots.insert(epoch, new_snapshot);
1067        }
1068    }
1069}
1070
1071#[derive(Error, Debug)]
1072pub enum EpochCommitteesError {
1073    #[error("could not lookup leader")]
1074    LeaderLookupError,
1075
1076    #[error("block {0} is not the root block for an epoch")]
1077    NoRootBlock(BlockNumber),
1078
1079    #[error("fetcher error: {0}")]
1080    Fetcher(#[source] anyhow::Error),
1081
1082    #[error("{0}")]
1083    Message(String),
1084
1085    #[error("state catchup error: {0}")]
1086    Catchup(#[source] anyhow::Error),
1087
1088    #[error("reward calculation error: {0}")]
1089    Reward(#[source] anyhow::Error),
1090}
1091
1092impl Inner {
1093    /// The DA committee that applies to `epoch`, or the non-epoch fallback
1094    /// when `epoch` is `None` or no explicit DA committee covers it.
1095    fn resolve_da_committee(&self, epoch: Option<EpochNumber>) -> Arc<DaCommittee> {
1096        if let Some(e) = epoch {
1097            // The greatest key ≤ `e` is the DA committee that applies.
1098            self.da_committees
1099                .range((Bound::Included(0.into()), Bound::Included(e)))
1100                .last()
1101                .map(|(_, committee)| committee.clone())
1102                .unwrap_or_else(|| self.non_epoch_snapshot.inner.da_committee.clone())
1103        } else {
1104            self.non_epoch_snapshot.inner.da_committee.clone()
1105        }
1106    }
1107
1108    /// Borrow the per-epoch `EpochCommittee` if loaded.
1109    fn epoch_committee(&self, e: EpochNumber) -> Option<&Arc<EpochCommittee>> {
1110        self.snapshots.get(&e).map(|s| &s.inner.committee)
1111    }
1112
1113    fn address(&self, e: EpochNumber, key: &BLSPubKey) -> anyhow::Result<Address> {
1114        self.epoch_committee(e)
1115            .context("state for found")?
1116            .address_mapping
1117            .get(key)
1118            .copied()
1119            .context(format!(
1120                "failed to get ethereum address for bls key {key}. epoch={e}"
1121            ))
1122    }
1123
1124    fn active_validators(&self, e: EpochNumber) -> anyhow::Result<AuthenticatedValidatorMap> {
1125        Ok(self
1126            .epoch_committee(e)
1127            .context("state not found")?
1128            .validators
1129            .clone())
1130    }
1131
1132    /// Rebuild (or insert) the snapshot for `epoch` carrying forward
1133    /// `randomized` from any existing snapshot for that epoch.
1134    fn put_epoch_committee(&mut self, epoch: EpochNumber, committee: Arc<EpochCommittee>) {
1135        let randomized = self
1136            .snapshots
1137            .get(&epoch)
1138            .and_then(|s| s.inner.randomized.clone());
1139        let da_committee = self.resolve_da_committee(Some(epoch));
1140        let first_epoch = self.first_epoch;
1141        self.snapshots.insert(
1142            epoch,
1143            EpochSnapshot::new(epoch, first_epoch, committee, randomized, da_committee),
1144        );
1145    }
1146
1147    /// Rebuild the snapshot for `epoch` with a new randomized committee,
1148    /// carrying forward the existing committee/da. No-op if no snapshot
1149    /// for `epoch` exists yet.
1150    fn put_randomized_committee(
1151        &mut self,
1152        epoch: EpochNumber,
1153        randomized: Arc<RandomizedCommittee<StakeTableEntry<PubKey>>>,
1154    ) {
1155        let Some(existing) = self.snapshots.get(&epoch).cloned() else {
1156            return;
1157        };
1158        let committee = existing.inner.committee.clone();
1159        let da_committee = existing.inner.da_committee.clone();
1160        let first_epoch = self.first_epoch;
1161        self.snapshots.insert(
1162            epoch,
1163            EpochSnapshot::new(
1164                epoch,
1165                first_epoch,
1166                committee,
1167                Some(randomized),
1168                da_committee,
1169            ),
1170        );
1171    }
1172}
1173
1174/// A consistent per-epoch view of `EpochCommittees`.
1175///
1176/// Returned by [`Membership::snapshot`].
1177#[derive(Clone, Debug)]
1178pub struct EpochSnapshot {
1179    inner: Arc<EpochSnapshotInner>,
1180}
1181
1182#[derive(Debug)]
1183struct EpochSnapshotInner {
1184    epoch: EpochNumber,
1185    first_epoch: Option<EpochNumber>,
1186    committee: Arc<EpochCommittee>,
1187    randomized: Option<Arc<RandomizedCommittee<StakeTableEntry<PubKey>>>>,
1188    da_committee: Arc<DaCommittee>,
1189}
1190
1191impl EpochSnapshot {
1192    fn new(
1193        epoch: EpochNumber,
1194        first_epoch: Option<EpochNumber>,
1195        committee: Arc<EpochCommittee>,
1196        randomized: Option<Arc<RandomizedCommittee<StakeTableEntry<PubKey>>>>,
1197        da_committee: Arc<DaCommittee>,
1198    ) -> Self {
1199        Self {
1200            inner: Arc::new(EpochSnapshotInner {
1201                epoch,
1202                first_epoch,
1203                committee,
1204                randomized,
1205                da_committee,
1206            }),
1207        }
1208    }
1209}
1210
1211impl EpochSnapshot {
1212    /// Index of `key` in this epoch's stake table, if present.
1213    pub fn validator_index(&self, key: &PubKey) -> Option<usize> {
1214        self.inner.committee.stake_table.get_index_of(key)
1215    }
1216
1217    /// The full validator record (account, stake, delegators, etc.) for `key`.
1218    pub fn validator_config(
1219        &self,
1220        key: &BLSPubKey,
1221    ) -> anyhow::Result<&AuthenticatedValidator<BLSPubKey>> {
1222        let address = self
1223            .inner
1224            .committee
1225            .address_mapping
1226            .get(key)
1227            .context(format!(
1228                "failed to get ethereum address for bls key {key}. epoch={}",
1229                self.inner.epoch
1230            ))?;
1231        self.inner
1232            .committee
1233            .validators
1234            .get(address)
1235            .context("validator not found")
1236    }
1237
1238    pub fn epoch_block_reward(&self) -> Option<RewardAmount> {
1239        self.inner.committee.block_reward
1240    }
1241
1242    pub fn validators(&self) -> &AuthenticatedValidatorMap {
1243        &self.inner.committee.validators
1244    }
1245}
1246
1247impl MembershipSnapshot<SeqTypes> for EpochSnapshot {
1248    type Error = EpochCommitteesError;
1249    type StakeTableHash = StakeTableState;
1250
1251    fn epoch(&self) -> EpochNumber {
1252        self.inner.epoch
1253    }
1254
1255    fn first_epoch(&self) -> Option<EpochNumber> {
1256        self.inner.first_epoch
1257    }
1258
1259    fn has_drb(&self) -> bool {
1260        self.inner.randomized.is_some()
1261    }
1262
1263    fn stake_table(&self) -> impl ExactSizeIterator<Item = &PeerConfig<SeqTypes>> + Send {
1264        self.inner.committee.stake_table.values()
1265    }
1266
1267    fn da_stake_table(&self) -> impl ExactSizeIterator<Item = &PeerConfig<SeqTypes>> + Send {
1268        self.inner.da_committee.committee.iter()
1269    }
1270
1271    fn committee_members(&self, _: ViewNumber) -> impl ExactSizeIterator<Item = &PubKey> + Send {
1272        self.inner.committee.stake_table.keys()
1273    }
1274
1275    fn da_committee_members(&self, _: ViewNumber) -> impl ExactSizeIterator<Item = &PubKey> + Send {
1276        self.inner.da_committee.indexed_committee.keys()
1277    }
1278
1279    fn stake(&self, key: &PubKey) -> Option<PeerConfig<SeqTypes>> {
1280        self.inner.committee.stake_table.get(key).cloned()
1281    }
1282
1283    fn da_stake(&self, key: &PubKey) -> Option<PeerConfig<SeqTypes>> {
1284        self.inner.da_committee.indexed_committee.get(key).cloned()
1285    }
1286
1287    fn has_stake(&self, key: &PubKey) -> bool {
1288        self.stake(key)
1289            .map(|x| x.stake_table_entry.stake() > U256::ZERO)
1290            .unwrap_or_default()
1291    }
1292
1293    fn has_da_stake(&self, key: &PubKey) -> bool {
1294        self.da_stake(key)
1295            .map(|x| x.stake_table_entry.stake() > U256::ZERO)
1296            .unwrap_or_default()
1297    }
1298
1299    /// Returns the leader's public key for a given view number in this epoch.
1300    ///
1301    /// # Errors
1302    ///
1303    /// Returns `LeaderLookupError` if `first_epoch` is unset, the snapshot's
1304    /// epoch is before `first_epoch`, or the randomized committee is missing.
1305    fn lookup_leader(&self, view: ViewNumber) -> Result<PubKey, Self::Error> {
1306        let inner = &self.inner;
1307        let Some(first_epoch) = inner.first_epoch else {
1308            error!(
1309                "leader requested for epoch {} but first_epoch is unset",
1310                inner.epoch,
1311            );
1312            return Err(EpochCommitteesError::LeaderLookupError);
1313        };
1314        if inner.epoch < first_epoch {
1315            error!(
1316                "leader requested for epoch {} before first epoch {first_epoch}",
1317                inner.epoch,
1318            );
1319            return Err(EpochCommitteesError::LeaderLookupError);
1320        }
1321        let Some(rand) = inner.randomized.as_deref() else {
1322            error!(
1323                "missing randomized committee for epoch {} in snapshot",
1324                inner.epoch,
1325            );
1326            return Err(EpochCommitteesError::LeaderLookupError);
1327        };
1328        Ok(PubKey::public_key(&select_randomized_leader(rand, *view)))
1329    }
1330
1331    fn stake_table_hash(&self) -> Option<Commitment<Self::StakeTableHash>> {
1332        self.inner.committee.stake_table_hash
1333    }
1334}
1335
1336/// A consistent pre-epoch view of `EpochCommittees`.
1337///
1338/// Returned by [`Membership::non_epoch_snapshot`].
1339#[derive(Clone, Debug)]
1340pub struct NonEpochSnapshot {
1341    inner: Arc<NonEpochSnapshotInner>,
1342}
1343
1344#[derive(Debug)]
1345struct NonEpochSnapshotInner {
1346    committee: Arc<NonEpochCommittee>,
1347    da_committee: Arc<DaCommittee>,
1348}
1349
1350impl NonEpochSnapshot {
1351    fn new(committee: Arc<NonEpochCommittee>, da_committee: Arc<DaCommittee>) -> Self {
1352        Self {
1353            inner: Arc::new(NonEpochSnapshotInner {
1354                committee,
1355                da_committee,
1356            }),
1357        }
1358    }
1359}
1360
1361impl NonEpochMembershipSnapshot<SeqTypes> for NonEpochSnapshot {
1362    type Error = EpochCommitteesError;
1363
1364    fn stake_table(&self) -> impl ExactSizeIterator<Item = &PeerConfig<SeqTypes>> + Send + '_ {
1365        self.inner.committee.stake_table.iter()
1366    }
1367
1368    fn da_stake_table(&self) -> impl ExactSizeIterator<Item = &PeerConfig<SeqTypes>> + Send + '_ {
1369        self.inner.da_committee.committee.iter()
1370    }
1371
1372    fn committee_members(
1373        &self,
1374        _: ViewNumber,
1375    ) -> impl ExactSizeIterator<Item = &PubKey> + Send + '_ {
1376        self.inner.committee.indexed_stake_table.keys()
1377    }
1378
1379    fn da_committee_members(
1380        &self,
1381        _: ViewNumber,
1382    ) -> impl ExactSizeIterator<Item = &PubKey> + Send + '_ {
1383        self.inner.da_committee.indexed_committee.keys()
1384    }
1385
1386    fn stake(&self, key: &PubKey) -> Option<PeerConfig<SeqTypes>> {
1387        self.inner.committee.indexed_stake_table.get(key).cloned()
1388    }
1389
1390    fn da_stake(&self, key: &PubKey) -> Option<PeerConfig<SeqTypes>> {
1391        self.inner.da_committee.indexed_committee.get(key).cloned()
1392    }
1393
1394    fn has_stake(&self, key: &PubKey) -> bool {
1395        self.stake(key)
1396            .map(|x| x.stake_table_entry.stake() > U256::ZERO)
1397            .unwrap_or_default()
1398    }
1399
1400    fn has_da_stake(&self, key: &PubKey) -> bool {
1401        self.da_stake(key)
1402            .map(|x| x.stake_table_entry.stake() > U256::ZERO)
1403            .unwrap_or_default()
1404    }
1405
1406    fn lookup_leader(&self, view: ViewNumber) -> Result<PubKey, Self::Error> {
1407        let leaders = &self.inner.committee.eligible_leaders;
1408        if leaders.is_empty() {
1409            return Err(EpochCommitteesError::LeaderLookupError);
1410        }
1411        let index = *view as usize % leaders.len();
1412        Ok(PubKey::public_key(&leaders[index].stake_table_entry))
1413    }
1414}
1415
1416#[cfg(test)]
1417mod tests {
1418    use std::sync::{
1419        Arc,
1420        atomic::{AtomicBool, AtomicUsize, Ordering},
1421    };
1422
1423    use committable::Committable;
1424    use hotshot_query_service::testing::mocks::MOCK_UPGRADE;
1425    use hotshot_types::{
1426        ValidatorConfig,
1427        traits::{BlockPayload, block_contents::BlockHeader},
1428    };
1429    use tokio::{task::JoinSet, time::Duration};
1430
1431    use super::*;
1432    use crate::{NodeState, Payload, Transaction};
1433
1434    /// Wall-clock target each concurrency test runs for. Long enough to
1435    /// catch flaky races that one-shot tests would miss; short enough to
1436    /// be tolerable in CI.
1437    const TEST_DURATION: Duration = Duration::from_secs(5);
1438
1439    fn build_committees(num_peers: u64) -> EpochCommittees {
1440        let peers: Vec<PeerConfig<SeqTypes>> = (0..num_peers)
1441            .map(|i| {
1442                ValidatorConfig::<SeqTypes>::generated_from_seed_indexed(
1443                    [42u8; 32],
1444                    i,
1445                    U256::from(100),
1446                    true,
1447                )
1448                .public_config()
1449            })
1450            .collect();
1451        EpochCommittees::new_stake(peers.clone(), peers, None, Fetcher::mock(), 100u64)
1452    }
1453
1454    // Concurrent reads must not panic or deadlock while a writer drives
1455    // real mutations on the same `Inner` lock.
1456    //
1457    // Per-call invariants (within a single method invocation) are
1458    // checked. Cross-call invariants are *not*: each public method
1459    // takes its own short-lived lock, so a sequence of two read calls
1460    // observes two snapshots in time and the writer can run between
1461    // them. See the `EpochCommittees` doc-comment for the rationale.
1462    //
1463    // To make the contention real, we pre-populate `inner.state` for
1464    // several extra epochs so the writer's `add_drb_result` calls
1465    // actually take the write lock. Without this they would early-exit
1466    // on the missing-state branch and never contend.
1467    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1468    async fn concurrent_reads_during_mutations() {
1469        let committees = build_committees(8);
1470        committees.set_first_epoch(EpochNumber::new(1), [0u8; 32]);
1471
1472        // Pre-populate snapshots for epochs 2..6 by cloning the genesis
1473        // committee. `add_drb_result(e)` is a no-op when no snapshot for
1474        // `e` exists, so without this the writer never takes the write
1475        // lock for those epochs.
1476        {
1477            let mut inner = committees.inner.write();
1478            let template = inner
1479                .epoch_committee(EpochNumber::genesis())
1480                .expect("genesis committee exists")
1481                .clone();
1482            for e in 2..6 {
1483                inner.put_epoch_committee(EpochNumber::new(e), template.clone());
1484            }
1485        }
1486
1487        let stop = Arc::new(AtomicBool::new(false));
1488        let mut tasks = JoinSet::new();
1489
1490        for _ in 0..8 {
1491            let c = committees.clone();
1492            let stop = Arc::clone(&stop);
1493            tasks.spawn(async move {
1494                let stable = EpochNumber::new(1);
1495                let mutating = EpochNumber::new(3);
1496                let view = ViewNumber::new(0);
1497                while !stop.load(Ordering::Relaxed) {
1498                    // Stable epoch — the writer never touches
1499                    // `inner.state[1]` or `inner.randomized_committees[1]`
1500                    // (both were set by `set_first_epoch` before this
1501                    // loop and stay unchanged thereafter), so the
1502                    // assertions below hold even across separate snapshots.
1503                    let stable_snap = c.snapshot(stable).expect("stable snapshot");
1504                    let len = stable_snap.stake_table().len();
1505                    assert_eq!(len, stable_snap.total_nodes());
1506                    let leader = stable_snap.lookup_leader(view).expect("leader");
1507                    assert!(
1508                        stable_snap.committee_members(view).any(|p| p == &leader),
1509                        "leader {leader:?} not in committee_members for stable epoch",
1510                    );
1511                    assert_eq!(c.first_epoch(), Some(stable));
1512
1513                    // Mutating epoch — the writer churns
1514                    // `randomized_committees[3]`. Just exercise the API
1515                    // path; the value can vary or be transiently absent
1516                    // between calls and that is the documented
1517                    // behaviour, not a bug.
1518                    let _ = c.snapshot(mutating);
1519                    if let Some(s) = c.snapshot(mutating) {
1520                        let _ = s.lookup_leader(view);
1521                    }
1522                    tokio::task::yield_now().await;
1523                }
1524            });
1525        }
1526
1527        // Writer driving real mutations against fields the readers see.
1528        // Loops until the test signals stop, so the contention window
1529        // matches `TEST_DURATION`.
1530        tasks.spawn({
1531            let c = committees.clone();
1532            let stop = Arc::clone(&stop);
1533            async move {
1534                let extra: Vec<PeerConfig<SeqTypes>> = (0..3)
1535                    .map(|i| {
1536                        ValidatorConfig::<SeqTypes>::generated_from_seed_indexed(
1537                            [99u8; 32],
1538                            i,
1539                            U256::from(50),
1540                            true,
1541                        )
1542                        .public_config()
1543                    })
1544                    .collect();
1545                let mut i: u64 = 0;
1546                while !stop.load(Ordering::Relaxed) {
1547                    // Pre-populated epochs 2..5 — these acquire the
1548                    // write lock and contend with reader read locks.
1549                    c.add_drb_result(EpochNumber::new(2 + (i % 4)), [(i % 256) as u8; 32]);
1550                    // Non-existent epoch — exercises the read-then-no-op
1551                    // branch of `add_drb_result` (read lock only).
1552                    c.add_drb_result(EpochNumber::new(10_000 + i), [0xAB; 32]);
1553                    if i.is_multiple_of(50) {
1554                        c.add_da_committee(i.into(), extra.clone());
1555                    }
1556                    if i.is_multiple_of(16) {
1557                        tokio::task::yield_now().await;
1558                    }
1559                    i += 1;
1560                }
1561            }
1562        });
1563
1564        tokio::time::sleep(TEST_DURATION).await;
1565        stop.store(true, Ordering::Relaxed);
1566        while let Some(res) = tasks.join_next().await {
1567            res.expect("task panicked");
1568        }
1569    }
1570
1571    // A task concurrent with `set_first_epoch` must never see a
1572    // partially-applied state, since all mutations in `set_first_epoch`
1573    // happen under a single write lock.
1574    //
1575    // We can't verify this through the public API because each method
1576    // takes its own lock — between two reader calls the writer can run
1577    // to completion (a real TOCTOU window in the new locking model, not
1578    // a torn read). Instead we take a single-locked snapshot of all
1579    // affected fields directly and assert the snapshot is internally
1580    // consistent: either the pre-state (nothing set) or the post-state
1581    // (everything set together).
1582    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1583    async fn set_first_epoch_is_atomic() {
1584        // Snapshot all fields touched by `set_first_epoch` under a
1585        // single read lock so the reader observes one consistent state.
1586        fn snapshot(c: &EpochCommittees, e: EpochNumber) -> Snapshot {
1587            let inner = c.inner.read();
1588            Snapshot {
1589                first_epoch: inner.first_epoch,
1590                state_e: inner.snapshots.contains_key(&e),
1591                state_e1: inner.snapshots.contains_key(&(e + 1)),
1592                rand_e: inner
1593                    .snapshots
1594                    .get(&e)
1595                    .is_some_and(|s| s.inner.randomized.is_some()),
1596                rand_e1: inner
1597                    .snapshots
1598                    .get(&(e + 1))
1599                    .is_some_and(|s| s.inner.randomized.is_some()),
1600            }
1601        }
1602
1603        #[derive(Debug)]
1604        struct Snapshot {
1605            first_epoch: Option<EpochNumber>,
1606            state_e: bool,
1607            state_e1: bool,
1608            rand_e: bool,
1609            rand_e1: bool,
1610        }
1611
1612        let target = EpochNumber::new(10);
1613
1614        // Concurrency bugs are flaky — loop until we've spent
1615        // `TEST_DURATION` widening the window for catching a torn
1616        // state. Each round is one race attempt against
1617        // `set_first_epoch`.
1618        let test_start = tokio::time::Instant::now();
1619        let mut round: u64 = 0;
1620        while test_start.elapsed() < TEST_DURATION {
1621            let committees = build_committees(4);
1622            let stop = Arc::new(AtomicBool::new(false));
1623            let post_observations = Arc::new(AtomicUsize::new(0));
1624
1625            let reader = {
1626                let c = committees.clone();
1627                let stop = Arc::clone(&stop);
1628                let post = Arc::clone(&post_observations);
1629                tokio::spawn(async move {
1630                    while !stop.load(Ordering::Relaxed) {
1631                        let s = snapshot(&c, target);
1632                        match s.first_epoch {
1633                            None => assert!(
1634                                !s.state_e && !s.state_e1 && !s.rand_e && !s.rand_e1,
1635                                "torn snapshot: first_epoch=None but some target state present: \
1636                                 {s:?}",
1637                            ),
1638                            Some(e) => {
1639                                assert_eq!(e, target, "only target is ever set");
1640                                assert!(
1641                                    s.state_e && s.state_e1 && s.rand_e && s.rand_e1,
1642                                    "torn snapshot: first_epoch=Some but some target state \
1643                                     missing: {s:?}",
1644                                );
1645                                post.fetch_add(1, Ordering::Relaxed);
1646                            },
1647                        }
1648                        tokio::task::yield_now().await;
1649                    }
1650                })
1651            };
1652
1653            // Brief warmup so the reader is in its loop.
1654            tokio::time::sleep(Duration::from_millis(2)).await;
1655            committees.set_first_epoch(target, [(round as u8) ^ 0xA5; 32]);
1656
1657            // Wait until the reader has observed the post-state at least
1658            // once, with a generous timeout.
1659            let deadline = tokio::time::Instant::now() + Duration::from_millis(200);
1660            while tokio::time::Instant::now() < deadline
1661                && post_observations.load(Ordering::Relaxed) == 0
1662            {
1663                tokio::task::yield_now().await;
1664            }
1665
1666            stop.store(true, Ordering::Relaxed);
1667            reader.await.expect("reader panicked");
1668            assert!(
1669                post_observations.load(Ordering::Relaxed) > 0,
1670                "round {round}: reader never observed post-set state",
1671            );
1672            round += 1;
1673        }
1674        assert!(round > 0, "test loop never executed a round");
1675    }
1676
1677    // Many writer tasks hammer `add_drb_result` for the same epoch with
1678    // distinct DRBs. While they do, reader tasks call `lookup_leader`,
1679    // which must always succeed once the randomized committee is set —
1680    // the writer overwrites the entry but never removes it. After the
1681    // writers drain, the entry must still be present and queryable.
1682    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1683    async fn concurrent_add_drb_result_same_epoch() {
1684        let committees = build_committees(4);
1685        let epoch = EpochNumber::new(1);
1686        // `has_randomized_stake_table` and `lookup_leader` both
1687        // require `first_epoch` to be set.
1688        committees.set_first_epoch(epoch, [0u8; 32]);
1689
1690        let stop = Arc::new(AtomicBool::new(false));
1691        let writes = Arc::new(AtomicUsize::new(0));
1692        let lookups = Arc::new(AtomicUsize::new(0));
1693
1694        let mut writers = JoinSet::new();
1695        let mut readers = JoinSet::new();
1696
1697        // Readers: lookup_leader must always succeed for an epoch
1698        // whose randomized committee has been populated, even while it
1699        // is being overwritten.
1700        for _ in 0..4 {
1701            let c = committees.clone();
1702            let stop = Arc::clone(&stop);
1703            let lookups = Arc::clone(&lookups);
1704            readers.spawn(async move {
1705                let view = ViewNumber::new(0);
1706                while !stop.load(Ordering::Relaxed) {
1707                    c.snapshot(epoch)
1708                        .expect("snapshot")
1709                        .lookup_leader(view)
1710                        .expect("randomized committee must remain present once set");
1711                    lookups.fetch_add(1, Ordering::Relaxed);
1712                    tokio::task::yield_now().await;
1713                }
1714            });
1715        }
1716
1717        // Writers: each task overwrites the randomized committee with
1718        // a unique DRB derived from its task id and iteration. Loops
1719        // until stop so the contention window matches `TEST_DURATION`.
1720        for tid in 0..8u8 {
1721            let c = committees.clone();
1722            let stop = Arc::clone(&stop);
1723            let writes = Arc::clone(&writes);
1724            writers.spawn(async move {
1725                let mut i: u64 = 0;
1726                while !stop.load(Ordering::Relaxed) {
1727                    let mut drb = [tid; 32];
1728                    drb[0] = (i & 0xFF) as u8;
1729                    c.add_drb_result(epoch, drb);
1730                    writes.fetch_add(1, Ordering::Relaxed);
1731                    if i.is_multiple_of(16) {
1732                        tokio::task::yield_now().await;
1733                    }
1734                    i += 1;
1735                }
1736            });
1737        }
1738
1739        tokio::time::sleep(TEST_DURATION).await;
1740        stop.store(true, Ordering::Relaxed);
1741        while let Some(res) = writers.join_next().await {
1742            res.expect("writer panicked");
1743        }
1744        while let Some(res) = readers.join_next().await {
1745            res.expect("reader panicked");
1746        }
1747
1748        assert!(writes.load(Ordering::Relaxed) > 0, "writers never advanced",);
1749        assert!(
1750            lookups.load(Ordering::Relaxed) > 0,
1751            "readers never observed the randomized committee",
1752        );
1753        let snap = committees
1754            .snapshot(epoch)
1755            .expect("randomized committee must survive concurrent writes");
1756        assert!(snap.has_drb(), "randomized committee must remain present");
1757        let view = ViewNumber::new(0);
1758        let _leader = snap
1759            .lookup_leader(view)
1760            .expect("lookup_leader succeeds when randomized committee is present");
1761    }
1762
1763    // Build an epoch-root header for `epoch_height = 100`. Block height
1764    // 95 satisfies `is_epoch_root(95, 100)` and produces target epoch 3
1765    // when passed to `add_epoch_root`.
1766    async fn build_epoch_root_header() -> Header {
1767        let instance = NodeState::mock_v2();
1768        let tx = Transaction::of_size(10);
1769        let (payload, _) = Payload::from_transactions([tx], &instance.genesis_state, &instance)
1770            .await
1771            .expect("payload");
1772        let metadata = payload.ns_table().clone();
1773        let header = Header::genesis(&instance, payload, &metadata, MOCK_UPGRADE.base);
1774        match header {
1775            Header::V2(mut h) => {
1776                h.height = 95;
1777                Header::V2(h)
1778            },
1779            other => panic!("expected V2 header from NodeState::mock_v2, got {other:?}"),
1780        }
1781    }
1782
1783    // `add_epoch_root` mutates `state[epoch]` and `all_validators[epoch]`
1784    // inside one `inner.write()` block. A reader observing both fields
1785    // under one read lock must see them flip together: pre-state
1786    // (`state[epoch].header == None`, `all_validators[epoch]` absent)
1787    // or post-state (`header == Some(_)`, `all_validators[epoch]`
1788    // present). A torn snapshot in either direction would indicate the
1789    // mutations leaked outside the single write lock.
1790    //
1791    // We pre-populate `state[epoch]` with `block_reward` and
1792    // `stake_table_hash` set so `add_epoch_root` reuses the validators
1793    // already in memory and skips the L1 fetch (the mock fetcher
1794    // points at a non-existent RPC endpoint and would fail). This
1795    // still drives the inner.write block we want to verify.
1796    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1797    async fn add_epoch_root_is_atomic() {
1798        let header = build_epoch_root_header().await;
1799        let target = EpochNumber::new(3);
1800
1801        fn snapshot(c: &EpochCommittees, e: EpochNumber) -> (bool, bool) {
1802            let inner = c.inner.read();
1803            let header_set = inner
1804                .snapshots
1805                .get(&e)
1806                .map(|s| s.inner.committee.header.is_some())
1807                .unwrap_or(false);
1808            let all_validators_present = inner.all_validators.contains_key(&e);
1809            (header_set, all_validators_present)
1810        }
1811
1812        // Each round is one race attempt against `add_epoch_root`. Loop
1813        // for `TEST_DURATION` to widen the window for catching a torn
1814        // observation across the inner.write block.
1815        let test_start = tokio::time::Instant::now();
1816        let mut round: u64 = 0;
1817        while test_start.elapsed() < TEST_DURATION {
1818            let committees = build_committees(4);
1819
1820            // Pre-populate the snapshot for `target` with block_reward and
1821            // stake_table_hash but no header. This lands `add_epoch_root`
1822            // on the second match arm (no L1 fetch) but still drives
1823            // the inner.write() mutation.
1824            {
1825                let mut inner = committees.inner.write();
1826                let template = inner
1827                    .epoch_committee(EpochNumber::genesis())
1828                    .expect("genesis committee exists");
1829                let prefilled = EpochCommittee {
1830                    block_reward: Some(RewardAmount::default()),
1831                    stake_table_hash: Some(StakeTableState::default().commit()),
1832                    header: None,
1833                    eligible_leaders: template.eligible_leaders.clone(),
1834                    stake_table: template.stake_table.clone(),
1835                    validators: template.validators.clone(),
1836                    address_mapping: template.address_mapping.clone(),
1837                };
1838                inner.put_epoch_committee(target, Arc::new(prefilled));
1839            }
1840
1841            let stop = Arc::new(AtomicBool::new(false));
1842            let post = Arc::new(AtomicUsize::new(0));
1843
1844            let reader = {
1845                let c = committees.clone();
1846                let stop = Arc::clone(&stop);
1847                let post = Arc::clone(&post);
1848                tokio::spawn(async move {
1849                    while !stop.load(Ordering::Relaxed) {
1850                        match snapshot(&c, target) {
1851                            (false, false) => {}, // pre-state
1852                            (true, true) => {
1853                                post.fetch_add(1, Ordering::Relaxed);
1854                            },
1855                            torn => panic!(
1856                                "round {round}: torn snapshot for epoch {target}: header_set={}, \
1857                                 all_validators_present={}",
1858                                torn.0, torn.1,
1859                            ),
1860                        }
1861                        tokio::task::yield_now().await;
1862                    }
1863                })
1864            };
1865
1866            // Brief warmup so the reader is in its loop before the
1867            // mutation lands.
1868            tokio::time::sleep(Duration::from_millis(2)).await;
1869            let coordinator = EpochMembershipCoordinator::<SeqTypes>::new(
1870                committees.clone(),
1871                *committees.epoch_height(),
1872                &hotshot_example_types::storage_types::TestStorage::default(),
1873            );
1874            coordinator
1875                .add_epoch_root(header.clone())
1876                .await
1877                .expect("add_epoch_root should succeed for the prefilled state");
1878
1879            let deadline = tokio::time::Instant::now() + Duration::from_millis(200);
1880            while tokio::time::Instant::now() < deadline && post.load(Ordering::Relaxed) == 0 {
1881                tokio::task::yield_now().await;
1882            }
1883            stop.store(true, Ordering::Relaxed);
1884            reader.await.expect("reader panicked");
1885            assert!(
1886                post.load(Ordering::Relaxed) > 0,
1887                "round {round}: reader never observed post-state",
1888            );
1889            round += 1;
1890        }
1891        assert!(round > 0, "test loop never executed a round");
1892    }
1893
1894    // `add_da_committee` updates the per-epoch `da_committees` map and
1895    // must retroactively rebuild every loaded snapshot whose epoch
1896    // resolves to the new committee. Without the rebuild, snapshots
1897    // baked the old DA at construction time and reads would silently
1898    // observe stale data until `add_epoch_root` rebuilt them.
1899    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1900    async fn add_da_committee_rebuilds_affected_snapshots() {
1901        fn da_keys(c: &EpochCommittees, e: EpochNumber) -> Vec<PubKey> {
1902            c.snapshot(e)
1903                .expect("snapshot")
1904                .da_stake_table()
1905                .map(|p| PubKey::public_key(&p.stake_table_entry))
1906                .collect()
1907        }
1908
1909        // `EpochNumber::genesis()` is 1, so `new_stake` seeds snapshots
1910        // for epochs 1 and 2. `set_first_epoch(2, ...)` then rebuilds
1911        // snapshots for epochs 2 and 3, all with the bootstrap DA.
1912        let committees = build_committees(4);
1913        committees.set_first_epoch(EpochNumber::new(2), [0u8; 32]);
1914
1915        let initial_e2 = da_keys(&committees, EpochNumber::new(2));
1916        let initial_e3 = da_keys(&committees, EpochNumber::new(3));
1917
1918        let new_da: Vec<PeerConfig<SeqTypes>> = (0..2)
1919            .map(|i| {
1920                ValidatorConfig::<SeqTypes>::generated_from_seed_indexed(
1921                    [123u8; 32],
1922                    i,
1923                    U256::from(50),
1924                    true,
1925                )
1926                .public_config()
1927            })
1928            .collect();
1929        let new_da_keys: Vec<PubKey> = new_da
1930            .iter()
1931            .map(|p| PubKey::public_key(&p.stake_table_entry))
1932            .collect();
1933        assert_ne!(
1934            new_da_keys, initial_e2,
1935            "test setup: new DA must differ from initial"
1936        );
1937
1938        // Apply the new DA starting at epoch 2. Snapshot(2) and
1939        // snapshot(3) were seeded with the bootstrap DA; both must
1940        // observe the new DA after this call. Snapshot(1) lies before
1941        // `first_epoch=2`, so it stays on the bootstrap DA.
1942        committees.add_da_committee(EpochNumber::new(2), new_da);
1943
1944        assert_eq!(
1945            da_keys(&committees, EpochNumber::new(2)),
1946            new_da_keys,
1947            "snapshot(2) must reflect the new DA",
1948        );
1949        assert_eq!(
1950            da_keys(&committees, EpochNumber::new(3)),
1951            new_da_keys,
1952            "snapshot(3) must reflect the new DA",
1953        );
1954        assert_eq!(
1955            da_keys(&committees, EpochNumber::new(1)),
1956            initial_e2,
1957            "snapshot(1) lies before first_epoch=2 and must keep the bootstrap DA",
1958        );
1959        // Sanity: the original epoch-2 and epoch-3 snapshots both used
1960        // the bootstrap DA before the call. If a future change to
1961        // `set_first_epoch` makes these diverge, this assertion will
1962        // catch it so the test setup can be revisited.
1963        assert_eq!(initial_e2, initial_e3);
1964    }
1965
1966    // A `add_da_committee` call with a `first_epoch` greater than every
1967    // entry in an existing layered map must only rebuild snapshots in
1968    // its own range, not earlier ranges owned by other committees.
1969    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1970    async fn add_da_committee_layered_does_not_rebuild_earlier_ranges() {
1971        fn da_keys(c: &EpochCommittees, e: EpochNumber) -> Vec<PubKey> {
1972            c.snapshot(e)
1973                .expect("snapshot")
1974                .da_stake_table()
1975                .map(|p| PubKey::public_key(&p.stake_table_entry))
1976                .collect()
1977        }
1978
1979        let committees = build_committees(4);
1980        committees.set_first_epoch(EpochNumber::new(1), [0u8; 32]);
1981
1982        // Pre-populate snapshots for epochs 2..6 (set_first_epoch only
1983        // covers 1 and 2, but the range query needs more epochs to
1984        // exercise the layered case).
1985        {
1986            let mut inner = committees.inner.write();
1987            let template = inner
1988                .epoch_committee(EpochNumber::genesis())
1989                .expect("genesis committee exists")
1990                .clone();
1991            for e in 3..6 {
1992                inner.put_epoch_committee(EpochNumber::new(e), template.clone());
1993            }
1994        }
1995
1996        let da_b: Vec<PeerConfig<SeqTypes>> = (0..2)
1997            .map(|i| {
1998                ValidatorConfig::<SeqTypes>::generated_from_seed_indexed(
1999                    [200u8; 32],
2000                    i,
2001                    U256::from(50),
2002                    true,
2003                )
2004                .public_config()
2005            })
2006            .collect();
2007        let da_b_keys: Vec<PubKey> = da_b
2008            .iter()
2009            .map(|p| PubKey::public_key(&p.stake_table_entry))
2010            .collect();
2011
2012        let da_c: Vec<PeerConfig<SeqTypes>> = (0..2)
2013            .map(|i| {
2014                ValidatorConfig::<SeqTypes>::generated_from_seed_indexed(
2015                    [201u8; 32],
2016                    i,
2017                    U256::from(50),
2018                    true,
2019                )
2020                .public_config()
2021            })
2022            .collect();
2023        let da_c_keys: Vec<PubKey> = da_c
2024            .iter()
2025            .map(|p| PubKey::public_key(&p.stake_table_entry))
2026            .collect();
2027
2028        // Insert C first at epoch 5, then B at epoch 3. Range [3, 5)
2029        // must be rebuilt with B; [5, ∞) must keep C.
2030        committees.add_da_committee(EpochNumber::new(5), da_c);
2031        committees.add_da_committee(EpochNumber::new(3), da_b);
2032
2033        assert_eq!(da_keys(&committees, EpochNumber::new(3)), da_b_keys);
2034        assert_eq!(da_keys(&committees, EpochNumber::new(4)), da_b_keys);
2035        assert_eq!(
2036            da_keys(&committees, EpochNumber::new(5)),
2037            da_c_keys,
2038            "epoch 5 must keep C — it is outside B's range",
2039        );
2040    }
2041}