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