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}