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}