Skip to main content

eth_state_diff/
pending_queue.rs

1//! Delta encoding for SSZ lists that behave as FIFO queues.
2//!
3//! This module computes compact deltas between serialized SSZ queues by
4//! identifying items that have been consumed from the front of the queue and
5//! items that have been appended to the back.
6//!
7//! The encoding supports two representations:
8//!
9//! - [`QueueDiff::Fifo`] records the number of items consumed from the front
10//!   and the serialized items appended to the back.
11//! - [`QueueDiff::FullReplacement`] stores the complete target queue when the
12//!   FIFO relationship cannot be established safely.
13//!
14//! The FIFO representation is suitable for consensus-layer queues whose
15//! logical behavior is append-at-the-back and consume-from-the-front, such as
16//! pending withdrawals and consolidations. It also supports queues such as
17//! pending deposits where reordering may occur: when the FIFO relationship
18//! cannot be proven from the serialized representation, the algorithm falls
19//! back to [`QueueDiff::FullReplacement`] rather than producing an unsafe
20//! delta.
21//!
22//! ## Candidate detection and validation
23//!
24//! To identify a candidate overlap, the encoder searches for the first item of
25//! the target queue within the base queue. Matching is performed only at valid
26//! SSZ item boundaries determined by `item_ssz_size`.
27//!
28//! Finding an item alone is not sufficient to establish a FIFO transition.
29//! After a candidate overlap is found, the encoder verifies that the remaining
30//! bytes of the base queue exactly match the corresponding prefix of the target
31//! queue. Only after this validation succeeds is a [`QueueDiff::Fifo`] emitted.
32//!
33//! If no valid overlap is found, or the remaining queue contents do not match,
34//! the encoder emits [`QueueDiff::FullReplacement`] containing the complete
35//! target queue.
36//!
37//! This conservative fallback ensures that an ambiguous or reordered queue
38//! is never represented as an incorrect FIFO delta.
39//!
40//! ## Representation
41//!
42//! For a valid FIFO transition:
43//!
44//! ```text
45//! base:   [A, B, C, D]
46//! target: [C, D, E, F]
47//!                 ^--- appended
48//!
49//! consumed_count = 2
50//! appended_items = [E, F]
51//! ```
52//!
53//! Applying the delta removes `A` and `B`, then appends `E` and `F`.
54//!
55//! ## Requirements
56//!
57//! `item_ssz_size` must be the fixed serialized SSZ size of one queue item
58//! and must be greater than zero. The input buffers must contain complete
59//! items, so their lengths must be exact multiples of `item_ssz_size`.
60//!
61//! The module operates directly on serialized SSZ bytes and does not require
62//! deserializing individual queue items during diff generation.
63//!
64//! ## Complexity
65//!
66//! [`diff_queue`] performs a linear scan of the base queue for the target head,
67//! followed by a linear validation of the candidate overlap. The resulting
68//! algorithm is O(n) in the size of the serialized queues.
69//!
70//! [`apply_queue`] performs O(n) work proportional to the bytes consumed and
71//! appended for a FIFO delta, or O(n) in the target queue size for a full
72//! replacement.
73//!
74//! # Example
75//!
76//! ```
77//! # use eth_state_diff::types::QueueDiff;
78//! # use eth_state_diff::pending_queue::diff_queue;
79//!
80//! const ITEM_SIZE: usize = 4;
81//!
82//! let base = b"AAAABBBBCCCC";
83//! let target = b"CCCCDDDDEEEE";
84//!
85//! let delta = diff_queue(base, target, ITEM_SIZE);
86//!
87//! assert_eq!(
88//!     delta,
89//!     QueueDiff::Fifo {
90//!         consumed_count: 2,
91//!         appended_items: b"DDDDEEEE".to_vec(),
92//!     }
93//! );
94//! ```
95
96use crate::{
97    types::{ArchivedQueueDiff, QueueDiff},
98    Error,
99};
100
101/// Finds the first occurrence of an SSZ-encoded queue item within `haystack`,
102/// considering only valid item boundaries.
103///
104/// The search is performed in `item_ssz_size`-byte chunks rather than with a
105/// byte-level substring search. This prevents a sequence of bytes occurring
106/// inside one SSZ item from being incorrectly interpreted as a queue-item
107/// boundary.
108///
109/// Returns the byte offset of the first matching item, or `None` if no aligned
110/// match exists.
111///
112/// # Requirements
113///
114/// `needle.len()` must equal `item_ssz_size`. If it does not, the function
115/// returns `None`.
116fn find_chunk_aligned(haystack: &[u8], needle: &[u8], item_ssz_size: usize) -> Option<usize> {
117    if needle.len() != item_ssz_size {
118        return None;
119    }
120
121    haystack
122        .chunks_exact(item_ssz_size)
123        .position(|chunk| chunk == needle)
124        .map(|idx| idx * item_ssz_size)
125}
126
127/// Computes a delta between two serialized SSZ queues.
128///
129/// The encoder first attempts to represent the transition as a FIFO operation:
130///
131/// 1. The first item of `target_ssz` is located in `base_ssz`.
132/// 2. The search is restricted to valid item boundaries using
133///    `item_ssz_size`.
134/// 3. The remaining bytes of the base queue are compared with the corresponding
135///    prefix of the target queue.
136/// 4. If they match exactly, the transition is represented as
137///    [`QueueDiff::Fifo`].
138/// 5. Otherwise, the complete target queue is stored as
139///    [`QueueDiff::FullReplacement`].
140///
141/// This validation is important for queues that may occasionally reorder
142/// items. An overlap by itself does not prove that the target is a continuation
143/// of the base queue.
144///
145/// # Arguments
146///
147/// * `base_ssz` - Serialized SSZ representation of the base queue.
148/// * `target_ssz` - Serialized SSZ representation of the target queue.
149/// * `item_ssz_size` - Fixed serialized SSZ size, in bytes, of one queue item.
150///
151/// # Returns
152///
153/// [`QueueDiff::Fifo`] when the target can be safely represented as consumed
154/// items followed by appended items. Otherwise returns
155/// [`QueueDiff::FullReplacement`] containing the complete target queue.
156///
157/// # Panics
158///
159/// Panics if `item_ssz_size` is zero or if either input contains an incomplete
160/// serialized item.
161///
162/// # Complexity
163///
164/// O(n) time, where *n* is the combined size of the queues in bytes, with
165/// O(m) additional space for the encoded appended or replacement bytes.
166///
167/// # Example
168///
169/// ```
170/// # use eth_state_diff::pending_queue::diff_queue;
171/// # use eth_state_diff::types::QueueDiff;
172///
173/// const ITEM_SIZE: usize = 4;
174///
175/// let base = b"AAAABBBBCCCC";
176/// let target = b"CCCCDDDDEEEE";
177///
178/// let delta = diff_queue(base, target, ITEM_SIZE);
179///
180/// assert_eq!(
181///     delta,
182///     QueueDiff::Fifo {
183///         consumed_count: 2,
184///         appended_items: b"DDDDEEEE".to_vec(),
185///     }
186/// );
187/// ```
188pub fn diff_queue(base_ssz: &[u8], target_ssz: &[u8], item_ssz_size: usize) -> QueueDiff {
189    assert!(item_ssz_size > 0, "item_ssz_size must be greater than 0");
190    assert!(
191        base_ssz.len() % item_ssz_size == 0,
192        "base_ssz length must be a multiple of item_ssz_size"
193    );
194    assert!(
195        target_ssz.len() % item_ssz_size == 0,
196        "target_ssz length must be a multiple of item_ssz_size"
197    );
198
199    // Edge case: target is empty, everything was consumed.
200    if target_ssz.is_empty() {
201        let consumed_count = base_ssz.len() / item_ssz_size;
202
203        return QueueDiff::Fifo {
204            consumed_count: u32::try_from(consumed_count)
205                .expect("queue item count exceeds u32::MAX"),
206            appended_items: Vec::new(),
207        };
208    }
209
210    // Edge case: base is empty, everything is an append.
211    if base_ssz.is_empty() {
212        return QueueDiff::Fifo {
213            consumed_count: 0,
214            appended_items: target_ssz.to_vec(),
215        };
216    }
217
218    let target_head = &target_ssz[..item_ssz_size];
219
220    match find_chunk_aligned(base_ssz, target_head, item_ssz_size) {
221        Some(byte_offset) => {
222            let remaining_base_bytes = &base_ssz[byte_offset..];
223            let expected_target_prefix_len = remaining_base_bytes.len();
224
225            // Validate that the overlapping portion is identical.
226            if expected_target_prefix_len <= target_ssz.len()
227                && &target_ssz[..expected_target_prefix_len] == remaining_base_bytes
228            {
229                let consumed_count = byte_offset / item_ssz_size;
230
231                let consumed_count =
232                    u32::try_from(consumed_count).expect("queue item count exceeds u32::MAX");
233
234                let appended_items = target_ssz[expected_target_prefix_len..].to_vec();
235
236                QueueDiff::Fifo {
237                    consumed_count,
238                    appended_items,
239                }
240            } else {
241                QueueDiff::FullReplacement(target_ssz.to_vec())
242            }
243        }
244        None => QueueDiff::FullReplacement(target_ssz.to_vec()),
245    }
246}
247
248/// Applies a queue delta to a serialized SSZ queue in place.
249///
250/// For [`QueueDiff::Fifo`], the specified number of items are removed from the
251/// front of `base`, after which the appended serialized items are added to the
252/// back.
253///
254/// For [`QueueDiff::FullReplacement`], the existing queue is cleared and
255/// replaced with the serialized target queue stored in the delta.
256///
257/// # Arguments
258///
259/// * `base` - Mutable serialized SSZ representation of the queue to update.
260/// * `delta` - Archived queue delta previously produced by [`diff_queue`] and
261///   serialized with `rkyv`.
262/// * `item_ssz_size` - Fixed serialized SSZ size of one queue item.
263///
264/// # Errors
265///
266/// Returns [`Error::MalformedDelta`] if:
267///
268/// - the archived delta cannot be deserialized;
269/// - `item_ssz_size` is inconsistent with the serialized delta;
270/// - the number of consumed items exceeds the number of items in `base`;
271/// - the consumed-byte calculation overflows;
272/// - appended or replacement bytes are not aligned to `item_ssz_size`.
273///
274/// # Behavior
275///
276/// After successful execution, `base` represents the target queue from which
277/// the delta was originally generated.
278///
279/// # Complexity
280///
281/// For [`QueueDiff::Fifo`], the operation is O(n) in the number of bytes
282/// removed and appended. Removing bytes from the front may require shifting
283/// the remaining contents of the `Vec`.
284///
285/// For [`QueueDiff::FullReplacement`], the operation is O(n) in the size of
286/// the replacement queue.
287///
288/// # Example
289///
290/// ```
291/// # use eth_state_diff::pending_queue::{apply_queue, diff_queue};
292///
293/// const ITEM_SIZE: usize = 4;
294///
295/// let mut base = b"AAAABBBBCCCC".to_vec();
296/// let target = b"CCCCDDDDEEEE";
297///
298/// let delta = diff_queue(&base, target, ITEM_SIZE);
299/// let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&delta).expect("failed to serialize");
300/// let archived = rkyv::access::<eth_state_diff::types::ArchivedQueueDiff, rkyv::rancor::Error>(&bytes)
301///     .expect("failed to access");
302///
303/// apply_queue(&mut base, archived, ITEM_SIZE).expect("failed to apply");
304///
305/// assert_eq!(base, target);
306/// ```
307pub fn apply_queue(
308    base: &mut Vec<u8>,
309    delta: &ArchivedQueueDiff,
310    item_ssz_size: usize,
311) -> Result<(), Error> {
312    if item_ssz_size == 0 {
313        return Err(Error::MalformedDelta(
314            "item_ssz_size must be greater than 0".into(),
315        ));
316    }
317
318    if base.len() % item_ssz_size != 0 {
319        return Err(Error::MalformedDelta(format!(
320            "base queue length {} is not a multiple of item size {}",
321            base.len(),
322            item_ssz_size
323        )));
324    }
325
326    let delta: QueueDiff = rkyv::deserialize::<QueueDiff, rkyv::rancor::Error>(delta)
327        .map_err(|_| Error::MalformedDelta("failed to deserialize queue delta".into()))?;
328
329    match delta {
330        QueueDiff::Fifo {
331            consumed_count,
332            appended_items,
333        } => {
334            if appended_items.len() % item_ssz_size != 0 {
335                return Err(Error::MalformedDelta(format!(
336                    "FIFO appended payload length {} is not a multiple of item size {}",
337                    appended_items.len(),
338                    item_ssz_size
339                )));
340            }
341
342            let bytes_to_drain = (consumed_count as usize)
343                .checked_mul(item_ssz_size)
344                .ok_or_else(|| {
345                    Error::MalformedDelta("FIFO consumed byte count overflows usize".into())
346                })?;
347
348            if bytes_to_drain > base.len() {
349                return Err(Error::MalformedDelta(format!(
350                    "FIFO consumes {} bytes from a queue containing only {} bytes",
351                    bytes_to_drain,
352                    base.len()
353                )));
354            }
355
356            base.drain(..bytes_to_drain);
357            base.extend_from_slice(&appended_items);
358        }
359
360        QueueDiff::FullReplacement(replacement) => {
361            if replacement.len() % item_ssz_size != 0 {
362                return Err(Error::MalformedDelta(format!(
363                    "replacement payload length {} is not a multiple of item size {}",
364                    replacement.len(),
365                    item_ssz_size
366                )));
367            }
368
369            base.clear();
370            base.extend_from_slice(&replacement);
371        }
372    }
373
374    Ok(())
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use crate::types::{ArchivedQueueDiff, QueueDiff};
381
382    fn archive(diff: &QueueDiff) -> rkyv::util::AlignedVec {
383        rkyv::to_bytes::<rkyv::rancor::Error>(diff).expect("test setup: failed to serialize delta")
384    }
385
386    fn archived(bytes: &[u8]) -> &ArchivedQueueDiff {
387        rkyv::access::<ArchivedQueueDiff, rkyv::rancor::Error>(bytes)
388            .expect("test setup: failed to access archived delta")
389    }
390
391    const ITEM_SIZE: usize = 4;
392
393    #[test]
394    fn diff_fifo_overlap() {
395        let base = b"AAAABBBBCCCC";
396        let target = b"CCCCDDDDEEEE";
397        let delta = diff_queue(base, target, ITEM_SIZE);
398        assert_eq!(
399            delta,
400            QueueDiff::Fifo {
401                consumed_count: 2,
402                appended_items: b"DDDDEEEE".to_vec(),
403            }
404        );
405    }
406
407    #[test]
408    fn diff_target_empty_all_consumed() {
409        let base = b"AAAABBBB";
410        let target = b"";
411        let delta = diff_queue(base, target, ITEM_SIZE);
412        assert_eq!(
413            delta,
414            QueueDiff::Fifo {
415                consumed_count: 2,
416                appended_items: Vec::new(),
417            }
418        );
419    }
420
421    #[test]
422    fn diff_base_empty_all_appended() {
423        let base = b"";
424        let target = b"AAAABBBB";
425        let delta = diff_queue(base, target, ITEM_SIZE);
426        assert_eq!(
427            delta,
428            QueueDiff::Fifo {
429                consumed_count: 0,
430                appended_items: b"AAAABBBB".to_vec(),
431            }
432        );
433    }
434
435    #[test]
436    fn diff_no_overlap_replacement() {
437        let base = b"AAAABBBB";
438        let target = b"CCCCDDDD";
439        let delta = diff_queue(base, target, ITEM_SIZE);
440        assert_eq!(delta, QueueDiff::FullReplacement(target.to_vec()));
441    }
442
443    #[test]
444    fn diff_false_positive_misaligned_item() {
445        // Base chunks: "AABB", "AABB"
446        // Target head: "BBAA" -> Not found as a chunk
447        let base = b"AABBAABB";
448        let target = b"BBAACCDD";
449        let delta = diff_queue(base, target, ITEM_SIZE);
450        assert_eq!(delta, QueueDiff::FullReplacement(target.to_vec()));
451    }
452
453    #[test]
454    fn diff_false_positive_prefix_mismatch() {
455        // Head matches "BBBB", but the remaining bytes of base don't match the target prefix
456        let base = b"AAAABBBBCCCC";
457        let target = b"BBBB1234CCCC";
458        let delta = diff_queue(base, target, ITEM_SIZE);
459        assert_eq!(delta, QueueDiff::FullReplacement(target.to_vec()));
460    }
461
462    #[test]
463    fn diff_false_positive_target_too_short() {
464        // Head matches "BBBB", but target is shorter than the remaining base bytes
465        let base = b"AAAABBBBCCCC";
466        let target = b"BBBB";
467        let delta = diff_queue(base, target, ITEM_SIZE);
468        assert_eq!(delta, QueueDiff::FullReplacement(target.to_vec()));
469    }
470
471    #[test]
472    #[should_panic(expected = "item_ssz_size must be greater than 0")]
473    fn diff_panic_zero_item_size() {
474        diff_queue(b"AAAA", b"AAAA", 0);
475    }
476
477    #[test]
478    #[should_panic(expected = "base_ssz length must be a multiple of item_ssz_size")]
479    fn diff_panic_base_misaligned() {
480        diff_queue(b"AAA", b"AAAA", ITEM_SIZE);
481    }
482
483    #[test]
484    #[should_panic(expected = "target_ssz length must be a multiple of item_ssz_size")]
485    fn diff_panic_target_misaligned() {
486        diff_queue(b"AAAA", b"AAA", ITEM_SIZE);
487    }
488
489    #[test]
490    fn apply_fifo_transition() {
491        let mut base = b"AAAABBBBCCCC".to_vec();
492        let target = b"CCCCDDDDEEEE";
493
494        let delta = diff_queue(&base, target, ITEM_SIZE);
495        let bytes = archive(&delta);
496        apply_queue(&mut base, archived(&bytes), ITEM_SIZE).expect("test setup: apply");
497
498        assert_eq!(base, target);
499    }
500
501    #[test]
502    fn apply_full_replacement() {
503        let mut base = b"AAAABBBB".to_vec();
504        let target = b"CCCCDDDD";
505
506        let delta = diff_queue(&base, target, ITEM_SIZE);
507        let bytes = archive(&delta);
508        apply_queue(&mut base, archived(&bytes), ITEM_SIZE).expect("test setup: apply");
509
510        assert_eq!(base, target);
511    }
512
513    #[test]
514    fn apply_empty_target_all_consumed() {
515        let mut base = b"AAAABBBB".to_vec();
516        let target = b"";
517
518        let delta = diff_queue(&base, target, ITEM_SIZE);
519        let bytes = archive(&delta);
520        apply_queue(&mut base, archived(&bytes), ITEM_SIZE).expect("test setup: apply");
521
522        assert!(base.is_empty());
523    }
524
525    #[test]
526    fn apply_empty_base_all_appended() {
527        let mut base = Vec::new();
528        let target = b"AAAABBBB";
529
530        let delta = diff_queue(&base, target, ITEM_SIZE);
531        let bytes = archive(&delta);
532        apply_queue(&mut base, archived(&bytes), ITEM_SIZE).expect("test setup: apply");
533
534        assert_eq!(base, target);
535    }
536
537    #[test]
538    fn apply_error_zero_item_size() {
539        let mut base = b"AAAA".to_vec();
540        let delta = QueueDiff::FullReplacement(b"BBBB".to_vec());
541        let bytes = archive(&delta);
542
543        let err =
544            apply_queue(&mut base, archived(&bytes), 0).expect_err("test setup: expected error");
545        assert!(matches!(err, Error::MalformedDelta(_)));
546    }
547
548    #[test]
549    fn apply_error_base_misaligned() {
550        let mut base = b"AAA".to_vec();
551        let delta = QueueDiff::FullReplacement(b"BBBB".to_vec());
552        let bytes = archive(&delta);
553
554        let err = apply_queue(&mut base, archived(&bytes), ITEM_SIZE)
555            .expect_err("test setup: expected error");
556        assert!(matches!(err, Error::MalformedDelta(_)));
557    }
558
559    #[test]
560    fn apply_error_appended_misaligned() {
561        let mut base = b"AAAA".to_vec();
562        let delta = QueueDiff::Fifo {
563            consumed_count: 0,
564            appended_items: b"BBB".to_vec(),
565        };
566        let bytes = archive(&delta);
567
568        let err = apply_queue(&mut base, archived(&bytes), ITEM_SIZE)
569            .expect_err("test setup: expected error");
570        assert!(matches!(err, Error::MalformedDelta(_)));
571    }
572
573    #[test]
574    fn apply_error_consume_exceeds_base() {
575        let mut base = b"AAAA".to_vec(); // 1 item
576        let delta = QueueDiff::Fifo {
577            consumed_count: 2,
578            appended_items: Vec::new(),
579        };
580        let bytes = archive(&delta);
581
582        let err = apply_queue(&mut base, archived(&bytes), ITEM_SIZE)
583            .expect_err("test setup: expected error");
584        assert!(matches!(err, Error::MalformedDelta(_)));
585    }
586
587    #[test]
588    fn apply_error_replacement_misaligned() {
589        let mut base = b"AAAA".to_vec();
590        let delta = QueueDiff::FullReplacement(b"BBB".to_vec());
591        let bytes = archive(&delta);
592
593        let err = apply_queue(&mut base, archived(&bytes), ITEM_SIZE)
594            .expect_err("test setup: expected error");
595        assert!(matches!(err, Error::MalformedDelta(_)));
596    }
597}