eth_state_diff/slashings.rs
1//! Delta encoding for Ethereum slashing vectors.
2//!
3//! Ethereum consensus stores slashing totals in a fixed-capacity circular
4//! buffer indexed by epoch.
5//!
6//! Unlike root and RANDAO buffers, where every slot or epoch transition may
7//! require recording a new value, slashing totals are comparatively sparse.
8//! This module therefore records only ring-buffer entries whose values differ
9//! between the base and target states at the ring positions touched by the
10//! epoch transition.
11//!
12//! The delta stores `(ring_index, value)` pairs. Applying the delta writes only
13//! those recorded values into the destination buffer; all other entries remain
14//! untouched.
15//!
16//! # Epoch Mapping
17//!
18//! Ring-buffer indices are derived from the epoch number:
19//!
20//! ```text
21//! ring_index = epoch % buffer_capacity
22//! ```
23//!
24//! [`diff_slashings`] examines the ring positions corresponding to the epoch
25//! boundaries crossed between `base_slot` and `target_slot`. The base epoch
26//! itself is not examined. For a transition from `base_epoch` to
27//! `target_epoch`, the epochs mapped to ring positions are:
28//!
29//! ```text
30//! base_epoch + 1, ..., target_epoch
31//! ```
32//!
33//! If both slots belong to the same epoch, no ring positions are examined and
34//! the resulting delta is empty.
35//!
36//! If the transition spans more than one complete ring cycle, the same
37//! ring-buffer index may be examined more than once. Each occurrence compares
38//! the current values at that index in the base and target buffers. Applying
39//! the resulting updates in order still reconstructs the target buffer.
40//!
41//! # Correctness
42//!
43//! The base and target buffers must have the same capacity and represent the
44//! same logical slashing ring. The delta stores ring indices rather than epoch
45//! numbers, so the buffer capacity is part of the implicit representation.
46//!
47//! Applying a delta to a buffer with a different capacity, layout, or unrelated
48//! state can write values to incorrect positions.
49//!
50//! The delta only records positions whose base and target values differ among
51//! the ring positions examined by the transition. Positions omitted from the
52//! delta are expected to already contain their target values.
53//!
54//! # Complexity
55//!
56//! If `E` is the number of epoch boundaries crossed by the transition:
57//!
58//! - [`diff_slashings`] runs in O(E) time and O(U) additional space, where `U`
59//! is the number of recorded updates.
60//! - [`apply_slashings`] runs in O(U) time and O(1) additional space.
61
62use crate::{
63 error::Error,
64 types::{ArchivedSlashingsDiff, SlashingsDiff, SLOTS_PER_EPOCH},
65};
66
67/// Computes a sparse delta between two slashing ring buffers.
68///
69/// The function examines the ring-buffer positions corresponding to epoch
70/// boundaries crossed between `base_slot` and `target_slot`.
71///
72/// For a base epoch `B` and target epoch `T`, the examined epochs are:
73///
74/// ```text
75/// B + 1, B + 2, ..., T
76/// ```
77///
78/// For each examined epoch `E`, its ring-buffer position is:
79///
80/// ```text
81/// index = E % buffer_capacity
82/// ```
83///
84/// The values at that position are then compared between `base_buffer` and
85/// `target_buffer`. If they differ, the target value is recorded in the
86/// returned [`SlashingsDiff`].
87///
88/// If the transition spans multiple complete ring cycles, a ring index may be
89/// examined multiple times. In that case, the same index may produce multiple
90/// updates containing the same target value. This is redundant but remains
91/// correct because applying the updates leaves the destination with the target
92/// value at that index.
93///
94/// # Arguments
95///
96/// * `base_slot` - Slot belonging to the base state.
97/// * `target_slot` - Slot belonging to the target state.
98/// * `base_buffer` - Slashing ring buffer belonging to the base state.
99/// * `target_buffer` - Slashing ring buffer belonging to the target state.
100///
101/// # Returns
102///
103/// A [`SlashingsDiff`] containing updates for ring-buffer positions whose base
104/// and target values differ at one or more epoch boundaries crossed by the
105/// transition.
106///
107/// If `base_slot` and `target_slot` belong to the same epoch, the returned
108/// delta contains no updates.
109///
110/// # Panics
111///
112/// Panics if `target_slot < base_slot`.
113///
114/// Panics if `base_buffer` or `target_buffer` is empty.
115///
116/// Panics if `base_buffer` and `target_buffer` have different lengths.
117///
118/// # Correctness
119///
120/// The two buffers must have the same capacity and correspond to the same
121/// logical slashing ring.
122///
123/// The delta stores ring indices rather than epoch numbers. Consequently, the
124/// buffer capacity and logical layout are part of the implicit encoding and
125/// must remain unchanged when applying the resulting delta.
126///
127/// # Example
128///
129/// ```
130/// use eth_state_diff::slashings::diff_slashings;
131///
132/// let base_buffer = vec![0u64; 4];
133/// let mut target_buffer = vec![0u64; 4];
134///
135/// // The transition crosses from epoch 0 to epoch 1.
136/// target_buffer[1] = 100;
137///
138/// let delta = diff_slashings(
139/// 0,
140/// eth_state_diff::types::SLOTS_PER_EPOCH,
141/// &base_buffer,
142/// &target_buffer,
143/// );
144///
145/// assert_eq!(delta.updates.len(), 1);
146/// assert_eq!(delta.updates[0], (1, 100));
147/// ```
148///
149/// # Complexity
150///
151/// If `E` epoch boundaries are crossed and `U` updates are recorded:
152///
153/// - Time: O(E)
154/// - Additional space: O(U)
155pub fn diff_slashings(
156 base_slot: u64,
157 target_slot: u64,
158 base_buffer: &[u64],
159 target_buffer: &[u64],
160) -> SlashingsDiff {
161 assert!(
162 target_slot >= base_slot,
163 "target_slot must be greater than or equal to base_slot"
164 );
165
166 assert!(!base_buffer.is_empty(), "slashing buffer must not be empty");
167
168 assert!(
169 !target_buffer.is_empty(),
170 "slashing buffer must not be empty"
171 );
172
173 assert_eq!(
174 base_buffer.len(),
175 target_buffer.len(),
176 "base and target slashing buffers must have the same capacity"
177 );
178
179 let base_epoch = base_slot / SLOTS_PER_EPOCH;
180 let target_epoch = target_slot / SLOTS_PER_EPOCH;
181 let capacity = base_buffer.len() as u64;
182
183 let mut updates = Vec::new();
184 let mut current_epoch = base_epoch;
185
186 while current_epoch < target_epoch {
187 current_epoch += 1;
188
189 let idx = (current_epoch % capacity) as usize;
190
191 let base_value = *base_buffer
192 .get(idx)
193 .expect("modulo arithmetic guarantees index is within bounds");
194 let target_value = *target_buffer
195 .get(idx)
196 .expect("modulo arithmetic guarantees index is within bounds");
197
198 if base_value != target_value {
199 let idx_u16 = u16::try_from(idx).expect("slashing buffer capacity exceeds u16");
200 updates.push((idx_u16, target_value));
201 }
202 }
203
204 SlashingsDiff { updates }
205}
206
207/// Applies a sparse slashing delta to a circular slashing buffer in place.
208///
209/// Each update in `delta` contains a ring-buffer index and its replacement
210/// value. Only the recorded indices are modified; all other entries in
211/// `base_buffer` remain unchanged.
212///
213/// The delta already contains the ring-buffer indices, so no epoch calculation
214/// is required during application.
215///
216/// # Arguments
217///
218/// * `base_buffer` - Destination slashing ring buffer. It is modified in place.
219/// * `delta` - Archived [`SlashingsDiff`] containing the sparse updates.
220///
221/// # Correctness
222///
223/// The destination buffer must have the same capacity and logical ring layout
224/// as the buffer used to create the delta.
225///
226/// After successful application, every index represented by the delta contains
227/// its recorded target value. Entries omitted from the delta retain their
228/// existing values.
229///
230/// # Panics
231///
232/// Panics if an update contains an index outside `base_buffer`.
233///
234/// # Example
235///
236/// ```
237/// use eth_state_diff::slashings::{apply_slashings, diff_slashings};
238/// use eth_state_diff::types::ArchivedSlashingsDiff;
239///
240/// let base_buffer = vec![0u64; 4];
241/// let mut target_buffer = vec![0u64; 4];
242/// target_buffer[1] = 100;
243///
244/// let delta = diff_slashings(
245/// 0,
246/// eth_state_diff::types::SLOTS_PER_EPOCH,
247/// &base_buffer,
248/// &target_buffer,
249/// );
250///
251/// let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&delta).expect("failed to serialize");
252/// let archived = rkyv::access::<ArchivedSlashingsDiff, rkyv::rancor::Error>(&bytes)
253/// .expect("failed to access");
254///
255/// let mut reconstructed = base_buffer.clone();
256/// apply_slashings(&mut reconstructed, archived).expect("failed to apply");
257///
258/// assert_eq!(reconstructed, target_buffer);
259/// ```
260///
261/// # Complexity
262///
263/// If `U` updates are stored in the delta:
264///
265/// - Time: O(U)
266/// - Additional space: O(1)
267pub fn apply_slashings(
268 base_buffer: &mut [u64],
269 delta: &ArchivedSlashingsDiff,
270) -> Result<(), Error> {
271 for update in delta.updates.iter() {
272 let idx = update.0.to_native() as usize;
273 let value = update.1.to_native();
274
275 let Some(target) = base_buffer.get_mut(idx) else {
276 return Err(Error::MalformedDelta(format!(
277 "slashing index {idx} is out of bounds for buffer of length {}",
278 base_buffer.len()
279 )));
280 };
281 *target = value;
282 }
283 Ok(())
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use crate::types::SlashingsDiff;
290
291 fn archive(diff: &SlashingsDiff) -> rkyv::util::AlignedVec {
292 rkyv::to_bytes::<rkyv::rancor::Error>(diff).expect("test setup: failed to serialize delta")
293 }
294
295 fn archived(bytes: &[u8]) -> &ArchivedSlashingsDiff {
296 rkyv::access::<ArchivedSlashingsDiff, rkyv::rancor::Error>(bytes)
297 .expect("test setup: failed to access archived delta")
298 }
299
300 #[test]
301 fn diff_same_epoch_is_empty() {
302 let base = vec![0u64; 4];
303 let target = vec![0u64; 4];
304
305 // Same epoch (epoch 0)
306 let delta = diff_slashings(0, SLOTS_PER_EPOCH - 1, &base, &target);
307 assert!(delta.updates.is_empty());
308
309 // Same epoch (epoch 1)
310 let delta = diff_slashings(SLOTS_PER_EPOCH, 2 * SLOTS_PER_EPOCH - 1, &base, &target);
311 assert!(delta.updates.is_empty());
312 }
313
314 #[test]
315 fn diff_single_epoch_transition() {
316 let base = vec![0u64; 4];
317 let mut target = vec![0u64; 4];
318 // Epoch 0 to 1 touches index 1
319 target[1] = 100;
320
321 let delta = diff_slashings(0, SLOTS_PER_EPOCH, &base, &target);
322
323 assert_eq!(delta.updates.len(), 1);
324 assert_eq!(delta.updates[0], (1, 100));
325 }
326
327 #[test]
328 fn diff_ignores_untouched_indices() {
329 let base = vec![0u64; 4];
330 let mut target = vec![0u64; 4];
331
332 // Epoch 0 to 1 only touches index 1
333 target[1] = 100; // Touched and modified
334 target[2] = 99; // Untouched, but modified in target
335
336 let delta = diff_slashings(0, SLOTS_PER_EPOCH, &base, &target);
337
338 // Should only record the update for index 1
339 assert_eq!(delta.updates.len(), 1);
340 assert_eq!(delta.updates[0], (1, 100));
341 }
342
343 #[test]
344 fn diff_wraps_around_capacity() {
345 let base = vec![0u64; 4];
346 let mut target = vec![0u64; 4];
347
348 // Epoch 2 to 6 -> touches epochs 3, 4, 5, 6
349 // Indices: 3 % 4 = 3, 4 % 4 = 0, 5 % 4 = 1, 6 % 4 = 2
350 target[3] = 10;
351 target[0] = 20;
352 target[1] = 30;
353 target[2] = 40;
354
355 let delta = diff_slashings(2 * SLOTS_PER_EPOCH, 6 * SLOTS_PER_EPOCH, &base, &target);
356
357 assert_eq!(delta.updates.len(), 4);
358 assert_eq!(delta.updates[0], (3, 10));
359 assert_eq!(delta.updates[1], (0, 20));
360 assert_eq!(delta.updates[2], (1, 30));
361 assert_eq!(delta.updates[3], (2, 40));
362 }
363
364 #[test]
365 fn diff_multiple_cycles_produces_redundant_updates() {
366 let base = vec![0u64; 4];
367 let mut target = vec![0u64; 4];
368
369 // Epoch 0 to 10 -> touches 10 epoch boundaries
370 // Indices touched: 1, 2, 3, 0, 1, 2, 3, 0, 1, 2
371 target[0] = 99;
372 target[1] = 88;
373 target[2] = 77;
374 target[3] = 66;
375
376 let delta = diff_slashings(0, 10 * SLOTS_PER_EPOCH, &base, &target);
377
378 // All 10 touched epochs differ from base, yielding 10 updates.
379 assert_eq!(delta.updates.len(), 10);
380
381 // Verify that the same index is recorded multiple times.
382 assert_eq!(delta.updates[0], (1, 88)); // Epoch 1
383 assert_eq!(delta.updates[4], (1, 88)); // Epoch 5
384 assert_eq!(delta.updates[8], (1, 88)); // Epoch 9
385 }
386
387 #[test]
388 fn diff_omits_unchanged_indices() {
389 let mut base = vec![0u64; 4];
390 let mut target = vec![0u64; 4];
391
392 // Epoch 0 to 2 -> touches indices 1 and 2
393 base[1] = 10;
394 base[2] = 20;
395
396 target[1] = 10; // Unchanged
397 target[2] = 99; // Changed
398
399 let delta = diff_slashings(0, 2 * SLOTS_PER_EPOCH, &base, &target);
400
401 assert_eq!(delta.updates.len(), 1);
402 assert_eq!(delta.updates[0], (2, 99));
403 }
404
405 #[test]
406 fn apply_reconstructs_target() {
407 let base = vec![0u64; 4];
408 let mut target = vec![0u64; 4];
409
410 // Epoch 0 to 10
411 target[0] = 99;
412 target[1] = 88;
413 target[2] = 77;
414 target[3] = 66;
415
416 let delta = diff_slashings(0, 10 * SLOTS_PER_EPOCH, &base, &target);
417 let bytes = archive(&delta);
418
419 let mut reconstructed = base.clone();
420 apply_slashings(&mut reconstructed, archived(&bytes)).expect("test setup: apply");
421
422 assert_eq!(reconstructed, target);
423 }
424
425 #[test]
426 fn apply_no_op_when_empty() {
427 let base = vec![0u64; 4];
428 let target = vec![0u64; 4];
429
430 // Same epoch -> empty delta
431 let delta = diff_slashings(0, SLOTS_PER_EPOCH - 1, &base, &target);
432 let bytes = archive(&delta);
433
434 let mut reconstructed = base.clone();
435 apply_slashings(&mut reconstructed, archived(&bytes)).expect("test setup: apply");
436
437 assert_eq!(reconstructed, base);
438 }
439
440 #[test]
441 #[should_panic(expected = "target_slot must be greater than or equal to base_slot")]
442 fn diff_panics_on_target_before_base() {
443 let base = vec![0u64; 4];
444 let target = vec![0u64; 4];
445 diff_slashings(SLOTS_PER_EPOCH, 0, &base, &target);
446 }
447
448 #[test]
449 #[should_panic(expected = "slashing buffer must not be empty")]
450 fn diff_panics_on_empty_base_buffer() {
451 diff_slashings(0, SLOTS_PER_EPOCH, &[], &[0u64]);
452 }
453
454 #[test]
455 #[should_panic(expected = "slashing buffer must not be empty")]
456 fn diff_panics_on_empty_target_buffer() {
457 diff_slashings(0, SLOTS_PER_EPOCH, &[0u64], &[]);
458 }
459
460 #[test]
461 #[should_panic(expected = "base and target slashing buffers must have the same capacity")]
462 fn diff_panics_on_mismatched_capacity() {
463 diff_slashings(0, SLOTS_PER_EPOCH, &[0u64; 4], &[0u64; 8]);
464 }
465}