Skip to main content

iota_types/
committee.rs

1// Copyright (c) 2021, Facebook, Inc. and its affiliates
2// Copyright (c) Mysten Labs, Inc.
3// Modifications Copyright (c) 2024 IOTA Stiftung
4// SPDX-License-Identifier: Apache-2.0
5
6use std::{
7    collections::{BTreeMap, BTreeSet, HashMap},
8    fmt::{Display, Formatter, Write},
9    hash::{Hash, Hasher},
10};
11
12use fastcrypto::traits::KeyPair;
13pub use iota_protocol_config::ProtocolVersion;
14use iota_sdk_types::{TransactionDigest, validator::ValidatorCommitteeMember};
15use once_cell::sync::OnceCell;
16use rand::{
17    Rng, SeedableRng,
18    rngs::{StdRng, ThreadRng},
19    seq::SliceRandom,
20};
21use serde::{Deserialize, Serialize};
22
23use super::base_types::*;
24use crate::{
25    crypto::{
26        AuthorityKeyPair, AuthorityPublicKey, NetworkPublicKey, random_committee_key_pairs_of_size,
27    },
28    error::{IotaError, IotaResult},
29    messages_checkpoint::{CertifiedCheckpointSummary, VerifiedCheckpoint},
30    multiaddr::Multiaddr,
31};
32
33pub type EpochId = u64;
34
35// TODO: the stake and voting power of a validator can be different so
36// in some places when we are actually referring to the voting power, we
37// should use a different type alias, field name, etc.
38pub type StakeUnit = u64;
39
40pub type CommitteeDigest = [u8; 32];
41
42// The voting power, quorum threshold and max voting power are defined in the
43// `voting_power.move` module. We're following the very same convention in the
44// validator binaries.
45
46/// Set total_voting_power as 10_000 by convention. Individual voting powers can
47/// be interpreted as easily understandable basis points (e.g., voting_power:
48/// 100 = 1%, voting_power: 1 = 0.01%). Fixing the total voting power allows
49/// clients to hardcode the quorum threshold and total_voting power rather
50/// than recomputing these.
51pub const TOTAL_VOTING_POWER: StakeUnit = 10_000;
52
53/// Quorum threshold for our fixed voting power--any message signed by this much
54/// voting power can be trusted up to BFT assumptions
55pub const QUORUM_THRESHOLD: StakeUnit = 6_667;
56
57/// Validity threshold defined by f+1
58pub const VALIDITY_THRESHOLD: StakeUnit = 3_334;
59
60#[derive(Clone, Debug, Serialize, Deserialize, Eq)]
61pub struct Committee {
62    pub epoch: EpochId,
63    pub voting_rights: Vec<(AuthorityName, StakeUnit)>,
64    expanded_keys: HashMap<AuthorityName, AuthorityPublicKey>,
65    index_map: HashMap<AuthorityName, usize>,
66}
67
68impl Committee {
69    pub fn new(epoch: EpochId, voting_rights: BTreeMap<AuthorityName, StakeUnit>) -> Self {
70        let mut voting_rights: Vec<(AuthorityName, StakeUnit)> =
71            voting_rights.iter().map(|(a, s)| (*a, *s)).collect();
72
73        assert!(!voting_rights.is_empty());
74        assert!(voting_rights.iter().any(|(_, s)| *s != 0));
75
76        voting_rights.sort_by_key(|(a, _)| *a);
77        let total_votes: StakeUnit = voting_rights.iter().map(|(_, votes)| *votes).sum();
78        assert_eq!(total_votes, TOTAL_VOTING_POWER);
79
80        let (expanded_keys, index_map) = Self::load_inner(&voting_rights);
81
82        Committee {
83            epoch,
84            voting_rights,
85            expanded_keys,
86            index_map,
87        }
88    }
89
90    /// Build a committee for `epoch` from a [`ValidatorCommitteeMember`]
91    /// list.
92    pub fn from_committee_members(epoch: EpochId, members: &[ValidatorCommitteeMember]) -> Self {
93        Self::new(
94            epoch,
95            members
96                .iter()
97                .map(|member| (member.public_key.into(), member.stake))
98                .collect(),
99        )
100    }
101
102    /// The committee's voting rights expressed as a
103    /// [`ValidatorCommitteeMember`] list used by `EndOfEpochData`.
104    pub fn committee_members(&self) -> Vec<ValidatorCommitteeMember> {
105        self.voting_rights
106            .iter()
107            .map(|(name, stake)| ValidatorCommitteeMember {
108                public_key: (*name).into(),
109                stake: *stake,
110            })
111            .collect()
112    }
113
114    /// Normalize the given weights to TOTAL_VOTING_POWER and create the
115    /// committee. Used for testing only: a production system is using the
116    /// voting weights of the Iota System object.
117    pub fn new_for_testing_with_normalized_voting_power(
118        epoch: EpochId,
119        mut voting_weights: BTreeMap<AuthorityName, StakeUnit>,
120    ) -> Self {
121        let num_nodes = voting_weights.len();
122        let total_votes: StakeUnit = voting_weights.values().cloned().sum();
123
124        let normalization_coef = TOTAL_VOTING_POWER as f64 / total_votes as f64;
125        let mut total_sum = 0;
126        for (idx, (_auth, weight)) in voting_weights.iter_mut().enumerate() {
127            if idx < num_nodes - 1 {
128                *weight = (*weight as f64 * normalization_coef).floor() as u64; // adjust the weights following the normalization coef
129                total_sum += *weight;
130            } else {
131                // the last element is taking all the rest
132                *weight = TOTAL_VOTING_POWER - total_sum;
133            }
134        }
135
136        Self::new(epoch, voting_weights)
137    }
138
139    // We call this if these have not yet been computed
140    pub fn load_inner(
141        voting_rights: &[(AuthorityName, StakeUnit)],
142    ) -> (
143        HashMap<AuthorityName, AuthorityPublicKey>,
144        HashMap<AuthorityName, usize>,
145    ) {
146        let expanded_keys: HashMap<AuthorityName, AuthorityPublicKey> = voting_rights
147            .iter()
148            .map(|(addr, _)| {
149                (
150                    *addr,
151                    (*addr)
152                        .try_into()
153                        .expect("Validator pubkey is always verified on-chain"),
154                )
155            })
156            .collect();
157
158        let index_map: HashMap<AuthorityName, usize> = voting_rights
159            .iter()
160            .enumerate()
161            .map(|(index, (addr, _))| (*addr, index))
162            .collect();
163        (expanded_keys, index_map)
164    }
165
166    pub fn authority_index(&self, author: &AuthorityName) -> Option<u32> {
167        self.index_map.get(author).map(|i| *i as u32)
168    }
169
170    pub fn authority_by_index(&self, index: u32) -> Option<&AuthorityName> {
171        self.voting_rights.get(index as usize).map(|(name, _)| name)
172    }
173
174    pub fn epoch(&self) -> EpochId {
175        self.epoch
176    }
177
178    pub fn public_key(&self, authority: &AuthorityName) -> IotaResult<&AuthorityPublicKey> {
179        debug_assert_eq!(self.expanded_keys.len(), self.voting_rights.len());
180        match self.expanded_keys.get(authority) {
181            Some(v) => Ok(v),
182            None => Err(IotaError::InvalidCommittee(format!(
183                "Authority #{} not found, committee size {}",
184                authority,
185                self.expanded_keys.len()
186            ))),
187        }
188    }
189
190    /// Samples authorities by weight
191    pub fn sample(&self) -> &AuthorityName {
192        // unwrap safe unless committee is empty
193        Self::choose_multiple_weighted(&self.voting_rights[..], 1, &mut ThreadRng::default())
194            .next()
195            .unwrap()
196    }
197
198    fn choose_multiple_weighted<'a>(
199        slice: &'a [(AuthorityName, StakeUnit)],
200        count: usize,
201        rng: &mut impl Rng,
202    ) -> impl Iterator<Item = &'a AuthorityName> {
203        // unwrap is safe because we validate the committee composition in `new` above.
204        // See https://docs.rs/rand/latest/rand/distributions/weighted/enum.WeightedError.html
205        // for possible errors.
206        slice
207            .choose_multiple_weighted(rng, count, |(_, weight)| *weight as f64)
208            .unwrap()
209            .map(|(a, _)| a)
210    }
211
212    pub fn choose_multiple_weighted_iter(
213        &self,
214        count: usize,
215    ) -> impl Iterator<Item = &AuthorityName> {
216        self.voting_rights
217            .choose_multiple_weighted(&mut ThreadRng::default(), count, |(_, weight)| {
218                *weight as f64
219            })
220            .unwrap()
221            .map(|(a, _)| a)
222    }
223
224    pub fn total_votes(&self) -> StakeUnit {
225        TOTAL_VOTING_POWER
226    }
227
228    pub fn quorum_threshold(&self) -> StakeUnit {
229        QUORUM_THRESHOLD
230    }
231
232    pub fn validity_threshold(&self) -> StakeUnit {
233        VALIDITY_THRESHOLD
234    }
235
236    pub fn threshold<const STRENGTH: bool>(&self) -> StakeUnit {
237        if STRENGTH {
238            QUORUM_THRESHOLD
239        } else {
240            VALIDITY_THRESHOLD
241        }
242    }
243
244    /// Calculates the effective threshold for protocol version selection.
245    /// This is the quorum threshold plus a buffer stake based on the given
246    /// basis points.
247    ///
248    /// The buffer is calculated as: f * buffer_stake_bps / 10000, rounded up
249    /// where f = total_votes - quorum_threshold
250    ///
251    /// buffer_stake_bps is clamped to a maximum of 10000.
252    pub fn effective_threshold(&self, mut buffer_stake_bps: u64) -> StakeUnit {
253        if buffer_stake_bps > 10000 {
254            buffer_stake_bps = 10000;
255        }
256        let quorum_threshold = self.quorum_threshold();
257        let f = self.total_votes() - quorum_threshold;
258        let buffer_stake = (f * buffer_stake_bps).div_ceil(10000);
259        quorum_threshold + buffer_stake
260    }
261
262    pub fn num_members(&self) -> usize {
263        self.voting_rights.len()
264    }
265
266    pub fn members(&self) -> impl Iterator<Item = &(AuthorityName, StakeUnit)> {
267        self.voting_rights.iter()
268    }
269
270    pub fn names(&self) -> impl Iterator<Item = &AuthorityName> {
271        self.voting_rights.iter().map(|(name, _)| name)
272    }
273
274    pub fn stakes(&self) -> impl Iterator<Item = StakeUnit> + '_ {
275        self.voting_rights.iter().map(|(_, stake)| *stake)
276    }
277
278    /// Returns the stake of the authority at the given index, or `None` if out
279    /// of bounds.
280    pub fn stake_by_index(&self, index: u32) -> Option<StakeUnit> {
281        self.voting_rights
282            .get(index as usize)
283            .map(|(_, stake)| *stake)
284    }
285
286    pub fn authority_exists(&self, name: &AuthorityName) -> bool {
287        self.voting_rights
288            .binary_search_by_key(name, |(a, _)| *a)
289            .is_ok()
290    }
291
292    /// Derive a seed deterministically from the transaction digest and shuffle
293    /// the validators.
294    pub fn shuffle_by_stake_from_tx_digest(
295        &self,
296        tx_digest: &TransactionDigest,
297    ) -> Vec<AuthorityName> {
298        // the 32 is as requirement of the default StdRng::from_seed choice
299        let digest_bytes = tx_digest.into_inner();
300
301        // permute the validators deterministically, based on the digest
302        let mut rng = StdRng::from_seed(digest_bytes);
303        self.shuffle_by_stake_with_rng(None, None, &mut rng)
304    }
305
306    // ===== Testing-only methods =====
307    //
308    pub fn new_simple_test_committee_of_size(size: usize) -> (Self, Vec<AuthorityKeyPair>) {
309        let key_pairs: Vec<_> = random_committee_key_pairs_of_size(size)
310            .into_iter()
311            .collect();
312        let committee = Self::new_for_testing_with_normalized_voting_power(
313            0,
314            key_pairs
315                .iter()
316                .map(|key| {
317                    (AuthorityName::from(key.public()), /* voting right */ 1)
318                })
319                .collect(),
320        );
321        (committee, key_pairs)
322    }
323
324    /// Generate a simple committee with 4 validators each with equal voting
325    /// stake of 1.
326    pub fn new_simple_test_committee() -> (Self, Vec<AuthorityKeyPair>) {
327        Self::new_simple_test_committee_of_size(4)
328    }
329}
330
331impl CommitteeTrait<AuthorityName> for Committee {
332    fn shuffle_by_stake_with_rng(
333        &self,
334        // try these authorities first
335        preferences: Option<&BTreeSet<AuthorityName>>,
336        // only attempt from these authorities.
337        restrict_to: Option<&BTreeSet<AuthorityName>>,
338        rng: &mut impl Rng,
339    ) -> Vec<AuthorityName> {
340        let restricted = self
341            .voting_rights
342            .iter()
343            .filter(|(name, _)| {
344                if let Some(restrict_to) = restrict_to {
345                    restrict_to.contains(name)
346                } else {
347                    true
348                }
349            })
350            .cloned();
351
352        let (preferred, rest): (Vec<_>, Vec<_>) = if let Some(preferences) = preferences {
353            restricted.partition(|(name, _)| preferences.contains(name))
354        } else {
355            (Vec::new(), restricted.collect())
356        };
357
358        Self::choose_multiple_weighted(&preferred, preferred.len(), rng)
359            .chain(Self::choose_multiple_weighted(&rest, rest.len(), rng))
360            .cloned()
361            .collect()
362    }
363
364    fn weight(&self, author: &AuthorityName) -> StakeUnit {
365        match self.voting_rights.binary_search_by_key(author, |(a, _)| *a) {
366            Err(_) => 0,
367            Ok(idx) => self.voting_rights[idx].1,
368        }
369    }
370}
371
372impl PartialEq for Committee {
373    fn eq(&self, other: &Self) -> bool {
374        self.epoch == other.epoch && self.voting_rights == other.voting_rights
375    }
376}
377
378impl Hash for Committee {
379    fn hash<H: Hasher>(&self, state: &mut H) {
380        self.epoch.hash(state);
381        self.voting_rights.hash(state);
382    }
383}
384
385impl Display for Committee {
386    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
387        let mut voting_rights = String::new();
388        for (name, vote) in &self.voting_rights {
389            write!(voting_rights, "{}: {}, ", name.concise(), vote)?;
390        }
391        write!(
392            f,
393            "Committee (epoch={:?}, voting_rights=[{}])",
394            self.epoch, voting_rights
395        )
396    }
397}
398
399pub trait CommitteeTrait<K: Ord> {
400    fn shuffle_by_stake_with_rng(
401        &self,
402        // try these authorities first
403        preferences: Option<&BTreeSet<K>>,
404        // only attempt from these authorities.
405        restrict_to: Option<&BTreeSet<K>>,
406        rng: &mut impl Rng,
407    ) -> Vec<K>;
408
409    fn shuffle_by_stake(
410        &self,
411        // try these authorities first
412        preferences: Option<&BTreeSet<K>>,
413        // only attempt from these authorities.
414        restrict_to: Option<&BTreeSet<K>>,
415    ) -> Vec<K> {
416        self.shuffle_by_stake_with_rng(preferences, restrict_to, &mut ThreadRng::default())
417    }
418
419    fn weight(&self, author: &K) -> StakeUnit;
420}
421
422#[derive(Clone, Debug, Serialize, Deserialize)]
423pub struct NetworkMetadata {
424    pub network_address: Multiaddr,
425    pub primary_address: Multiaddr,
426    pub network_public_key: Option<NetworkPublicKey>,
427}
428
429#[derive(Clone, Debug, Serialize, Deserialize)]
430pub struct CommitteeWithNetworkMetadata {
431    epoch_id: EpochId,
432    validators: BTreeMap<AuthorityName, (StakeUnit, NetworkMetadata)>,
433
434    #[serde(skip)]
435    committee: OnceCell<Committee>,
436}
437
438impl CommitteeWithNetworkMetadata {
439    pub fn new(
440        epoch_id: EpochId,
441        validators: BTreeMap<AuthorityName, (StakeUnit, NetworkMetadata)>,
442    ) -> Self {
443        Self {
444            epoch_id,
445            validators,
446            committee: OnceCell::new(),
447        }
448    }
449    pub fn epoch(&self) -> EpochId {
450        self.epoch_id
451    }
452
453    pub fn validators(&self) -> &BTreeMap<AuthorityName, (StakeUnit, NetworkMetadata)> {
454        &self.validators
455    }
456
457    pub fn committee(&self) -> &Committee {
458        self.committee.get_or_init(|| {
459            Committee::new(
460                self.epoch_id,
461                self.validators
462                    .iter()
463                    .map(|(name, (stake, _))| (*name, *stake))
464                    .collect(),
465            )
466        })
467    }
468}
469
470impl Display for CommitteeWithNetworkMetadata {
471    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
472        write!(
473            f,
474            "CommitteeWithNetworkMetadata (epoch={}, validators={:?})",
475            self.epoch_id, self.validators
476        )
477    }
478}
479
480/// Verifies the committee chain: the sequence of committees linked by each
481/// epoch's certified closing checkpoint, whose `end_of_epoch_data` elects the
482/// committee of the next epoch.
483///
484/// Starting from a trusted committee (typically the genesis committee, the
485/// operator's trust root), feed it each epoch's closing
486/// [`CertifiedCheckpointSummary`] in epoch order via
487/// [`Self::verify_epoch_close`]; every summary a consumer obtains this way is
488/// committee-verified, with no trust in whatever transport delivered it.
489///
490/// The walk is transport-agnostic by design — callers drive their own loop
491/// (an in-memory list, a remote-store stream, files on disk) and feed
492/// summaries in; this type only holds the verification state.
493#[derive(Debug)]
494pub struct CommitteeChainVerifier {
495    committee: Committee,
496}
497
498impl CommitteeChainVerifier {
499    /// Start the walk at a trusted committee — the trust root for everything
500    /// verified after it.
501    pub fn new(trusted_committee: Committee) -> Self {
502        Self {
503            committee: trusted_committee,
504        }
505    }
506
507    /// The epoch whose closing checkpoint must be fed next.
508    pub fn epoch(&self) -> EpochId {
509        self.committee.epoch
510    }
511
512    /// The committee of [`Self::epoch`] (trusted root or chain-verified).
513    pub fn committee(&self) -> &Committee {
514        &self.committee
515    }
516
517    /// Verify `summary` as the certified closing checkpoint of
518    /// [`Self::epoch`] and advance to the committee it elects for the next
519    /// epoch.
520    ///
521    /// Errors leave the verifier unchanged, e.g. if the summary is for a
522    /// different epoch, its signatures don't verify under the current
523    /// committee, or it is not a close-of-epoch checkpoint (no
524    /// `end_of_epoch_data`).
525    pub fn verify_epoch_close(
526        &mut self,
527        summary: CertifiedCheckpointSummary,
528    ) -> IotaResult<VerifiedCheckpoint> {
529        // The structural checks run before the (expensive) signature
530        // verification. They can only ever reject; nothing from the summary
531        // is trusted until the signatures verify.
532        if summary.data().epoch != self.committee.epoch {
533            return Err(IotaError::WrongEpoch {
534                expected_epoch: self.committee.epoch,
535                actual_epoch: summary.data().epoch,
536            });
537        }
538
539        if summary.data().end_of_epoch_data.is_none() {
540            return Err(IotaError::GenericAuthority {
541                error: format!(
542                    "checkpoint {} is not the closing checkpoint of epoch {} (no \
543                     end-of-epoch data)",
544                    summary.data().sequence_number,
545                    self.committee.epoch,
546                ),
547            });
548        }
549
550        let verified = summary.try_into_verified(&self.committee)?;
551        let end_of_epoch_data = verified
552            .end_of_epoch_data
553            .as_ref()
554            .expect("checked before verification");
555
556        self.committee = Committee::from_committee_members(
557            self.committee
558                .epoch
559                .checked_add(1)
560                .ok_or(IotaError::AdvanceEpoch {
561                    error: "epoch number overflow".to_string(),
562                })?,
563            &end_of_epoch_data.next_epoch_committee,
564        );
565        Ok(verified)
566    }
567}
568
569#[cfg(test)]
570mod test {
571    use fastcrypto::traits::KeyPair;
572    use iota_sdk_types::checkpoint::{CheckpointSummary, EndOfEpochData};
573
574    use super::*;
575    use crate::{
576        crypto::{AuthorityKeyPair, get_key_pair},
577        messages_checkpoint::SignedCheckpointSummary,
578        utils::make_committee_key,
579    };
580
581    const RNG_SEED: [u8; 32] = [
582        21, 23, 199, 200, 234, 250, 252, 178, 94, 15, 202, 178, 62, 186, 88, 137, 233, 192, 130,
583        157, 179, 179, 65, 9, 31, 249, 221, 123, 225, 112, 199, 247,
584    ];
585
586    #[test]
587    fn test_shuffle_by_weight() {
588        let (_, sec1): (_, AuthorityKeyPair) = get_key_pair();
589        let (_, sec2): (_, AuthorityKeyPair) = get_key_pair();
590        let (_, sec3): (_, AuthorityKeyPair) = get_key_pair();
591        let a1: AuthorityName = sec1.public().into();
592        let a2: AuthorityName = sec2.public().into();
593        let a3: AuthorityName = sec3.public().into();
594
595        let mut authorities = BTreeMap::new();
596        authorities.insert(a1, 1);
597        authorities.insert(a2, 1);
598        authorities.insert(a3, 1);
599
600        let committee = Committee::new_for_testing_with_normalized_voting_power(0, authorities);
601
602        assert_eq!(committee.shuffle_by_stake(None, None).len(), 3);
603
604        let mut pref = BTreeSet::new();
605        pref.insert(a2);
606
607        // preference always comes first
608        for _ in 0..100 {
609            assert_eq!(
610                a2,
611                *committee
612                    .shuffle_by_stake(Some(&pref), None)
613                    .first()
614                    .unwrap()
615            );
616        }
617
618        let mut restrict = BTreeSet::new();
619        restrict.insert(a2);
620
621        for _ in 0..100 {
622            let res = committee.shuffle_by_stake(None, Some(&restrict));
623            assert_eq!(1, res.len());
624            assert_eq!(a2, res[0]);
625        }
626
627        // empty preferences are valid
628        let res = committee.shuffle_by_stake(Some(&BTreeSet::new()), None);
629        assert_eq!(3, res.len());
630
631        let res = committee.shuffle_by_stake(None, Some(&BTreeSet::new()));
632        assert_eq!(0, res.len());
633    }
634
635    /// `CommitteeChainVerifier` accepts a correctly signed chain of closing
636    /// checkpoints, advancing one committee per epoch — and rejects, without
637    /// advancing, a summary for the wrong epoch, one signed by a different
638    /// committee, and one that is not a close of epoch.
639    #[test]
640    fn committee_chain_verifier_walks_and_rejects() {
641        let mut rng = StdRng::from_seed(RNG_SEED);
642        let (keys, committee) = make_committee_key(&mut rng);
643        let (other_keys, other_committee) = make_committee_key(&mut rng);
644
645        let close_of_epoch = |epoch: EpochId, end_of_epoch_data: Option<EndOfEpochData>| {
646            let summary = CheckpointSummary {
647                epoch,
648                sequence_number: epoch,
649                network_total_transactions: 0,
650                content_digest: Default::default(),
651                previous_digest: None,
652                epoch_rolling_gas_cost_summary: Default::default(),
653                end_of_epoch_data,
654                timestamp_ms: 0,
655                version_specific_data: Vec::new(),
656                checkpoint_commitments: Vec::new(),
657            };
658            let signatures = keys
659                .iter()
660                .map(|k| SignedCheckpointSummary::sign(epoch, &summary, k, k.public().into()))
661                .collect();
662            let committee_at_epoch =
663                Committee::new(epoch, committee.voting_rights.iter().cloned().collect());
664            CertifiedCheckpointSummary::new(summary, signatures, &committee_at_epoch)
665                .expect("test summary must certify")
666        };
667        let handing_forward = Some(EndOfEpochData {
668            next_epoch_committee: committee.committee_members(),
669            next_epoch_protocol_version: 1,
670            epoch_commitments: Vec::new(),
671            epoch_supply_change: 0,
672        });
673
674        let mut verifier = CommitteeChainVerifier::new(committee.clone());
675
676        // Wrong epoch: epoch 1's close fed while epoch 0 is expected.
677        assert!(matches!(
678            verifier.verify_epoch_close(close_of_epoch(1, handing_forward.clone())),
679            Err(IotaError::WrongEpoch { .. })
680        ));
681        assert_eq!(verifier.epoch(), 0, "a rejected summary must not advance");
682
683        // Not a close of epoch.
684        verifier
685            .verify_epoch_close(close_of_epoch(0, None))
686            .expect_err("a non-closing checkpoint must be rejected");
687        assert_eq!(verifier.epoch(), 0);
688
689        // The close-of-epoch check runs before the (expensive) signature
690        // verification: a non-closing summary certified by a foreign
691        // committee yields the structural error, not a signature error.
692        let foreign_non_closing = {
693            let summary = CheckpointSummary {
694                epoch: 0,
695                sequence_number: 0,
696                network_total_transactions: 0,
697                content_digest: Default::default(),
698                previous_digest: None,
699                epoch_rolling_gas_cost_summary: Default::default(),
700                end_of_epoch_data: None,
701                timestamp_ms: 0,
702                version_specific_data: Vec::new(),
703                checkpoint_commitments: Vec::new(),
704            };
705            let signatures = other_keys
706                .iter()
707                .map(|k| SignedCheckpointSummary::sign(0, &summary, k, k.public().into()))
708                .collect();
709            CertifiedCheckpointSummary::new(summary, signatures, &other_committee)
710                .expect("certifies under the foreign committee")
711        };
712        assert!(matches!(
713            verifier.verify_epoch_close(foreign_non_closing),
714            Err(IotaError::GenericAuthority { .. })
715        ));
716        assert_eq!(verifier.epoch(), 0);
717
718        // The valid chain walks: epoch 0 then epoch 1.
719        verifier
720            .verify_epoch_close(close_of_epoch(0, handing_forward.clone()))
721            .expect("epoch 0 close must verify");
722        assert_eq!(verifier.epoch(), 1);
723        verifier
724            .verify_epoch_close(close_of_epoch(1, handing_forward.clone()))
725            .expect("epoch 1 close must verify");
726        assert_eq!(verifier.epoch(), 2);
727
728        // A different trust root rejects the same (validly signed) chain.
729        let mut wrong_root = CommitteeChainVerifier::new(other_committee);
730        wrong_root
731            .verify_epoch_close(close_of_epoch(0, handing_forward))
732            .expect_err("a chain signed by a different committee must be rejected");
733        assert_eq!(wrong_root.epoch(), 0);
734    }
735}