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}