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 expected to have the correct width for their corresponding
114//! [`ValidatorField`]. Invalid widths currently result in a panic during
115//! reconstruction.
116//!
117//! Similarly, an out-of-range validator index is ignored by the trait-based
118//! application API because [`ValidatorMutTarget::get_mut`] returns `None`.
119//!
120//! Therefore, deltas should generally be treated as trusted output from
121//! [`diff_validators`] or [`diff_validators_iter`], rather than as an
122//! independently validated interchange format.
123//!
124//! [`ValidatorsDiff`]: crate::types::ValidatorsDiff
125//! [`ValidatorField`]: crate::types::ValidatorField
126//! [`ValidatorSnapshot`]: crate::validators::ValidatorSnapshot
127//! [`ValidatorMut`]: crate::validators::ValidatorMut
128//! [`ValidatorMutTarget`]: crate::validators::ValidatorMutTarget
129//! [`VALIDATOR_SSZ_SIZE`]: crate::types::VALIDATOR_SSZ_SIZE
130//! [`MIN_VALIDATOR_WITHDRAWABILITY_DELAY`]: crate::types::MIN_VALIDATOR_WITHDRAWABILITY_DELAY
131
132use crate::types::{
133    ArchivedValidatorField, ArchivedValidatorsDiff, ValidatorField, ValidatorPatch, ValidatorsDiff,
134    MIN_VALIDATOR_WITHDRAWABILITY_DELAY, VALIDATOR_SSZ_SIZE,
135};
136
137/// Read-only view of a validator used by the delta encoder.
138///
139/// Implementations provide access to the validator fields that can participate
140/// in a delta. The trait deliberately does not require a concrete validator
141/// type, allowing the same diff algorithm to operate on both flat SSZ records
142/// and native consensus-client data structures.
143///
144/// [`to_ssz_bytes`](Self::to_ssz_bytes) is only required when a validator is
145/// present in the target registry but not in the base registry.
146pub trait ValidatorSnapshot {
147    fn withdrawal_credentials(&self) -> &[u8; 32];
148    fn effective_balance(&self) -> u64;
149    fn is_slashed(&self) -> bool;
150    fn activation_eligibility_epoch(&self) -> u64;
151    fn activation_epoch(&self) -> u64;
152    fn exit_epoch(&self) -> u64;
153    fn withdrawable_epoch(&self) -> u64;
154
155    /// Serializes the validator to its consensus SSZ representation.
156    /// Only required for validators appended to the target registry.
157    fn to_ssz_bytes(&self) -> Vec<u8>;
158}
159
160/// Mutable view of a validator used during delta reconstruction.
161///
162/// Implementations expose the fields that may be modified by a
163/// [`ValidatorField`] patch.
164///
165/// The current slashed status is required so that applying an `exit_epoch`
166/// patch can deterministically reconstruct `withdrawable_epoch` for
167/// non-slashed validators.
168pub trait ValidatorMut {
169    /// Returns the current slashed status. Required for deterministic
170    /// reconstruction of `withdrawable_epoch` when `exit_epoch` changes.
171    fn is_slashed(&self) -> bool;
172
173    fn set_withdrawal_credentials(&mut self, value: &[u8; 32]);
174    fn set_effective_balance(&mut self, value: u64);
175    fn set_slashed(&mut self, value: bool);
176    fn set_activation_eligibility_epoch(&mut self, value: u64);
177    fn set_activation_epoch(&mut self, value: u64);
178    fn set_exit_epoch(&mut self, value: u64);
179    fn set_withdrawable_epoch(&mut self, value: u64);
180}
181
182/// Mutable access to a validator collection used during reconstruction.
183///
184/// This trait abstracts over the client's validator storage representation.
185/// Implementations may back the collection with a contiguous vector, a
186/// persistent list, a tree, or another native data structure.
187///
188/// The collection is expected to preserve validator ordering: validator index
189/// `i` in the delta must refer to the same validator index `i` in the target
190/// collection.
191///
192/// New validators are supplied as complete consensus SSZ records through
193/// [`push_from_ssz`](Self::push_from_ssz).
194pub trait ValidatorMutTarget {
195    type Validator<'a>: ValidatorMut
196    where
197        Self: 'a;
198
199    /// Fetches a mutable validator view by index.
200    fn get_mut(&mut self, index: usize) -> Option<Self::Validator<'_>>;
201
202    /// Appends a newly seen validator from its raw SSZ bytes.
203    fn push_from_ssz(&mut self, ssz_bytes: &[u8]);
204}
205
206/// Computes a compact validator delta from two contiguous SSZ byte buffers.
207///
208/// Both buffers must contain zero or more complete validator records, with
209/// each record occupying exactly [`VALIDATOR_SSZ_SIZE`] bytes.
210///
211/// The resulting [`ValidatorsDiff`] contains:
212///
213/// - field-level patches for validators present in both buffers; and
214/// - complete SSZ records for validators appended to the target buffer.
215///
216/// The function does not allocate or deserialize individual validator
217/// structures while scanning the common portion of the buffers. Fields are
218/// read directly from their fixed offsets in the SSZ representation.
219///
220/// # Panics
221///
222/// Panics if either input contains a trailing partial validator record.
223///
224/// # Examples
225///
226/// A delta can be applied to the original buffer to reconstruct the target:
227///
228/// ```ignore
229/// let delta = diff_validators(&base, &target);
230/// apply_validators(&mut base, &delta);
231///
232/// assert_eq!(base, target);
233/// ```
234///
235/// [`VALIDATOR_SSZ_SIZE`]: crate::types::VALIDATOR_SSZ_SIZE
236pub fn diff_validators(base_bytes: &[u8], target_bytes: &[u8]) -> ValidatorsDiff {
237    diff_validators_impl(
238        base_bytes
239            .chunks_exact(VALIDATOR_SSZ_SIZE)
240            .map(ByteValidator),
241        target_bytes
242            .chunks_exact(VALIDATOR_SSZ_SIZE)
243            .map(ByteValidator),
244    )
245}
246
247/// Computes a compact validator delta using client-provided validator views.
248///
249/// This is the native-storage counterpart to [`diff_validators`]. Instead of
250/// requiring the validator registries to be represented as contiguous SSZ
251/// buffers, the caller supplies iterators of [`ValidatorSnapshot`] values.
252///
253/// Only the fields exposed by [`ValidatorSnapshot`] are inspected. Validators
254/// that occur only in the target iterator are serialized through
255/// [`ValidatorSnapshot::to_ssz_bytes`] and stored in the resulting delta.
256///
257/// The iterators must report their exact validator counts through
258/// [`ExactSizeIterator`].
259///
260/// This function is useful when a consensus client stores validators in a
261/// persistent list, tree, or another non-contiguous data structure and wants
262/// to avoid materializing the complete registry as SSZ bytes.
263///
264/// # Panics
265///
266/// Panics if an iterator's reported length is inconsistent with the number of
267/// items it yields.
268///
269/// [`diff_validators`]: crate::validators::diff_validators
270/// [`ValidatorSnapshot`]: crate::validators::ValidatorSnapshot
271pub fn diff_validators_iter<I1, I2, V1, V2>(base: I1, target: I2) -> ValidatorsDiff
272where
273    I1: ExactSizeIterator<Item = V1>,
274    I2: ExactSizeIterator<Item = V2>,
275    V1: ValidatorSnapshot,
276    V2: ValidatorSnapshot,
277{
278    diff_validators_impl(base, target)
279}
280
281/// The core diffing algorithm, agnostic to the input memory layout.
282fn diff_validators_impl<I1, I2, V1, V2>(mut base: I1, mut target: I2) -> ValidatorsDiff
283where
284    I1: ExactSizeIterator<Item = V1>,
285    I2: ExactSizeIterator<Item = V2>,
286    V1: ValidatorSnapshot,
287    V2: ValidatorSnapshot,
288{
289    let common_len = base.len().min(target.len());
290    let mut patches = Vec::with_capacity(512);
291    let mut appended_validators = Vec::new();
292
293    for i in 0..common_len {
294        let b = base.next().unwrap();
295        let t = target.next().unwrap();
296
297        let wc = t.withdrawal_credentials();
298        let eb = t.effective_balance();
299        let slashed = t.is_slashed();
300        let aee = t.activation_eligibility_epoch();
301        let ae = t.activation_epoch();
302        let ee = t.exit_epoch();
303
304        if b.withdrawal_credentials() == wc
305            && b.effective_balance() == eb
306            && b.is_slashed() == slashed
307            && b.activation_eligibility_epoch() == aee
308            && b.activation_epoch() == ae
309            && b.exit_epoch() == ee
310            && (!slashed || b.withdrawable_epoch() == t.withdrawable_epoch())
311        {
312            continue;
313        }
314
315        let index = i as u32;
316
317        if b.withdrawal_credentials() != wc {
318            patches.push(ValidatorPatch {
319                index,
320                field: ValidatorField::WithdrawalCredentials,
321                value: wc.to_vec(),
322            });
323        }
324        if b.effective_balance() != eb {
325            patches.push(ValidatorPatch {
326                index,
327                field: ValidatorField::EffectiveBalance,
328                value: eb.to_le_bytes().to_vec(),
329            });
330        }
331        if b.is_slashed() != slashed {
332            patches.push(ValidatorPatch {
333                index,
334                field: ValidatorField::Slashed,
335                value: vec![slashed as u8],
336            });
337        }
338        if b.activation_eligibility_epoch() != aee {
339            patches.push(ValidatorPatch {
340                index,
341                field: ValidatorField::ActivationEligibilityEpoch,
342                value: aee.to_le_bytes().to_vec(),
343            });
344        }
345        if b.activation_epoch() != ae {
346            patches.push(ValidatorPatch {
347                index,
348                field: ValidatorField::ActivationEpoch,
349                value: ae.to_le_bytes().to_vec(),
350            });
351        }
352        if b.exit_epoch() != ee {
353            patches.push(ValidatorPatch {
354                index,
355                field: ValidatorField::ExitEpoch,
356                value: ee.to_le_bytes().to_vec(),
357            });
358        }
359        if slashed && b.withdrawable_epoch() != t.withdrawable_epoch() {
360            patches.push(ValidatorPatch {
361                index,
362                field: ValidatorField::WithdrawableEpochSlashed,
363                value: t.withdrawable_epoch().to_le_bytes().to_vec(),
364            });
365        }
366    }
367
368    for t_val in target {
369        appended_validators.extend(t_val.to_ssz_bytes());
370    }
371
372    ValidatorsDiff {
373        patches,
374        appended_validators,
375    }
376}
377
378/// Applies a validator delta to a contiguous SSZ byte buffer in place.
379///
380/// The buffer must represent the same base validator registry against which
381/// the delta was generated.
382///
383/// Existing validators are modified according to the delta's field patches.
384/// Newly appended validators are added from their raw SSZ representations.
385///
386/// This function does not require deserializing the validator registry into
387/// Rust validator structures. Existing records are modified directly at their
388/// fixed SSZ offsets.
389///
390/// # Panics
391///
392/// Panics if a patch contains a value with an invalid width for its field.
393///
394/// # Correctness
395///
396/// The delta is expected to have been generated from the same base registry.
397/// This function does not verify that the current contents of the buffer
398/// correspond to the original base state.
399///
400/// [`ArchivedValidatorsDiff`]: crate::types::ArchivedValidatorsDiff
401pub fn apply_validators(base: &mut Vec<u8>, delta: &ArchivedValidatorsDiff) {
402    apply_validators_iter(&mut ByteValidatorTarget(base), delta)
403}
404
405/// Applies a validator delta directly to a client's native validator
406/// collection.
407///
408/// The target collection is accessed through [`ValidatorMutTarget`], allowing
409/// the caller to mutate validators without converting the entire registry
410/// into a contiguous SSZ byte buffer.
411///
412/// For every patch, the corresponding validator is obtained with
413/// [`ValidatorMutTarget::get_mut`] and the specified field is updated.
414///
415/// Newly appended validators are passed to
416/// [`ValidatorMutTarget::push_from_ssz`] as complete
417/// [`VALIDATOR_SSZ_SIZE`]-byte SSZ records.
418///
419/// For a non-slashed validator whose `exit_epoch` is patched, the
420/// `withdrawable_epoch` is reconstructed automatically as:
421///
422/// ```text
423/// exit_epoch + MIN_VALIDATOR_WITHDRAWABILITY_DELAY
424/// ```
425///
426/// For slashed validators, `withdrawable_epoch` is restored explicitly from
427/// the [`ValidatorField::WithdrawableEpochSlashed`] patch.
428///
429/// # Missing validators
430///
431/// If [`ValidatorMutTarget::get_mut`] returns `None` for a patch index, that
432/// patch is skipped.
433///
434/// Implementations should therefore ensure that the target collection has the
435/// same base validator ordering and length expected by the delta.
436///
437/// # Panics
438///
439/// Panics if a patch contains a value with an invalid width for its field.
440///
441/// [`ValidatorMutTarget`]: crate::validators::ValidatorMutTarget
442/// [`ValidatorField::WithdrawableEpochSlashed`]:
443///     crate::types::ValidatorField::WithdrawableEpochSlashed
444/// [`VALIDATOR_SSZ_SIZE`]: crate::types::VALIDATOR_SSZ_SIZE
445/// [`MIN_VALIDATOR_WITHDRAWABILITY_DELAY`]:
446///     crate::types::MIN_VALIDATOR_WITHDRAWABILITY_DELAY
447pub fn apply_validators_iter<T: ValidatorMutTarget>(
448    target: &mut T,
449    delta: &ArchivedValidatorsDiff,
450) {
451    for patch in delta.patches.iter() {
452        let idx = patch.index.to_native() as usize;
453        let val_bytes = patch.value.as_slice();
454
455        if let Some(mut validator) = target.get_mut(idx) {
456            match &patch.field {
457                ArchivedValidatorField::WithdrawalCredentials => {
458                    let bytes: [u8; 32] = val_bytes.try_into().unwrap();
459                    validator.set_withdrawal_credentials(&bytes);
460                }
461                ArchivedValidatorField::EffectiveBalance => {
462                    let eb = u64::from_le_bytes(val_bytes.try_into().unwrap());
463                    validator.set_effective_balance(eb);
464                }
465                ArchivedValidatorField::Slashed => {
466                    validator.set_slashed(val_bytes[0] != 0);
467                }
468                ArchivedValidatorField::ActivationEligibilityEpoch => {
469                    let epoch = u64::from_le_bytes(val_bytes.try_into().unwrap());
470                    validator.set_activation_eligibility_epoch(epoch);
471                }
472                ArchivedValidatorField::ActivationEpoch => {
473                    let epoch = u64::from_le_bytes(val_bytes.try_into().unwrap());
474                    validator.set_activation_epoch(epoch);
475                }
476                ArchivedValidatorField::ExitEpoch => {
477                    let ee = u64::from_le_bytes(val_bytes.try_into().unwrap());
478                    validator.set_exit_epoch(ee);
479
480                    // Deterministic reconstruction for non-slashed validators
481                    if !validator.is_slashed() {
482                        let we = ee.saturating_add(MIN_VALIDATOR_WITHDRAWABILITY_DELAY);
483                        validator.set_withdrawable_epoch(we);
484                    }
485                }
486                ArchivedValidatorField::WithdrawableEpochSlashed => {
487                    let we = u64::from_le_bytes(val_bytes.try_into().unwrap());
488                    validator.set_withdrawable_epoch(we);
489                }
490            }
491        }
492    }
493
494    debug_assert_eq!(
495        delta.appended_validators.len() % VALIDATOR_SSZ_SIZE,
496        0,
497        "appended validator data must contain complete SSZ records",
498    );
499
500    for chunk in delta
501        .appended_validators
502        .as_slice()
503        .chunks_exact(VALIDATOR_SSZ_SIZE)
504    {
505        target.push_from_ssz(chunk);
506    }
507}
508
509/// A zero-cost wrapper that allows byte slices to implement `ValidatorSnapshot`.
510struct ByteValidator<'a>(&'a [u8]);
511
512impl<'a> ValidatorSnapshot for ByteValidator<'a> {
513    #[inline]
514    fn withdrawal_credentials(&self) -> &[u8; 32] {
515        self.0[48..80].try_into().unwrap()
516    }
517    #[inline]
518    fn effective_balance(&self) -> u64 {
519        u64::from_le_bytes(self.0[80..88].try_into().unwrap())
520    }
521    #[inline]
522    fn is_slashed(&self) -> bool {
523        self.0[88] != 0
524    }
525    #[inline]
526    fn activation_eligibility_epoch(&self) -> u64 {
527        u64::from_le_bytes(self.0[89..97].try_into().unwrap())
528    }
529    #[inline]
530    fn activation_epoch(&self) -> u64 {
531        u64::from_le_bytes(self.0[97..105].try_into().unwrap())
532    }
533    #[inline]
534    fn exit_epoch(&self) -> u64 {
535        u64::from_le_bytes(self.0[105..113].try_into().unwrap())
536    }
537    #[inline]
538    fn withdrawable_epoch(&self) -> u64 {
539        u64::from_le_bytes(self.0[113..121].try_into().unwrap())
540    }
541    #[inline]
542    fn to_ssz_bytes(&self) -> Vec<u8> {
543        self.0.to_vec()
544    }
545}
546
547/// Mutable byte slice wrapper implementing `ValidatorMut`.
548struct ByteValidatorMut<'a>(&'a mut [u8]);
549
550impl<'a> ValidatorMut for ByteValidatorMut<'a> {
551    #[inline]
552    fn is_slashed(&self) -> bool {
553        self.0[88] != 0
554    }
555
556    #[inline]
557    fn set_withdrawal_credentials(&mut self, v: &[u8; 32]) {
558        self.0[48..80].copy_from_slice(v);
559    }
560    #[inline]
561    fn set_effective_balance(&mut self, v: u64) {
562        self.0[80..88].copy_from_slice(&v.to_le_bytes());
563    }
564    #[inline]
565    fn set_slashed(&mut self, v: bool) {
566        self.0[88] = v as u8;
567    }
568    #[inline]
569    fn set_activation_eligibility_epoch(&mut self, v: u64) {
570        self.0[89..97].copy_from_slice(&v.to_le_bytes());
571    }
572    #[inline]
573    fn set_activation_epoch(&mut self, v: u64) {
574        self.0[97..105].copy_from_slice(&v.to_le_bytes());
575    }
576    #[inline]
577    fn set_exit_epoch(&mut self, v: u64) {
578        self.0[105..113].copy_from_slice(&v.to_le_bytes());
579    }
580    #[inline]
581    fn set_withdrawable_epoch(&mut self, v: u64) {
582        self.0[113..121].copy_from_slice(&v.to_le_bytes());
583    }
584}
585
586/// Collection wrapper implementing `ValidatorMutTarget` for `Vec<u8>`.
587struct ByteValidatorTarget<'a>(&'a mut Vec<u8>);
588
589impl<'a> ValidatorMutTarget for ByteValidatorTarget<'a> {
590    type Validator<'b>
591        = ByteValidatorMut<'b>
592    where
593        Self: 'b;
594
595    fn get_mut(&mut self, index: usize) -> Option<Self::Validator<'_>> {
596        let start = index.checked_mul(VALIDATOR_SSZ_SIZE)?;
597        let end = start.checked_add(VALIDATOR_SSZ_SIZE)?;
598
599        if end > self.0.len() {
600            return None;
601        }
602
603        let slice = unsafe {
604            let ptr = self.0.as_mut_ptr().add(start);
605            std::slice::from_raw_parts_mut(ptr, VALIDATOR_SSZ_SIZE)
606        };
607
608        Some(ByteValidatorMut(slice))
609    }
610
611    fn push_from_ssz(&mut self, ssz_bytes: &[u8]) {
612        self.0.extend_from_slice(ssz_bytes);
613    }
614}