penumbra_sdk_stake/
uptime.rs

1use bitvec::prelude::*;
2
3use penumbra_sdk_proto::{penumbra::core::component::stake::v1 as pb, DomainType};
4use serde::{Deserialize, Serialize};
5
6/// Records information on a validator's uptime.
7///
8/// This structure provides two operations:
9///
10/// - recording that a validator did or did not sign a particular block;
11/// - reporting how many of the last N blocks a validator has missed signing.
12///
13/// Internally, the `Uptime` uses a bit vector as a ring buffer, with a `1` bit
14/// recording that the validator signed the block, and `0` recording that it
15/// didn't.  For a new validator, the [`Uptime::new`] method initializes the bit
16/// vector with all `1`s as a grace period to ensure that we don't need to
17/// special-case genesis states, new validators, etc.
18#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(try_from = "pb::Uptime", into = "pb::Uptime")]
20pub struct Uptime {
21    // Note: tracking this means we *could* in principle answer queries by
22    // height, they just might be surprising for new validators (we just report
23    // *failures* to sign, not didn't sign)
24    //
25    // can also possibly extend this impl to support resizing the window when we
26    // get to that
27    as_of_block_height: u64,
28    signatures: BitVec<u8, Lsb0>,
29}
30
31impl std::fmt::Debug for Uptime {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        f.debug_struct("Uptime").finish()
34    }
35}
36
37impl Uptime {
38    /// Initialize an uptime tracker for a new validator.
39    ///
40    /// This method should not be used for existing validators.  Instead,
41    /// deserialize the tracker (that should have been) created when the
42    /// validator was created.
43    pub fn new(initial_block_height: u64, signed_blocks_window_len: usize) -> Self {
44        let signatures = bitvec![u8, Lsb0; 1; signed_blocks_window_len];
45
46        Self {
47            as_of_block_height: initial_block_height,
48            signatures,
49        }
50    }
51
52    /// Mark that the validator signed the block at the given height (`true`),
53    /// or did not sign (`false`).
54    ///
55    /// This method errors only if the provided `height` isn't one greater than
56    /// the current block height.  This should not happen in normal use (i.e.,
57    /// it's probably reasonable to `expect` on the error), but the method
58    /// takes an explicit height and checks it to flag misuse and detect bugs.
59    pub fn mark_height_as_signed(&mut self, height: u64, signed: bool) -> anyhow::Result<()> {
60        if height != self.as_of_block_height + 1 {
61            anyhow::bail!(
62                "Last block height was {} but next block height is {}",
63                self.as_of_block_height,
64                height
65            );
66        }
67
68        // Use the bit vector as a ring buffer, overwriting the record for N blocks ago with this one.
69        let index = (height as usize) % self.signatures.len();
70        self.signatures.set(index, signed);
71        self.as_of_block_height = height;
72
73        Ok(())
74    }
75
76    /// Counts the number of missed blocks over the window.
77    pub fn num_missed_blocks(&self) -> usize {
78        self.signatures.iter_zeros().len()
79    }
80
81    /// Enumerates the missed blocks over the window in terms of absolute block height.
82    pub fn missed_blocks(&self) -> impl Iterator<Item = u64> + DoubleEndedIterator + '_ {
83        // The height of the latest block that's been recorded:
84        let current_height = self.as_of_block_height;
85        // The length of the window of blocks being recorded:
86        let window_len = self.signatures.len();
87        // The earliest height of a block that has been recorded:
88        let earliest_height = current_height.saturating_sub(window_len as u64 - 1);
89        // The range of block heights that have been recorded:
90        let all_heights = earliest_height..=current_height;
91        // Filter out the heights that were signed:
92        all_heights.filter_map(move |height| {
93            // Index the bit vector as the ring buffer that it is, and invert the bit corresponding
94            // to this height to find out whether it was missed:
95            let index = (height as usize) % window_len;
96            let signed = self.signatures[index];
97            Some(height).filter(|_| !signed)
98        })
99    }
100
101    /// Returns the block height up to which this tracker has recorded.
102    pub fn as_of_height(&self) -> u64 {
103        self.as_of_block_height
104    }
105
106    /// Returns the size of the window of blocks being recorded.
107    pub fn missed_blocks_window(&self) -> usize {
108        self.signatures.len()
109    }
110}
111
112impl DomainType for Uptime {
113    type Proto = pb::Uptime;
114}
115
116impl From<Uptime> for pb::Uptime {
117    fn from(mut val: Uptime) -> pb::Uptime {
118        // canonicalize any unused data
119        val.signatures.set_uninitialized(true);
120        let window_len = val.signatures.len() as u32;
121        let bitvec = val.signatures.into_vec();
122        pb::Uptime {
123            as_of_block_height: val.as_of_block_height,
124            window_len,
125            bitvec,
126        }
127    }
128}
129
130impl TryFrom<pb::Uptime> for Uptime {
131    type Error = anyhow::Error;
132    fn try_from(msg: pb::Uptime) -> Result<Uptime, Self::Error> {
133        let mut signatures = BitVec::from_vec(msg.bitvec);
134        if signatures.len() < msg.window_len as usize {
135            anyhow::bail!("not enough data in bitvec buffer");
136        }
137        signatures.truncate(msg.window_len as usize);
138        Ok(Uptime {
139            signatures,
140            as_of_block_height: msg.as_of_block_height,
141        })
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    use proptest::prelude::*;
150    use std::collections::VecDeque;
151
152    #[test]
153    fn counts_missed_blocks() {
154        let window = 128;
155        let mut uptime = Uptime::new(0, window);
156
157        // Simulate missing every 4th block for a full window
158        for h in 1..(window + 1) {
159            uptime.mark_height_as_signed(h as u64, h % 4 != 0).unwrap();
160        }
161        assert_eq!(uptime.num_missed_blocks(), window / 4);
162
163        // Now miss no blocks and check that the old data is forgotten
164        for h in (window + 1)..(2 * window + 1) {
165            uptime.mark_height_as_signed(h as u64, true).unwrap();
166        }
167        assert_eq!(uptime.num_missed_blocks(), 0);
168
169        // Finally, check that the sanity-checking works
170        assert!(uptime.mark_height_as_signed(0, true).is_err());
171    }
172
173    /// Basic check that if we miss block 1, we report that we missed block 1.
174    #[test]
175    fn enumerate_missed_first_block() {
176        let window = 128;
177        let mut uptime = Uptime::new(0, window);
178
179        // Mark the first block as missed
180        uptime.mark_height_as_signed(1, false).unwrap();
181        let missed_blocks: Vec<_> = uptime.missed_blocks().collect();
182
183        // Check that exactly the first block is missed
184        assert_eq!(missed_blocks, vec![1]);
185    }
186
187    proptest! {
188        /// Ensure that the `Uptime` struct simulates a fixed size queue of (height, signed) tuples,
189        /// and that the `missed_blocks` iterator returns the correct missed blocks.
190        #[test]
191        fn enumerate_uptime_simulates_bounded_queue(
192            (window_len, signed_blocks) in
193                (1..=16usize).prop_flat_map(move |window_len| {
194                    proptest::collection::vec(proptest::bool::ANY, 0..window_len * 2)
195                        .prop_map(move |signed_blocks| (window_len, signed_blocks))
196                })
197        ) {
198            // We're going to simulate the `Uptime` struct with a VecDeque of (height, signed)
199            // tuples whose length we will keep bounded by the window length.
200            let mut uptime = Uptime::new(0, window_len);
201            let mut simulated_uptime = VecDeque::new();
202
203            // For each (height, signed) tuple in our generated sequence, mark the height as signed
204            // or not signed.
205            for (height, signed) in signed_blocks.into_iter().enumerate() {
206                // Convert the height to a u64 and add 1 because the `Uptime` struct starts out with
207                // an internal height of 0:
208                let height = height as u64 + 1;
209                // Mark it using the real `Uptime` struct:
210                uptime.mark_height_as_signed(height, signed).unwrap();
211                // Mark it using our simulated `VecDeque`, taking care to keep its length bounded by
212                // the window length:
213                simulated_uptime.push_back((height, signed));
214                if simulated_uptime.len() > window_len {
215                    simulated_uptime.pop_front();
216                }
217            }
218
219            // Compare the missed blocks from the real `Uptime` struct with the simulated `VecDeque`:
220            let missed_blocks: Vec<_> = uptime.missed_blocks().collect();
221
222            // Retain only the heights from the simulated `VecDeque` that were not signed:
223            simulated_uptime.retain(|(_, signed)| !signed);
224            let simulated_missed_blocks: Vec<_> =
225                simulated_uptime.into_iter().map(|(height, _)| height).collect();
226
227            prop_assert_eq!(missed_blocks, simulated_missed_blocks);
228        }
229    }
230
231    #[test]
232    fn proto_round_trip() {
233        // make a weird size window
234        let mut uptime = Uptime::new(0, 113);
235        // Fill it with some data
236        for h in 1..300u64 {
237            uptime.mark_height_as_signed(h, h % 13 != 0).unwrap();
238        }
239
240        let bytes = uptime.encode_to_vec();
241        let uptime2 = Uptime::decode(bytes.as_slice()).unwrap();
242        assert_eq!(uptime, uptime2);
243    }
244}