Skip to main content

nklave_core/state/
validator.rs

1//! Per-validator safety state management
2//!
3//! Tracks signing history to prevent slashable signatures.
4//! Supports multiple blockchain networks: Ethereum, Cosmos.
5
6use crate::policy::types::ChainType;
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8use std::collections::BTreeMap;
9
10/// Serialize a fixed-size byte array as hex
11fn serialize_bytes<S, const N: usize>(bytes: &[u8; N], serializer: S) -> Result<S::Ok, S::Error>
12where
13    S: Serializer,
14{
15    serializer.serialize_str(&hex::encode(bytes))
16}
17
18/// Deserialize a fixed-size byte array from hex
19fn deserialize_bytes<'de, D, const N: usize>(deserializer: D) -> Result<[u8; N], D::Error>
20where
21    D: Deserializer<'de>,
22{
23    let s: String = Deserialize::deserialize(deserializer)?;
24    let s = s.strip_prefix("0x").unwrap_or(&s);
25    let bytes = hex::decode(s).map_err(serde::de::Error::custom)?;
26    if bytes.len() != N {
27        return Err(serde::de::Error::custom(format!(
28            "expected {} bytes, got {}",
29            N,
30            bytes.len()
31        )));
32    }
33    let mut arr = [0u8; N];
34    arr.copy_from_slice(&bytes);
35    Ok(arr)
36}
37
38/// Safety state for a single validator
39///
40/// Supports multiple chain types (Ethereum, Cosmos) with chain-specific state.
41/// For backward compatibility, checkpoints without chain_state are assumed to be Ethereum.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ValidatorState {
44    /// Validator's public key (48 bytes for BLS, 32 bytes for Ed25519)
45    /// For Ethereum: 48-byte BLS public key
46    /// For Cosmos: 32-byte Ed25519 public key (padded to 48 bytes for compatibility)
47    #[serde(serialize_with = "serialize_bytes", deserialize_with = "deserialize_bytes")]
48    pub pubkey: [u8; 48],
49
50    // Legacy fields for backward compatibility with existing checkpoints
51    // These are used when chain_state is None (legacy Ethereum-only format)
52    /// Highest slot for which a block was signed (Ethereum legacy)
53    #[serde(default)]
54    pub last_signed_block_slot: Option<u64>,
55
56    /// Map of slot -> signing_root for block proposals (Ethereum legacy)
57    #[serde(default)]
58    pub block_signing_roots: BTreeMap<u64, [u8; 32]>,
59
60    /// Highest source epoch in any signed attestation (Ethereum legacy)
61    #[serde(default)]
62    pub highest_source_epoch: u64,
63
64    /// Highest target epoch in any signed attestation (Ethereum legacy)
65    #[serde(default)]
66    pub highest_target_epoch: u64,
67
68    /// Attestation history for surround vote detection (Ethereum legacy)
69    #[serde(default)]
70    pub attestation_history: AttestationHistory,
71
72    // New multi-chain field
73    /// Chain-specific state (new format)
74    /// If None, uses legacy Ethereum fields above
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub chain_state: Option<ChainState>,
77}
78
79impl ValidatorState {
80    /// Create a new Ethereum validator state with the given public key
81    pub fn new(pubkey: [u8; 48]) -> Self {
82        Self {
83            pubkey,
84            last_signed_block_slot: None,
85            block_signing_roots: BTreeMap::new(),
86            highest_source_epoch: 0,
87            highest_target_epoch: 0,
88            attestation_history: AttestationHistory::new(),
89            chain_state: None, // Use legacy format for Ethereum by default
90        }
91    }
92
93    /// Create a new Ethereum validator state (explicit)
94    pub fn new_ethereum(pubkey: [u8; 48]) -> Self {
95        Self {
96            pubkey,
97            last_signed_block_slot: None,
98            block_signing_roots: BTreeMap::new(),
99            highest_source_epoch: 0,
100            highest_target_epoch: 0,
101            attestation_history: AttestationHistory::new(),
102            chain_state: Some(ChainState::Ethereum(EthereumState::new())),
103        }
104    }
105
106    /// Create a new Cosmos validator state
107    ///
108    /// Note: Ed25519 pubkeys are 32 bytes, so they are zero-padded to 48 bytes
109    pub fn new_cosmos(pubkey: [u8; 32]) -> Self {
110        let mut full_pubkey = [0u8; 48];
111        full_pubkey[..32].copy_from_slice(&pubkey);
112
113        Self {
114            pubkey: full_pubkey,
115            // Legacy fields not used for Cosmos
116            last_signed_block_slot: None,
117            block_signing_roots: BTreeMap::new(),
118            highest_source_epoch: 0,
119            highest_target_epoch: 0,
120            attestation_history: AttestationHistory::new(),
121            chain_state: Some(ChainState::Cosmos(CosmosState::new())),
122        }
123    }
124
125    /// Get the chain type for this validator
126    pub fn chain_type(&self) -> ChainType {
127        match &self.chain_state {
128            Some(cs) => cs.chain_type(),
129            None => ChainType::Ethereum, // Legacy format is Ethereum
130        }
131    }
132
133    /// Get the Ethereum state for this validator
134    ///
135    /// Returns the chain_state if it's Ethereum, otherwise uses legacy fields
136    pub fn ethereum_state(&self) -> Option<EthereumState> {
137        match &self.chain_state {
138            Some(ChainState::Ethereum(state)) => Some(state.clone()),
139            Some(ChainState::Cosmos(_)) => None,
140            None => {
141                // Legacy format - construct EthereumState from legacy fields
142                Some(EthereumState {
143                    last_signed_block_slot: self.last_signed_block_slot,
144                    block_signing_roots: self.block_signing_roots.clone(),
145                    highest_source_epoch: self.highest_source_epoch,
146                    highest_target_epoch: self.highest_target_epoch,
147                    attestation_history: self.attestation_history.clone(),
148                })
149            }
150        }
151    }
152
153    /// Get the Cosmos state for this validator
154    pub fn cosmos_state(&self) -> Option<&CosmosState> {
155        match &self.chain_state {
156            Some(ChainState::Cosmos(state)) => Some(state),
157            _ => None,
158        }
159    }
160
161    /// Get the Cosmos state mutably
162    pub fn cosmos_state_mut(&mut self) -> Option<&mut CosmosState> {
163        match &mut self.chain_state {
164            Some(ChainState::Cosmos(state)) => Some(state),
165            _ => None,
166        }
167    }
168
169    // ========================================================================
170    // Ethereum-specific methods (for backward compatibility)
171    // ========================================================================
172
173    /// Get the signing root for a block at the given slot, if any
174    pub fn get_block_signing_root(&self, slot: u64) -> Option<&[u8; 32]> {
175        match &self.chain_state {
176            Some(ChainState::Ethereum(state)) => state.block_signing_roots.get(&slot),
177            Some(ChainState::Cosmos(_)) => None,
178            None => self.block_signing_roots.get(&slot),
179        }
180    }
181
182    /// Record that a block was signed for the given slot
183    pub fn record_block_signing(&mut self, slot: u64, signing_root: [u8; 32]) {
184        match &mut self.chain_state {
185            Some(ChainState::Ethereum(state)) => {
186                state.record_block_signing(slot, signing_root);
187            }
188            Some(ChainState::Cosmos(_)) => {
189                // No-op for Cosmos validators
190            }
191            None => {
192                // Legacy format
193                self.block_signing_roots.insert(slot, signing_root);
194                if self.last_signed_block_slot.is_none_or(|s| slot > s) {
195                    self.last_signed_block_slot = Some(slot);
196                }
197            }
198        }
199    }
200
201    /// Get the signing root for an attestation with the given source/target, if any
202    pub fn get_attestation_signing_root(
203        &self,
204        source_epoch: u64,
205        target_epoch: u64,
206    ) -> Option<&[u8; 32]> {
207        match &self.chain_state {
208            Some(ChainState::Ethereum(state)) => {
209                state.attestation_history.get_signing_root(source_epoch, target_epoch)
210            }
211            Some(ChainState::Cosmos(_)) => None,
212            None => self.attestation_history.get_signing_root(source_epoch, target_epoch),
213        }
214    }
215
216    /// Record that an attestation was signed
217    pub fn record_attestation_signing(
218        &mut self,
219        source_epoch: u64,
220        target_epoch: u64,
221        signing_root: [u8; 32],
222    ) {
223        match &mut self.chain_state {
224            Some(ChainState::Ethereum(state)) => {
225                state.record_attestation_signing(source_epoch, target_epoch, signing_root);
226            }
227            Some(ChainState::Cosmos(_)) => {
228                // No-op for Cosmos validators
229            }
230            None => {
231                // Legacy format
232                self.attestation_history.record(source_epoch, target_epoch, signing_root);
233                if source_epoch > self.highest_source_epoch {
234                    self.highest_source_epoch = source_epoch;
235                }
236                if target_epoch > self.highest_target_epoch {
237                    self.highest_target_epoch = target_epoch;
238                }
239            }
240        }
241    }
242
243    /// Prune old entries to limit memory usage
244    /// Keeps entries within the weak subjectivity period
245    pub fn prune(&mut self, min_slot: u64, min_epoch: u64) {
246        match &mut self.chain_state {
247            Some(ChainState::Ethereum(state)) => {
248                state.prune(min_slot, min_epoch);
249            }
250            Some(ChainState::Cosmos(state)) => {
251                // For Cosmos, use min_slot as min_height (they're similar concepts)
252                state.prune(min_slot as i64);
253            }
254            None => {
255                // Legacy format
256                self.block_signing_roots = self.block_signing_roots.split_off(&min_slot);
257                self.attestation_history.prune(min_epoch);
258            }
259        }
260    }
261}
262
263/// History of signed attestations for surround vote detection
264///
265/// Uses min-span and max-span data structures for efficient detection
266#[derive(Debug, Clone, Default, Serialize, Deserialize)]
267pub struct AttestationHistory {
268    /// Map of (source_epoch, target_epoch) -> signing_root
269    /// Used for double vote detection and idempotent re-signing
270    signed_attestations: BTreeMap<(u64, u64), [u8; 32]>,
271
272    /// For each source epoch, track the minimum target epoch seen
273    /// Used to detect if a new attestation surrounds an existing one
274    min_target_by_source: BTreeMap<u64, u64>,
275
276    /// For each source epoch, track the maximum target epoch seen
277    /// Used to detect if a new attestation is surrounded by an existing one
278    max_target_by_source: BTreeMap<u64, u64>,
279}
280
281impl AttestationHistory {
282    /// Create a new empty attestation history
283    pub fn new() -> Self {
284        Self::default()
285    }
286
287    /// Get the signing root for an attestation, if it exists
288    pub fn get_signing_root(&self, source_epoch: u64, target_epoch: u64) -> Option<&[u8; 32]> {
289        self.signed_attestations.get(&(source_epoch, target_epoch))
290    }
291
292    /// Iterate over all signed attestations
293    pub fn iter(&self) -> impl Iterator<Item = ((u64, u64), &[u8; 32])> {
294        self.signed_attestations.iter().map(|(&k, v)| (k, v))
295    }
296
297    /// Record a new signed attestation
298    pub fn record(&mut self, source_epoch: u64, target_epoch: u64, signing_root: [u8; 32]) {
299        self.signed_attestations
300            .insert((source_epoch, target_epoch), signing_root);
301
302        // Update min target for this source
303        self.min_target_by_source
304            .entry(source_epoch)
305            .and_modify(|t| {
306                if target_epoch < *t {
307                    *t = target_epoch;
308                }
309            })
310            .or_insert(target_epoch);
311
312        // Update max target for this source
313        self.max_target_by_source
314            .entry(source_epoch)
315            .and_modify(|t| {
316                if target_epoch > *t {
317                    *t = target_epoch;
318                }
319            })
320            .or_insert(target_epoch);
321    }
322
323    /// Get the minimum target epoch for any attestation with source > given source
324    /// Used to detect surrounding votes
325    pub fn get_min_target_for_source_gt(&self, source_epoch: u64) -> Option<u64> {
326        // Find all sources greater than the given source
327        // and return the minimum target among them
328        self.min_target_by_source
329            .range((source_epoch + 1)..)
330            .map(|(_, &target)| target)
331            .min()
332    }
333
334    /// Get the maximum target epoch for any attestation with source < given source
335    /// Used to detect surrounded votes
336    pub fn get_max_target_for_source_lt(&self, source_epoch: u64) -> Option<u64> {
337        // Find all sources less than the given source
338        // and return the maximum target among them
339        self.max_target_by_source
340            .range(..source_epoch)
341            .map(|(_, &target)| target)
342            .max()
343    }
344
345    /// Prune entries older than the given epoch
346    pub fn prune(&mut self, min_epoch: u64) {
347        self.signed_attestations
348            .retain(|(source, _), _| *source >= min_epoch);
349        self.min_target_by_source
350            .retain(|source, _| *source >= min_epoch);
351        self.max_target_by_source
352            .retain(|source, _| *source >= min_epoch);
353    }
354}
355
356// ============================================================================
357// Cosmos/CometBFT State
358// ============================================================================
359
360/// Signed message type in Tendermint consensus
361#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
362pub enum CosmosSignedMsgType {
363    /// Prevote (0x01)
364    Prevote,
365    /// Precommit (0x02)
366    Precommit,
367    /// Proposal (0x20)
368    Proposal,
369}
370
371/// Information about a signed vote in Cosmos
372#[derive(Debug, Clone, Serialize, Deserialize)]
373pub struct CosmosSignedVote {
374    /// Hash of the block that was signed (None = nil vote)
375    pub block_hash: Option<[u8; 32]>,
376    /// When the signing occurred (unix timestamp)
377    pub signed_at: u64,
378}
379
380/// Cosmos/CometBFT-specific validator state
381///
382/// Tracks signed votes to prevent double-signing at the same (height, round, type).
383#[derive(Debug, Clone, Default, Serialize, Deserialize)]
384pub struct CosmosState {
385    /// Chain ID this validator is bound to
386    pub chain_id: Option<String>,
387
388    /// Map of (height, round, type) -> signed vote info
389    /// Only stores the signature for each (height, round, type) combination
390    signed_votes: BTreeMap<(i64, i32, CosmosSignedMsgType), CosmosSignedVote>,
391
392    /// Highest height we've signed at
393    pub highest_height: i64,
394}
395
396impl CosmosState {
397    /// Create a new empty Cosmos state
398    pub fn new() -> Self {
399        Self::default()
400    }
401
402    /// Get the signed vote at a specific (height, round, type), if any
403    pub fn get_signed_vote(
404        &self,
405        height: i64,
406        round: i32,
407        msg_type: CosmosSignedMsgType,
408    ) -> Option<&CosmosSignedVote> {
409        self.signed_votes.get(&(height, round, msg_type))
410    }
411
412    /// Record a new signed vote
413    pub fn record_vote(
414        &mut self,
415        height: i64,
416        round: i32,
417        msg_type: CosmosSignedMsgType,
418        block_hash: Option<[u8; 32]>,
419    ) {
420        let now = std::time::SystemTime::now()
421            .duration_since(std::time::UNIX_EPOCH)
422            .unwrap_or_default()
423            .as_secs();
424
425        self.signed_votes.insert(
426            (height, round, msg_type),
427            CosmosSignedVote {
428                block_hash,
429                signed_at: now,
430            },
431        );
432
433        if height > self.highest_height {
434            self.highest_height = height;
435        }
436    }
437
438    /// Prune old entries to limit memory usage
439    ///
440    /// Keeps entries within the specified height range
441    pub fn prune(&mut self, min_height: i64) {
442        self.signed_votes.retain(|(height, _, _), _| *height >= min_height);
443    }
444
445    /// Get the number of tracked votes
446    pub fn len(&self) -> usize {
447        self.signed_votes.len()
448    }
449
450    /// Check if the state is empty
451    pub fn is_empty(&self) -> bool {
452        self.signed_votes.is_empty()
453    }
454}
455
456// ============================================================================
457// Chain-agnostic state wrapper
458// ============================================================================
459
460/// Chain-specific state variants
461#[derive(Debug, Clone, Serialize, Deserialize)]
462#[serde(tag = "chain_type")]
463pub enum ChainState {
464    /// Ethereum Beacon Chain state
465    Ethereum(EthereumState),
466    /// Cosmos/CometBFT state
467    Cosmos(CosmosState),
468}
469
470impl ChainState {
471    /// Get the chain type for this state
472    pub fn chain_type(&self) -> ChainType {
473        match self {
474            ChainState::Ethereum(_) => ChainType::Ethereum,
475            ChainState::Cosmos(_) => ChainType::Cosmos,
476        }
477    }
478
479    /// Get the Ethereum state if this is an Ethereum validator
480    pub fn as_ethereum(&self) -> Option<&EthereumState> {
481        match self {
482            ChainState::Ethereum(state) => Some(state),
483            _ => None,
484        }
485    }
486
487    /// Get the Ethereum state mutably if this is an Ethereum validator
488    pub fn as_ethereum_mut(&mut self) -> Option<&mut EthereumState> {
489        match self {
490            ChainState::Ethereum(state) => Some(state),
491            _ => None,
492        }
493    }
494
495    /// Get the Cosmos state if this is a Cosmos validator
496    pub fn as_cosmos(&self) -> Option<&CosmosState> {
497        match self {
498            ChainState::Cosmos(state) => Some(state),
499            _ => None,
500        }
501    }
502
503    /// Get the Cosmos state mutably if this is a Cosmos validator
504    pub fn as_cosmos_mut(&mut self) -> Option<&mut CosmosState> {
505        match self {
506            ChainState::Cosmos(state) => Some(state),
507            _ => None,
508        }
509    }
510}
511
512/// Ethereum-specific validator state
513///
514/// This is the existing ValidatorState fields extracted for multi-chain support.
515#[derive(Debug, Clone, Serialize, Deserialize)]
516pub struct EthereumState {
517    /// Highest slot for which a block was signed
518    pub last_signed_block_slot: Option<u64>,
519
520    /// Map of slot -> signing_root for block proposals
521    /// Used to allow idempotent re-signing of the same block
522    pub block_signing_roots: BTreeMap<u64, [u8; 32]>,
523
524    /// Highest source epoch in any signed attestation
525    pub highest_source_epoch: u64,
526
527    /// Highest target epoch in any signed attestation
528    pub highest_target_epoch: u64,
529
530    /// Attestation history for surround vote detection
531    pub attestation_history: AttestationHistory,
532}
533
534impl Default for EthereumState {
535    fn default() -> Self {
536        Self::new()
537    }
538}
539
540impl EthereumState {
541    /// Create a new empty Ethereum state
542    pub fn new() -> Self {
543        Self {
544            last_signed_block_slot: None,
545            block_signing_roots: BTreeMap::new(),
546            highest_source_epoch: 0,
547            highest_target_epoch: 0,
548            attestation_history: AttestationHistory::new(),
549        }
550    }
551
552    /// Get the signing root for a block at the given slot, if any
553    pub fn get_block_signing_root(&self, slot: u64) -> Option<&[u8; 32]> {
554        self.block_signing_roots.get(&slot)
555    }
556
557    /// Record that a block was signed for the given slot
558    pub fn record_block_signing(&mut self, slot: u64, signing_root: [u8; 32]) {
559        self.block_signing_roots.insert(slot, signing_root);
560        if self.last_signed_block_slot.is_none_or(|s| slot > s) {
561            self.last_signed_block_slot = Some(slot);
562        }
563    }
564
565    /// Get the signing root for an attestation with the given source/target, if any
566    pub fn get_attestation_signing_root(
567        &self,
568        source_epoch: u64,
569        target_epoch: u64,
570    ) -> Option<&[u8; 32]> {
571        self.attestation_history
572            .get_signing_root(source_epoch, target_epoch)
573    }
574
575    /// Record that an attestation was signed
576    pub fn record_attestation_signing(
577        &mut self,
578        source_epoch: u64,
579        target_epoch: u64,
580        signing_root: [u8; 32],
581    ) {
582        self.attestation_history
583            .record(source_epoch, target_epoch, signing_root);
584
585        if source_epoch > self.highest_source_epoch {
586            self.highest_source_epoch = source_epoch;
587        }
588        if target_epoch > self.highest_target_epoch {
589            self.highest_target_epoch = target_epoch;
590        }
591    }
592
593    /// Prune old entries to limit memory usage
594    pub fn prune(&mut self, min_slot: u64, min_epoch: u64) {
595        // Prune block signing roots older than min_slot
596        self.block_signing_roots = self.block_signing_roots.split_off(&min_slot);
597
598        // Prune attestation history
599        self.attestation_history.prune(min_epoch);
600    }
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606
607    fn make_root(val: u8) -> [u8; 32] {
608        let mut root = [0u8; 32];
609        root[0] = val;
610        root
611    }
612
613    #[test]
614    fn test_validator_state_new() {
615        let pubkey = [1u8; 48];
616        let state = ValidatorState::new(pubkey);
617
618        assert_eq!(state.pubkey, pubkey);
619        assert_eq!(state.last_signed_block_slot, None);
620        assert!(state.block_signing_roots.is_empty());
621        assert_eq!(state.highest_source_epoch, 0);
622        assert_eq!(state.highest_target_epoch, 0);
623    }
624
625    #[test]
626    fn test_block_signing() {
627        let mut state = ValidatorState::new([0u8; 48]);
628        let root = make_root(1);
629
630        assert!(state.get_block_signing_root(100).is_none());
631
632        state.record_block_signing(100, root);
633
634        assert_eq!(state.get_block_signing_root(100), Some(&root));
635        assert_eq!(state.last_signed_block_slot, Some(100));
636    }
637
638    #[test]
639    fn test_attestation_signing() {
640        let mut state = ValidatorState::new([0u8; 48]);
641        let root = make_root(1);
642
643        assert!(state.get_attestation_signing_root(10, 11).is_none());
644
645        state.record_attestation_signing(10, 11, root);
646
647        assert_eq!(state.get_attestation_signing_root(10, 11), Some(&root));
648        assert_eq!(state.highest_source_epoch, 10);
649        assert_eq!(state.highest_target_epoch, 11);
650    }
651
652    #[test]
653    fn test_surround_detection_spans() {
654        let mut history = AttestationHistory::new();
655
656        // Record attestation (5, 10)
657        history.record(5, 10, make_root(1));
658
659        // Check spans
660        // For source > 5, min target should be 10
661        assert_eq!(history.get_min_target_for_source_gt(4), Some(10));
662        assert_eq!(history.get_min_target_for_source_gt(5), None);
663
664        // For source < 5, max target should be None (nothing recorded)
665        assert_eq!(history.get_max_target_for_source_lt(5), None);
666
667        // Record another attestation (3, 12)
668        history.record(3, 12, make_root(2));
669
670        // Now for source < 5, max target should be 12
671        assert_eq!(history.get_max_target_for_source_lt(5), Some(12));
672    }
673}