eth_state_diff/recent_roots.rs
1//! Delta encoding for fixed-capacity Ethereum consensus root buffers.
2//!
3//! Ethereum consensus stores historical roots, such as block roots and state
4//! roots, in fixed-capacity circular buffers. As new slots are processed,
5//! entries are written at positions derived from their slot number, eventually
6//! wrapping around and overwriting older entries.
7//!
8//! Rather than storing the complete root buffer, this module records only the
9//! roots written during a requested slot range. Applying the delta replays
10//! those writes into another root buffer using the same slot-to-index mapping.
11//!
12//! The delta contains no explicit buffer indices. Each index is reconstructed
13//! from the slot number and the buffer capacity:
14//!
15//! ```text
16//! buffer_index = slot % buffer_capacity
17//! ```
18//!
19//! # Representation
20//!
21//! [`RootsDiff`] stores one 32-byte root for every slot in the half-open range
22//! `[base_slot, target_slot)`.
23//!
24//! For example, a transition from slot `100` to slot `103` records the roots
25//! for slots:
26//!
27//! ```text
28//! 100, 101, 102
29//! ```
30//!
31//! The root for `target_slot` itself is not included.
32//!
33//! # Correctness
34//!
35//! The destination buffer must have the same capacity as the buffer supplied
36//! to [`diff_roots`]. The delta does not store explicit buffer indices, so
37//! changing the capacity changes the modulo mapping and can cause roots to be
38//! written to different positions.
39//!
40//! The `base_slot` supplied to [`apply_roots`] must also be the same starting
41//! slot used to generate the delta. Because the delta stores only the sequence
42//! of roots, changing the starting slot changes the positions at which those
43//! roots are written.
44//!
45//! The delta represents only the recorded slot writes. Contents at positions
46//! not covered by the slot range are preserved when the delta is applied.
47//!
48//! # Complexity
49//!
50//! If `N = target_slot - base_slot`:
51//!
52//! - [`diff_roots`] runs in O(N) time and uses O(N) additional space.
53//! - [`apply_roots`] runs in O(N) time and uses O(1) additional space.
54
55use crate::{
56 error::Error,
57 types::{ArchivedRootsDiff, RootsDiff},
58};
59
60/// Computes the sequence of roots written during a slot range.
61///
62/// The returned delta contains one root for every slot in the half-open range
63/// `[base_slot, target_slot)`.
64///
65/// For each slot `s`, the root is read from the circular buffer at:
66///
67/// ```text
68/// buffer_index = s % buffer.len()
69/// ```
70///
71/// The root corresponding to `target_slot` is not included.
72///
73/// # Arguments
74///
75/// * `base_slot` - The first slot whose root is included in the delta.
76/// * `target_slot` - The slot immediately following the final recorded root.
77/// * `buffer` - Circular root buffer belonging to the target state.
78///
79/// # Returns
80///
81/// A [`RootsDiff`] containing the target root for every slot in
82/// `[base_slot, target_slot)`.
83///
84/// If `base_slot == target_slot`, the returned delta contains no roots.
85///
86/// # Panics
87///
88/// Panics if `target_slot < base_slot`.
89///
90/// Panics if `buffer` is empty because circular-buffer indexing requires a
91/// non-zero capacity.
92///
93/// # Correctness
94///
95/// The buffer capacity is part of the implicit representation because root
96/// indices are reconstructed using modulo arithmetic.
97///
98/// The buffer supplied to [`apply_roots`] must therefore have the same capacity
99/// as `buffer`. The same `base_slot` must also be supplied when applying the
100/// resulting delta.
101///
102/// # Example
103///
104/// ```
105/// use eth_state_diff::recent_roots::diff_roots;
106///
107/// let mut target_buffer = vec![[0u8; 32]; 4];
108/// target_buffer[0] = [1u8; 32];
109/// target_buffer[1] = [2u8; 32];
110/// target_buffer[2] = [3u8; 32];
111///
112/// let delta = diff_roots(0, 3, &target_buffer);
113///
114/// assert_eq!(delta.roots.len(), 3);
115/// assert_eq!(delta.roots[0], [1u8; 32]);
116/// assert_eq!(delta.roots[1], [2u8; 32]);
117/// assert_eq!(delta.roots[2], [3u8; 32]);
118/// ```
119///
120/// # Complexity
121///
122/// Let `N = target_slot - base_slot`.
123///
124/// - Time: O(N)
125/// - Additional space: O(N)
126pub fn diff_roots(base_slot: u64, target_slot: u64, buffer: &[[u8; 32]]) -> RootsDiff {
127 assert!(
128 target_slot >= base_slot,
129 "target_slot must be greater than or equal to base_slot"
130 );
131
132 assert!(!buffer.is_empty(), "root buffer must not be empty");
133
134 let capacity = buffer.len() as u64;
135
136 let span = target_slot - base_slot;
137 let roots_cap = usize::try_from(span).expect("root span exceeds usize capacity");
138 let mut roots = Vec::with_capacity(roots_cap);
139
140 for i in 0..span {
141 let slot = base_slot + i;
142 let idx = (slot % capacity) as usize;
143 roots.push(
144 *buffer
145 .get(idx)
146 .expect("modulo arithmetic guarantees index is within bounds"),
147 );
148 }
149
150 RootsDiff { roots }
151}
152
153/// Applies a root delta to a circular root buffer in place.
154///
155/// Each root stored in `delta` is written to the destination buffer using the
156/// same slot-to-index mapping used by [`diff_roots`]:
157///
158/// ```text
159/// buffer_index = slot % buffer_capacity
160/// ```
161///
162/// The first root in the delta corresponds to `base_slot`. Each subsequent
163/// root corresponds to the next slot.
164///
165/// # Arguments
166///
167/// * `base_slot` - The slot corresponding to the first root stored in `delta`.
168/// This must be the same starting slot used to generate the delta.
169/// * `base_buffer` - Destination circular root buffer. It is modified in place
170/// and must have the same capacity as the buffer used to generate the delta.
171/// * `delta` - Archived [`RootsDiff`] containing the roots to replay.
172///
173/// # Correctness
174///
175/// This function is the application counterpart to [`diff_roots`].
176///
177/// For correct reconstruction, `base_buffer` must have the same capacity as
178/// the buffer supplied to [`diff_roots`], and `base_slot` must be the same
179/// starting slot used when generating the delta.
180///
181/// The delta does not contain explicit buffer indices. Indices are derived
182/// from `base_slot` and the destination buffer capacity. Contents at positions
183/// outside the recorded slot range are preserved.
184///
185/// # Errors
186///
187/// Returns [`Error::InvalidDelta`] if `base_buffer` is empty.
188///
189/// # Example
190///
191/// ```
192/// use eth_state_diff::recent_roots::{apply_roots, diff_roots};
193/// use eth_state_diff::types::ArchivedRootsDiff;
194///
195/// let mut target_buffer = vec![[0u8; 32]; 4];
196/// target_buffer[0] = [1u8; 32];
197/// target_buffer[1] = [2u8; 32];
198/// target_buffer[2] = [3u8; 32];
199///
200/// let delta = diff_roots(0, 3, &target_buffer);
201///
202/// let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&delta).expect("valid delta");
203/// let archived = rkyv::access::<ArchivedRootsDiff, rkyv::rancor::Error>(&bytes)
204/// .expect("test setup: failed to access archived delta");
205///
206/// let mut reconstructed = vec![[0u8; 32]; 4];
207/// apply_roots(0, &mut reconstructed, archived).expect("valid delta");
208///
209/// assert_eq!(reconstructed, target_buffer);
210/// ```
211///
212/// # Complexity
213///
214/// If `N` roots are stored in `delta`:
215///
216/// - Time: O(N)
217/// - Additional space: O(1)
218pub fn apply_roots(
219 base_slot: u64,
220 base_buffer: &mut [[u8; 32]],
221 delta: &ArchivedRootsDiff,
222) -> Result<(), Error> {
223 if base_buffer.is_empty() {
224 return Err(Error::InvalidDelta("root buffer must not be empty".into()));
225 }
226
227 let capacity = base_buffer.len() as u64;
228
229 for (i, root) in delta.roots.iter().enumerate() {
230 let slot = base_slot + i as u64;
231 let idx = (slot % capacity) as usize;
232
233 let Some(value) = base_buffer.get_mut(idx) else {
234 return Err(Error::InvalidDelta(format!(
235 "root index {idx} is out of bounds for buffer capacity {capacity}",
236 )));
237 };
238
239 *value = *root;
240 }
241
242 Ok(())
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248 use crate::types::ArchivedRootsDiff;
249
250 /// Helper to perform a full roundtrip: diff -> rkyv serialize -> rkyv access -> apply
251 fn assert_roundtrip(
252 base_slot: u64,
253 target_slot: u64,
254 initial_buffer: &mut [[u8; 32]],
255 target_buffer: &[[u8; 32]],
256 ) {
257 let delta = diff_roots(base_slot, target_slot, target_buffer);
258
259 let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&delta).expect("test setup: serialize");
260 let archived = rkyv::access::<ArchivedRootsDiff, rkyv::rancor::Error>(&bytes)
261 .expect("test setup: failed to access archived delta");
262
263 apply_roots(base_slot, initial_buffer, archived).expect("test setup: apply");
264
265 assert_eq!(initial_buffer, target_buffer);
266 }
267
268 #[test]
269 fn test_diff_empty_span() {
270 let buffer = vec![[0u8; 32]; 4];
271 let delta = diff_roots(5, 5, &buffer);
272 assert!(delta.roots.is_empty());
273 }
274
275 #[test]
276 fn test_diff_and_apply_single_slot() {
277 let mut target_buffer = vec![[0u8; 32]; 4];
278 target_buffer[0] = [1u8; 32];
279
280 let mut base_buffer = vec![[0u8; 32]; 4];
281 assert_roundtrip(0, 1, &mut base_buffer, &target_buffer);
282 }
283
284 #[test]
285 fn test_diff_and_apply_no_wrap() {
286 let mut target_buffer = vec![[0u8; 32]; 4];
287 target_buffer[0] = [1u8; 32];
288 target_buffer[1] = [2u8; 32];
289 target_buffer[2] = [3u8; 32];
290
291 let mut base_buffer = vec![[0u8; 32]; 4];
292 assert_roundtrip(0, 3, &mut base_buffer, &target_buffer);
293 }
294
295 #[test]
296 fn test_diff_and_apply_with_wrap() {
297 // Capacity 4. Slots 2, 3, 4.
298 // Indices: 2 % 4 = 2, 3 % 4 = 3, 4 % 4 = 0.
299 let mut target_buffer = vec![[0u8; 32]; 4];
300 target_buffer[1] = [55u8; 32]; // Untouched index
301 target_buffer[2] = [10u8; 32];
302 target_buffer[3] = [11u8; 32];
303 target_buffer[0] = [12u8; 32]; // Wrapped around
304
305 // Base buffer must match the untouched index!
306 let mut base_buffer = vec![[0u8; 32]; 4];
307 base_buffer[1] = [55u8; 32];
308
309 assert_roundtrip(2, 5, &mut base_buffer, &target_buffer);
310 }
311
312 #[test]
313 fn test_diff_and_apply_multiple_wraps() {
314 // Capacity 2. Slots 0, 1, 2, 3, 4.
315 // Indices: 0, 1, 0, 1, 0.
316 let mut target_buffer = vec![[0u8; 32]; 2];
317 target_buffer[0] = [4u8; 32]; // Overwritten by slot 4
318 target_buffer[1] = [3u8; 32]; // Overwritten by slot 3
319
320 let mut base_buffer = vec![[0u8; 32]; 2];
321 assert_roundtrip(0, 5, &mut base_buffer, &target_buffer);
322 }
323
324 #[test]
325 fn test_apply_errors_on_empty_buffer() {
326 let delta = RootsDiff {
327 roots: vec![[0u8; 32]],
328 };
329
330 let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&delta).expect("test setup: serialize");
331 let archived = rkyv::access::<ArchivedRootsDiff, rkyv::rancor::Error>(&bytes)
332 .expect("test setup: failed to access archived delta");
333
334 let mut buffer: Vec<[u8; 32]> = vec![];
335 let result = apply_roots(0, &mut buffer, archived);
336 assert!(result.is_err());
337 let err_str = format!("{}", result.expect_err("test setup"));
338 assert!(err_str.contains("root buffer must not be empty"));
339 }
340}