Skip to main content

eth_state_diff/
validators.rs

1//! Delta encoding and reconstruction for Ethereum validator registries.
2//!
3//! This module computes compact [`ValidatorsDiff`] values between two validator
4//! registries and applies those deltas to reconstruct the target registry.
5//!
6//! Validator records are fixed-width in their consensus SSZ representation,
7//! with [`VALIDATOR_SSZ_SIZE`] bytes per validator. The delta format avoids
8//! storing complete validator records when only individual fields have
9//! changed.
10//!
11//! ## What is encoded
12//!
13//! For validators present in both the base and target registries, the encoder
14//! stores patches only for fields whose values changed:
15//!
16//! - [`ValidatorField::WithdrawalCredentials`]
17//! - [`ValidatorField::EffectiveBalance`]
18//! - [`ValidatorField::Slashed`]
19//! - [`ValidatorField::ActivationEligibilityEpoch`]
20//! - [`ValidatorField::ActivationEpoch`]
21//! - [`ValidatorField::ExitEpoch`]
22//! - [`ValidatorField::WithdrawableEpochSlashed`]
23//!
24//! Validators that exist only in the target registry are stored as their raw
25//! SSZ representations in [`ValidatorsDiff::appended_validators`].
26//!
27//! The `pubkey` field is not patched because validator public keys are
28//! immutable after registration.
29//!
30//! ## Withdrawable epoch
31//!
32//! `withdrawable_epoch` is handled specially because its value is derivable
33//! for non-slashed validators. When `exit_epoch` changes on a non-slashed
34//! validator, the application side reconstructs:
35//!
36//! ```text
37//! withdrawable_epoch = exit_epoch + MIN_VALIDATOR_WITHDRAWABILITY_DELAY
38//! ```
39//!
40//! For slashed validators, `withdrawable_epoch` is explicitly encoded because
41//! it is not reconstructed from `exit_epoch` alone.
42//!
43//! This makes the delta smaller while preserving the target validator state.
44//!
45//! ## Two integration APIs
46//!
47//! The module provides two equivalent APIs for different validator storage
48//! layouts.
49//!
50//! ### Contiguous SSZ storage
51//!
52//! [`diff_validators`] and [`apply_validators`] operate directly on
53//! `Vec<u8>`/byte slices containing consecutive SSZ validator records.
54//!
55//! This is useful for clients that keep their validator registry in a flat
56//! SSZ-compatible representation.
57//!
58//! ### Native client storage
59//!
60//! [`diff_validators_iter`] and [`apply_validators_iter`] operate through
61//! [`ValidatorSnapshot`] and [`ValidatorMutTarget`].
62//!
63//! This allows a consensus client to diff and reconstruct validators without
64//! first converting its native data structure into one large byte buffer.
65//!
66//! The iterator API is particularly useful for clients whose validator
67//! registry is backed by persistent lists, trees, or other non-contiguous
68//! structures.
69//!
70//! ## SSZ representation
71//!
72//! The flat representation assumed by this module is the canonical
73//! consensus-layer validator SSZ layout:
74//!
75//! | Offset | Size | Field |
76//! |---:|---:|---|
77//! | `0` | `48` | `pubkey` |
78//! | `48` | `32` | `withdrawal_credentials` |
79//! | `80` | `8` | `effective_balance` |
80//! | `88` | `1` | `slashed` |
81//! | `89` | `8` | `activation_eligibility_epoch` |
82//! | `97` | `8` | `activation_epoch` |
83//! | `105` | `8` | `exit_epoch` |
84//! | `113` | `8` | `withdrawable_epoch` |
85//!
86//! The total size is [`VALIDATOR_SSZ_SIZE`] bytes.
87//!
88//! ## Complexity
89//!
90//! Diffing is linear in the number of validators:
91//!
92//! ```text
93//! O(min(base_len, target_len) + appended_validators)
94//! ```
95//!
96//! Applying a delta is linear in the number of patches plus the number of
97//! appended validators:
98//!
99//! ```text
100//! O(patches + appended_validators)
101//! ```
102//!
103//! The contiguous byte implementation performs in-place mutation and does not
104//! require rebuilding the existing validator registry.
105//!
106//! ## Delta validity
107//!
108//! [`apply_validators`] and [`apply_validators_iter`] assume that the supplied
109//! delta was produced for the corresponding base validator registry.
110//! Application does not independently verify that every patch matches the
111//! expected base value.
112//!
113//! Patch values are validated for the width required by their corresponding
114//! [`ValidatorField`]. Invalid patch data is reported as
115//! [`Error::MalformedDelta`] rather than causing reconstruction to panic.
116//!
117//! A patch referring to a validator index that does not exist in the target
118//! collection is reported as [`Error::InvalidDelta`].
119//!
120//! Appended validator data must contain complete
121//! [`VALIDATOR_SSZ_SIZE`]-byte SSZ records.
122//!
123//! [`ValidatorsDiff`]  
124//! [`ValidatorField`]  
125//! [`ValidatorSnapshot`]  
126//! [`ValidatorMut`]  
127//! [`ValidatorMutTarget`]  
128//! [`VALIDATOR_SSZ_SIZE`]  
129//! [`MIN_VALIDATOR_WITHDRAWABILITY_DELAY`]  
130
131use crate::{
132    error::Error,
133    types::{
134        ArchivedValidatorField, ArchivedValidatorsDiff, ValidatorField, ValidatorPatch,
135        ValidatorsDiff, MIN_VALIDATOR_WITHDRAWABILITY_DELAY, VALIDATOR_SSZ_SIZE,
136    },
137};
138
139/// Read-only view of a validator used by the delta encoder.
140///
141/// Implementations provide access to the validator fields that can participate
142/// in a delta. The trait deliberately does not require a concrete validator
143/// type, allowing the same diff algorithm to operate on both flat SSZ records
144/// and native consensus-client data structures.
145///
146/// [`to_ssz_bytes`](Self::to_ssz_bytes) is only required when a validator is
147/// present in the target registry but not in the base registry.
148pub trait ValidatorSnapshot {
149    fn withdrawal_credentials(&self) -> &[u8; 32];
150    fn effective_balance(&self) -> u64;
151    fn is_slashed(&self) -> bool;
152    fn activation_eligibility_epoch(&self) -> u64;
153    fn activation_epoch(&self) -> u64;
154    fn exit_epoch(&self) -> u64;
155    fn withdrawable_epoch(&self) -> u64;
156
157    /// Serializes the validator to its consensus SSZ representation.
158    /// Only required for validators appended to the target registry.
159    fn to_ssz_bytes(&self) -> Vec<u8>;
160}
161
162/// Mutable view of a validator used during delta reconstruction.
163///
164/// Implementations expose the fields that may be modified by a
165/// [`ValidatorField`] patch.
166///
167/// The current slashed status is required so that applying an `exit_epoch`
168/// patch can deterministically reconstruct `withdrawable_epoch` for
169/// non-slashed validators.
170pub trait ValidatorMut {
171    /// Returns the current slashed status. Required for deterministic
172    /// reconstruction of `withdrawable_epoch` when `exit_epoch` changes.
173    fn is_slashed(&self) -> bool;
174
175    fn set_withdrawal_credentials(&mut self, value: &[u8; 32]);
176    fn set_effective_balance(&mut self, value: u64);
177    fn set_slashed(&mut self, value: bool);
178    fn set_activation_eligibility_epoch(&mut self, value: u64);
179    fn set_activation_epoch(&mut self, value: u64);
180    fn set_exit_epoch(&mut self, value: u64);
181    fn set_withdrawable_epoch(&mut self, value: u64);
182}
183
184/// Mutable access to a validator collection used during reconstruction.
185///
186/// This trait abstracts over the client's validator storage representation.
187/// Implementations may back the collection with a contiguous vector, a
188/// persistent list, a tree, or another native data structure.
189///
190/// The collection is expected to preserve validator ordering: validator index
191/// `i` in the delta must refer to the same validator index `i` in the target
192/// collection.
193///
194/// New validators are supplied as complete consensus SSZ records through
195/// [`push_from_ssz`](Self::push_from_ssz).
196pub trait ValidatorMutTarget {
197    type Validator<'a>: ValidatorMut
198    where
199        Self: 'a;
200
201    /// Fetches a mutable validator view by index.
202    fn get_mut(&mut self, index: usize) -> Option<Self::Validator<'_>>;
203
204    /// Appends a newly seen validator from its raw SSZ bytes.
205    fn push_from_ssz(&mut self, ssz_bytes: &[u8]);
206}
207
208/// Computes a compact validator delta from two contiguous SSZ byte buffers.
209///
210/// Both buffers must contain zero or more complete validator records, with
211/// each record occupying exactly [`VALIDATOR_SSZ_SIZE`] bytes.
212///
213/// The resulting [`ValidatorsDiff`] contains:
214///
215/// - field-level patches for validators present in both buffers; and
216/// - complete SSZ records for validators appended to the target buffer.
217///
218/// The function does not allocate or deserialize individual validator
219/// structures while scanning the common portion of the buffers. Fields are
220/// read directly from their fixed offsets in the SSZ representation.
221///
222/// # Examples
223///
224/// A delta can be applied to the original buffer to reconstruct the target:
225///
226/// ```ignore
227/// let delta = diff_validators(&base, &target);
228/// apply_validators(&mut base, &delta);
229///
230/// assert_eq!(base, target);
231/// ```
232///
233/// [`VALIDATOR_SSZ_SIZE`]: crate::types::VALIDATOR_SSZ_SIZE
234pub fn diff_validators(base_bytes: &[u8], target_bytes: &[u8]) -> ValidatorsDiff {
235    diff_validators_impl(
236        base_bytes
237            .chunks_exact(VALIDATOR_SSZ_SIZE)
238            .map(ByteValidator::new),
239        target_bytes
240            .chunks_exact(VALIDATOR_SSZ_SIZE)
241            .map(ByteValidator::new),
242    )
243}
244
245/// Computes a compact validator delta using client-provided validator views.
246///
247/// This is the native-storage counterpart to [`diff_validators`]. Instead of
248/// requiring the validator registries to be represented as contiguous SSZ
249/// buffers, the caller supplies iterators of [`ValidatorSnapshot`] values.
250///
251/// Only the fields exposed by [`ValidatorSnapshot`] are inspected. Validators
252/// that occur only in the target iterator are serialized through
253/// [`ValidatorSnapshot::to_ssz_bytes`] and stored in the resulting delta.
254///
255/// The iterators must report their exact validator counts through
256/// [`ExactSizeIterator`].
257///
258/// This function is useful when a consensus client stores validators in a
259/// persistent list, tree, or another non-contiguous data structure and wants
260/// to avoid materializing the complete registry as SSZ bytes.
261///
262/// [`diff_validators`]
263/// [`ValidatorSnapshot`]
264pub fn diff_validators_iter<I1, I2, V1, V2>(base: I1, target: I2) -> ValidatorsDiff
265where
266    I1: ExactSizeIterator<Item = V1>,
267    I2: ExactSizeIterator<Item = V2>,
268    V1: ValidatorSnapshot,
269    V2: ValidatorSnapshot,
270{
271    diff_validators_impl(base, target)
272}
273
274/// The core diffing algorithm, agnostic to the input memory layout.
275fn diff_validators_impl<I1, I2, V1, V2>(mut base: I1, mut target: I2) -> ValidatorsDiff
276where
277    I1: ExactSizeIterator<Item = V1>,
278    I2: ExactSizeIterator<Item = V2>,
279    V1: ValidatorSnapshot,
280    V2: ValidatorSnapshot,
281{
282    let mut patches = Vec::with_capacity(512);
283    let mut appended_validators = Vec::new();
284
285    for (i, (b, t)) in base.by_ref().zip(target.by_ref()).enumerate() {
286        let wc = t.withdrawal_credentials();
287        let eb = t.effective_balance();
288        let slashed = t.is_slashed();
289        let aee = t.activation_eligibility_epoch();
290        let ae = t.activation_epoch();
291        let ee = t.exit_epoch();
292
293        if b.withdrawal_credentials() == wc
294            && b.effective_balance() == eb
295            && b.is_slashed() == slashed
296            && b.activation_eligibility_epoch() == aee
297            && b.activation_epoch() == ae
298            && b.exit_epoch() == ee
299            && (!slashed || b.withdrawable_epoch() == t.withdrawable_epoch())
300        {
301            continue;
302        }
303
304        let index = u32::try_from(i).expect("validator index exceeds u32 range");
305
306        if b.withdrawal_credentials() != wc {
307            patches.push(ValidatorPatch {
308                index,
309                field: ValidatorField::WithdrawalCredentials,
310                value: wc.to_vec(),
311            });
312        }
313        if b.effective_balance() != eb {
314            patches.push(ValidatorPatch {
315                index,
316                field: ValidatorField::EffectiveBalance,
317                value: eb.to_le_bytes().to_vec(),
318            });
319        }
320        if b.is_slashed() != slashed {
321            patches.push(ValidatorPatch {
322                index,
323                field: ValidatorField::Slashed,
324                value: vec![slashed as u8],
325            });
326        }
327        if b.activation_eligibility_epoch() != aee {
328            patches.push(ValidatorPatch {
329                index,
330                field: ValidatorField::ActivationEligibilityEpoch,
331                value: aee.to_le_bytes().to_vec(),
332            });
333        }
334        if b.activation_epoch() != ae {
335            patches.push(ValidatorPatch {
336                index,
337                field: ValidatorField::ActivationEpoch,
338                value: ae.to_le_bytes().to_vec(),
339            });
340        }
341        if b.exit_epoch() != ee {
342            patches.push(ValidatorPatch {
343                index,
344                field: ValidatorField::ExitEpoch,
345                value: ee.to_le_bytes().to_vec(),
346            });
347        }
348        if slashed && b.withdrawable_epoch() != t.withdrawable_epoch() {
349            patches.push(ValidatorPatch {
350                index,
351                field: ValidatorField::WithdrawableEpochSlashed,
352                value: t.withdrawable_epoch().to_le_bytes().to_vec(),
353            });
354        }
355    }
356
357    for t_val in target {
358        appended_validators.extend(t_val.to_ssz_bytes());
359    }
360
361    ValidatorsDiff {
362        patches,
363        appended_validators,
364    }
365}
366
367/// Applies a validator delta to a contiguous SSZ byte buffer in place.
368///
369/// The buffer must represent the same base validator registry against which
370/// the delta was generated.
371///
372/// Existing validators are modified according to the delta's field patches.
373/// Newly appended validators are added from their raw SSZ representations.
374///
375/// This function does not require deserializing the validator registry into
376/// Rust validator structures. Existing records are modified directly at their
377/// fixed SSZ offsets.
378///
379/// # Errors
380///
381/// Returns [`Error::MalformedDelta`] if a patch contains a value with an
382/// invalid width for its field.
383///
384/// Returns [`Error::InvalidDelta`] if a patch refers to a validator index
385/// that does not exist in the buffer.
386///
387/// # Correctness
388///
389/// The delta is expected to have been generated from the same base registry.
390/// This function does not verify that the current contents of the buffer
391/// correspond to the original base state.
392///
393/// [`ArchivedValidatorsDiff`]: crate::types::ArchivedValidatorsDiff
394/// [`Error`]: crate::Error
395pub fn apply_validators(base: &mut Vec<u8>, delta: &ArchivedValidatorsDiff) -> Result<(), Error> {
396    apply_validators_iter(&mut ByteValidatorTarget(base), delta)
397}
398
399/// Applies a validator delta directly to a client's native validator
400/// collection.
401///
402/// The target collection is accessed through [`ValidatorMutTarget`], allowing
403/// the caller to mutate validators without converting the entire registry
404/// into a contiguous SSZ byte buffer.
405///
406/// For every patch, the corresponding validator is obtained with
407/// [`ValidatorMutTarget::get_mut`] and the specified field is updated.
408///
409/// Newly appended validators are passed to
410/// [`ValidatorMutTarget::push_from_ssz`] as complete
411/// [`VALIDATOR_SSZ_SIZE`]-byte SSZ records.
412///
413/// For a non-slashed validator whose `exit_epoch` is patched, the
414/// `withdrawable_epoch` is reconstructed automatically as:
415///
416/// ```text
417/// exit_epoch + MIN_VALIDATOR_WITHDRAWABILITY_DELAY
418/// ```
419///
420/// For slashed validators, `withdrawable_epoch` is restored explicitly from
421/// the [`ValidatorField::WithdrawableEpochSlashed`] patch.
422///
423/// # Errors
424///
425/// Returns [`Error::MalformedDelta`] if a patch contains a value with an
426/// invalid width for its field.
427///
428/// Returns [`Error::InvalidDelta`] if a patch refers to a validator index
429/// that does not exist in the target collection.
430///
431/// [`ValidatorMutTarget`]: crate::validators::ValidatorMutTarget
432/// [`ValidatorField::WithdrawableEpochSlashed`]:
433///     crate::types::ValidatorField::WithdrawableEpochSlashed
434/// [`VALIDATOR_SSZ_SIZE`]: crate::types::VALIDATOR_SSZ_SIZE
435/// [`MIN_VALIDATOR_WITHDRAWABILITY_DELAY`]:
436///     crate::types::MIN_VALIDATOR_WITHDRAWABILITY_DELAY
437/// [`Error`]: crate::Error
438pub fn apply_validators_iter<T: ValidatorMutTarget>(
439    target: &mut T,
440    delta: &ArchivedValidatorsDiff,
441) -> Result<(), Error> {
442    for patch in delta.patches.iter() {
443        let idx = patch.index.to_native() as usize;
444        let val_bytes = patch.value.as_slice();
445
446        let mut validator = target.get_mut(idx).ok_or_else(|| {
447            Error::InvalidDelta(format!("validator patch index {idx} is out of bounds"))
448        })?;
449
450        match &patch.field {
451            ArchivedValidatorField::WithdrawalCredentials => {
452                let bytes: [u8; 32] = val_bytes.try_into().map_err(|_| {
453                    Error::MalformedDelta(format!(
454                        "withdrawal credentials patch has invalid width: \
455                         expected 32 bytes, got {}",
456                        val_bytes.len()
457                    ))
458                })?;
459
460                validator.set_withdrawal_credentials(&bytes);
461            }
462            ArchivedValidatorField::EffectiveBalance => {
463                let bytes: [u8; 8] = val_bytes.try_into().map_err(|_| {
464                    Error::MalformedDelta(format!(
465                        "effective balance patch has invalid width: \
466                         expected 8 bytes, got {}",
467                        val_bytes.len()
468                    ))
469                })?;
470
471                let eb = u64::from_le_bytes(bytes);
472                validator.set_effective_balance(eb);
473            }
474            ArchivedValidatorField::Slashed => {
475                let [value] = <[u8; 1]>::try_from(val_bytes).map_err(|_| {
476                    Error::MalformedDelta(format!(
477                        "slashed patch has invalid width: expected 1 byte, got {}",
478                        val_bytes.len()
479                    ))
480                })?;
481
482                validator.set_slashed(value != 0);
483            }
484            ArchivedValidatorField::ActivationEligibilityEpoch => {
485                let bytes: [u8; 8] = val_bytes.try_into().map_err(|_| {
486                    Error::MalformedDelta(format!(
487                        "activation eligibility epoch patch has invalid width: \
488                         expected 8 bytes, got {}",
489                        val_bytes.len()
490                    ))
491                })?;
492
493                let epoch = u64::from_le_bytes(bytes);
494                validator.set_activation_eligibility_epoch(epoch);
495            }
496            ArchivedValidatorField::ActivationEpoch => {
497                let bytes: [u8; 8] = val_bytes.try_into().map_err(|_| {
498                    Error::MalformedDelta(format!(
499                        "activation epoch patch has invalid width: \
500                         expected 8 bytes, got {}",
501                        val_bytes.len()
502                    ))
503                })?;
504
505                let epoch = u64::from_le_bytes(bytes);
506                validator.set_activation_epoch(epoch);
507            }
508            ArchivedValidatorField::ExitEpoch => {
509                let bytes: [u8; 8] = val_bytes.try_into().map_err(|_| {
510                    Error::MalformedDelta(format!(
511                        "exit epoch patch has invalid width: \
512                         expected 8 bytes, got {}",
513                        val_bytes.len()
514                    ))
515                })?;
516
517                let ee = u64::from_le_bytes(bytes);
518                validator.set_exit_epoch(ee);
519
520                // Deterministic reconstruction for non-slashed validators.
521                if !validator.is_slashed() {
522                    let we = ee.saturating_add(MIN_VALIDATOR_WITHDRAWABILITY_DELAY);
523                    validator.set_withdrawable_epoch(we);
524                }
525            }
526            ArchivedValidatorField::WithdrawableEpochSlashed => {
527                let bytes: [u8; 8] = val_bytes.try_into().map_err(|_| {
528                    Error::MalformedDelta(format!(
529                        "withdrawable epoch patch has invalid width: \
530                         expected 8 bytes, got {}",
531                        val_bytes.len()
532                    ))
533                })?;
534
535                let we = u64::from_le_bytes(bytes);
536                validator.set_withdrawable_epoch(we);
537            }
538        }
539    }
540
541    if delta.appended_validators.len() % VALIDATOR_SSZ_SIZE != 0 {
542        return Err(Error::MalformedDelta(
543            "appended validator data does not contain complete SSZ records".into(),
544        ));
545    }
546
547    for chunk in delta
548        .appended_validators
549        .as_slice()
550        .chunks_exact(VALIDATOR_SSZ_SIZE)
551    {
552        target.push_from_ssz(chunk);
553    }
554
555    Ok(())
556}
557
558/// A zero-cost wrapper that allows byte slices to implement
559/// [`ValidatorSnapshot`].
560///
561/// The wrapped slice must contain exactly one complete validator SSZ record.
562struct ByteValidator<'a>(&'a [u8]);
563
564impl<'a> ByteValidator<'a> {
565    fn new(bytes: &'a [u8]) -> Self {
566        debug_assert_eq!(
567            bytes.len(),
568            VALIDATOR_SSZ_SIZE,
569            "ByteValidator must contain exactly one complete SSZ validator record",
570        );
571
572        Self(bytes)
573    }
574
575    #[inline]
576    fn bytes<const N: usize>(&self, start: usize) -> [u8; N] {
577        self.0
578            .get(start..start + N)
579            .and_then(|bytes| bytes.try_into().ok())
580            .expect("ByteValidator contains a complete SSZ validator record")
581    }
582}
583
584impl<'a> ValidatorSnapshot for ByteValidator<'a> {
585    #[inline]
586    fn withdrawal_credentials(&self) -> &[u8; 32] {
587        self.0
588            .get(48..80)
589            .and_then(|bytes| bytes.try_into().ok())
590            .expect("ByteValidator contains a complete SSZ validator record")
591    }
592
593    #[inline]
594    fn effective_balance(&self) -> u64 {
595        u64::from_le_bytes(self.bytes::<8>(80))
596    }
597
598    #[inline]
599    fn is_slashed(&self) -> bool {
600        self.bytes::<1>(88)[0] != 0
601    }
602
603    #[inline]
604    fn activation_eligibility_epoch(&self) -> u64 {
605        u64::from_le_bytes(self.bytes::<8>(89))
606    }
607
608    #[inline]
609    fn activation_epoch(&self) -> u64 {
610        u64::from_le_bytes(self.bytes::<8>(97))
611    }
612
613    #[inline]
614    fn exit_epoch(&self) -> u64 {
615        u64::from_le_bytes(self.bytes::<8>(105))
616    }
617
618    #[inline]
619    fn withdrawable_epoch(&self) -> u64 {
620        u64::from_le_bytes(self.bytes::<8>(113))
621    }
622
623    #[inline]
624    fn to_ssz_bytes(&self) -> Vec<u8> {
625        self.0.to_vec()
626    }
627}
628
629/// Mutable byte slice wrapper implementing `ValidatorMut`.
630struct ByteValidatorMut<'a>(&'a mut [u8]);
631
632impl<'a> ValidatorMut for ByteValidatorMut<'a> {
633    #[inline]
634    fn is_slashed(&self) -> bool {
635        *self
636            .0
637            .get(88)
638            .expect("ByteValidatorMut contains a complete SSZ validator record")
639            != 0
640    }
641
642    #[inline]
643    fn set_withdrawal_credentials(&mut self, v: &[u8; 32]) {
644        self.0
645            .get_mut(48..80)
646            .expect("ByteValidatorMut contains a complete SSZ validator record")
647            .copy_from_slice(v);
648    }
649
650    #[inline]
651    fn set_effective_balance(&mut self, v: u64) {
652        self.0
653            .get_mut(80..88)
654            .expect("ByteValidatorMut contains a complete SSZ validator record")
655            .copy_from_slice(&v.to_le_bytes());
656    }
657
658    #[inline]
659    fn set_slashed(&mut self, v: bool) {
660        *self
661            .0
662            .get_mut(88)
663            .expect("ByteValidatorMut contains a complete SSZ validator record") = v as u8;
664    }
665
666    #[inline]
667    fn set_activation_eligibility_epoch(&mut self, v: u64) {
668        self.0
669            .get_mut(89..97)
670            .expect("ByteValidatorMut contains a complete SSZ validator record")
671            .copy_from_slice(&v.to_le_bytes());
672    }
673
674    #[inline]
675    fn set_activation_epoch(&mut self, v: u64) {
676        self.0
677            .get_mut(97..105)
678            .expect("ByteValidatorMut contains a complete SSZ validator record")
679            .copy_from_slice(&v.to_le_bytes());
680    }
681
682    #[inline]
683    fn set_exit_epoch(&mut self, v: u64) {
684        self.0
685            .get_mut(105..113)
686            .expect("ByteValidatorMut contains a complete SSZ validator record")
687            .copy_from_slice(&v.to_le_bytes());
688    }
689
690    #[inline]
691    fn set_withdrawable_epoch(&mut self, v: u64) {
692        self.0
693            .get_mut(113..121)
694            .expect("ByteValidatorMut contains a complete SSZ validator record")
695            .copy_from_slice(&v.to_le_bytes());
696    }
697}
698
699/// Collection wrapper implementing `ValidatorMutTarget` for `Vec<u8>`.
700struct ByteValidatorTarget<'a>(&'a mut Vec<u8>);
701
702impl<'a> ValidatorMutTarget for ByteValidatorTarget<'a> {
703    type Validator<'b>
704        = ByteValidatorMut<'b>
705    where
706        Self: 'b;
707
708    fn get_mut(&mut self, index: usize) -> Option<Self::Validator<'_>> {
709        let start = index.checked_mul(VALIDATOR_SSZ_SIZE)?;
710        let end = start.checked_add(VALIDATOR_SSZ_SIZE)?;
711        self.0.get_mut(start..end).map(ByteValidatorMut)
712    }
713
714    fn push_from_ssz(&mut self, ssz_bytes: &[u8]) {
715        self.0.extend_from_slice(ssz_bytes);
716    }
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722    use crate::types::{ArchivedValidatorsDiff, ValidatorField};
723
724    fn archive(diff: &ValidatorsDiff) -> rkyv::util::AlignedVec {
725        rkyv::to_bytes::<rkyv::rancor::Error>(diff).expect("test setup: failed to serialize delta")
726    }
727
728    fn archived(bytes: &[u8]) -> &ArchivedValidatorsDiff {
729        rkyv::access::<ArchivedValidatorsDiff, rkyv::rancor::Error>(bytes)
730            .expect("test setup: failed to access archived delta")
731    }
732
733    #[derive(Clone, Debug, PartialEq, Eq)]
734    struct MockValidator {
735        withdrawal_credentials: [u8; 32],
736        effective_balance: u64,
737        slashed: bool,
738        activation_eligibility_epoch: u64,
739        activation_epoch: u64,
740        exit_epoch: u64,
741        withdrawable_epoch: u64,
742    }
743
744    impl MockValidator {
745        fn new(id: u8) -> Self {
746            Self {
747                withdrawal_credentials: [id; 32],
748                effective_balance: 32_000_000_000,
749                slashed: false,
750                activation_eligibility_epoch: 0,
751                activation_epoch: 0,
752                exit_epoch: 0,
753                withdrawable_epoch: 0,
754            }
755        }
756
757        /// Serializes to the exact canonical SSZ layout expected by ByteValidator.
758        fn to_ssz_bytes(&self) -> Vec<u8> {
759            let mut bytes = vec![0u8; VALIDATOR_SSZ_SIZE];
760            bytes[48..80].copy_from_slice(&self.withdrawal_credentials);
761            bytes[80..88].copy_from_slice(&self.effective_balance.to_le_bytes());
762            bytes[88] = self.slashed as u8;
763            bytes[89..97].copy_from_slice(&self.activation_eligibility_epoch.to_le_bytes());
764            bytes[97..105].copy_from_slice(&self.activation_epoch.to_le_bytes());
765            bytes[105..113].copy_from_slice(&self.exit_epoch.to_le_bytes());
766            bytes[113..121].copy_from_slice(&self.withdrawable_epoch.to_le_bytes());
767            bytes
768        }
769    }
770
771    impl ValidatorSnapshot for MockValidator {
772        fn withdrawal_credentials(&self) -> &[u8; 32] {
773            &self.withdrawal_credentials
774        }
775        fn effective_balance(&self) -> u64 {
776            self.effective_balance
777        }
778        fn is_slashed(&self) -> bool {
779            self.slashed
780        }
781        fn activation_eligibility_epoch(&self) -> u64 {
782            self.activation_eligibility_epoch
783        }
784        fn activation_epoch(&self) -> u64 {
785            self.activation_epoch
786        }
787        fn exit_epoch(&self) -> u64 {
788            self.exit_epoch
789        }
790        fn withdrawable_epoch(&self) -> u64 {
791            self.withdrawable_epoch
792        }
793        fn to_ssz_bytes(&self) -> Vec<u8> {
794            self.to_ssz_bytes()
795        }
796    }
797
798    /// Mock mutable wrapper for the apply API.
799    struct MockMutValidator<'a>(&'a mut MockValidator);
800
801    impl<'a> ValidatorMut for MockMutValidator<'a> {
802        fn is_slashed(&self) -> bool {
803            self.0.slashed
804        }
805        fn set_withdrawal_credentials(&mut self, v: &[u8; 32]) {
806            self.0.withdrawal_credentials = *v;
807        }
808        fn set_effective_balance(&mut self, v: u64) {
809            self.0.effective_balance = v;
810        }
811        fn set_slashed(&mut self, v: bool) {
812            self.0.slashed = v;
813        }
814        fn set_activation_eligibility_epoch(&mut self, v: u64) {
815            self.0.activation_eligibility_epoch = v;
816        }
817        fn set_activation_epoch(&mut self, v: u64) {
818            self.0.activation_epoch = v;
819        }
820        fn set_exit_epoch(&mut self, v: u64) {
821            self.0.exit_epoch = v;
822        }
823        fn set_withdrawable_epoch(&mut self, v: u64) {
824            self.0.withdrawable_epoch = v;
825        }
826    }
827
828    /// Mock collection target for the apply API.
829    struct MockValidatorTarget {
830        validators: Vec<MockValidator>,
831    }
832
833    impl ValidatorMutTarget for MockValidatorTarget {
834        type Validator<'a>
835            = MockMutValidator<'a>
836        where
837            Self: 'a;
838
839        fn get_mut(&mut self, index: usize) -> Option<Self::Validator<'_>> {
840            self.validators.get_mut(index).map(MockMutValidator)
841        }
842
843        fn push_from_ssz(&mut self, ssz_bytes: &[u8]) {
844            // In production, apply_validators_iter guarantees chunks_exact(VALIDATOR_SSZ_SIZE)
845            let wc = ssz_bytes
846                .get(48..80)
847                .and_then(|s| s.try_into().ok())
848                .expect("test setup: valid wc bytes");
849            let eb = u64::from_le_bytes(
850                ssz_bytes
851                    .get(80..88)
852                    .and_then(|s| s.try_into().ok())
853                    .expect("test setup: valid eb bytes"),
854            );
855            let slashed = ssz_bytes.get(88).copied().expect("test setup") != 0;
856            let aee = u64::from_le_bytes(
857                ssz_bytes
858                    .get(89..97)
859                    .and_then(|s| s.try_into().ok())
860                    .expect("test setup: valid aee bytes"),
861            );
862            let ae = u64::from_le_bytes(
863                ssz_bytes
864                    .get(97..105)
865                    .and_then(|s| s.try_into().ok())
866                    .expect("test setup: valid ae bytes"),
867            );
868            let ee = u64::from_le_bytes(
869                ssz_bytes
870                    .get(105..113)
871                    .and_then(|s| s.try_into().ok())
872                    .expect("test setup: valid ee bytes"),
873            );
874            let we = u64::from_le_bytes(
875                ssz_bytes
876                    .get(113..121)
877                    .and_then(|s| s.try_into().ok())
878                    .expect("test setup: valid we bytes"),
879            );
880
881            self.validators.push(MockValidator {
882                withdrawal_credentials: wc,
883                effective_balance: eb,
884                slashed,
885                activation_eligibility_epoch: aee,
886                activation_epoch: ae,
887                exit_epoch: ee,
888                withdrawable_epoch: we,
889            });
890        }
891    }
892
893    #[test]
894    fn test_no_changes_iter() {
895        let base = vec![MockValidator::new(0), MockValidator::new(1)];
896        let target = base.clone();
897        let delta = diff_validators_iter(base.into_iter(), target.into_iter());
898        assert_eq!(delta.patches.len(), 0);
899        assert_eq!(delta.appended_validators.len(), 0);
900    }
901
902    #[test]
903    fn test_single_field_change_iter() {
904        let base = vec![MockValidator::new(0)];
905        let mut target = vec![MockValidator::new(0)];
906        target[0].effective_balance = 31_000_000_000;
907
908        let delta = diff_validators_iter(base.clone().into_iter(), target.clone().into_iter());
909        assert_eq!(delta.patches.len(), 1);
910        assert_eq!(delta.patches[0].field, ValidatorField::EffectiveBalance);
911    }
912
913    #[test]
914    fn test_appended_validators_iter() {
915        let base = vec![MockValidator::new(0)];
916        let target = vec![MockValidator::new(0), MockValidator::new(1)];
917
918        let delta = diff_validators_iter(base.into_iter(), target.into_iter());
919        assert_eq!(delta.patches.len(), 0);
920        assert_eq!(delta.appended_validators.len(), VALIDATOR_SSZ_SIZE);
921    }
922
923    #[test]
924    fn test_withdrawable_epoch_auto_reconstructed_non_slashed() {
925        let base = MockValidator::new(0);
926        let mut target = base.clone();
927
928        target.exit_epoch = 100;
929        target.withdrawable_epoch = 100 + MIN_VALIDATOR_WITHDRAWABILITY_DELAY;
930
931        let delta = diff_validators_iter(
932            std::iter::once(base.clone()),
933            std::iter::once(target.clone()),
934        );
935
936        // Should ONLY patch exit_epoch. Withdrawable epoch is derived.
937        assert_eq!(delta.patches.len(), 1);
938        assert_eq!(delta.patches[0].field, ValidatorField::ExitEpoch);
939
940        let bytes = archive(&delta);
941        let archived = archived(&bytes);
942
943        let mut state = MockValidatorTarget {
944            validators: vec![base],
945        };
946        apply_validators_iter(&mut state, archived).expect("test setup: apply failed");
947
948        assert_eq!(state.validators[0], target);
949    }
950
951    #[test]
952    fn test_withdrawable_epoch_explicit_patch_slashed() {
953        let mut base = MockValidator::new(0);
954        base.slashed = true;
955
956        let mut target = base.clone();
957        target.exit_epoch = 100;
958        target.withdrawable_epoch = 150; // Slashed can have early withdrawal
959
960        let delta = diff_validators_iter(
961            std::iter::once(base.clone()),
962            std::iter::once(target.clone()),
963        );
964
965        // MUST patch BOTH exit_epoch and withdrawable_epoch for slashed validators
966        assert_eq!(delta.patches.len(), 2);
967        let fields: Vec<&ValidatorField> = delta.patches.iter().map(|p| &p.field).collect();
968        assert!(fields.contains(&&ValidatorField::ExitEpoch));
969        assert!(fields.contains(&&ValidatorField::WithdrawableEpochSlashed));
970
971        let bytes = archive(&delta);
972        let archived = archived(&bytes);
973
974        let mut state = MockValidatorTarget {
975            validators: vec![base],
976        };
977        apply_validators_iter(&mut state, archived).expect("test setup: apply failed");
978
979        assert_eq!(state.validators[0], target);
980    }
981
982    #[test]
983    fn test_byte_api_roundtrip() {
984        let base = [MockValidator::new(0), MockValidator::new(1)];
985        let target = [MockValidator::new(0), MockValidator::new(2)];
986
987        let base_bytes: Vec<u8> = base.iter().flat_map(|v| v.to_ssz_bytes()).collect();
988        let target_bytes: Vec<u8> = target.iter().flat_map(|v| v.to_ssz_bytes()).collect();
989
990        let delta = diff_validators(&base_bytes, &target_bytes);
991
992        let bytes = archive(&delta);
993        let archived = archived(&bytes);
994
995        let mut reconstructed = base_bytes;
996        apply_validators(&mut reconstructed, archived).expect("test setup: apply failed");
997
998        assert_eq!(reconstructed, target_bytes);
999    }
1000
1001    #[test]
1002    fn test_apply_out_of_bounds_index() {
1003        let base = vec![MockValidator::new(0)];
1004        let mut target = vec![MockValidator::new(0)];
1005        target[0].effective_balance = 100;
1006
1007        let delta = diff_validators_iter(base.into_iter(), target.into_iter());
1008        let bytes = archive(&delta);
1009        let archived = archived(&bytes);
1010
1011        // Try applying to an empty collection
1012        let mut state = MockValidatorTarget { validators: vec![] };
1013
1014        let result = apply_validators_iter(&mut state, archived);
1015        assert!(result.is_err());
1016        let err_str = format!("{}", result.expect_err("test setup"));
1017        assert!(err_str.contains("out of bounds"));
1018    }
1019
1020    #[test]
1021    fn test_apply_invalid_patch_width() {
1022        // Construct a delta with an explicitly invalid patch width natively
1023        let delta = ValidatorsDiff {
1024            patches: vec![ValidatorPatch {
1025                index: 0,
1026                field: ValidatorField::EffectiveBalance, // Expects 8 bytes
1027                value: vec![0; 4],                       // Invalid! Only 4 bytes
1028            }],
1029            appended_validators: vec![],
1030        };
1031
1032        let bytes = archive(&delta);
1033        let archived = archived(&bytes);
1034
1035        let mut state = MockValidatorTarget {
1036            validators: vec![MockValidator::new(0)],
1037        };
1038
1039        let result = apply_validators_iter(&mut state, archived);
1040        assert!(result.is_err());
1041        let err_str = format!("{}", result.expect_err("test setup"));
1042        assert!(
1043            err_str.contains("expected 8 bytes, got 4"),
1044            "Error message should mention invalid width"
1045        );
1046    }
1047
1048    #[test]
1049    fn test_apply_truncated_appended_validators() {
1050        // Construct a delta where the appended validators length is not a multiple of SSZ size
1051        let delta = ValidatorsDiff {
1052            patches: vec![],
1053            appended_validators: vec![0u8; VALIDATOR_SSZ_SIZE - 1], // 1 byte short of a full record
1054        };
1055
1056        let bytes = archive(&delta);
1057        let archived = archived(&bytes);
1058
1059        let mut state = MockValidatorTarget { validators: vec![] };
1060
1061        let result = apply_validators_iter(&mut state, archived);
1062        assert!(result.is_err());
1063        let err_str = format!("{}", result.expect_err("test setup"));
1064        assert!(
1065            err_str.contains("does not contain complete SSZ records"),
1066            "Error message should mention truncated appended data"
1067        );
1068    }
1069}