Skip to main content

eth_state_diff/
historical_log.rs

1//! Delta encoding for append-only historical logs.
2//!
3//! Both `historical_roots` (Phase0 through Bellatrix) and
4//! `historical_summaries` (Capella and later) are append-only logs that grow
5//! according to the historical-period boundary defined by the consensus
6//! protocol.
7//!
8//! This module uses the base and target slots to determine how many historical
9//! items must have been appended. It therefore avoids comparing the complete
10//! serialized log contents.
11//!
12//! The resulting delta contains only the newly appended SSZ items.
13
14use crate::types::{ArchivedHistoricalLogDiff, HistoricalLogDiff};
15
16/// Number of slots in one historical period.
17const SLOTS_PER_HISTORICAL_PERIOD: u64 = 8192;
18
19/// Calculates the expected number of historical log items at a given slot.
20///
21/// When `activation_slot` is provided, the log is considered inactive through
22/// that slot and begins accumulating items afterwards. When no activation slot
23/// is provided, the calculation starts from genesis.
24///
25/// This helper follows the historical-period calculation used by the
26/// corresponding consensus state field.
27#[inline]
28fn calculate_log_count(slot: u64, activation_slot: Option<u64>) -> u64 {
29    match activation_slot {
30        Some(act_slot) if slot <= act_slot => 0,
31        Some(act_slot) => (slot - act_slot) / SLOTS_PER_HISTORICAL_PERIOD,
32        None => (slot + 1) / SLOTS_PER_HISTORICAL_PERIOD,
33    }
34}
35
36/// Computes a delta between two historical log states.
37///
38/// The number of appended items is derived from `base_slot` and `target_slot`
39/// using the protocol-defined historical period rather than by comparing the
40/// serialized base and target logs.
41///
42/// Only the newly appended portion of `target_ssz` is stored in the returned
43/// [`HistoricalLogDiff`].
44///
45/// # Arguments
46///
47/// * `base_slot` - Slot corresponding to the base state.
48/// * `target_slot` - Slot corresponding to the target state.
49/// * `target_ssz` - Complete serialized SSZ representation of the target
50///   historical log.
51/// * `item_ssz_size` - Serialized SSZ size of one historical log item.
52/// * `activation_slot` - Optional slot at which the historical log becomes
53///   active. This is used for logs introduced by a later fork.
54///
55/// # Returns
56///
57/// - [`HistoricalLogDiff::Unchanged`] if the protocol-derived log length has
58///   not increased between the two slots.
59/// - [`HistoricalLogDiff::Append`] containing the newly appended serialized
60///   items otherwise.
61///
62/// # Panics
63///
64/// This function does not intentionally panic in release builds. In debug
65/// builds, it asserts that `target_ssz` contains enough bytes for the number
66/// of items derived from the slot calculation.
67///
68/// The caller must provide a non-zero `item_ssz_size` and a target SSZ buffer
69/// consistent with the protocol-derived log length.
70///
71/// # Complexity
72///
73/// O(k) time and O(k) additional space, where *k* is the number of newly
74/// appended serialized bytes. The number of existing log entries does not
75/// affect the computation.
76///
77/// # Example
78///
79/// ```
80/// use eth_state_diff::historical_log::diff_historical_log;
81/// use eth_state_diff::types::HistoricalLogDiff;
82///
83/// const ITEM_SIZE: usize = 32;
84///
85/// // One historical period has elapsed, so one item is appended.
86/// let target = vec![0u8; ITEM_SIZE];
87///
88/// let delta = diff_historical_log(
89///     0,
90///     8192,
91///     &target,
92///     ITEM_SIZE,
93///     None,
94/// );
95///
96/// assert_eq!(
97///     delta,
98///     HistoricalLogDiff::Append(target),
99/// );
100/// ```
101pub fn diff_historical_log(
102    base_slot: u64,
103    target_slot: u64,
104    target_ssz: &[u8],
105    item_ssz_size: usize,
106    activation_slot: Option<u64>,
107) -> HistoricalLogDiff {
108    let base_count = calculate_log_count(base_slot, activation_slot);
109    let target_count = calculate_log_count(target_slot, activation_slot);
110
111    // The consensus protocol strictly progresses forward in slots, meaning
112    // `target_count` should always be >= `base_count`. A smaller target count
113    // indicates an invalid state transition by the caller.
114    let Some(items_to_append) = target_count.checked_sub(base_count) else {
115        // If the caller violates the forward-progress invariant, we treat it
116        // as no items appended to avoid panicking on underflow.
117        return HistoricalLogDiff::Unchanged;
118    };
119
120    if items_to_append == 0 {
121        return HistoricalLogDiff::Unchanged;
122    }
123
124    // Safely calculate the required bytes, guarding against usize overflow
125    // on 32-bit systems if a caller provides an absurd `target_slot`.
126    let required_bytes = usize::try_from(items_to_append)
127        .ok()
128        .and_then(|count| count.checked_mul(item_ssz_size))
129        .expect("items_to_append * item_ssz_size fits in usize");
130
131    debug_assert!(
132        target_ssz.len() >= required_bytes,
133        "Historical log math expects {required_bytes} bytes, but target_ssz is too short",
134    );
135
136    let start_byte = target_ssz.len().checked_sub(required_bytes).expect(
137        "target_ssz must be large enough to hold the derived items, as guaranteed by caller",
138    );
139
140    let appended_bytes = target_ssz
141        .get(start_byte..)
142        .expect("start_byte is valid as it is derived from target_ssz.len()");
143
144    HistoricalLogDiff::Append(appended_bytes.to_vec())
145}
146
147/// Applies a historical log delta to a serialized historical log in place.
148///
149/// [`HistoricalLogDiff::Unchanged`] leaves the existing log untouched.
150///
151/// [`HistoricalLogDiff::Append`] appends the serialized historical items
152/// contained in the delta to the existing log.
153///
154/// The delta is assumed to have been generated for the supplied base state.
155/// This function does not validate that the base log corresponds to the
156/// state from which the delta was created.
157///
158/// # Complexity
159///
160/// - [`HistoricalLogDiff::Unchanged`][]: O(1).
161/// - [`HistoricalLogDiff::Append`]: O(k), where *k* is the number of appended
162///   bytes.
163///
164/// # Example
165///
166/// ```
167/// use eth_state_diff::historical_log::{
168///     apply_historical_log,
169///     diff_historical_log,
170/// };
171/// use eth_state_diff::types::ArchivedHistoricalLogDiff;
172///
173/// const ITEM_SIZE: usize = 4;
174///
175/// let mut base = vec![1u8, 2, 3, 4];
176/// let target = vec![1u8, 2, 3, 4, 5, 6, 7, 8];
177///
178/// let delta = diff_historical_log(
179///     8191,
180///     16384,
181///     &target,
182///     ITEM_SIZE,
183///     None,
184/// );
185///
186/// let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&delta).expect("failed to serialize");
187/// let archived = rkyv::access::<ArchivedHistoricalLogDiff, rkyv::rancor::Error>(&bytes)
188///     .expect("failed to access");
189///
190/// apply_historical_log(&mut base, archived);
191///
192/// assert_eq!(base, target);
193/// ```
194pub fn apply_historical_log(base: &mut Vec<u8>, delta: &ArchivedHistoricalLogDiff) {
195    match delta {
196        ArchivedHistoricalLogDiff::Unchanged => {}
197
198        ArchivedHistoricalLogDiff::Append(bytes) => {
199            base.extend_from_slice(bytes.as_slice());
200        }
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::types::ArchivedHistoricalLogDiff;
208
209    const ITEM_SIZE: usize = 32;
210
211    fn archive(diff: &HistoricalLogDiff) -> rkyv::util::AlignedVec {
212        rkyv::to_bytes::<rkyv::rancor::Error>(diff).expect("test setup: failed to serialize delta")
213    }
214
215    fn archived(bytes: &[u8]) -> &ArchivedHistoricalLogDiff {
216        rkyv::access::<ArchivedHistoricalLogDiff, rkyv::rancor::Error>(bytes)
217            .expect("test setup: failed to access archived delta")
218    }
219
220    #[test]
221    fn test_calculate_log_count_genesis() {
222        assert_eq!(calculate_log_count(0, None), 0);
223    }
224
225    #[test]
226    fn test_calculate_log_count_first_period_end() {
227        // Slot 8191 is the last slot of the first period.
228        assert_eq!(calculate_log_count(8191, None), 1);
229    }
230
231    #[test]
232    fn test_calculate_log_count_second_period_start() {
233        // Slot 8192 is the first slot of the second period.
234        assert_eq!(calculate_log_count(8192, None), 1);
235    }
236
237    #[test]
238    fn test_calculate_log_count_exact_multiple() {
239        // Slot 16383 is the last slot of the second period.
240        assert_eq!(calculate_log_count(16383, None), 2);
241    }
242
243    #[test]
244    fn test_calculate_log_count_before_activation() {
245        assert_eq!(calculate_log_count(0, Some(1000)), 0);
246        assert_eq!(calculate_log_count(1000, Some(1000)), 0);
247    }
248
249    #[test]
250    fn test_calculate_log_count_just_after_activation() {
251        // 1 slot after activation isn't enough for a full period
252        assert_eq!(calculate_log_count(1001, Some(1000)), 0);
253    }
254
255    #[test]
256    fn test_calculate_log_count_first_period_after_activation() {
257        // Exactly 8192 slots after activation slot 1000 is slot 9192
258        assert_eq!(calculate_log_count(9192, Some(1000)), 1);
259    }
260
261    #[test]
262    fn test_diff_unchanged_within_period() {
263        let target = vec![0u8; ITEM_SIZE * 2]; // Target has 2 items
264                                               // Both slots are strictly inside the first period, no new boundary
265                                               // crossed
266        let delta = diff_historical_log(100, 200, &target, ITEM_SIZE, None);
267        assert!(matches!(delta, HistoricalLogDiff::Unchanged));
268    }
269
270    #[test]
271    fn test_diff_append_crosses_one_period() {
272        // Base at 0 (0 items). Target at 8192 (1 item).
273        let target = vec![1u8; ITEM_SIZE];
274        let delta = diff_historical_log(0, 8192, &target, ITEM_SIZE, None);
275
276        match delta {
277            HistoricalLogDiff::Append(bytes) => assert_eq!(bytes, vec![1u8; ITEM_SIZE]),
278            _ => panic!("test setup: expected Append"),
279        }
280    }
281
282    #[test]
283    fn test_diff_append_crosses_two_periods() {
284        // Base at 8192 (1 item). Target at 16384 (2 items). Appends 1 item.
285        let target = vec![0u8; ITEM_SIZE * 2];
286        let delta = diff_historical_log(8191, 16383, &target, ITEM_SIZE, None);
287
288        match delta {
289            HistoricalLogDiff::Append(bytes) => {
290                // Crucial test: ensure it ONLY grabs the appended slice, not the whole buffer
291                assert_eq!(bytes.len(), ITEM_SIZE);
292                assert_eq!(bytes, vec![0u8; ITEM_SIZE]);
293            }
294            _ => panic!("test setup: expected Append"),
295        }
296    }
297
298    #[test]
299    fn test_diff_unchanged_with_activation_slot() {
300        let target = vec![0u8; ITEM_SIZE];
301        let delta = diff_historical_log(0, 100, &target, ITEM_SIZE, Some(1000));
302        assert!(matches!(delta, HistoricalLogDiff::Unchanged));
303    }
304
305    #[test]
306    fn test_diff_append_with_activation_slot() {
307        let target = vec![1u8; ITEM_SIZE];
308        let delta = diff_historical_log(1000, 9192, &target, ITEM_SIZE, Some(1000));
309
310        match delta {
311            HistoricalLogDiff::Append(bytes) => assert_eq!(bytes, vec![1u8; ITEM_SIZE]),
312            _ => panic!("test setup: expected Append"),
313        }
314    }
315
316    #[test]
317    fn test_apply_unchanged() {
318        let mut base = vec![0u8; ITEM_SIZE];
319        let delta = HistoricalLogDiff::Unchanged;
320
321        let bytes = archive(&delta);
322        let archived = archived(&bytes);
323
324        apply_historical_log(&mut base, archived);
325        assert_eq!(base, vec![0u8; ITEM_SIZE]);
326    }
327
328    #[test]
329    fn test_apply_append() {
330        let mut base = vec![0u8; ITEM_SIZE];
331        let delta = HistoricalLogDiff::Append(vec![1u8; ITEM_SIZE]);
332
333        let bytes = archive(&delta);
334        let archived = archived(&bytes);
335
336        apply_historical_log(&mut base, archived);
337
338        let mut expected = vec![0u8; ITEM_SIZE];
339        expected.extend_from_slice(&[1u8; ITEM_SIZE]);
340        assert_eq!(base, expected);
341    }
342
343    #[test]
344    fn test_full_roundtrip_no_change() {
345        // Both slots are inside the same period, so 0 items are appended
346        let base_slot = 100;
347        let target_slot = 200;
348        let target_ssz = vec![0u8; ITEM_SIZE]; // 1 item exists, but no new ones
349
350        let delta = diff_historical_log(base_slot, target_slot, &target_ssz, ITEM_SIZE, None);
351
352        let bytes = archive(&delta);
353        let archived = archived(&bytes);
354
355        let mut base_ssz = vec![0u8; ITEM_SIZE];
356        apply_historical_log(&mut base_ssz, archived);
357
358        assert_eq!(base_ssz, target_ssz);
359    }
360
361    #[test]
362    fn test_full_roundtrip_with_append() {
363        // Crossing the first boundary (slot 8191 is the end of the first period)
364        let base_slot = 0;
365        let target_slot = 8191;
366        let target_ssz = vec![0u8; ITEM_SIZE]; // 1 item appended
367
368        let delta = diff_historical_log(base_slot, target_slot, &target_ssz, ITEM_SIZE, None);
369
370        let bytes = archive(&delta);
371        let archived = archived(&bytes);
372
373        let mut base_ssz = vec![]; // Base had 0 items
374        apply_historical_log(&mut base_ssz, archived);
375
376        assert_eq!(base_ssz, target_ssz);
377    }
378}