Skip to main content

eth_state_diff/
participation.rs

1//! Compact delta encoding for Ethereum epoch participation flags.
2//!
3//! This module computes and applies deltas between validator participation
4//! vectors.
5//!
6//! Participation changes are typically sparse: during an epoch transition,
7//! most validators retain their existing participation flags while only a
8//! subset of validators receive new values. The delta therefore stores only
9//! modified indices and their replacement values rather than the complete
10//! target vector.
11//!
12//! ## Encoding
13//!
14//! The delta has two representations:
15//!
16//! - [`ParticipationDiff::AllZeros`] represents a target vector containing
17//!   only zero-valued participation flags.
18//! - [`ParticipationDiff::Sparse`] stores only changed entries.
19//!
20//! For the sparse representation, modified indices are encoded as
21//! delta-varint gaps between successive modified indices. The corresponding
22//! replacement values are stored separately in `new_values`. This avoids
23//! storing unchanged participation flags and makes sequences of nearby
24//! changes particularly compact.
25//!
26//! Validators present in the target vector but not in the base vector are
27//! stored separately in `extension` and appended during application.
28//!
29//! ## APIs
30//!
31//! [`diff_participation`] and [`apply_participation`] operate on contiguous
32//! vectors and provide the specialized [`ParticipationDiff::AllZeros`] fast
33//! path.
34//!
35//! [`diff_participation_iter`] and [`apply_participation_iter`] operate through
36//! iterators and [`crate::ListMutTarget`], allowing consensus clients with
37//! tree-backed or otherwise non-contiguous state representations to compute
38//! and apply participation deltas without first materializing the complete
39//! vector.
40//!
41//! The iterator-based diff API always produces the sparse representation.
42//! The slice-based API can additionally detect an all-zero target and use the
43//! more compact [`ParticipationDiff::AllZeros`] representation.
44//!
45//! ## Reconstruction
46//!
47//! Applying a sparse delta updates only the modified indices of the existing
48//! collection and then appends any values in `extension`.
49//!
50//! Applying an [`ParticipationDiff::AllZeros`] delta replaces the destination
51//! with a vector of the specified length containing only zero values.
52//!
53//! ## Complexity
54//!
55//! Diff generation is `O(n)` time and `O(m)` additional space, where `n` is
56//! the number of participation flags examined and `m` is the number of
57//! modified entries.
58//!
59//! Sparse delta application is `O(m + e)` time, where `m` is the number of
60//! modified entries and `e` is the number of appended participation flags.
61//!
62//! The contiguous all-zero fast path performs `O(n)` work to initialize the
63//! resulting vector.
64//!
65//! ## Serialization
66//!
67//! [`ParticipationDiff`] is designed to be serialized with `rkyv` and can be
68//! subsequently compressed with a general-purpose compressor such as `zstd`.
69
70use crate::{
71    balances::{read_varint, write_varint},
72    types::{ArchivedParticipationDiff, ParticipationDiff},
73    Error,
74};
75
76/// Computes a compact delta between two participation flag slices.
77///
78/// The returned [`ParticipationDiff`] contains the information required to
79/// reconstruct `target` from `base`.
80///
81/// If every flag in `target` is zero, the function uses the specialized
82/// [`ParticipationDiff::AllZeros`] representation. Otherwise it produces a
83/// sparse delta containing only modified flags.
84///
85/// This is the contiguous-slice convenience API. Clients whose participation
86/// flags are stored in a non-contiguous representation can use
87/// [`diff_participation_iter`] instead.
88///
89/// # Complexity
90///
91/// `O(n)` time and `O(m)` additional space, where `n` is the number of flags
92/// examined and `m` is the number of modified flags.
93pub fn diff_participation(base: &[u8], target: &[u8]) -> ParticipationDiff {
94    if target.iter().all(|&value| value == 0) {
95        return ParticipationDiff::AllZeros(
96            target
97                .len()
98                .try_into()
99                .expect("target length exceeds u32::MAX"),
100        );
101    }
102
103    diff_participation_iter(base.iter().copied(), target.iter().copied())
104}
105
106/// Applies a participation delta to a contiguous vector in place.
107///
108/// This is the contiguous-vector convenience API. Sparse deltas are delegated
109/// to [`apply_participation_iter`], while [`ParticipationDiff::AllZeros`] is
110/// handled directly by replacing the destination with a zero-filled vector of
111/// the encoded length.
112///
113/// After successful application, `base` contains the target participation
114/// vector from which `delta` was produced.
115///
116/// # Errors
117///
118/// Returns [`Error::InvalidDelta`] if an encoded [`ParticipationDiff::AllZeros`]
119/// length cannot be represented by the target vector or if a sparse delta is
120/// internally inconsistent.
121///
122/// Returns [`Error::MalformedDelta`] if a sparse delta contains invalid
123/// serialized payload data, such as a truncated or overflowing varint.
124///
125/// # Complexity
126///
127/// Sparse deltas require `O(m + e)` work, where `m` is the number of modified
128/// entries and `e` is the number of appended flags.
129///
130/// An [`ParticipationDiff::AllZeros`] delta requires `O(n)` work to construct
131/// the resulting zero-filled vector of length `n`.
132pub fn apply_participation(
133    base: &mut Vec<u8>,
134    delta: &ArchivedParticipationDiff,
135) -> Result<(), Error> {
136    match delta {
137        ArchivedParticipationDiff::AllZeros(len) => {
138            let len = usize::try_from(len.to_native()).map_err(|_| {
139                Error::InvalidDelta("all-zero participation length does not fit in usize".into())
140            })?;
141
142            base.clear();
143            base.resize(len, 0);
144
145            Ok(())
146        }
147        ArchivedParticipationDiff::Sparse { .. } => apply_participation_iter(base, delta),
148    }
149}
150
151/// Computes a compact sparse delta between two participation flag iterators.
152///
153/// This API is intended for consensus clients whose participation flags are
154/// stored in tree-backed or otherwise non-contiguous structures. The caller
155/// can expose the values through [`ExactSizeIterator`]s without first
156/// materializing the complete vectors as contiguous buffers.
157///
158/// The iterators are consumed during diff generation.
159///
160/// Unlike [`diff_participation`], this function always returns
161/// [`ParticipationDiff::Sparse`]. It does not perform the all-zero
162/// specialization because the iterator is consumed while determining the
163/// changed entries.
164///
165/// Values remaining in `target` after the common portion are treated as
166/// newly appended participation flags and are stored in the delta's
167/// `extension` field.
168///
169/// # Complexity
170///
171/// `O(n)` time and `O(m + e)` additional space, where `n` is the size of the
172/// common portion, `m` is the number of modified entries, and `e` is the
173/// number of appended target entries.
174pub fn diff_participation_iter<I1, I2>(mut base: I1, mut target: I2) -> ParticipationDiff
175where
176    I1: ExactSizeIterator<Item = u8>,
177    I2: ExactSizeIterator<Item = u8>,
178{
179    let common_len = base.len().min(target.len());
180
181    let mut sparse_indices = Vec::with_capacity(50_000);
182    let mut new_values = Vec::with_capacity(50_000);
183    let mut last_idx = 0u64;
184
185    for i in 0..common_len {
186        let Some(v1) = base.next() else {
187            break;
188        };
189
190        let Some(v2) = target.next() else {
191            break;
192        };
193
194        if v1 != v2 {
195            let idx = i as u64;
196
197            let gap = idx
198                .checked_sub(last_idx)
199                .expect("changed indices are processed in strictly increasing iterator order");
200            write_varint(gap, &mut sparse_indices);
201
202            new_values.push(v2);
203            last_idx = idx;
204        }
205    }
206
207    let extension = target.collect();
208
209    ParticipationDiff::Sparse {
210        sparse_indices,
211        new_values,
212        extension,
213    }
214}
215
216/// Applies a sparse participation delta to a mutable collection in place.
217///
218/// This API is intended for consensus clients whose participation flags are
219/// stored in tree-backed or otherwise non-contiguous structures.
220///
221/// The destination collection is updated according to the sparse entries in
222/// `delta`. Each encoded index gap identifies the next modified entry, whose
223/// value is replaced with the corresponding entry from `new_values`. Values
224/// in `extension` are then appended to the destination.
225///
226/// [`ParticipationDiff::AllZeros`] is not supported by this generic API
227/// because [`crate::ListMutTarget`] does not provide an operation for clearing
228/// or resizing an existing collection. Callers should use
229/// [`apply_participation`] when they need to support that representation.
230///
231/// # Errors
232///
233/// Returns [`Error::InvalidDelta`] if the supplied delta is not a sparse
234/// representation, if the number of encoded indices does not match the number
235/// of replacement values, if an encoded index cannot be represented as a
236/// `usize`, if an index falls outside the destination collection, or if the
237/// destination collection cannot provide the required element.
238///
239/// Returns [`Error::MalformedDelta`] if the sparse index payload contains an
240/// invalid or truncated varint.
241///
242/// # Complexity
243///
244/// `O(m + e)` time and `O(1)` additional working space, excluding allocations
245/// performed by the destination collection when it grows.
246///
247/// Here `m` is the number of modified entries and `e` is the number of
248/// appended entries.
249pub fn apply_participation_iter<T: crate::ListMutTarget<u8>>(
250    target: &mut T,
251    delta: &ArchivedParticipationDiff,
252) -> Result<(), Error> {
253    let ArchivedParticipationDiff::Sparse {
254        sparse_indices,
255        new_values,
256        extension,
257    } = delta
258    else {
259        return Err(Error::InvalidDelta(
260            "AllZeros participation delta cannot be applied through the generic iterator API"
261                .into(),
262        ));
263    };
264
265    let indices_raw = sparse_indices.as_slice();
266    let mut cursor = 0usize;
267    let mut current_idx = 0usize;
268
269    for value in new_values.iter() {
270        let gap = read_varint(indices_raw, &mut cursor)?;
271
272        let gap = usize::try_from(gap).map_err(|_| {
273            Error::MalformedDelta("participation index gap does not fit in usize".into())
274        })?;
275
276        current_idx = current_idx.checked_add(gap).ok_or_else(|| {
277            Error::MalformedDelta(
278                "participation index overflow while decoding sparse indices".into(),
279            )
280        })?;
281
282        let Some(target_value) = target.get_mut(current_idx) else {
283            return Err(Error::InvalidDelta(format!(
284                "participation index {current_idx} is outside target collection of length {}",
285                target.len()
286            )));
287        };
288
289        *target_value = *value;
290    }
291
292    if cursor != indices_raw.len() {
293        return Err(Error::InvalidDelta(
294            "participation sparse index payload contains unused bytes".into(),
295        ));
296    }
297
298    for byte in extension.iter() {
299        target.push(*byte);
300    }
301
302    Ok(())
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::types::ArchivedParticipationDiff;
309    use crate::ListMutTarget;
310
311    struct MockTarget {
312        inner: Vec<u8>,
313    }
314
315    impl ListMutTarget<u8> for MockTarget {
316        fn len(&self) -> usize {
317            self.inner.len()
318        }
319
320        fn get_mut(&mut self, index: usize) -> Option<&mut u8> {
321            self.inner.get_mut(index)
322        }
323
324        fn push(&mut self, value: u8) {
325            self.inner.push(value);
326        }
327    }
328
329    /// Helper to assert a full roundtrip using the slice APIs, guaranteeing a sparse delta.
330    fn assert_sparse_roundtrip(base: &[u8], target: &[u8]) {
331        let delta = diff_participation(base, target);
332
333        match &delta {
334            ParticipationDiff::AllZeros(_) => panic!("test setup: expected sparse delta"),
335            ParticipationDiff::Sparse { .. } => {}
336        }
337
338        let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&delta).expect("test setup: serialize");
339        let archived = rkyv::access::<ArchivedParticipationDiff, rkyv::rancor::Error>(&bytes)
340            .expect("test setup: failed to access archived delta");
341
342        let mut reconstructed = base.to_vec();
343        apply_participation(&mut reconstructed, archived).expect("test setup: apply");
344
345        assert_eq!(reconstructed, target);
346    }
347
348    #[test]
349    fn test_diff_slice_no_changes() {
350        let base = vec![1, 2, 3];
351        let target = vec![1, 2, 3];
352        let delta = diff_participation(&base, &target);
353
354        match delta {
355            ParticipationDiff::Sparse {
356                sparse_indices,
357                new_values,
358                extension,
359            } => {
360                assert!(sparse_indices.is_empty());
361                assert!(new_values.is_empty());
362                assert!(extension.is_empty());
363            }
364            _ => panic!("test setup: expected sparse"),
365        }
366    }
367
368    #[test]
369    fn test_diff_slice_all_zeros_fast_path() {
370        let base = vec![1, 2, 3];
371        let target = vec![0, 0, 0];
372        let delta = diff_participation(&base, &target);
373
374        match delta {
375            ParticipationDiff::AllZeros(len) => assert_eq!(len, 3),
376            _ => panic!("expected AllZeros fast path"),
377        }
378    }
379
380    #[test]
381    fn test_apply_all_zeros() {
382        let mut base = vec![1, 2, 3];
383        let delta = ParticipationDiff::AllZeros(5);
384
385        let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&delta).expect("test setup: serialize");
386        let archived = rkyv::access::<ArchivedParticipationDiff, rkyv::rancor::Error>(&bytes)
387            .expect("test setup: failed to access archived delta");
388
389        apply_participation(&mut base, archived).expect("test setup: apply");
390
391        assert_eq!(base, vec![0, 0, 0, 0, 0]);
392    }
393
394    #[test]
395    fn test_diff_iter_all_zeros_produces_sparse() {
396        let base = vec![1, 2, 3];
397        let target = vec![0, 0, 0];
398        let delta = diff_participation_iter(base.into_iter(), target.into_iter());
399
400        match delta {
401            ParticipationDiff::Sparse { .. } => {} // Pass
402            _ => panic!("iterator API must produce sparse, not AllZeros"),
403        }
404    }
405
406    #[test]
407    fn test_sparse_roundtrip_with_changes() {
408        let base = vec![0, 0, 0, 0];
409        let target = vec![1, 0, 2, 0];
410        assert_sparse_roundtrip(&base, &target);
411    }
412
413    #[test]
414    fn test_sparse_roundtrip_with_appended() {
415        let base = vec![0];
416        let target = vec![0, 5, 6];
417        assert_sparse_roundtrip(&base, &target);
418    }
419
420    #[test]
421    fn test_sparse_roundtrip_combined() {
422        let base = vec![10, 20, 30];
423        let target = vec![10, 99, 30, 40, 50]; // idx 1 changed, 3 & 4 appended
424        assert_sparse_roundtrip(&base, &target);
425    }
426
427    #[test]
428    fn test_apply_all_zeros_via_iter_api_errors() {
429        let delta = ParticipationDiff::AllZeros(5);
430        let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&delta).expect("test setup: serialize");
431        let archived = rkyv::access::<ArchivedParticipationDiff, rkyv::rancor::Error>(&bytes)
432            .expect("test setup: failed to access archived delta");
433
434        let mut target = MockTarget { inner: vec![] };
435        let result = apply_participation_iter(&mut target, archived);
436
437        assert!(result.is_err());
438        let err_str = format!("{}", result.expect_err("test setup"));
439        assert!(err_str.contains("AllZeros participation delta cannot be applied"));
440    }
441
442    #[test]
443    fn test_apply_sparse_mismatched_counts() {
444        // 1 encoded index, but 0 replacement values
445        let delta = ParticipationDiff::Sparse {
446            sparse_indices: vec![0], // gap = 0
447            new_values: vec![],
448            extension: vec![],
449        };
450        let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&delta).expect("test setup: serialize");
451        let archived = rkyv::access::<ArchivedParticipationDiff, rkyv::rancor::Error>(&bytes)
452            .expect("test setup: failed to access archived delta");
453
454        let mut target = MockTarget { inner: vec![0] };
455        let result = apply_participation_iter(&mut target, archived);
456
457        assert!(result.is_err());
458        let err_str = format!("{}", result.expect_err("test setup"));
459        assert!(err_str.contains("unused bytes"));
460    }
461
462    #[test]
463    fn test_apply_sparse_index_out_of_bounds() {
464        // gap = 5, but target collection has length 3
465        let delta = ParticipationDiff::Sparse {
466            sparse_indices: vec![5],
467            new_values: vec![99],
468            extension: vec![],
469        };
470        let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&delta).expect("test setup: serialize");
471        let archived = rkyv::access::<ArchivedParticipationDiff, rkyv::rancor::Error>(&bytes)
472            .expect("test setup: failed to access archived delta");
473
474        let mut target = MockTarget { inner: vec![0; 3] };
475        let result = apply_participation_iter(&mut target, archived);
476
477        assert!(result.is_err());
478        let err_str = format!("{}", result.expect_err("test setup"));
479        assert!(err_str.contains("outside target collection"));
480    }
481
482    #[test]
483    fn test_apply_sparse_truncated_varint() {
484        let delta = ParticipationDiff::Sparse {
485            sparse_indices: vec![0xFF], // Continuation bit set, but buffer ends
486            new_values: vec![0],
487            extension: vec![],
488        };
489        let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&delta).expect("test setup: serialize");
490        let archived = rkyv::access::<ArchivedParticipationDiff, rkyv::rancor::Error>(&bytes)
491            .expect("test setup: failed to access archived delta");
492
493        let mut target = MockTarget { inner: vec![0] };
494        let result = apply_participation_iter(&mut target, archived);
495
496        assert!(result.is_err());
497        let err_str = format!("{}", result.expect_err("test setup"));
498        assert!(err_str.contains("truncated varint"));
499    }
500
501    #[test]
502    fn test_apply_sparse_index_sum_overflow() {
503        // Gap 1: 999 (valid index, fits in target)
504        // Gap 2: u64::MAX (causes checked_add overflow to trigger on the 2nd loop)
505        let sparse_indices: Vec<u8> = vec![
506            0xE7, 0x07, // varint(999)
507            0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, // varint(u64::MAX)
508        ];
509
510        let delta = ParticipationDiff::Sparse {
511            sparse_indices,
512            new_values: vec![0, 0],
513            extension: vec![],
514        };
515        let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&delta).expect("test setup: serialize");
516        let archived = rkyv::access::<ArchivedParticipationDiff, rkyv::rancor::Error>(&bytes)
517            .expect("test setup: failed to access archived delta");
518
519        // Target must be large enough to satisfy the first gap (999)
520        let mut target = MockTarget {
521            inner: vec![0; 1000],
522        };
523        let result = apply_participation_iter(&mut target, archived);
524
525        assert!(result.is_err());
526        let err_str = format!("{}", result.expect_err("test setup"));
527        assert!(err_str.contains("index overflow"));
528    }
529}