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::{
8    collections::BTreeMap,
9    pin::pin,
10    sync::{
11        Arc,
12        atomic::{AtomicU64, Ordering},
13    },
14    time::{Duration, Instant},
15};
16
17use futures::future::BoxFuture;
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20use tokio::time::timeout;
21use tokio_util::sync::CancellationToken;
22use tracing::{error, info, warn};
23use vbs::version::Version;
24use versions::DRB_AND_HEADER_UPGRADE_VERSION;
25
26use crate::{
27    HotShotConfig,
28    data::EpochNumber,
29    traits::{
30        node_implementation::NodeType,
31        storage::{LoadDrbProgressFn, StoreDrbProgressFn},
32    },
33};
34
35#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
36pub struct DrbInput {
37    /// The epoch we are calculating the result for
38    pub epoch: u64,
39    /// The iteration this seed is from. For fresh calculations, this should be `0`.
40    pub iteration: u64,
41    /// the value of the drb calculation at the current iteration
42    pub value: [u8; 32],
43    /// difficulty value for the DRB calculation
44    pub difficulty_level: u64,
45}
46
47pub type DrbDifficultySelectorFn =
48    Arc<dyn Fn(Version) -> BoxFuture<'static, u64> + Send + Sync + 'static>;
49
50pub fn drb_difficulty_selector<TYPES: NodeType>(
51    config: &HotShotConfig<TYPES>,
52) -> DrbDifficultySelectorFn {
53    let base_difficulty = config.drb_difficulty;
54    let upgrade_difficulty = config.drb_upgrade_difficulty;
55    Arc::new(move |version| {
56        Box::pin(async move {
57            if version >= DRB_AND_HEADER_UPGRADE_VERSION {
58                upgrade_difficulty
59            } else {
60                base_difficulty
61            }
62        })
63    })
64}
65
66// TODO: Add the following consts once we bench the hash time.
67// <https://github.com/EspressoSystems/HotShot/issues/3880>
68// /// Highest number of hashes that a hardware can complete in a second.
69// const `HASHES_PER_SECOND`
70// /// Time a DRB calculation will take, in terms of number of views.
71// const `DRB_CALCULATION_NUM_VIEW`: u64 = 300;
72
73// TODO: Replace this with an accurate number calculated by `fn difficulty_level()` once we bench
74// the hash time.
75// <https://github.com/EspressoSystems/HotShot/issues/3880>
76/// Arbitrary number of times the hash function will be repeatedly called.
77pub const DIFFICULTY_LEVEL: u64 = 10;
78
79/// Interval at which to store the results
80pub const DRB_CHECKPOINT_INTERVAL: u64 = 1_000_000_000;
81
82/// Hashes between cancellation checks. Bounds both how long hashing continues after
83/// `cancel` fires and how stale a supervisor's view of [`Heartbeat`] can be; independent
84/// of the `DRB_CHECKPOINT_INTERVAL` persistence cadence.
85const DRB_CANCEL_BATCH: u64 = 100_000;
86
87/// DRB seed input for epoch 1 and 2.
88pub const INITIAL_DRB_SEED_INPUT: [u8; 32] = [0; 32];
89/// DRB result for epoch 1 and 2.
90pub const INITIAL_DRB_RESULT: [u8; 32] = [0; 32];
91
92/// Alias for DRB seed input for `compute_drb_result`, serialized from the QC signature.
93pub type DrbSeedInput = [u8; 32];
94
95/// Alias for DRB result from `compute_drb_result`.
96pub type DrbResult = [u8; 32];
97
98/// Number of previous results and seeds to keep
99pub const KEEP_PREVIOUS_RESULT_COUNT: u64 = 8;
100
101// TODO: Use `HASHES_PER_SECOND` * `VIEW_TIMEOUT` * `DRB_CALCULATION_NUM_VIEW` to calculate this
102// once we bench the hash time.
103// <https://github.com/EspressoSystems/HotShot/issues/3880>
104/// Difficulty level of the DRB calculation.
105///
106/// Represents the number of times the hash function will be repeatedly called.
107#[must_use]
108pub fn difficulty_level() -> u64 {
109    unimplemented!("Use an arbitrary `DIFFICULTY_LEVEL` for now before we bench the hash time.");
110}
111
112/// A counter a long computation bumps as it makes progress.
113///
114/// Lets a supervisor tell a slow computation apart from a stalled one without
115/// knowing anything about the work: a computation that is running moves the
116/// counter, one parked in an `.await` or queued behind a saturated thread pool
117/// does not. Bumped every `DRB_CANCEL_BATCH` hashes, so a reader that sees no
118/// change over a window longer than one batch can conclude the chain is stuck.
119#[derive(Clone, Debug, Default)]
120pub struct Heartbeat(Arc<AtomicU64>);
121
122impl Heartbeat {
123    /// Report that progress was made.
124    pub fn beat(&self) {
125        self.0.fetch_add(1, Ordering::Relaxed);
126    }
127
128    /// The current count. Only ever compared against an earlier reading of the
129    /// same `Heartbeat`; the absolute value carries no meaning.
130    pub fn count(&self) -> u64 {
131        self.0.load(Ordering::Relaxed)
132    }
133
134    /// Await `f`, giving up once this heartbeat did not move for `deadline`.
135    pub async fn while_alive<T, F>(&self, deadline: Duration, f: F) -> Option<T>
136    where
137        F: Future<Output = T>,
138    {
139        let mut f = pin!(f);
140        let mut ticks = self.count();
141        loop {
142            match timeout(deadline, &mut f).await {
143                Ok(val) => return Some(val),
144                Err(_) => {
145                    let observed = self.count();
146                    if observed == ticks {
147                        return None;
148                    }
149                    ticks = observed;
150                },
151            }
152        }
153    }
154}
155
156/// Hash `hash` repeatedly `count` times, checking `cancel` and bumping `heartbeat`
157/// between batches.
158///
159/// Returns `None` if cancelled, `Some(hash)` otherwise.
160fn hash_batches(
161    mut hash: [u8; 32],
162    count: u64,
163    cancel: CancellationToken,
164    heartbeat: Heartbeat,
165) -> Option<[u8; 32]> {
166    let mut done = 0u64;
167    while done < count {
168        if cancel.is_cancelled() {
169            return None;
170        }
171        let n = DRB_CANCEL_BATCH.min(count - done);
172        for _ in 0..n {
173            hash = Sha256::digest(hash).into();
174        }
175        done += n;
176        heartbeat.beat();
177    }
178    Some(hash)
179}
180
181/// Default upper bound on loading previously stored DRB progress at the
182/// start of a computation. Sized like the stake-table storage bound
183/// (espresso's `DEFAULT_STORAGE_READ_TIMEOUT`).
184pub const DRB_PROGRESS_LOAD_TIMEOUT: Duration = Duration::from_secs(60);
185
186/// Compute the DRB result for the leader rotation.
187///
188/// This is to be started two epochs in advance and spawned in a non-blocking thread.
189/// Returns `None` if the computation was cancelled via `cancel`.
190///
191/// # Arguments
192/// * `drb_seed_input` - Serialized QC signature.
193/// * `progress_load_timeout` - Bound on the initial stored-progress load
194///   (see [`DRB_PROGRESS_LOAD_TIMEOUT`]).
195/// * `heartbeat` - Bumped as the hash chain advances, so a supervisor can tell
196///   a long computation apart from a stalled one. Pass `Heartbeat::default()`
197///   when nobody is watching.
198/// * `cancel` - Token that stops the hash loop when fired.
199#[must_use]
200pub async fn compute_drb_result(
201    drb_input: DrbInput,
202    store_drb_progress: StoreDrbProgressFn,
203    load_drb_progress: LoadDrbProgressFn,
204    progress_load_timeout: Duration,
205    heartbeat: Heartbeat,
206    cancel: CancellationToken,
207) -> Option<DrbResult> {
208    info!(target: "announce::drb", ?drb_input, "beginning drb calculation");
209    let mut drb_input = drb_input;
210
211    match tokio::time::timeout(progress_load_timeout, load_drb_progress(drb_input.epoch)).await {
212        Ok(Ok(loaded_drb_input)) => {
213            if loaded_drb_input.difficulty_level != drb_input.difficulty_level {
214                error!(
215                    ?drb_input,
216                    ?loaded_drb_input,
217                    "we are calculating the drb result with input that has a different difficulty \
218                     level for this epoch than a previously stored one => discarding the value \
219                     from storage"
220                );
221            } else if loaded_drb_input.iteration >= drb_input.iteration {
222                drb_input = loaded_drb_input;
223            }
224        },
225        Ok(Err(e)) => {
226            warn!(
227                epoch = drb_input.epoch,
228                "loading stored drb progress failed; computing from the given input: {e}"
229            );
230        },
231        Err(_) => {
232            warn!(
233                epoch = drb_input.epoch,
234                ?progress_load_timeout,
235                "loading stored drb progress timed out; computing from the given input"
236            );
237        },
238    }
239
240    // The stored-progress load above can block for its whole timeout without the
241    // chain having started; report progress only now that it has.
242    heartbeat.beat();
243
244    let mut hash: [u8; 32] = drb_input.value;
245    let mut iteration = drb_input.iteration;
246    let remaining_iterations = drb_input
247        .difficulty_level
248        .checked_sub(iteration)
249        .unwrap_or_else(|| {
250            panic!(
251                "DRB difficulty level {} exceeds the iteration {} of the input we were given. \
252                 This is a fatal error",
253                drb_input.difficulty_level, iteration
254            )
255        });
256
257    let final_checkpoint = remaining_iterations / DRB_CHECKPOINT_INTERVAL;
258
259    let mut last_time = Instant::now();
260    let mut last_iteration = iteration;
261
262    // loop up to, but not including, the `final_checkpoint`
263    for _ in 0..final_checkpoint {
264        let c = cancel.clone();
265        let h = heartbeat.clone();
266        hash =
267            tokio::task::spawn_blocking(move || hash_batches(hash, DRB_CHECKPOINT_INTERVAL, c, h))
268                .await
269                .expect("completes")?;
270
271        iteration += DRB_CHECKPOINT_INTERVAL;
272
273        let updated_drb_input = DrbInput {
274            epoch: drb_input.epoch,
275            iteration,
276            value: hash,
277            difficulty_level: drb_input.difficulty_level,
278        };
279
280        let elapsed_time = last_time.elapsed().as_millis();
281
282        let store_drb_progress = store_drb_progress.clone();
283        tokio::spawn(async move {
284            info!(
285                target: "announce::drb",
286                ?updated_drb_input,
287                %last_iteration,
288                %elapsed_time,
289                "storing partial drb progress"
290            );
291            if let Err(err) = store_drb_progress(updated_drb_input).await {
292                warn!(%err, "failed to store drb progress during calculation");
293            }
294        });
295
296        last_time = Instant::now();
297        last_iteration = iteration;
298    }
299
300    let final_checkpoint_iteration = iteration;
301    // Holds by construction: `iteration` only ever reaches multiples of the checkpoint
302    // interval not exceeding `difficulty_level`.
303    let remainder = drb_input
304        .difficulty_level
305        .checked_sub(final_checkpoint_iteration)
306        .expect("sufficient iterations");
307
308    // perform the remaining iterations
309    let c = cancel.clone();
310    let h = heartbeat.clone();
311    let drb_result = tokio::task::spawn_blocking(move || hash_batches(hash, remainder, c, h))
312        .await
313        .expect("DRB spawn_blocking panicked")?;
314
315    let final_drb_input = DrbInput {
316        epoch: drb_input.epoch,
317        iteration: drb_input.difficulty_level,
318        value: drb_result,
319        difficulty_level: drb_input.difficulty_level,
320    };
321
322    info!(target: "announce::drb", ?final_drb_input, "completed drb calculation");
323
324    let store_drb_progress = store_drb_progress.clone();
325    tokio::spawn(async move {
326        if let Err(err) = store_drb_progress(final_drb_input).await {
327            warn!(%err, "failed to store drb progress during calculation");
328        }
329    });
330
331    Some(drb_result)
332}
333
334/// Seeds for DRB computation and computed results.
335#[derive(Clone, Debug)]
336pub struct DrbResults {
337    /// Stored results from computations
338    pub results: BTreeMap<EpochNumber, DrbResult>,
339}
340
341impl DrbResults {
342    #[must_use]
343    /// Constructor with initial values for epochs 1 and 2.
344    pub fn new() -> Self {
345        Self {
346            results: BTreeMap::from([
347                (EpochNumber::new(1), INITIAL_DRB_RESULT),
348                (EpochNumber::new(2), INITIAL_DRB_RESULT),
349            ]),
350        }
351    }
352
353    pub fn store_result(&mut self, epoch: EpochNumber, result: DrbResult) {
354        self.results.insert(epoch, result);
355    }
356
357    /// Garbage collects internal data structures
358    pub fn garbage_collect(&mut self, epoch: EpochNumber) {
359        if epoch.u64() < KEEP_PREVIOUS_RESULT_COUNT {
360            return;
361        }
362
363        let retain_epoch = epoch - KEEP_PREVIOUS_RESULT_COUNT;
364        // N.B. x.split_off(y) returns the part of the map where key >= y
365
366        // Remove result entries older than EPOCH
367        self.results = self.results.split_off(&retain_epoch);
368    }
369}
370
371impl Default for DrbResults {
372    fn default() -> Self {
373        Self::new()
374    }
375}
376
377/// Functions for leader selection based on the DRB.
378///
379/// The algorithm we use is:
380///
381/// Initialization:
382/// - obtain `drb: [u8; 32]` from the DRB calculation
383/// - sort the stake table for a given epoch by `xor(drb, public_key)`
384/// - generate a cdf of the cumulative stake using this newly-sorted table,
385///   along with a hash of the stake table entries
386///
387/// Selecting a leader:
388/// - calculate the SHA512 hash of the `drb_result`, `view_number` and `stake_table_hash`
389/// - find the first index in the cdf for which the remainder of this hash modulo the `total_stake`
390///   is strictly smaller than the cdf entry
391/// - return the corresponding node as the leader for that view
392pub mod election {
393    use alloy_primitives::{U256, U512};
394    use sha2::{Digest, Sha256, Sha512};
395
396    use crate::traits::signature_key::{SignatureKey, StakeTableEntryType};
397
398    /// Calculate `xor(drb.cycle(), public_key)`, returning the result as a vector of bytes
399    fn cyclic_xor(drb: [u8; 32], public_key: Vec<u8>) -> Vec<u8> {
400        let drb: Vec<u8> = drb.to_vec();
401
402        let mut result: Vec<u8> = vec![];
403
404        for (drb_byte, public_key_byte) in public_key.iter().zip(drb.iter().cycle()) {
405            result.push(drb_byte ^ public_key_byte);
406        }
407
408        result
409    }
410
411    /// Generate the stake table CDF, as well as a hash of the resulting stake table
412    pub fn generate_stake_cdf<Key: SignatureKey, Entry: StakeTableEntryType<Key>>(
413        mut stake_table: Vec<Entry>,
414        drb: [u8; 32],
415    ) -> RandomizedCommittee<Entry> {
416        // sort by xor(public_key, drb_result)
417        stake_table.sort_by(|a, b| {
418            cyclic_xor(drb, a.public_key().to_bytes())
419                .cmp(&cyclic_xor(drb, b.public_key().to_bytes()))
420        });
421
422        let mut hasher = Sha256::new();
423
424        let mut cumulative_stake = U256::from(0);
425        let mut cdf = vec![];
426
427        for entry in stake_table {
428            cumulative_stake += entry.stake();
429            hasher.update(entry.public_key().to_bytes());
430
431            cdf.push((entry, cumulative_stake));
432        }
433
434        RandomizedCommittee {
435            cdf,
436            stake_table_hash: hasher.finalize().into(),
437            drb,
438        }
439    }
440
441    /// select the leader for a view
442    ///
443    /// # Panics
444    /// Panics if `cdf` is empty. Results in undefined behaviour if `cdf` is not ordered.
445    ///
446    /// Note that we try to downcast a U512 to a U256,
447    /// but this should never panic because the U512 should be strictly smaller than U256::MAX by construction.
448    pub fn select_randomized_leader<
449        SignatureKey,
450        Entry: StakeTableEntryType<SignatureKey> + Clone,
451    >(
452        randomized_committee: &RandomizedCommittee<Entry>,
453        view: u64,
454    ) -> Entry {
455        let RandomizedCommittee {
456            cdf,
457            stake_table_hash,
458            drb,
459        } = randomized_committee;
460        // We hash the concatenated drb, view and stake table hash.
461        let mut hasher = Sha512::new();
462        hasher.update(drb);
463        hasher.update(view.to_le_bytes());
464        hasher.update(stake_table_hash);
465        let raw_breakpoint: [u8; 64] = hasher.finalize().into();
466
467        // then calculate the remainder modulo the total stake as a U512
468        let remainder: U512 =
469            U512::from_le_bytes(raw_breakpoint) % U512::from(cdf.last().unwrap().1);
470
471        // and drop the top 32 bytes, downcasting to a U256
472        let breakpoint: U256 = U256::from_le_slice(&remainder.to_le_bytes_vec()[0..32]);
473
474        // now find the first index where the breakpoint is strictly smaller than the cdf
475        //
476        // in principle, this may result in an index larger than `cdf.len()`.
477        // however, we have ensured by construction that `breakpoint < total_stake`
478        // and so the largest index we can actually return is `cdf.len() - 1`
479        let index = cdf.partition_point(|(_, cumulative_stake)| breakpoint >= *cumulative_stake);
480
481        // and return the corresponding entry
482        cdf[index].0.clone()
483    }
484
485    #[derive(Clone, Debug)]
486    pub struct RandomizedCommittee<Entry> {
487        /// cdf of nodes by cumulative stake
488        cdf: Vec<(Entry, U256)>,
489        /// Hash of the stake table
490        stake_table_hash: [u8; 32],
491        /// DRB result
492        drb: [u8; 32],
493    }
494
495    impl<Entry> RandomizedCommittee<Entry> {
496        pub fn drb_result(&self) -> [u8; 32] {
497            self.drb
498        }
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use std::{collections::HashMap, time::Duration};
505
506    use alloy_primitives::U256;
507    use rand::RngCore;
508    use sha2::{Digest, Sha256};
509    use tokio_util::sync::CancellationToken;
510
511    use super::{
512        DRB_CANCEL_BATCH, DRB_CHECKPOINT_INTERVAL, DRB_PROGRESS_LOAD_TIMEOUT, DrbInput, Heartbeat,
513        compute_drb_result,
514        election::{generate_stake_cdf, select_randomized_leader},
515        hash_batches,
516    };
517    use crate::{
518        signature_key::BLSPubKey,
519        stake_table::StakeTableEntry,
520        traits::{
521            signature_key::{BuilderSignatureKey, StakeTableEntryType},
522            storage::{LoadDrbProgressFn, null_load_drb_progress_fn, null_store_drb_progress_fn},
523        },
524    };
525
526    #[test]
527    fn test_hash_batches_pre_cancelled() {
528        let cancel = CancellationToken::new();
529        cancel.cancel();
530        assert_eq!(
531            hash_batches([0u8; 32], 10, cancel, Heartbeat::default()),
532            None
533        );
534    }
535
536    #[test]
537    fn test_hash_batches_count_zero() {
538        let input = [42u8; 32];
539        let result = hash_batches(input, 0, CancellationToken::new(), Heartbeat::default());
540        assert_eq!(result, Some(input));
541    }
542
543    #[test]
544    fn test_hash_batches_small_count() {
545        let input = [1u8; 32];
546        let count = 5u64;
547        // count < DRB_CANCEL_BATCH, so a single batch completes
548        let mut expected = input;
549        for _ in 0..count {
550            expected = Sha256::digest(expected).into();
551        }
552        let result = hash_batches(input, count, CancellationToken::new(), Heartbeat::default());
553        assert_eq!(result, Some(expected));
554    }
555
556    #[test]
557    fn test_hash_batches_cancel_after_first_batch() {
558        // count spans 2 batches; cancel after first batch boundary
559        let count = DRB_CANCEL_BATCH + 1;
560        let cancel = CancellationToken::new();
561
562        // Compute one full batch, then cancel, then try to hash the remainder
563        let intermediate = hash_batches(
564            [0u8; 32],
565            DRB_CANCEL_BATCH,
566            CancellationToken::new(),
567            Heartbeat::default(),
568        )
569        .expect("first batch must complete");
570        cancel.cancel();
571        // The remaining 1 iteration sees cancelled token on first check
572        let result = hash_batches(
573            intermediate,
574            count - DRB_CANCEL_BATCH,
575            cancel,
576            Heartbeat::default(),
577        );
578        assert_eq!(result, None);
579    }
580
581    #[tokio::test]
582    async fn test_compute_drb_result_cancelled() {
583        // A pre-cancelled token must abandon a multi-checkpoint computation
584        // immediately rather than running it to completion.
585        let cancel = CancellationToken::new();
586        cancel.cancel();
587        let drb_input = DrbInput {
588            epoch: 1,
589            iteration: 0,
590            value: [0u8; 32],
591            difficulty_level: DRB_CHECKPOINT_INTERVAL * 5,
592        };
593        let result = compute_drb_result(
594            drb_input,
595            null_store_drb_progress_fn(),
596            null_load_drb_progress_fn(),
597            DRB_PROGRESS_LOAD_TIMEOUT,
598            Heartbeat::default(),
599            cancel,
600        )
601        .await;
602        assert_eq!(result, None);
603    }
604
605    // A stalled progress-load query must not park the computation: the caller
606    // holds its `drb_computations` claim across this call, and no heartbeat is
607    // reported until the chain itself starts, so a supervisor would abandon the
608    // attempt. Past the bound, the chain is computed from the given input.
609    #[tokio::test]
610    async fn test_compute_drb_result_hung_progress_load() {
611        let hung_load: LoadDrbProgressFn =
612            std::sync::Arc::new(|_| Box::pin(std::future::pending()));
613        let drb_input = DrbInput {
614            epoch: 1,
615            iteration: 0,
616            value: [0u8; 32],
617            difficulty_level: 10,
618        };
619        let result = tokio::time::timeout(
620            Duration::from_secs(5),
621            compute_drb_result(
622                drb_input,
623                null_store_drb_progress_fn(),
624                hung_load,
625                Duration::from_millis(100),
626                Heartbeat::default(),
627                CancellationToken::new(),
628            ),
629        )
630        .await
631        .expect("the computation parked on the hung progress load instead of bounding it");
632        assert!(
633            result.is_some(),
634            "computation with a hung progress load returned no result"
635        );
636    }
637
638    #[test]
639    fn test_randomized_leader() {
640        let mut rng = rand::thread_rng();
641        // use an arbitrary Sha256 output.
642        let drb: [u8; 32] = Sha256::digest(b"drb").into();
643        // a stake table with 10 nodes, each with a stake of 1-100
644        let stake_table_entries: Vec<_> = (0..10)
645            .map(|i| StakeTableEntry {
646                stake_key: BLSPubKey::generated_from_seed_indexed([0u8; 32], i).0,
647                stake_amount: U256::from(rng.next_u64() % 100 + 1),
648            })
649            .collect();
650        let randomized_committee = generate_stake_cdf(stake_table_entries.clone(), drb);
651
652        // Number of views to test
653        let num_views = 100000;
654        let mut selected = HashMap::<_, u64>::new();
655        // Test the leader election for 100000 views.
656        for i in 0..num_views {
657            let leader = select_randomized_leader(&randomized_committee, i);
658            *selected.entry(leader).or_insert(0) += 1;
659        }
660
661        // Total variation distance
662        let mut tvd = 0.;
663        let total_stakes = stake_table_entries
664            .iter()
665            .map(|e| e.stake())
666            .sum::<U256>()
667            .to::<u64>() as f64;
668        for entry in stake_table_entries {
669            let expected = entry.stake().to::<u64>() as f64 / total_stakes;
670            let actual = *selected.get(&entry).unwrap_or(&0) as f64 / num_views as f64;
671            tvd += (expected - actual).abs();
672        }
673
674        // sanity check
675        assert!(tvd >= 0.0);
676        // Allow a small margin of error
677        assert!(tvd < 0.03);
678    }
679}