Skip to main content

hotmint_consensus/
liveness.rs

1use std::collections::HashMap;
2
3use hotmint_types::Height;
4use hotmint_types::validator::{ValidatorId, ValidatorSet};
5
6/// Tracks validator liveness within an epoch by counting missed commit-QC
7/// signatures.
8///
9/// **Deterministic**: All nodes derive liveness data from committed QC signer
10/// bitfields stored on-chain, so the same set of offline validators is
11/// reported deterministically at epoch boundaries.
12pub struct LivenessTracker {
13    /// Number of committed blocks each validator missed signing.
14    missed: HashMap<ValidatorId, u64>,
15    /// Total committed blocks tracked in this epoch.
16    total_commits: u64,
17    /// Threshold ratio (numerator / denominator). A validator is considered
18    /// offline if `missed / total_commits > threshold_num / threshold_den`.
19    threshold_num: u64,
20    threshold_den: u64,
21}
22
23impl Default for LivenessTracker {
24    fn default() -> Self {
25        Self::new()
26    }
27}
28
29impl LivenessTracker {
30    /// Create a new tracker. Default offline threshold: missed > 50% of commits.
31    pub fn new() -> Self {
32        Self {
33            missed: HashMap::new(),
34            total_commits: 0,
35            threshold_num: 50,
36            threshold_den: 100,
37        }
38    }
39
40    /// Record a committed block's QC signer bitfield.
41    ///
42    /// `signers` is the commit-QC's signer bitfield (index-aligned with the
43    /// validator set). Validators whose bit is `false` are counted as having
44    /// missed this commit.
45    pub fn record_commit(&mut self, validator_set: &ValidatorSet, signers: &[bool]) {
46        self.total_commits += 1;
47        for (idx, vi) in validator_set.validators().iter().enumerate() {
48            let signed = signers.get(idx).copied().unwrap_or(false);
49            if !signed {
50                *self.missed.entry(vi.id).or_insert(0) += 1;
51            }
52        }
53    }
54
55    /// Return validators whose miss rate exceeds the offline threshold.
56    ///
57    /// Returns `(ValidatorId, missed_count, total_commits)` for each offline
58    /// validator, suitable for the application to apply downtime slashing.
59    pub fn offline_validators(&self) -> Vec<(ValidatorId, u64, u64)> {
60        if self.total_commits == 0 {
61            return vec![];
62        }
63        let mut result = Vec::new();
64        for (&id, &missed) in &self.missed {
65            // missed / total > threshold_num / threshold_den
66            // ⟹ missed * threshold_den > threshold_num * total  (no floating point)
67            if missed * self.threshold_den > self.threshold_num * self.total_commits {
68                result.push((id, missed, self.total_commits));
69            }
70        }
71        result.sort_by_key(|&(id, _, _)| id.0);
72        result
73    }
74
75    /// Reset the tracker for a new epoch.
76    pub fn reset(&mut self) {
77        self.missed.clear();
78        self.total_commits = 0;
79    }
80
81    /// Get liveness stats for a specific validator.
82    pub fn stats(&self, id: ValidatorId) -> (u64, u64) {
83        let missed = self.missed.get(&id).copied().unwrap_or(0);
84        (missed, self.total_commits)
85    }
86
87    /// Current number of tracked commits.
88    pub fn total_commits(&self) -> u64 {
89        self.total_commits
90    }
91}
92
93/// Offline evidence reported to the application at epoch boundaries.
94#[derive(Debug, Clone)]
95pub struct OfflineEvidence {
96    pub validator: ValidatorId,
97    pub missed_commits: u64,
98    pub total_commits: u64,
99    /// The height at which this evidence was produced (last committed block).
100    pub evidence_height: Height,
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use hotmint_types::crypto::PublicKey;
107    use hotmint_types::validator::{ValidatorInfo, ValidatorSet};
108
109    fn make_vs(n: usize) -> ValidatorSet {
110        let validators: Vec<ValidatorInfo> = (0..n)
111            .map(|i| ValidatorInfo {
112                id: ValidatorId(i as u64),
113                public_key: PublicKey(vec![i as u8]),
114                power: 1,
115            })
116            .collect();
117        ValidatorSet::new(validators)
118    }
119
120    #[test]
121    fn test_all_sign() {
122        let vs = make_vs(4);
123        let mut tracker = LivenessTracker::new();
124        for _ in 0..10 {
125            tracker.record_commit(&vs, &[true, true, true, true]);
126        }
127        assert!(tracker.offline_validators().is_empty());
128        assert_eq!(tracker.total_commits(), 10);
129    }
130
131    #[test]
132    fn test_one_offline() {
133        let vs = make_vs(4);
134        let mut tracker = LivenessTracker::new();
135        // V3 never signs
136        for _ in 0..10 {
137            tracker.record_commit(&vs, &[true, true, true, false]);
138        }
139        let offline = tracker.offline_validators();
140        assert_eq!(offline.len(), 1);
141        assert_eq!(offline[0].0, ValidatorId(3));
142        assert_eq!(offline[0].1, 10); // missed all 10
143    }
144
145    #[test]
146    fn test_threshold_boundary() {
147        let vs = make_vs(4);
148        let mut tracker = LivenessTracker::new();
149        // V2 misses exactly 50% → NOT offline (threshold is >50%)
150        for i in 0..10 {
151            let v2_signs = i % 2 == 0;
152            tracker.record_commit(&vs, &[true, true, v2_signs, true]);
153        }
154        assert!(tracker.offline_validators().is_empty());
155
156        // V2 misses 6/10 = 60% → offline
157        tracker.reset();
158        for i in 0..10 {
159            let v2_signs = i < 4; // signs first 4, misses last 6
160            tracker.record_commit(&vs, &[true, true, v2_signs, true]);
161        }
162        let offline = tracker.offline_validators();
163        assert_eq!(offline.len(), 1);
164        assert_eq!(offline[0].0, ValidatorId(2));
165    }
166
167    #[test]
168    fn test_reset() {
169        let vs = make_vs(4);
170        let mut tracker = LivenessTracker::new();
171        for _ in 0..5 {
172            tracker.record_commit(&vs, &[true, true, true, false]);
173        }
174        assert_eq!(tracker.total_commits(), 5);
175        tracker.reset();
176        assert_eq!(tracker.total_commits(), 0);
177        assert!(tracker.offline_validators().is_empty());
178    }
179}