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::types::{
128    ArchivedBalancesDiff, BalancesDiff, BitTagVec, SET_NO_CHANGE, SET_TO_DIFF, SET_TO_TARGET_VALUE,
129    SET_TO_ZERO,
130};
131
132/// Computes a compact balance delta between two contiguous balance slices.
133///
134/// The returned [`BalancesDiff`] contains the information required to
135/// reconstruct `target` from `base`.
136///
137/// The common portion of the two slices is encoded using packed two-bit tags,
138/// statistical mode correction, zig-zag encoded signed differences, and
139/// explicit target values where necessary. If `target` contains more balances
140/// than `base`, the additional balances are stored in
141/// [`BalancesDiff::appended_balances`].
142///
143/// This is a convenience wrapper around [`diff_balances_iter`].
144///
145/// # Complexity
146///
147/// `O(n)` time and `O(k)` additional space, where `n` is the number of balances
148/// in the common portion and `k` is the number of changed balances.
149///
150/// # Examples
151///
152/// ```ignore
153/// let delta = diff_balances(&base, &target);
154/// ```
155///
156/// [`BalancesDiff`]: crate::types::BalancesDiff
157/// [`diff_balances_iter`]: crate::balances::diff_balances_iter
158pub fn diff_balances(base: &[u64], target: &[u64]) -> BalancesDiff {
159    diff_balances_iter(base.iter().copied(), target.iter().copied())
160}
161
162/// Computes a compact balance delta between two balance iterators.
163///
164/// This is the generic counterpart to [`diff_balances`]. It is intended for
165/// consensus clients whose balance storage is not represented as a contiguous
166/// `&[u64]`.
167///
168/// The iterators must implement [`ExactSizeIterator`], allowing the function
169/// to determine the size of the common portion and distinguish existing
170/// balances from balances appended to the target.
171///
172/// The iterators are consumed during encoding and do not need to implement
173/// [`Clone`].
174///
175/// The common portion is first collected into a compact list of changed
176/// balances so that the most frequently occurring balance difference can be
177/// selected as the encoding mode. Remaining items in the target iterator are
178/// stored as appended balances.
179///
180/// # Complexity
181///
182/// `O(n)` time and `O(k + a)` additional space, where:
183///
184/// - `n` is the number of balances in the common portion;
185/// - `k` is the number of changed balances; and
186/// - `a` is the number of balances appended to the target.
187///
188/// # Panics
189///
190/// Panics if an iterator violates the [`ExactSizeIterator`] contract and
191/// yields a different number of items than reported by `len()`.
192///
193/// [`diff_balances`]: crate::balances::diff_balances
194pub fn diff_balances_iter<I1, I2>(mut base: I1, mut target: I2) -> BalancesDiff
195where
196    I1: ExactSizeIterator<Item = u64>,
197    I2: ExactSizeIterator<Item = u64>,
198{
199    let common_len = base.len().min(target.len());
200    let mut changes = Vec::with_capacity(1024);
201
202    // Pass 1: Identify changes and store them in a compact intermediate buffer.
203    // This avoids requiring the caller's iterator to be `Clone` while still
204    // allowing us to find the statistical mode.
205    for i in 0..common_len {
206        let v1 = base.next().unwrap();
207        let v2 = target.next().unwrap();
208
209        if v1 != v2 {
210            changes.push(Change {
211                idx: i,
212                diff: v2 as i64 - v1 as i64,
213                target: v2,
214            });
215        }
216    }
217
218    let mode = find_mode(&changes);
219    let (tags, varint_payload, target_values) = encode(common_len, &changes, mode);
220
221    // Any remaining items in the target iterator are newly appended balances.
222    BalancesDiff {
223        tags,
224        mode,
225        varint_payload,
226        target_values,
227        appended_balances: target.collect(),
228    }
229}
230
231/// Applies a balance delta to a mutable balance collection in place.
232///
233/// `target` must initially contain the **base balance snapshot** used to
234/// generate `delta`.
235///
236/// Existing balances are reconstructed according to the packed tags in the
237/// delta. Changed balances encoded as differences are updated relative to
238/// their current base value, while explicit and zero-valued entries replace
239/// the corresponding balance directly.
240///
241/// Any balances stored in [`BalancesDiff::appended_balances`] are appended to
242/// the collection after the common portion has been reconstructed.
243///
244/// This API accepts [`crate::ListMutTarget`] so that consensus clients can
245/// apply the delta directly to tree-backed or persistent balance collections
246/// without first materializing them as a `Vec<u64>`.
247///
248/// # Correctness
249///
250/// The supplied collection must correspond to the base snapshot for which
251/// `delta` was generated. The delta does not contain enough information to
252/// independently validate the original base balances.
253///
254/// # Complexity
255///
256/// The delta is processed in linear order over the encoded balance entries.
257/// The total cost therefore depends on the complexity of
258/// [`crate::ListMutTarget::get_mut`] for the supplied collection.
259///
260/// # Panics
261///
262/// In debug builds, panics if the length of the supplied collection does not
263/// match the length represented by the delta's tag vector.
264///
265/// The function also assumes that the serialized delta is structurally valid.
266/// Invalid varint payloads or inconsistent tag/payload data may panic during
267/// reconstruction.
268///
269/// [`BalancesDiff::appended_balances`]: crate::types::BalancesDiff::appended_balances
270pub fn apply_balances_iter<T: crate::ListMutTarget<u64>>(
271    target: &mut T,
272    delta: &ArchivedBalancesDiff,
273) {
274    let mode = delta.mode.to_native();
275    let tag_len = delta.tags.len.to_native() as usize;
276
277    debug_assert_eq!(
278        target.len(),
279        tag_len,
280        "Target balance length does not match delta tag length"
281    );
282
283    let mut target_iter = delta.target_values.iter();
284    let payload = delta.varint_payload.as_slice();
285    let mut payload_cursor = 0usize;
286    let mut base_idx = 0usize;
287
288    for &tag_byte in delta.tags.data.iter() {
289        if base_idx >= tag_len {
290            break;
291        }
292
293        // Fast path: four consecutive SET_NO_CHANGE entries.
294        if tag_byte == 0 {
295            base_idx = (base_idx + 4).min(tag_len);
296            continue;
297        }
298
299        for bit in 0..4 {
300            if base_idx >= tag_len {
301                break;
302            }
303
304            let tag = (tag_byte >> (bit * 2)) & 0b11;
305
306            match tag {
307                SET_NO_CHANGE => {}
308                SET_TO_ZERO => {
309                    *target.get_mut(base_idx).unwrap() = 0;
310                }
311                SET_TO_TARGET_VALUE => {
312                    *target.get_mut(base_idx).unwrap() = target_iter.next().unwrap().to_native();
313                }
314                SET_TO_DIFF => {
315                    let encoded = read_varint(payload, &mut payload_cursor);
316                    let corrected = zigzag_decode(encoded);
317                    let diff = corrected + mode;
318
319                    // Bind to a variable to avoid multiple tree traversals
320                    // in tree-backed implementations like Grandine's.
321                    let val = target.get_mut(base_idx).unwrap();
322                    *val = (*val as i64 + diff) as u64;
323                }
324                _ => unreachable!("Invalid 2-bit tag state encountered during apply"),
325            }
326
327            base_idx += 1;
328        }
329    }
330
331    if !delta.appended_balances.is_empty() {
332        for val in delta.appended_balances.iter() {
333            target.push(val.to_native());
334        }
335    }
336}
337
338/// Applies a balance delta to a contiguous balance vector in place.
339///
340/// `base` must initially contain the balance snapshot from which `delta` was
341/// generated. After application, `base` contains the reconstructed target
342/// snapshot.
343///
344/// This is a convenience wrapper around [`apply_balances_iter`] for clients
345/// that store balances contiguously.
346///
347/// # Complexity
348///
349/// `O(n + a)` time and `O(1)` additional working space, excluding storage
350/// required for appended balances.
351///
352/// # Panics
353///
354/// In debug builds, panics if the length of `base` does not match the length
355/// represented by the delta's tag vector.
356///
357/// [`apply_balances_iter`]: crate::balances::apply_balances_iter
358pub fn apply_balances(base: &mut Vec<u64>, delta: &ArchivedBalancesDiff) {
359    apply_balances_iter(base, delta)
360}
361
362/// Intermediate representation of a changed balance.
363///
364/// This is retained between the comparison pass and encoding pass so that
365/// `diff_balances_iter` can determine the statistical mode without requiring
366/// the source iterators to be cloned.
367struct Change {
368    idx: usize,
369    diff: i64,
370    target: u64,
371}
372
373/// Finds the most frequently occurring representable balance difference.
374///
375/// Only differences that fit within `i32` participate in mode selection.
376/// Larger differences are always encoded as explicit target values and
377/// therefore do not benefit from mode correction.
378///
379/// Returns `0` when no representable balance difference exists.
380fn find_mode(changes: &[Change]) -> i64 {
381    let mut freq_map = FxHashMap::default();
382    freq_map.reserve(256);
383
384    for change in changes {
385        if i32::try_from(change.diff).is_ok() {
386            *freq_map.entry(change.diff).or_insert(0usize) += 1;
387        }
388    }
389
390    freq_map
391        .into_iter()
392        .max_by_key(|&(_, count)| count)
393        .map(|(val, _)| val)
394        .unwrap_or(0)
395}
396
397/// Encodes changed balances using the selected statistical mode.
398///
399/// Each changed balance is assigned a two-bit tag. Differences that fit in
400/// `i32` are encoded as zig-zag varints after subtracting `mode`; values that
401/// cannot use the difference representation are stored as absolute target
402/// values.
403fn encode(common_len: usize, changes: &[Change], mode: i64) -> (BitTagVec, Vec<u8>, Vec<u64>) {
404    let mut tags = BitTagVec::new(common_len);
405    let mut varint_payload = Vec::with_capacity(changes.len());
406    let mut target_values = Vec::new();
407
408    for change in changes {
409        let Change { idx, diff, target } = *change;
410
411        if target == 0 {
412            tags.set(idx, SET_TO_ZERO);
413        } else if diff == target as i64 {
414            // Implies base was 0
415            tags.set(idx, SET_TO_TARGET_VALUE);
416            target_values.push(target);
417        } else if i32::try_from(diff).is_ok() {
418            tags.set(idx, SET_TO_DIFF);
419            let corrected = diff - mode;
420            write_varint(zigzag_encode(corrected), &mut varint_payload);
421        } else {
422            tags.set(idx, SET_TO_TARGET_VALUE);
423            target_values.push(target);
424        }
425    }
426
427    (tags, varint_payload, target_values)
428}
429
430/// Encodes a signed integer using zig-zag encoding.
431///
432/// Negative and positive values of similar magnitude are mapped to nearby
433/// unsigned values, making small signed differences efficient to represent
434/// with the subsequent variable-length encoding.
435#[inline]
436fn zigzag_encode(n: i64) -> u64 {
437    ((n << 1) ^ (n >> 63)) as u64
438}
439
440/// Decodes a value previously encoded with [`zigzag_encode`].
441#[inline]
442fn zigzag_decode(n: u64) -> i64 {
443    ((n >> 1) as i64) ^ -((n & 1) as i64)
444}
445
446/// Encodes an unsigned integer using the variable-length format used by the
447/// balance delta representation.
448///
449/// Each output byte stores seven payload bits. The high bit indicates whether
450/// another byte follows.
451#[inline]
452pub(super) fn write_varint(mut val: u64, buf: &mut Vec<u8>) {
453    loop {
454        if val < 0x80 {
455            buf.push(val as u8);
456            break;
457        }
458        buf.push((val as u8) | 0x80);
459        val >>= 7;
460    }
461}
462
463/// Decodes one unsigned variable-length integer from `buf`.
464///
465/// `cursor` is advanced past the decoded integer.
466///
467/// The caller must provide a structurally valid varint payload. This function
468/// does not perform bounds or overflow validation and may panic when the
469/// payload is malformed or truncated.
470#[inline]
471pub(super) fn read_varint(buf: &[u8], cursor: &mut usize) -> u64 {
472    let mut val = 0u64;
473    let mut shift = 0u32;
474
475    loop {
476        let byte = buf[*cursor];
477        *cursor += 1;
478
479        val |= ((byte & 0x7F) as u64) << shift;
480
481        if (byte & 0x80) == 0 {
482            break;
483        }
484        shift += 7;
485    }
486
487    val
488}