Skip to main content

sui_rpc/light_client/
epoch_cache.rs

1use std::sync::Arc;
2
3use sui_sdk_types::ValidatorCommittee;
4
5use super::error::LightClientError;
6
7/// A cache of validator committees indexed by epoch.
8///
9/// The cache always knows about the *current* committee (the one in
10/// effect at the network's most recently observed epoch) and remembers
11/// each completed epoch's committee. Lookups via
12/// [`Self::committee_for_epoch`] return the committee that was active
13/// during the requested epoch, or `None` if the epoch falls outside
14/// the half-open range `[start_epoch, current_epoch]`.
15///
16/// The cache is advanced by calling [`Self::apply_ratchet_update`] with
17/// the committee that takes effect for the next epoch. The driver in
18/// `super::ratchet` is responsible for fetching and BLS-verifying the
19/// end-of-epoch summaries that feed these updates.
20///
21/// Committees are stored behind `Arc` so lookups don't have to clone
22/// the (potentially ~100-member) committee body. Callers receive an
23/// `Arc<ValidatorCommittee>` they can clone cheaply.
24#[derive(Debug, Clone)]
25pub struct EpochCache {
26    /// Committees for `[starting_epoch, current_epoch)`, one entry per
27    /// epoch in ascending order. `completed_committees[i]` covers
28    /// `starting_epoch + i`.
29    completed_committees: Vec<Arc<ValidatorCommittee>>,
30
31    /// The epoch number of `completed_committees[0]`, or — if the
32    /// vector is empty — the epoch of `current_committee` (i.e. the
33    /// only committee the cache knows about).
34    starting_epoch: u64,
35
36    /// The committee in effect for the current (open-ended) epoch.
37    current_committee: Arc<ValidatorCommittee>,
38}
39
40impl EpochCache {
41    /// Build a fresh cache seeded with `starting_committee`.
42    ///
43    /// `starting_committee.epoch` becomes the cache's starting epoch
44    /// and the committee is treated as covering that epoch onwards
45    /// until the first ratchet update is applied. The starting epoch
46    /// need not be zero — clients that bootstrap from a bundled trust
47    /// anchor or resume from a known checkpoint may seed the cache
48    /// partway through the chain.
49    pub fn new(starting_committee: ValidatorCommittee) -> Self {
50        let starting_epoch = starting_committee.epoch;
51        Self {
52            completed_committees: Vec::new(),
53            starting_epoch,
54            current_committee: Arc::new(starting_committee),
55        }
56    }
57
58    /// The committee in effect for the current epoch.
59    pub fn current_committee(&self) -> &ValidatorCommittee {
60        &self.current_committee
61    }
62
63    /// The epoch number the cache is currently tracking.
64    pub fn current_epoch(&self) -> u64 {
65        self.current_committee.epoch
66    }
67
68    /// The earliest epoch the cache has a committee for.
69    pub fn starting_epoch(&self) -> u64 {
70        self.starting_epoch
71    }
72
73    /// Look up the validator committee that was in effect during
74    /// `epoch`.
75    ///
76    /// Returns `None` if `epoch` falls outside the range the cache
77    /// knows about — either before [`Self::starting_epoch`] or
78    /// strictly after [`Self::current_epoch`].
79    pub fn committee_for_epoch(&self, epoch: u64) -> Option<Arc<ValidatorCommittee>> {
80        if epoch == self.current_epoch() {
81            return Some(self.current_committee.clone());
82        }
83        if epoch < self.starting_epoch {
84            return None;
85        }
86        let idx = usize::try_from(epoch - self.starting_epoch).ok()?;
87        self.completed_committees.get(idx).cloned()
88    }
89
90    /// Advance the cache: move the current epoch into
91    /// `completed_committees` and install `new_committee` as the new
92    /// current epoch.
93    ///
94    /// `new_committee.epoch` must be exactly one greater than the
95    /// cache's current epoch.
96    pub fn apply_ratchet_update(
97        &mut self,
98        new_committee: ValidatorCommittee,
99    ) -> Result<(), LightClientError> {
100        let current_epoch = self.current_epoch();
101        if new_committee.epoch != current_epoch + 1 {
102            return Err(LightClientError::InvalidEpochAdvance {
103                current: current_epoch,
104                provided: new_committee.epoch,
105            });
106        }
107
108        let completed = std::mem::replace(&mut self.current_committee, Arc::new(new_committee));
109        self.completed_committees.push(completed);
110        Ok(())
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    use sui_sdk_types::ValidatorCommittee;
119
120    fn committee(epoch: u64) -> ValidatorCommittee {
121        ValidatorCommittee {
122            epoch,
123            members: Vec::new(),
124        }
125    }
126
127    /// Fresh cache reports the starting committee for its own epoch
128    /// and nothing else.
129    #[test]
130    fn fresh_cache_returns_starting_committee_only_for_its_epoch() {
131        let cache = EpochCache::new(committee(0));
132        assert_eq!(cache.current_epoch(), 0);
133        assert_eq!(cache.starting_epoch(), 0);
134        assert_eq!(cache.committee_for_epoch(0).map(|c| c.epoch), Some(0));
135        assert!(cache.committee_for_epoch(1).is_none());
136        assert!(cache.committee_for_epoch(1_000_000).is_none());
137    }
138
139    /// A cache seeded at a non-zero epoch reports `None` for any
140    /// earlier epoch and serves the starting committee for its own
141    /// epoch.
142    #[test]
143    fn cache_seeded_at_non_zero_epoch_rejects_earlier_epochs() {
144        let cache = EpochCache::new(committee(1029));
145        assert_eq!(cache.starting_epoch(), 1029);
146        assert_eq!(cache.current_epoch(), 1029);
147        assert!(cache.committee_for_epoch(0).is_none());
148        assert!(cache.committee_for_epoch(1028).is_none());
149        assert_eq!(cache.committee_for_epoch(1029).map(|c| c.epoch), Some(1029));
150        assert!(cache.committee_for_epoch(1030).is_none());
151    }
152
153    /// A non-zero starting epoch advances normally and forms the floor
154    /// for lookups.
155    #[test]
156    fn non_zero_start_advances_normally() {
157        let mut cache = EpochCache::new(committee(1029));
158        cache.apply_ratchet_update(committee(1030)).unwrap();
159        cache.apply_ratchet_update(committee(1031)).unwrap();
160
161        assert_eq!(cache.starting_epoch(), 1029);
162        assert_eq!(cache.current_epoch(), 1031);
163        for epoch in 1029..=1031 {
164            assert_eq!(
165                cache.committee_for_epoch(epoch).map(|c| c.epoch),
166                Some(epoch),
167            );
168        }
169        assert!(cache.committee_for_epoch(1028).is_none());
170        assert!(cache.committee_for_epoch(1032).is_none());
171    }
172
173    /// After one ratchet update, the previous committee covers its own
174    /// epoch and the new committee covers the next.
175    #[test]
176    fn single_ratchet_records_completed_epoch() {
177        let mut cache = EpochCache::new(committee(0));
178        cache.apply_ratchet_update(committee(1)).unwrap();
179
180        assert_eq!(cache.current_epoch(), 1);
181        assert_eq!(cache.committee_for_epoch(0).map(|c| c.epoch), Some(0));
182        assert_eq!(cache.committee_for_epoch(1).map(|c| c.epoch), Some(1));
183        assert!(cache.committee_for_epoch(2).is_none());
184    }
185
186    /// Lookups across many completed epochs land on the right entry
187    /// in O(1) time.
188    #[test]
189    fn lookup_indexes_into_completed_committees() {
190        let mut cache = EpochCache::new(committee(0));
191        for epoch in 1..=4 {
192            cache.apply_ratchet_update(committee(epoch)).unwrap();
193        }
194
195        for epoch in 0..=4 {
196            assert_eq!(
197                cache.committee_for_epoch(epoch).map(|c| c.epoch),
198                Some(epoch),
199                "epoch {epoch} should be in the cache"
200            );
201        }
202        assert!(cache.committee_for_epoch(5).is_none());
203    }
204
205    /// Ratchet updates that skip an epoch are rejected.
206    #[test]
207    fn rejects_non_consecutive_epoch_advance() {
208        let mut cache = EpochCache::new(committee(0));
209        let err = cache.apply_ratchet_update(committee(2)).unwrap_err();
210        assert!(
211            matches!(
212                err,
213                LightClientError::InvalidEpochAdvance {
214                    current: 0,
215                    provided: 2,
216                }
217            ),
218            "got {err:?}"
219        );
220    }
221
222    /// Ratchet updates that re-issue the current epoch are rejected.
223    #[test]
224    fn rejects_repeating_current_epoch() {
225        let mut cache = EpochCache::new(committee(7));
226        let err = cache.apply_ratchet_update(committee(7)).unwrap_err();
227        assert!(
228            matches!(
229                err,
230                LightClientError::InvalidEpochAdvance {
231                    current: 7,
232                    provided: 7,
233                }
234            ),
235            "got {err:?}"
236        );
237    }
238}