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}