Skip to main content

eth_state_diff/
randao_mixes.rs

1//! Delta encoding for Ethereum RANDAO mix buffers.
2//!
3//! Ethereum consensus maintains historical RANDAO mixes in a fixed-capacity
4//! circular buffer. Each epoch writes one mix, with the buffer index derived
5//! from the epoch number modulo the buffer capacity.
6//!
7//! Rather than storing the complete RANDAO buffer, this module stores only the
8//! sequence of mixes needed to reconstruct the target buffer over the epochs
9//! covered by a slot transition. Applying the delta replays those epoch writes
10//! using the same circular-buffer indexing rule.
11//!
12//! The delta therefore contains no buffer indices. Indices are reconstructed
13//! deterministically from the starting slot and the destination buffer's
14//! capacity.
15//!
16//! # Representation
17//!
18//! [`RandaoDiff`] stores one 32-byte RANDAO mix for each epoch in the inclusive
19//! range from the epoch containing `base_slot` through the epoch containing
20//! `target_slot`.
21//!
22//! For an epoch `e` and buffer capacity `N`, the mix is stored at:
23//!
24//! ```text
25//! buffer_index = e % N
26//! ```
27//!
28//! During application, the same indexing rule is used starting from the epoch
29//! containing `base_slot`.
30//!
31//! # Correctness
32//!
33//! The source and destination buffers must have the same capacity. The delta
34//! does not store explicit buffer indices; instead, indices are reconstructed
35//! from the starting epoch and the destination buffer capacity.
36//!
37//! Consequently, applying a delta to a buffer with a different capacity can
38//! write mixes to different positions and will not reconstruct the original
39//! target state.
40//!
41//! The caller must also provide the same `base_slot` used when generating the
42//! delta. Because the delta stores only the sequence of mixes, changing the
43//! starting slot changes the epochs and therefore the destination indices at
44//! which those mixes are written.
45//!
46//! # Workflow
47//!
48//! The typical workflow is:
49//!
50//! 1. Call [`diff_randao`] with the base slot, target slot, and target RANDAO
51//!    buffer.
52//! 2. Serialize the resulting [`RandaoDiff`] using `rkyv`.
53//! 3. Store or compress the serialized delta.
54//! 4. Deserialize/access the archived delta and apply it with
55//!    [`apply_randao`] using the same `base_slot`.
56//!
57//! # Complexity
58//!
59//! If `E` is the number of epochs covered by the transition:
60//!
61//! - [`diff_randao`] runs in O(E) time and uses O(E) additional space.
62//! - [`apply_randao`] runs in O(E) time and uses O(1) additional space.
63
64use crate::{
65    types::{ArchivedRandaoDiff, RandaoDiff, SLOTS_PER_EPOCH},
66    Error,
67};
68
69/// Computes a RANDAO delta between two consensus slots.
70///
71/// The returned delta contains one RANDAO mix for every epoch in the inclusive
72/// range from the epoch containing `base_slot` through the epoch containing
73/// `target_slot`.
74///
75/// Each mix is read from `target_buffer` using the circular-buffer indexing
76/// rule:
77///
78/// ```text
79/// buffer_index = epoch % target_buffer.len()
80/// ```
81///
82/// Only the mixes corresponding to the covered epochs are stored. The complete
83/// target buffer is not copied.
84///
85/// # Arguments
86///
87/// * `base_slot` - Starting consensus slot. The epoch containing this slot is
88///   the first epoch represented in the delta.
89/// * `target_slot` - Ending consensus slot. The epoch containing this slot is
90///   the final epoch represented in the delta.
91/// * `target_buffer` - RANDAO mix buffer belonging to the target state.
92///
93/// # Returns
94///
95/// A [`RandaoDiff`] containing one mix for each epoch from the base epoch
96/// through the target epoch, inclusive.
97///
98/// # Panics
99///
100/// Panics if `target_slot < base_slot`.
101///
102/// Panics if `target_buffer` is empty because circular-buffer indexing requires
103/// a non-zero capacity.
104///
105/// # Correctness
106///
107/// The delta stores mixes in chronological epoch order rather than storing
108/// their circular-buffer indices. During application, indices are reconstructed
109/// using modulo arithmetic and the capacity of the destination buffer.
110///
111/// The buffer used with [`apply_randao`] must therefore have the same capacity
112/// as `target_buffer`, and `apply_randao` must be given the same `base_slot`.
113///
114/// # Example
115///
116/// ```
117/// use eth_state_diff::randao_mixes::diff_randao;
118///
119/// let base_slot = 0;
120/// let target_slot = 64;
121///
122/// let target_buffer = vec![[0u8; 32]; 4];
123///
124/// let delta = diff_randao(base_slot, target_slot, &target_buffer);
125///
126/// // With 32 slots per epoch, slots 0 and 64 belong to epochs 0 and 2.
127/// // The inclusive epoch range is therefore 0..=2.
128/// assert_eq!(delta.mixes.len(), 3);
129/// ```
130///
131/// # Complexity
132///
133/// If `E` is the number of epochs from the base epoch through the target
134/// epoch, inclusive:
135///
136/// - Time: O(E)
137/// - Additional space: O(E)
138pub fn diff_randao(base_slot: u64, target_slot: u64, target_buffer: &[[u8; 32]]) -> RandaoDiff {
139    assert!(
140        target_slot >= base_slot,
141        "target_slot must be greater than or equal to base_slot"
142    );
143
144    assert!(!target_buffer.is_empty(), "RANDAO buffer must not be empty");
145
146    let base_epoch = base_slot / SLOTS_PER_EPOCH;
147    let target_epoch = target_slot / SLOTS_PER_EPOCH;
148    let capacity = target_buffer.len() as u64;
149
150    let epoch_count = (target_epoch - base_epoch)
151        .checked_add(1)
152        .expect("epoch count does not overflow u64");
153    let mixes_cap = usize::try_from(epoch_count).expect("epoch count fits in usize");
154    let mut mixes = Vec::with_capacity(mixes_cap);
155
156    for epoch in base_epoch..=target_epoch {
157        let idx = (epoch % capacity) as usize;
158        mixes.push(
159            *target_buffer
160                .get(idx)
161                .expect("modulo arithmetic guarantees index is within bounds"),
162        );
163    }
164
165    RandaoDiff { mixes }
166}
167
168/// Applies a RANDAO delta to a circular mix buffer in place.
169///
170/// Each mix stored in `delta` is written to the destination buffer at the
171/// position corresponding to its epoch. The first mix is written to the epoch
172/// containing `base_slot`; each subsequent mix advances by one epoch.
173///
174/// The destination index is reconstructed using:
175///
176/// ```text
177/// buffer_index = epoch % base_buffer.len()
178/// ```
179///
180/// No allocation is performed while applying the delta.
181///
182/// # Arguments
183///
184/// * `base_slot` - Starting consensus slot corresponding to the first mix in
185///   `delta`. This must be the same starting slot used by [`diff_randao`].
186/// * `base_buffer` - Destination RANDAO circular buffer. It is modified in
187///   place and must have the same capacity as the buffer used to generate
188///   `delta`.
189/// * `delta` - Archived [`RandaoDiff`] containing the mixes to replay.
190///
191/// # Correctness
192///
193/// This function is the application counterpart to [`diff_randao`].
194///
195/// For correct reconstruction, `base_buffer` must have the same capacity as
196/// the target buffer supplied to [`diff_randao`], and `base_slot` must be the
197/// same starting slot used when generating the delta.
198///
199/// The delta does not contain explicit buffer indices. The indices are derived
200/// from the starting epoch and the buffer capacity. Changing either value
201/// changes where the recorded mixes are written.
202///
203/// # Errors
204///
205/// Returns [`Error::InvalidDelta`] if `base_buffer` is empty.
206///
207/// # Example
208///
209/// ```
210/// use eth_state_diff::randao_mixes::{apply_randao, diff_randao};
211/// use eth_state_diff::types::ArchivedRandaoDiff;
212///
213/// let base_slot = 0;
214/// let target_slot = 64;
215///
216/// // With 32 slots per epoch, epochs 0, 1, and 2 are covered.
217/// let mut target_buffer = vec![[0u8; 32]; 4];
218/// target_buffer[0] = [1u8; 32];
219/// target_buffer[1] = [2u8; 32];
220/// target_buffer[2] = [3u8; 32];
221///
222/// let delta = diff_randao(base_slot, target_slot, &target_buffer);
223///
224/// let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&delta).expect("valid delta");
225/// let archived = rkyv::access::<ArchivedRandaoDiff, rkyv::rancor::Error>(&bytes)
226///     .expect("test setup: failed to access archived delta");
227///
228/// let mut reconstructed = vec![[0u8; 32]; 4];
229/// apply_randao(base_slot, &mut reconstructed, archived).expect("valid delta");
230///
231/// assert_eq!(reconstructed, target_buffer);
232/// ```
233///
234/// # Complexity
235///
236/// If `E` is the number of mixes stored in `delta`:
237///
238/// - Time: O(E)
239/// - Additional space: O(1)
240pub fn apply_randao(
241    base_slot: u64,
242    base_buffer: &mut [[u8; 32]],
243    delta: &ArchivedRandaoDiff,
244) -> Result<(), Error> {
245    if base_buffer.is_empty() {
246        return Err(Error::InvalidDelta(
247            "RANDAO buffer must not be empty".into(),
248        ));
249    }
250
251    let capacity = base_buffer.len() as u64;
252    let mut current_epoch = base_slot / SLOTS_PER_EPOCH;
253
254    for mix in delta.mixes.iter() {
255        let idx = (current_epoch % capacity) as usize;
256
257        let Some(value) = base_buffer.get_mut(idx) else {
258            return Err(Error::InvalidDelta(format!(
259                "RANDAO index {idx} is out of bounds for buffer capacity {capacity}",
260            )));
261        };
262
263        *value = *mix;
264        current_epoch += 1;
265    }
266
267    Ok(())
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use crate::types::ArchivedRandaoDiff;
274
275    /// Helper to perform a full roundtrip: diff -> rkyv serialize -> rkyv access -> apply
276    fn assert_roundtrip(
277        base_slot: u64,
278        target_slot: u64,
279        initial_buffer: &mut [[u8; 32]],
280        target_buffer: &[[u8; 32]],
281    ) {
282        let delta = diff_randao(base_slot, target_slot, target_buffer);
283
284        let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&delta).expect("test setup: serialize");
285        let archived = rkyv::access::<ArchivedRandaoDiff, rkyv::rancor::Error>(&bytes)
286            .expect("test setup: failed to access archived delta");
287
288        apply_randao(base_slot, initial_buffer, archived).expect("test setup: apply");
289
290        assert_eq!(initial_buffer, target_buffer);
291    }
292
293    #[test]
294    fn test_diff_and_apply_single_epoch() {
295        // Slots 0 to 31 are all epoch 0
296        let mut target_buffer = vec![[0u8; 32]; 4];
297        target_buffer[0] = [1u8; 32];
298
299        let mut base_buffer = vec![[0u8; 32]; 4];
300        assert_roundtrip(0, 31, &mut base_buffer, &target_buffer);
301    }
302
303    #[test]
304    fn test_diff_and_apply_multiple_epochs() {
305        // Slots 0 to 64 span epochs 0, 1, and 2.
306        let mut target_buffer = vec![[0u8; 32]; 4];
307        target_buffer[0] = [1u8; 32];
308        target_buffer[1] = [2u8; 32];
309        target_buffer[2] = [3u8; 32];
310
311        let mut base_buffer = vec![[0u8; 32]; 4];
312        assert_roundtrip(0, 64, &mut base_buffer, &target_buffer);
313    }
314
315    #[test]
316    fn test_diff_and_apply_with_wrap() {
317        // Capacity 2. Slots 0 to 95 span epochs 0, 1, and 2.
318        // Indices: 0 % 2 = 0, 1 % 2 = 1, 2 % 2 = 0.
319        let mut target_buffer = vec![[0u8; 32]; 2];
320        target_buffer[0] = [3u8; 32]; // Overwritten by epoch 2
321        target_buffer[1] = [2u8; 32]; // Overwritten by epoch 1
322
323        let mut base_buffer = vec![[0u8; 32]; 2];
324        assert_roundtrip(0, 95, &mut base_buffer, &target_buffer);
325    }
326
327    #[test]
328    fn test_diff_exact_slot_boundaries() {
329        // Slot 32 is the exact start of epoch 1. Slot 63 is the end of epoch 1.
330        let buffer = vec![[0u8; 32]; 4];
331
332        let delta = diff_randao(32, 63, &buffer);
333        // Should record exactly 1 mix (epoch 1)
334        assert_eq!(delta.mixes.len(), 1);
335    }
336
337    #[test]
338    fn test_apply_errors_on_empty_buffer() {
339        let delta = RandaoDiff {
340            mixes: vec![[0u8; 32]],
341        };
342
343        let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&delta).expect("test setup: serialize");
344        let archived = rkyv::access::<ArchivedRandaoDiff, rkyv::rancor::Error>(&bytes)
345            .expect("test setup: failed to access archived delta");
346
347        let mut buffer: Vec<[u8; 32]> = vec![];
348        let result = apply_randao(0, &mut buffer, archived);
349
350        assert!(result.is_err());
351        let err_str = format!("{}", result.expect_err("test setup"));
352        assert!(err_str.contains("RANDAO buffer must not be empty"));
353    }
354}