Skip to main content

eth_state_diff/
validators.rs

1//! Delta encoding for Ethereum validator records.
2//!
3//! This module operates directly on the canonical SSZ byte representation of
4//! validators rather than materializing Rust validator structs.
5//!
6//! Working directly on SSZ bytes provides several advantages:
7//!
8//! - avoids per-validator deserialization,
9//! - performs only fixed-offset memory comparisons,
10//! - minimizes allocations, and
11//! - integrates naturally with consensus clients that already store validator
12//!   registries as contiguous SSZ data.
13//!
14//! Only mutable validator fields defined by the Ethereum consensus protocol are
15//! encoded. Immutable fields such as the validator public key are never included
16//! in generated patches.
17//!
18//! Certain protocol-derived fields are reconstructed deterministically during
19//! application instead of being stored explicitly. For example,
20//! `withdrawable_epoch` for non-slashed validators is recomputed from
21//! `exit_epoch` according to the Ethereum consensus specification, reducing the
22//! size of the encoded delta without losing information.
23
24use crate::types::{
25    ArchivedValidatorField, ArchivedValidatorsDiff, ValidatorField, ValidatorPatch, ValidatorsDiff,
26    MIN_VALIDATOR_WITHDRAWABILITY_DELAY, VALIDATOR_SSZ_SIZE,
27};
28
29/// Computes a compact delta between two validator registry SSZ buffers.
30///
31/// Both inputs must contain contiguous serialized validators in canonical SSZ
32/// form.
33///
34/// The encoder compares validators field-by-field using fixed protocol offsets.
35/// Only fields whose values differ are emitted as patches.
36///
37/// Immutable validator fields are ignored, while protocol-derived fields are
38/// omitted whenever they can be reconstructed deterministically during
39/// application.
40///
41/// Validators appended to the end of the registry are copied verbatim into the
42/// resulting delta.
43///
44/// # Arguments
45///
46/// * `base_bytes` - Base validator registry as contiguous SSZ bytes.
47/// * `target_bytes` - Target validator registry as contiguous SSZ bytes.
48///
49/// # Complexity
50///
51/// O(n), where *n* is the number of validators.
52///
53/// # Panics
54///
55/// Never.
56pub fn diff_validators(base_bytes: &[u8], target_bytes: &[u8]) -> ValidatorsDiff {
57    let base_len = base_bytes.len() / VALIDATOR_SSZ_SIZE;
58    let target_len = target_bytes.len() / VALIDATOR_SSZ_SIZE;
59    let common_len = base_len.min(target_len);
60
61    let mut patches = Vec::with_capacity(512);
62
63    for i in 0..common_len {
64        let b_start = i * VALIDATOR_SSZ_SIZE;
65        let t_start = i * VALIDATOR_SSZ_SIZE;
66
67        // Fast path: validator is identical.
68        if base_bytes[b_start..b_start + VALIDATOR_SSZ_SIZE]
69            == target_bytes[t_start..t_start + VALIDATOR_SSZ_SIZE]
70        {
71            continue;
72        }
73
74        // 1. Withdrawal Credentials (Offset 48, 32 bytes)
75        let base_wc = &base_bytes[b_start + 48..b_start + 80];
76        let target_wc = &target_bytes[t_start + 48..t_start + 80];
77        if base_wc != target_wc {
78            patches.push(ValidatorPatch {
79                index: i as u32,
80                field: ValidatorField::WithdrawalCredentials,
81                value: target_wc.to_vec(),
82            });
83        }
84
85        // 2. Effective Balance (Offset 80, 8 bytes)
86        let base_eb = &base_bytes[b_start + 80..b_start + 88];
87        let target_eb = &target_bytes[t_start + 80..t_start + 88];
88        if base_eb != target_eb {
89            patches.push(ValidatorPatch {
90                index: i as u32,
91                field: ValidatorField::EffectiveBalance,
92                value: target_eb.to_vec(),
93            });
94        }
95
96        // 3. Slashed (Offset 88, 1 byte)
97        if base_bytes[b_start + 88] != target_bytes[t_start + 88] {
98            patches.push(ValidatorPatch {
99                index: i as u32,
100                field: ValidatorField::Slashed,
101                value: vec![target_bytes[t_start + 88]],
102            });
103        }
104
105        // 4. Activation Eligibility Epoch (Offset 89, 8 bytes)
106        let base_aee = &base_bytes[b_start + 89..b_start + 97];
107        let target_aee = &target_bytes[t_start + 89..t_start + 97];
108        if base_aee != target_aee {
109            patches.push(ValidatorPatch {
110                index: i as u32,
111                field: ValidatorField::ActivationEligibilityEpoch,
112                value: target_aee.to_vec(),
113            });
114        }
115
116        // 5. Activation Epoch (Offset 97, 8 bytes)
117        let base_ae = &base_bytes[b_start + 97..b_start + 105];
118        let target_ae = &target_bytes[t_start + 97..t_start + 105];
119        if base_ae != target_ae {
120            patches.push(ValidatorPatch {
121                index: i as u32,
122                field: ValidatorField::ActivationEpoch,
123                value: target_ae.to_vec(),
124            });
125        }
126
127        // 6. Exit Epoch (Offset 105, 8 bytes)
128        let base_ee = &base_bytes[b_start + 105..b_start + 113];
129        let target_ee = &target_bytes[t_start + 105..t_start + 113];
130        if base_ee != target_ee {
131            patches.push(ValidatorPatch {
132                index: i as u32,
133                field: ValidatorField::ExitEpoch,
134                value: target_ee.to_vec(),
135            });
136        }
137
138        // 7. Withdrawable Epoch (only stored for slashed validators)
139        if target_bytes[t_start + 88] != 0 {
140            let base_we = &base_bytes[b_start + 113..b_start + 121];
141            let target_we = &target_bytes[t_start + 113..t_start + 121];
142
143            if base_we != target_we {
144                patches.push(ValidatorPatch {
145                    index: i as u32,
146                    field: ValidatorField::WithdrawableEpochSlashed,
147                    value: target_we.to_vec(),
148                });
149            }
150        }
151    }
152
153    // Handle appended validators (raw SSZ tail).
154    let appended_start = common_len * VALIDATOR_SSZ_SIZE;
155    let appended_validators = if target_bytes.len() > appended_start {
156        target_bytes[appended_start..].to_vec()
157    } else {
158        Vec::new()
159    };
160
161    ValidatorsDiff {
162        patches,
163        appended_validators,
164    }
165}
166
167/// Applies a validator delta to an SSZ validator registry in-place.
168///
169/// After successful application, `base` is byte-for-byte identical to the
170/// original target registry used to produce `delta`.
171///
172/// For non-slashed validators, `withdrawable_epoch` is reconstructed
173/// deterministically from `exit_epoch` according to the Ethereum consensus
174/// specification rather than being stored explicitly in the delta.
175///
176/// Newly appended validators are copied directly to the end of the registry.
177///
178/// # Panics
179///
180/// Panics in debug builds if a patch references a validator outside the current
181/// registry.
182///
183/// Supplying a delta that was not produced from the provided base registry
184/// results in undefined reconstructed state.
185pub fn apply_validators(base: &mut Vec<u8>, delta: &ArchivedValidatorsDiff) {
186    for patch in delta.patches.iter() {
187        let idx = patch.index.to_native() as usize;
188        let start = idx * VALIDATOR_SSZ_SIZE;
189
190        // Safety check: Ensure we don't write past the end of existing validators
191        debug_assert!(
192            start + VALIDATOR_SSZ_SIZE <= base.len(),
193            "Patch index out of bounds"
194        );
195
196        let val_bytes = patch.value.as_slice();
197
198        match &patch.field {
199            ArchivedValidatorField::WithdrawalCredentials => {
200                base[start + 48..start + 80].copy_from_slice(val_bytes);
201            }
202
203            ArchivedValidatorField::EffectiveBalance => {
204                base[start + 80..start + 88].copy_from_slice(val_bytes);
205            }
206
207            ArchivedValidatorField::Slashed => {
208                // val_bytes is guaranteed to be 1 byte from the diff logic
209                base[start + 88] = val_bytes[0];
210            }
211
212            ArchivedValidatorField::ActivationEligibilityEpoch => {
213                base[start + 89..start + 97].copy_from_slice(val_bytes);
214            }
215
216            ArchivedValidatorField::ActivationEpoch => {
217                base[start + 97..start + 105].copy_from_slice(val_bytes);
218            }
219
220            ArchivedValidatorField::ExitEpoch => {
221                base[start + 105..start + 113].copy_from_slice(val_bytes);
222
223                // DETERMINISTIC RECONSTRUCTION
224                // Check the SLASHED flag at offset 88 of the CURRENT state.
225                // (It might have just been updated in this same loop, which is correct).
226                let is_slashed = base[start + 88] != 0;
227
228                if !is_slashed {
229                    // Parse the new exit_epoch from the patch value (little-endian)
230                    let exit_bytes: [u8; 8] = val_bytes
231                        .try_into()
232                        .expect("Exit epoch patch must be 8 bytes");
233                    let exit_epoch = u64::from_le_bytes(exit_bytes);
234
235                    // Calculate and write withdrawable_epoch
236                    let withdrawable_epoch =
237                        exit_epoch.saturating_add(MIN_VALIDATOR_WITHDRAWABILITY_DELAY);
238                    base[start + 113..start + 121]
239                        .copy_from_slice(&withdrawable_epoch.to_le_bytes());
240                }
241            }
242
243            ArchivedValidatorField::WithdrawableEpochSlashed => {
244                // Only reached if the validator is slashed. We just write the raw bytes.
245                base[start + 113..start + 121].copy_from_slice(val_bytes);
246            }
247        }
248    }
249
250    // Append new validators
251    if !delta.appended_validators.is_empty() {
252        base.extend_from_slice(delta.appended_validators.as_slice());
253    }
254}