Skip to main content

hotshot_types/
drb.rs

1// Copyright (c) 2021-2024 Espresso Systems (espressosys.com)
2// This file is part of the HotShot repository.
3
4// You should have received a copy of the MIT License
5// along with the HotShot repository. If not, see <https://mit-license.org/>.
6
7use std::{collections::BTreeMap, sync::Arc, time::Instant};
8
9use futures::future::BoxFuture;
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use tokio_util::sync::CancellationToken;
13use tracing::{error, info, warn};
14use vbs::version::Version;
15use versions::DRB_AND_HEADER_UPGRADE_VERSION;
16
17use crate::{
18    HotShotConfig,
19    data::EpochNumber,
20    traits::{
21        node_implementation::NodeType,
22        storage::{LoadDrbProgressFn, StoreDrbProgressFn},
23    },
24};
25
26#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
27pub struct DrbInput {
28    /// The epoch we are calculating the result for
29    pub epoch: u64,
30    /// The iteration this seed is from. For fresh calculations, this should be `0`.
31    pub iteration: u64,
32    /// the value of the drb calculation at the current iteration
33    pub value: [u8; 32],
34    /// difficulty value for the DRB calculation
35    pub difficulty_level: u64,
36}
37
38pub type DrbDifficultySelectorFn =
39    Arc<dyn Fn(Version) -> BoxFuture<'static, u64> + Send + Sync + 'static>;
40
41pub fn drb_difficulty_selector<TYPES: NodeType>(
42    config: &HotShotConfig<TYPES>,
43) -> DrbDifficultySelectorFn {
44    let base_difficulty = config.drb_difficulty;
45    let upgrade_difficulty = config.drb_upgrade_difficulty;
46    Arc::new(move |version| {
47        Box::pin(async move {
48            if version >= DRB_AND_HEADER_UPGRADE_VERSION {
49                upgrade_difficulty
50            } else {
51                base_difficulty
52            }
53        })
54    })
55}
56
57// TODO: Add the following consts once we bench the hash time.
58// <https://github.com/EspressoSystems/HotShot/issues/3880>
59// /// Highest number of hashes that a hardware can complete in a second.
60// const `HASHES_PER_SECOND`
61// /// Time a DRB calculation will take, in terms of number of views.
62// const `DRB_CALCULATION_NUM_VIEW`: u64 = 300;
63
64// TODO: Replace this with an accurate number calculated by `fn difficulty_level()` once we bench
65// the hash time.
66// <https://github.com/EspressoSystems/HotShot/issues/3880>
67/// Arbitrary number of times the hash function will be repeatedly called.
68pub const DIFFICULTY_LEVEL: u64 = 10;
69
70/// Interval at which to store the results
71pub const DRB_CHECKPOINT_INTERVAL: u64 = 1_000_000_000;
72
73/// Hashes between cancellation checks. Bounds how long hashing continues after `cancel`
74/// fires; independent of the `DRB_CHECKPOINT_INTERVAL` persistence cadence.
75const DRB_CANCEL_BATCH: u64 = 1_000_000;
76
77/// DRB seed input for epoch 1 and 2.
78pub const INITIAL_DRB_SEED_INPUT: [u8; 32] = [0; 32];
79/// DRB result for epoch 1 and 2.
80pub const INITIAL_DRB_RESULT: [u8; 32] = [0; 32];
81
82/// Alias for DRB seed input for `compute_drb_result`, serialized from the QC signature.
83pub type DrbSeedInput = [u8; 32];
84
85/// Alias for DRB result from `compute_drb_result`.
86pub type DrbResult = [u8; 32];
87
88/// Number of previous results and seeds to keep
89pub const KEEP_PREVIOUS_RESULT_COUNT: u64 = 8;
90
91// TODO: Use `HASHES_PER_SECOND` * `VIEW_TIMEOUT` * `DRB_CALCULATION_NUM_VIEW` to calculate this
92// once we bench the hash time.
93// <https://github.com/EspressoSystems/HotShot/issues/3880>
94/// Difficulty level of the DRB calculation.
95///
96/// Represents the number of times the hash function will be repeatedly called.
97#[must_use]
98pub fn difficulty_level() -> u64 {
99    unimplemented!("Use an arbitrary `DIFFICULTY_LEVEL` for now before we bench the hash time.");
100}
101
102/// Hash `hash` repeatedly `count` times, checking `cancel` between batches.
103///
104/// Returns `None` if cancelled, `Some(hash)` otherwise.
105fn hash_batches(mut hash: [u8; 32], count: u64, cancel: CancellationToken) -> Option<[u8; 32]> {
106    let mut done = 0u64;
107    while done < count {
108        if cancel.is_cancelled() {
109            return None;
110        }
111        let n = DRB_CANCEL_BATCH.min(count - done);
112        for _ in 0..n {
113            hash = Sha256::digest(hash).into();
114        }
115        done += n;
116    }
117    Some(hash)
118}
119
120/// Compute the DRB result for the leader rotation.
121///
122/// This is to be started two epochs in advance and spawned in a non-blocking thread.
123/// Returns `None` if the computation was cancelled via `cancel`.
124///
125/// # Arguments
126/// * `drb_seed_input` - Serialized QC signature.
127/// * `cancel` - Token that stops the hash loop when fired.
128#[must_use]
129pub async fn compute_drb_result(
130    drb_input: DrbInput,
131    store_drb_progress: StoreDrbProgressFn,
132    load_drb_progress: LoadDrbProgressFn,
133    cancel: CancellationToken,
134) -> Option<DrbResult> {
135    info!(target: "announce::drb", ?drb_input, "beginning drb calculation");
136    let mut drb_input = drb_input;
137
138    if let Ok(loaded_drb_input) = load_drb_progress(drb_input.epoch).await {
139        if loaded_drb_input.difficulty_level != drb_input.difficulty_level {
140            error!(
141                ?drb_input,
142                ?loaded_drb_input,
143                "we are calculating the drb result with input that has a different difficulty \
144                 level for this epoch than a previously stored one => discarding the value from \
145                 storage"
146            );
147        } else if loaded_drb_input.iteration >= drb_input.iteration {
148            drb_input = loaded_drb_input;
149        }
150    }
151
152    let mut hash: [u8; 32] = drb_input.value;
153    let mut iteration = drb_input.iteration;
154    let remaining_iterations = drb_input
155        .difficulty_level
156        .checked_sub(iteration)
157        .unwrap_or_else(|| {
158            panic!(
159                "DRB difficulty level {} exceeds the iteration {} of the input we were given. \
160                 This is a fatal error",
161                drb_input.difficulty_level, iteration
162            )
163        });
164
165    let final_checkpoint = remaining_iterations / DRB_CHECKPOINT_INTERVAL;
166
167    let mut last_time = Instant::now();
168    let mut last_iteration = iteration;
169
170    // loop up to, but not including, the `final_checkpoint`
171    for _ in 0..final_checkpoint {
172        let c = cancel.clone();
173        hash = tokio::task::spawn_blocking(move || hash_batches(hash, DRB_CHECKPOINT_INTERVAL, c))
174            .await
175            .expect("completes")?;
176
177        iteration += DRB_CHECKPOINT_INTERVAL;
178
179        let updated_drb_input = DrbInput {
180            epoch: drb_input.epoch,
181            iteration,
182            value: hash,
183            difficulty_level: drb_input.difficulty_level,
184        };
185
186        let elapsed_time = last_time.elapsed().as_millis();
187
188        let store_drb_progress = store_drb_progress.clone();
189        tokio::spawn(async move {
190            info!(
191                target: "announce::drb",
192                ?updated_drb_input,
193                %last_iteration,
194                %elapsed_time,
195                "storing partial drb progress"
196            );
197            if let Err(err) = store_drb_progress(updated_drb_input).await {
198                warn!(%err, "failed to store drb progress during calculation");
199            }
200        });
201
202        last_time = Instant::now();
203        last_iteration = iteration;
204    }
205
206    let final_checkpoint_iteration = iteration;
207    // Holds by construction: `iteration` only ever reaches multiples of the checkpoint
208    // interval not exceeding `difficulty_level`.
209    let remainder = drb_input
210        .difficulty_level
211        .checked_sub(final_checkpoint_iteration)
212        .expect("sufficient iterations");
213
214    // perform the remaining iterations
215    let c = cancel.clone();
216    let drb_result = tokio::task::spawn_blocking(move || hash_batches(hash, remainder, c))
217        .await
218        .expect("DRB spawn_blocking panicked")?;
219
220    let final_drb_input = DrbInput {
221        epoch: drb_input.epoch,
222        iteration: drb_input.difficulty_level,
223        value: drb_result,
224        difficulty_level: drb_input.difficulty_level,
225    };
226
227    info!(target: "announce::drb", ?final_drb_input, "completed drb calculation");
228
229    let store_drb_progress = store_drb_progress.clone();
230    tokio::spawn(async move {
231        if let Err(err) = store_drb_progress(final_drb_input).await {
232            warn!(%err, "failed to store drb progress during calculation");
233        }
234    });
235
236    Some(drb_result)
237}
238
239/// Seeds for DRB computation and computed results.
240#[derive(Clone, Debug)]
241pub struct DrbResults {
242    /// Stored results from computations
243    pub results: BTreeMap<EpochNumber, DrbResult>,
244}
245
246impl DrbResults {
247    #[must_use]
248    /// Constructor with initial values for epochs 1 and 2.
249    pub fn new() -> Self {
250        Self {
251            results: BTreeMap::from([
252                (EpochNumber::new(1), INITIAL_DRB_RESULT),
253                (EpochNumber::new(2), INITIAL_DRB_RESULT),
254            ]),
255        }
256    }
257
258    pub fn store_result(&mut self, epoch: EpochNumber, result: DrbResult) {
259        self.results.insert(epoch, result);
260    }
261
262    /// Garbage collects internal data structures
263    pub fn garbage_collect(&mut self, epoch: EpochNumber) {
264        if epoch.u64() < KEEP_PREVIOUS_RESULT_COUNT {
265            return;
266        }
267
268        let retain_epoch = epoch - KEEP_PREVIOUS_RESULT_COUNT;
269        // N.B. x.split_off(y) returns the part of the map where key >= y
270
271        // Remove result entries older than EPOCH
272        self.results = self.results.split_off(&retain_epoch);
273    }
274}
275
276impl Default for DrbResults {
277    fn default() -> Self {
278        Self::new()
279    }
280}
281
282/// Functions for leader selection based on the DRB.
283///
284/// The algorithm we use is:
285///
286/// Initialization:
287/// - obtain `drb: [u8; 32]` from the DRB calculation
288/// - sort the stake table for a given epoch by `xor(drb, public_key)`
289/// - generate a cdf of the cumulative stake using this newly-sorted table,
290///   along with a hash of the stake table entries
291///
292/// Selecting a leader:
293/// - calculate the SHA512 hash of the `drb_result`, `view_number` and `stake_table_hash`
294/// - find the first index in the cdf for which the remainder of this hash modulo the `total_stake`
295///   is strictly smaller than the cdf entry
296/// - return the corresponding node as the leader for that view
297pub mod election {
298    use alloy_primitives::{U256, U512};
299    use sha2::{Digest, Sha256, Sha512};
300
301    use crate::traits::signature_key::{SignatureKey, StakeTableEntryType};
302
303    /// Calculate `xor(drb.cycle(), public_key)`, returning the result as a vector of bytes
304    fn cyclic_xor(drb: [u8; 32], public_key: Vec<u8>) -> Vec<u8> {
305        let drb: Vec<u8> = drb.to_vec();
306
307        let mut result: Vec<u8> = vec![];
308
309        for (drb_byte, public_key_byte) in public_key.iter().zip(drb.iter().cycle()) {
310            result.push(drb_byte ^ public_key_byte);
311        }
312
313        result
314    }
315
316    /// Generate the stake table CDF, as well as a hash of the resulting stake table
317    pub fn generate_stake_cdf<Key: SignatureKey, Entry: StakeTableEntryType<Key>>(
318        mut stake_table: Vec<Entry>,
319        drb: [u8; 32],
320    ) -> RandomizedCommittee<Entry> {
321        // sort by xor(public_key, drb_result)
322        stake_table.sort_by(|a, b| {
323            cyclic_xor(drb, a.public_key().to_bytes())
324                .cmp(&cyclic_xor(drb, b.public_key().to_bytes()))
325        });
326
327        let mut hasher = Sha256::new();
328
329        let mut cumulative_stake = U256::from(0);
330        let mut cdf = vec![];
331
332        for entry in stake_table {
333            cumulative_stake += entry.stake();
334            hasher.update(entry.public_key().to_bytes());
335
336            cdf.push((entry, cumulative_stake));
337        }
338
339        RandomizedCommittee {
340            cdf,
341            stake_table_hash: hasher.finalize().into(),
342            drb,
343        }
344    }
345
346    /// select the leader for a view
347    ///
348    /// # Panics
349    /// Panics if `cdf` is empty. Results in undefined behaviour if `cdf` is not ordered.
350    ///
351    /// Note that we try to downcast a U512 to a U256,
352    /// but this should never panic because the U512 should be strictly smaller than U256::MAX by construction.
353    pub fn select_randomized_leader<
354        SignatureKey,
355        Entry: StakeTableEntryType<SignatureKey> + Clone,
356    >(
357        randomized_committee: &RandomizedCommittee<Entry>,
358        view: u64,
359    ) -> Entry {
360        let RandomizedCommittee {
361            cdf,
362            stake_table_hash,
363            drb,
364        } = randomized_committee;
365        // We hash the concatenated drb, view and stake table hash.
366        let mut hasher = Sha512::new();
367        hasher.update(drb);
368        hasher.update(view.to_le_bytes());
369        hasher.update(stake_table_hash);
370        let raw_breakpoint: [u8; 64] = hasher.finalize().into();
371
372        // then calculate the remainder modulo the total stake as a U512
373        let remainder: U512 =
374            U512::from_le_bytes(raw_breakpoint) % U512::from(cdf.last().unwrap().1);
375
376        // and drop the top 32 bytes, downcasting to a U256
377        let breakpoint: U256 = U256::from_le_slice(&remainder.to_le_bytes_vec()[0..32]);
378
379        // now find the first index where the breakpoint is strictly smaller than the cdf
380        //
381        // in principle, this may result in an index larger than `cdf.len()`.
382        // however, we have ensured by construction that `breakpoint < total_stake`
383        // and so the largest index we can actually return is `cdf.len() - 1`
384        let index = cdf.partition_point(|(_, cumulative_stake)| breakpoint >= *cumulative_stake);
385
386        // and return the corresponding entry
387        cdf[index].0.clone()
388    }
389
390    #[derive(Clone, Debug)]
391    pub struct RandomizedCommittee<Entry> {
392        /// cdf of nodes by cumulative stake
393        cdf: Vec<(Entry, U256)>,
394        /// Hash of the stake table
395        stake_table_hash: [u8; 32],
396        /// DRB result
397        drb: [u8; 32],
398    }
399
400    impl<Entry> RandomizedCommittee<Entry> {
401        pub fn drb_result(&self) -> [u8; 32] {
402            self.drb
403        }
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use std::collections::HashMap;
410
411    use alloy_primitives::U256;
412    use rand::RngCore;
413    use sha2::{Digest, Sha256};
414    use tokio_util::sync::CancellationToken;
415
416    use super::{
417        DRB_CANCEL_BATCH, DRB_CHECKPOINT_INTERVAL, DrbInput, compute_drb_result,
418        election::{generate_stake_cdf, select_randomized_leader},
419        hash_batches,
420    };
421    use crate::{
422        signature_key::BLSPubKey,
423        stake_table::StakeTableEntry,
424        traits::{
425            signature_key::{BuilderSignatureKey, StakeTableEntryType},
426            storage::{null_load_drb_progress_fn, null_store_drb_progress_fn},
427        },
428    };
429
430    #[test]
431    fn test_hash_batches_pre_cancelled() {
432        let cancel = CancellationToken::new();
433        cancel.cancel();
434        assert_eq!(hash_batches([0u8; 32], 10, cancel), None);
435    }
436
437    #[test]
438    fn test_hash_batches_count_zero() {
439        let input = [42u8; 32];
440        let result = hash_batches(input, 0, CancellationToken::new());
441        assert_eq!(result, Some(input));
442    }
443
444    #[test]
445    fn test_hash_batches_small_count() {
446        let input = [1u8; 32];
447        let count = 5u64;
448        // count < DRB_CANCEL_BATCH, so a single batch completes
449        let mut expected = input;
450        for _ in 0..count {
451            expected = Sha256::digest(expected).into();
452        }
453        let result = hash_batches(input, count, CancellationToken::new());
454        assert_eq!(result, Some(expected));
455    }
456
457    #[test]
458    fn test_hash_batches_cancel_after_first_batch() {
459        // count spans 2 batches; cancel after first batch boundary
460        let count = DRB_CANCEL_BATCH + 1;
461        let cancel = CancellationToken::new();
462
463        // Compute one full batch, then cancel, then try to hash the remainder
464        let intermediate = hash_batches([0u8; 32], DRB_CANCEL_BATCH, CancellationToken::new())
465            .expect("first batch must complete");
466        cancel.cancel();
467        // The remaining 1 iteration sees cancelled token on first check
468        let result = hash_batches(intermediate, count - DRB_CANCEL_BATCH, cancel);
469        assert_eq!(result, None);
470    }
471
472    #[tokio::test]
473    async fn test_compute_drb_result_cancelled() {
474        // A pre-cancelled token must abandon a multi-checkpoint computation
475        // immediately rather than running it to completion.
476        let cancel = CancellationToken::new();
477        cancel.cancel();
478        let drb_input = DrbInput {
479            epoch: 1,
480            iteration: 0,
481            value: [0u8; 32],
482            difficulty_level: DRB_CHECKPOINT_INTERVAL * 5,
483        };
484        let result = compute_drb_result(
485            drb_input,
486            null_store_drb_progress_fn(),
487            null_load_drb_progress_fn(),
488            cancel,
489        )
490        .await;
491        assert_eq!(result, None);
492    }
493
494    #[test]
495    fn test_randomized_leader() {
496        let mut rng = rand::thread_rng();
497        // use an arbitrary Sha256 output.
498        let drb: [u8; 32] = Sha256::digest(b"drb").into();
499        // a stake table with 10 nodes, each with a stake of 1-100
500        let stake_table_entries: Vec<_> = (0..10)
501            .map(|i| StakeTableEntry {
502                stake_key: BLSPubKey::generated_from_seed_indexed([0u8; 32], i).0,
503                stake_amount: U256::from(rng.next_u64() % 100 + 1),
504            })
505            .collect();
506        let randomized_committee = generate_stake_cdf(stake_table_entries.clone(), drb);
507
508        // Number of views to test
509        let num_views = 100000;
510        let mut selected = HashMap::<_, u64>::new();
511        // Test the leader election for 100000 views.
512        for i in 0..num_views {
513            let leader = select_randomized_leader(&randomized_committee, i);
514            *selected.entry(leader).or_insert(0) += 1;
515        }
516
517        // Total variation distance
518        let mut tvd = 0.;
519        let total_stakes = stake_table_entries
520            .iter()
521            .map(|e| e.stake())
522            .sum::<U256>()
523            .to::<u64>() as f64;
524        for entry in stake_table_entries {
525            let expected = entry.stake().to::<u64>() as f64 / total_stakes;
526            let actual = *selected.get(&entry).unwrap_or(&0) as f64 / num_views as f64;
527            tvd += (expected - actual).abs();
528        }
529
530        // sanity check
531        assert!(tvd >= 0.0);
532        // Allow a small margin of error
533        assert!(tvd < 0.03);
534    }
535}