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 are expected to contain
59//! complete items, so their lengths should be exact multiples of
60//! `item_ssz_size`.
61//!
62//! The module operates directly on serialized SSZ bytes and does not require
63//! deserializing individual queue items during diff generation.
64//!
65//! ## Complexity
66//!
67//! [`diff_queue`] performs a linear scan of the base queue for the target head,
68//! followed by a linear validation of the candidate overlap. The resulting
69//! algorithm is O(n) in the size of the serialized queues.
70//!
71//! [`apply_queue`] performs O(n) work proportional to the bytes consumed and
72//! appended for a FIFO delta, or O(n) in the target queue size for a full
73//! replacement.
74//!
75//! # Example
76//!
77//! ```
78//! # use eth_state_diff::types::QueueDiff;
79//! # use eth_state_diff::pending_queue::diff_queue;
80//!
81//! const ITEM_SIZE: usize = 4;
82//!
83//! let base = b"AAAABBBBCCCC";
84//! let target = b"CCCCDDDDEEEE";
85//!
86//! let delta = diff_queue(base, target, ITEM_SIZE);
87//!
88//! assert_eq!(
89//! delta,
90//! QueueDiff::Fifo {
91//! consumed_count: 2,
92//! appended_items: b"DDDDEEEE".to_vec(),
93//! }
94//! );
95//!
96
97use crate::types::{ArchivedQueueDiff, QueueDiff};
98
99/// Finds the first occurrence of an SSZ-encoded queue item within `haystack`,
100/// considering only valid item boundaries.
101///
102/// The search is performed in `item_ssz_size`-byte chunks rather than with a
103/// byte-level substring search. This prevents a sequence of bytes occurring
104/// inside one SSZ item from being incorrectly interpreted as a queue-item
105/// boundary.
106///
107/// Returns the byte offset of the first matching item, or `None` if no aligned
108/// match exists.
109///
110/// # Requirements
111///
112/// `needle.len()` must equal `item_ssz_size`. If it does not, the function
113/// returns `None`.
114fn find_chunk_aligned(haystack: &[u8], needle: &[u8], item_ssz_size: usize) -> Option<usize> {
115 if needle.len() != item_ssz_size {
116 return None;
117 }
118
119 haystack
120 .chunks_exact(item_ssz_size)
121 .position(|chunk| chunk == needle)
122 .map(|idx| idx * item_ssz_size)
123}
124
125/// Computes a delta between two serialized SSZ queues.
126///
127/// The encoder first attempts to represent the transition as a FIFO operation:
128///
129/// 1. The first item of `target_ssz` is located in `base_ssz`.
130/// 2. The search is restricted to valid item boundaries using
131/// `item_ssz_size`.
132/// 3. The remaining bytes of the base queue are compared with the corresponding
133/// prefix of the target queue.
134/// 4. If they match exactly, the transition is represented as
135/// [`QueueDiff::Fifo`].
136/// 5. Otherwise, the complete target queue is stored as
137/// [`QueueDiff::FullReplacement`].
138///
139/// This validation is important for queues that may occasionally reorder
140/// items. An overlap by itself does not prove that the target is a continuation
141/// of the base queue.
142///
143/// # Arguments
144///
145/// * `base_ssz` - Serialized SSZ representation of the base queue.
146/// * `target_ssz` - Serialized SSZ representation of the target queue.
147/// * `item_ssz_size` - Fixed serialized SSZ size, in bytes, of one queue item.
148///
149/// # Returns
150///
151/// [`QueueDiff::Fifo`] when the target can be safely represented as consumed
152/// items followed by appended items. Otherwise returns
153/// [`QueueDiff::FullReplacement`] containing the complete target queue.
154///
155/// # Edge cases
156///
157/// - If `target_ssz` is empty, all items in the base queue are considered
158/// consumed and no items are appended.
159/// - If `base_ssz` is empty, the target queue is represented as a pure append.
160/// - If the target head cannot be found at an item boundary, the encoder falls
161/// back to full replacement.
162/// - If the target head is found but the remaining base queue does not exactly
163/// match the target prefix, the encoder falls back to full replacement.
164///
165/// # Panics
166///
167/// Panics if `item_ssz_size` is zero.
168///
169/// # Complexity
170///
171/// O(n) time, where *n* is the combined size of the queues in bytes, with
172/// O(m) additional space for the encoded appended or replacement bytes.
173///
174/// # Example
175///
176/// ```
177/// # use eth_state_diff::pending_queue::diff_queue;
178/// # use eth_state_diff::types::QueueDiff;
179///
180/// const ITEM_SIZE: usize = 4;
181///
182/// let base = b"AAAABBBBCCCC";
183/// let target = b"CCCCDDDDEEEE";
184///
185/// let delta = diff_queue(base, target, ITEM_SIZE);
186///
187/// assert_eq!(
188/// delta,
189/// QueueDiff::Fifo {
190/// consumed_count: 2,
191/// appended_items: b"DDDDEEEE".to_vec(),
192/// }
193/// );
194/// ```
195pub fn diff_queue(base_ssz: &[u8], target_ssz: &[u8], item_ssz_size: usize) -> QueueDiff {
196 assert!(item_ssz_size > 0, "item_ssz_size must be greater than 0");
197
198 // Edge case: target is empty, everything was consumed.
199 if target_ssz.is_empty() {
200 return QueueDiff::Fifo {
201 consumed_count: base_ssz.len() as u32 / item_ssz_size as u32,
202 appended_items: Vec::new(),
203 };
204 }
205
206 // Edge case: base is empty, everything is an append.
207 if base_ssz.is_empty() {
208 return QueueDiff::Fifo {
209 consumed_count: 0,
210 appended_items: target_ssz.to_vec(),
211 };
212 }
213
214 let target_head = &target_ssz[..item_ssz_size];
215
216 match find_chunk_aligned(base_ssz, target_head, item_ssz_size) {
217 Some(byte_offset) => {
218 let remaining_base_bytes = &base_ssz[byte_offset..];
219 let expected_target_prefix_len = remaining_base_bytes.len();
220
221 // Validate that the overlapping portion is identical.
222 if expected_target_prefix_len <= target_ssz.len()
223 && &target_ssz[..expected_target_prefix_len] == remaining_base_bytes
224 {
225 let consumed_count = (byte_offset / item_ssz_size) as u32;
226 let appended_items = target_ssz[expected_target_prefix_len..].to_vec();
227
228 QueueDiff::Fifo {
229 consumed_count,
230 appended_items,
231 }
232 } else {
233 QueueDiff::FullReplacement(target_ssz.to_vec())
234 }
235 }
236 None => QueueDiff::FullReplacement(target_ssz.to_vec()),
237 }
238}
239
240/// Applies a queue delta to a serialized SSZ queue in place.
241///
242/// For [`QueueDiff::Fifo`], the specified number of items are removed from the
243/// front of `base`, after which the appended serialized items are added to the
244/// back.
245///
246/// For [`QueueDiff::FullReplacement`], the existing queue is cleared and
247/// replaced with the serialized target queue stored in the delta.
248///
249/// # Arguments
250///
251/// * `base` - Mutable serialized SSZ representation of the queue to update.
252/// * `delta` - Archived queue delta previously produced by [`diff_queue`] and
253/// serialized with `rkyv`.
254/// * `item_ssz_size` - Fixed serialized SSZ size of one queue item.
255///
256/// # Behavior
257///
258/// After successful execution, `base` represents the target queue from which
259/// the delta was originally generated.
260///
261/// # Panics
262///
263/// Panics if the archived delta cannot be deserialized.
264///
265/// # Complexity
266///
267/// For [`QueueDiff::Fifo`], the operation is O(n) in the number of bytes
268/// removed and appended. Removing bytes from the front may require shifting
269/// the remaining contents of the `Vec`.
270///
271/// For [`QueueDiff::FullReplacement`], the operation is O(n) in the size of
272/// the replacement queue.
273///
274/// # Example
275///
276/// ```
277/// # use eth_state_diff::pending_queue::{apply_queue, diff_queue};
278///
279/// const ITEM_SIZE: usize = 4;
280///
281/// let mut base = b"AAAABBBBCCCC".to_vec();
282/// let target = b"CCCCDDDDEEEE";
283///
284/// let delta = diff_queue(&base, target, ITEM_SIZE);
285/// let archived = rkyv::to_bytes::<rkyv::rancor::Error>(&delta).unwrap();
286/// let archived = unsafe { rkyv::access_unchecked::<
287/// eth_state_diff::types::ArchivedQueueDiff
288/// >(&archived) };
289///
290/// apply_queue(&mut base, archived, ITEM_SIZE);
291///
292/// assert_eq!(base, target);
293/// ```
294pub fn apply_queue(base: &mut Vec<u8>, delta: &ArchivedQueueDiff, item_ssz_size: usize) {
295 let delta: QueueDiff = rkyv::deserialize::<QueueDiff, rkyv::rancor::Error>(delta)
296 .expect("Failed to deserialize QueueDiff");
297
298 match delta {
299 QueueDiff::Fifo {
300 consumed_count,
301 appended_items,
302 } => {
303 let bytes_to_drain = consumed_count as usize * item_ssz_size;
304
305 if bytes_to_drain > base.len() {
306 base.clear();
307 } else {
308 base.drain(..bytes_to_drain);
309 }
310
311 if !appended_items.is_empty() {
312 base.extend_from_slice(&appended_items);
313 }
314 }
315
316 QueueDiff::FullReplacement(replacement) => {
317 base.clear();
318
319 if !replacement.is_empty() {
320 base.extend_from_slice(&replacement);
321 }
322 }
323 }
324}