Skip to main content

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}