Skip to main content

eth_state_diff/
balances.rs

1//! Compact delta encoding and reconstruction for Ethereum validator balances.
2//!
3//! This module computes compact binary deltas between two validator balance
4//! snapshots and applies those deltas to reconstruct the target snapshot.
5//!
6//! The encoding is specialized for Ethereum beacon-chain balances, where most
7//! validators either retain the same balance or change by a relatively small
8//! amount between consecutive states.
9//!
10//! ## Encoding
11//!
12//! For each balance in the portion shared by the base and target snapshots,
13//! the delta records one of four states using a packed two-bit tag:
14//!
15//! - [`SET_NO_CHANGE`] — the balance is unchanged;
16//! - [`SET_TO_ZERO`] — the target balance is zero;
17//! - [`SET_TO_DIFF`] — the target is reconstructed by applying a signed
18//!   difference to the base balance;
19//! - [`SET_TO_TARGET_VALUE`] — the target balance is stored explicitly.
20//!
21//! Changed balances whose difference fits in an `i32` are normally encoded as
22//! signed differences. The most frequently occurring difference is selected as
23//! the [`BalancesDiff::mode`], and encoded differences store only the
24//! difference relative to that mode.
25//!
26//! Signed corrected differences are encoded as zig-zag integers followed by a
27//! variable-length integer encoding. This makes small and frequently occurring
28//! changes inexpensive to store.
29//!
30//! Differences that do not fit in an `i32` are encoded as explicit target
31//! values.
32//!
33//! Balances that exist only in the target snapshot are stored in
34//! [`BalancesDiff::appended_balances`].
35//!
36//! ## Two-pass encoding
37//!
38//! [`diff_balances_iter`] cannot require its input iterators to implement
39//! [`Clone`]. It therefore performs the diff in two logical passes:
40//!
41//! 1. the common portion of the iterators is consumed into a compact
42//!    intermediate list of changes;
43//! 2. the statistical mode is selected and the changes are encoded.
44//!
45//! Any remaining items in the target iterator are treated as newly appended
46//! balances.
47//!
48//! ## Iterator API
49//!
50//! [`diff_balances`] is the convenience API for contiguous balance slices.
51//! [`diff_balances_iter`] is intended for consensus clients whose balances are
52//! stored in persistent lists, trees, or other non-contiguous structures.
53//!
54//! The iterator API avoids requiring the caller to materialize the complete
55//! balance registry as a flat buffer.
56//!
57//! ## Reconstruction
58//!
59//! [`apply_balances`] and [`apply_balances_iter`] mutate the supplied balance
60//! collection in place.
61//!
62//! The supplied collection must represent the **base snapshot** from which the
63//! delta was generated. After successful application, it contains the target
64//! balances.
65//!
66//! Existing balances are updated in place. Target balances that extend beyond
67//! the base snapshot are appended from the delta.
68//!
69//! ## Complexity
70//!
71//! [`diff_balances`] and [`diff_balances_iter`] run in:
72//!
73//! ```text
74//! O(n)
75//! ```
76//!
77//! where `n` is the number of balances in the common portion of the snapshots.
78//!
79//! Delta generation additionally requires storage proportional to the number
80//! of changed balances:
81//!
82//! ```text
83//! O(k)
84//! ```
85//!
86//! where `k` is the number of changed balances.
87//!
88//! [`apply_balances`] and [`apply_balances_iter`] run in:
89//!
90//! ```text
91//! O(n + a)
92//! ```
93//!
94//! where `n` is the number of balances represented by the tag vector and `a`
95//! is the number of appended balances.
96//!
97//! Reconstruction operates in place and does not require allocating a second
98//! balance buffer.
99//!
100//! ## Serialization
101//!
102//! [`BalancesDiff`] is designed to be serialized using `rkyv` and can then be
103//! passed to a general-purpose compressor such as zstd.
104//!
105//! The delta representation itself is independent of the serialization and
106//! compression layer.
107//!
108//! ## Delta validity
109//!
110//! Applying a delta assumes that the supplied base collection corresponds to
111//! the base snapshot used to create the delta. The application functions do
112//! not independently verify the original balance values.
113//!
114//! In particular, a `SET_TO_DIFF` entry applies its decoded difference to the
115//! current value in the supplied target collection. Applying the same delta to
116//! a different base snapshot therefore does not generally produce the intended
117//! target snapshot.
118//!
119//! [`BalancesDiff`]: crate::types::BalancesDiff
120//! [`SET_NO_CHANGE`]: crate::types::SET_NO_CHANGE
121//! [`SET_TO_ZERO`]: crate::types::SET_TO_ZERO
122//! [`SET_TO_DIFF`]: crate::types::SET_TO_DIFF
123//! [`SET_TO_TARGET_VALUE`]: crate::types::SET_TO_TARGET_VALUE
124
125use rustc_hash::FxHashMap;
126
127use crate::{
128    types::{
129        ArchivedBalancesDiff, BalancesDiff, BitTagVec, SET_NO_CHANGE, SET_TO_DIFF,
130        SET_TO_TARGET_VALUE, SET_TO_ZERO,
131    },
132    Error,
133};
134
135/// Computes a compact balance delta between two contiguous balance slices.
136///
137/// The returned [`BalancesDiff`] contains the information required to
138/// reconstruct `target` from `base`.
139///
140/// The common portion of the two slices is encoded using packed two-bit tags,
141/// statistical mode correction, zig-zag encoded signed differences, and
142/// explicit target values where necessary. If `target` contains more balances
143/// than `base`, the additional balances are stored in
144/// [`BalancesDiff::appended_balances`].
145///
146/// This is a convenience wrapper around [`diff_balances_iter`].
147///
148/// # Complexity
149///
150/// `O(n)` time and `O(k)` additional space, where `n` is the number of balances
151/// in the common portion and `k` is the number of changed balances.
152///
153/// # Examples
154///
155/// ```ignore
156/// let delta = diff_balances(&base, &target);
157/// ```
158///
159/// [`BalancesDiff`]: crate::types::BalancesDiff
160/// [`diff_balances_iter`]: crate::balances::diff_balances_iter
161pub fn diff_balances(base: &[u64], target: &[u64]) -> BalancesDiff {
162    diff_balances_iter(base.iter().copied(), target.iter().copied())
163}
164
165/// Computes a compact balance delta between two balance iterators.
166///
167/// This is the generic counterpart to [`diff_balances`]. It is intended for
168/// consensus clients whose balance storage is not represented as a contiguous
169/// `&[u64]`.
170///
171/// The iterators must implement [`ExactSizeIterator`], allowing the function
172/// to determine the size of the common portion and distinguish existing
173/// balances from balances appended to the target.
174///
175/// The iterators are consumed during encoding and do not need to implement
176/// [`Clone`].
177///
178/// The common portion is first collected into a compact list of changed
179/// balances so that the most frequently occurring balance difference can be
180/// selected as the encoding mode. Remaining items in the target iterator are
181/// stored as appended balances.
182///
183/// # Complexity
184///
185/// `O(n)` time and `O(k + a)` additional space, where:
186///
187/// - `n` is the number of balances in the common portion;
188/// - `k` is the number of changed balances; and
189/// - `a` is the number of balances appended to the target.
190///
191/// [`diff_balances`]: crate::balances::diff_balances
192pub fn diff_balances_iter<I1, I2>(mut base: I1, mut target: I2) -> BalancesDiff
193where
194    I1: ExactSizeIterator<Item = u64>,
195    I2: ExactSizeIterator<Item = u64>,
196{
197    let common_len = base.len().min(target.len());
198    let mut changes = Vec::with_capacity(1024);
199
200    for idx in 0..common_len {
201        let Some(v1) = base.next() else {
202            break;
203        };
204        let Some(v2) = target.next() else {
205            break;
206        };
207
208        if v1 != v2 {
209            let diff = v2
210                .checked_sub(v1)
211                .and_then(|value| i64::try_from(value).ok())
212                .or_else(|| {
213                    v1.checked_sub(v2)
214                        .and_then(|value| i64::try_from(value).ok())
215                        .map(|value| -value)
216                });
217
218            changes.push(Change {
219                idx,
220                diff,
221                target: v2,
222            });
223        }
224    }
225
226    let mode = find_mode(&changes);
227    let (tags, varint_payload, target_values) = encode(common_len, &changes, mode);
228
229    BalancesDiff {
230        tags,
231        mode,
232        varint_payload,
233        target_values,
234        appended_balances: target.collect(),
235    }
236}
237
238/// Applies a balance delta to a mutable balance collection in place.
239///
240/// `target` must initially contain the **base balance snapshot** used to
241/// generate `delta`.
242///
243/// Existing balances are reconstructed according to the packed tags in the
244/// delta. Changed balances encoded as differences are updated relative to
245/// their current base value, while explicit and zero-valued entries replace
246/// the corresponding balance directly.
247///
248/// Any balances stored in [`BalancesDiff::appended_balances`] are appended to
249/// the collection after the common portion has been reconstructed.
250///
251/// This API accepts [`crate::ListMutTarget`] so that consensus clients can
252/// apply the delta directly to tree-backed or persistent balance collections
253/// without first materializing them as a `Vec<u64>`.
254///
255/// # Errors
256///
257/// Returns [`Error::MalformedDelta`] if the delta is structurally invalid,
258/// contains incomplete payload data, or cannot be safely applied to the
259/// supplied target collection.
260pub fn apply_balances_iter<T: crate::ListMutTarget<u64>>(
261    target: &mut T,
262    delta: &ArchivedBalancesDiff,
263) -> Result<(), Error> {
264    let mode = delta.mode.to_native();
265
266    let tag_len = usize::try_from(delta.tags.len.to_native())
267        .map_err(|_| Error::MalformedDelta("delta tag length does not fit in usize".into()))?;
268
269    if target.len() != tag_len {
270        return Err(Error::MalformedDelta(format!(
271            "target length {} does not match delta tag length {tag_len}",
272            target.len()
273        )));
274    }
275
276    let mut target_iter = delta.target_values.iter();
277    let payload = delta.varint_payload.as_slice();
278    let mut payload_cursor = 0usize;
279    let mut base_idx = 0usize;
280
281    for &tag_byte in delta.tags.data.iter() {
282        if base_idx >= tag_len {
283            break;
284        }
285
286        // Fast path: four consecutive SET_NO_CHANGE entries.
287        if tag_byte == 0 {
288            base_idx = (base_idx + 4).min(tag_len);
289            continue;
290        }
291
292        for bit in 0..4 {
293            if base_idx >= tag_len {
294                break;
295            }
296
297            let tag = (tag_byte >> (bit * 2)) & 0b11;
298
299            match tag {
300                SET_NO_CHANGE => {}
301
302                SET_TO_ZERO => {
303                    let Some(value) = target.get_mut(base_idx) else {
304                        return Err(Error::MalformedDelta(format!(
305                            "target collection is missing balance at index {base_idx}"
306                        )));
307                    };
308
309                    *value = 0;
310                }
311
312                SET_TO_TARGET_VALUE => {
313                    let Some(target_value) = target_iter.next() else {
314                        return Err(Error::MalformedDelta(
315                            "target-value payload is shorter than the encoded target-value tags"
316                                .into(),
317                        ));
318                    };
319
320                    let Some(value) = target.get_mut(base_idx) else {
321                        return Err(Error::MalformedDelta(format!(
322                            "target collection is missing balance at index {base_idx}"
323                        )));
324                    };
325
326                    *value = target_value.to_native();
327                }
328
329                SET_TO_DIFF => {
330                    let encoded = read_varint(payload, &mut payload_cursor)?;
331                    let corrected = zigzag_decode(encoded);
332
333                    let Some(diff) = corrected.checked_add(mode) else {
334                        return Err(Error::MalformedDelta(
335                            "decoded balance difference overflows i64".into(),
336                        ));
337                    };
338
339                    let Some(value) = target.get_mut(base_idx) else {
340                        return Err(Error::MalformedDelta(format!(
341                            "target collection is missing balance at index {base_idx}"
342                        )));
343                    };
344
345                    let base_value = i64::try_from(*value).map_err(|_| {
346                        Error::MalformedDelta(format!(
347                            "base balance at index {base_idx} exceeds i64 range"
348                        ))
349                    })?;
350
351                    let updated = base_value
352                        .checked_add(diff)
353                        .and_then(|value| u64::try_from(value).ok())
354                        .ok_or_else(|| {
355                            Error::MalformedDelta(format!(
356                                "decoded balance difference produces an invalid value at index {base_idx}"
357                            ))
358                        })?;
359
360                    *value = updated;
361                }
362
363                _ => {
364                    return Err(Error::MalformedDelta(format!(
365                        "invalid balance tag {tag} at index {base_idx}"
366                    )));
367                }
368            }
369
370            base_idx += 1;
371        }
372    }
373
374    if !delta.appended_balances.is_empty() {
375        for value in delta.appended_balances.iter() {
376            target.push(value.to_native());
377        }
378    }
379
380    Ok(())
381}
382
383/// Applies a balance delta to a contiguous balance vector in place.
384///
385/// `base` must initially contain the balance snapshot from which `delta` was
386/// generated. After successful application, `base` contains the reconstructed
387/// target snapshot.
388///
389/// This is a convenience wrapper around [`apply_balances_iter`] for clients
390/// that store balances contiguously.
391///
392/// # Errors
393///
394/// Returns an error if `delta` is malformed or if its represented base length
395/// does not match `base`.
396///
397/// [`apply_balances_iter`]: crate::balances::apply_balances_iter
398pub fn apply_balances(base: &mut Vec<u64>, delta: &ArchivedBalancesDiff) -> Result<(), Error> {
399    apply_balances_iter(base, delta)
400}
401
402/// Intermediate representation of a changed balance.
403///
404/// This is retained between the comparison pass and encoding pass so that
405/// `diff_balances_iter` can determine the statistical mode without requiring
406/// the source iterators to be cloned.
407struct Change {
408    idx: usize,
409    diff: Option<i64>,
410    target: u64,
411}
412
413/// Finds the most frequently occurring representable balance difference.
414///
415/// Only differences that fit within `i32` participate in mode selection.
416/// Larger differences are always encoded as explicit target values and
417/// therefore do not benefit from mode correction.
418///
419/// Returns `0` when no representable balance difference exists.
420fn find_mode(changes: &[Change]) -> i64 {
421    let mut freq_map = FxHashMap::default();
422    freq_map.reserve(256);
423
424    for change in changes {
425        let Some(diff) = change.diff else {
426            continue;
427        };
428
429        if i32::try_from(diff).is_ok() {
430            *freq_map.entry(diff).or_insert(0usize) += 1;
431        }
432    }
433
434    freq_map
435        .into_iter()
436        .max_by_key(|&(_, count)| count)
437        .map(|(value, _)| value)
438        .unwrap_or(0)
439}
440
441/// Encodes changed balances using the selected statistical mode.
442///
443/// Each changed balance is assigned a two-bit tag. Differences that fit in
444/// `i32` are encoded as zig-zag varints after subtracting `mode`; values that
445/// cannot use the difference representation are stored as absolute target
446/// values.
447fn encode(common_len: usize, changes: &[Change], mode: i64) -> (BitTagVec, Vec<u8>, Vec<u64>) {
448    let mut tags = BitTagVec::new(common_len);
449    let mut varint_payload = Vec::with_capacity(changes.len());
450    let mut target_values = Vec::new();
451
452    for change in changes {
453        let Change { idx, diff, target } = *change;
454
455        if target == 0 {
456            tags.set(idx, SET_TO_ZERO);
457            continue;
458        }
459
460        let Some(diff) = diff else {
461            tags.set(idx, SET_TO_TARGET_VALUE);
462            target_values.push(target);
463            continue;
464        };
465
466        if i32::try_from(diff).is_ok() {
467            tags.set(idx, SET_TO_DIFF);
468
469            // `diff` and `mode` are both representable i32 values, so their
470            // difference is within the i64 range.
471            let corrected = diff - mode;
472            write_varint(zigzag_encode(corrected), &mut varint_payload);
473        } else {
474            tags.set(idx, SET_TO_TARGET_VALUE);
475            target_values.push(target);
476        }
477    }
478
479    (tags, varint_payload, target_values)
480}
481
482/// Encodes a signed integer using zig-zag encoding.
483///
484/// Negative and positive values of similar magnitude are mapped to nearby
485/// unsigned values, making small signed differences efficient to represent
486/// with the subsequent variable-length encoding.
487#[inline]
488fn zigzag_encode(n: i64) -> u64 {
489    ((n as u64) << 1) ^ ((n >> 63) as u64)
490}
491
492/// Decodes a value previously encoded with [`zigzag_encode`].
493#[inline]
494fn zigzag_decode(n: u64) -> i64 {
495    ((n >> 1) as i64) ^ -((n & 1) as i64)
496}
497
498/// Encodes an unsigned integer using the variable-length format used by the
499/// balance delta representation.
500///
501/// Each output byte stores seven payload bits. The high bit indicates whether
502/// another byte follows.
503#[inline]
504pub(super) fn write_varint(mut val: u64, buf: &mut Vec<u8>) {
505    loop {
506        if val < 0x80 {
507            buf.push(val as u8);
508            break;
509        }
510
511        buf.push((val as u8) | 0x80);
512        val >>= 7;
513    }
514}
515
516/// Decodes one unsigned variable-length integer from `buf`.
517///
518/// `cursor` is advanced past the decoded integer.
519///
520/// Returns an error if the payload is truncated or contains more than ten
521/// bytes, or if the final byte contains bits outside the `u64` range.
522#[inline]
523pub(super) fn read_varint(buf: &[u8], cursor: &mut usize) -> Result<u64, Error> {
524    let mut value = 0u64;
525
526    for shift in (0..=63).step_by(7) {
527        let Some(&byte) = buf.get(*cursor) else {
528            return Err(Error::MalformedDelta(format!(
529                "truncated varint payload at byte offset {}",
530                *cursor
531            )));
532        };
533
534        *cursor += 1;
535
536        let payload = (byte & 0x7f) as u64;
537
538        if shift == 63 && payload > 1 {
539            return Err(Error::MalformedDelta(format!(
540                "varint overflows u64 at byte offset {}",
541                *cursor - 1
542            )));
543        }
544
545        value |= payload << shift;
546
547        if byte & 0x80 == 0 {
548            return Ok(value);
549        }
550    }
551
552    Err(Error::MalformedDelta(
553        "varint exceeds the maximum length for a u64".into(),
554    ))
555}