Skip to main content

hopper_core/diff/
mod.rs

1//! State Diff Engine: field-level change tracking.
2//!
3//! Captures before/after snapshots of account data and computes diffs.
4//! Use cases:
5//! - Audit trails
6//! - Test assertions
7//! - Post-mutation invariant verification
8//! - Debugging state transitions
9//!
10//! ## Usage
11//!
12//! ```ignore
13//! // Capture before state
14//! let snap = StateSnapshot::<256>::capture(account_data);
15//!
16//! // ... mutations happen ...
17//!
18//! // Compute diff
19//! let diff = snap.diff(account_data);
20//! if diff.has_changes() {
21//!     let regions = diff.changed_regions::<8>();
22//!     let mut i = 0;
23//!     while i < regions.len() {
24//!         if let Some(r) = regions.get(i) {
25//!             // r.offset, r.length
26//!         }
27//!         i += 1;
28//!     }
29//! }
30//! ```
31//!
32//! ## Design
33//!
34//! Snapshots are stack-allocated using const generics. The maximum snapshot
35//! size is a compile-time parameter. For accounts larger than the snapshot
36//! buffer, only the first N bytes are captured. Use `was_truncated()` to
37//! detect this.
38
39use hopper_runtime::error::ProgramError;
40
41// -- State Snapshot --
42
43/// A stack-allocated snapshot of account data.
44///
45/// `SIZE` is the maximum number of bytes captured.
46pub struct StateSnapshot<const SIZE: usize> {
47    data: [u8; SIZE],
48    len: usize,
49    /// True if the source data was longer than SIZE (truncated capture).
50    truncated: bool,
51}
52
53impl<const SIZE: usize> StateSnapshot<SIZE> {
54    /// Capture a snapshot of account data.
55    ///
56    /// If the data is longer than SIZE, only the first SIZE bytes are captured
57    /// and `was_truncated()` returns true.
58    #[inline]
59    pub fn capture(data: &[u8]) -> Self {
60        let truncated = data.len() > SIZE;
61        let len = if truncated { SIZE } else { data.len() };
62        let mut snapshot = Self {
63            data: [0u8; SIZE],
64            len,
65            truncated,
66        };
67        let mut i = 0;
68        while i < len {
69            snapshot.data[i] = data[i];
70            i += 1;
71        }
72        snapshot
73    }
74
75    /// Length of captured data.
76    #[inline(always)]
77    pub fn len(&self) -> usize {
78        self.len
79    }
80
81    /// Whether no data was captured.
82    #[inline(always)]
83    pub fn is_empty(&self) -> bool {
84        self.len == 0
85    }
86
87    /// Whether the source data was larger than the snapshot buffer.
88    #[inline(always)]
89    pub fn was_truncated(&self) -> bool {
90        self.truncated
91    }
92
93    /// Get the captured data.
94    #[inline(always)]
95    pub fn data(&self) -> &[u8] {
96        &self.data[..self.len]
97    }
98
99    /// Compute a diff against current data.
100    ///
101    /// Returns a `StateDiff` describing all changed regions.
102    #[inline]
103    pub fn diff<'a>(&'a self, current: &'a [u8]) -> StateDiff<'a> {
104        let compare_len = if current.len() < self.len {
105            current.len()
106        } else {
107            self.len
108        };
109
110        StateDiff {
111            old: &self.data[..compare_len],
112            new: &current[..compare_len],
113            old_full_len: self.len,
114            new_full_len: current.len(),
115        }
116    }
117
118    /// Check if any bytes changed compared to current data.
119    #[inline]
120    pub fn has_changes(&self, current: &[u8]) -> bool {
121        if current.len() != self.len {
122            return true;
123        }
124        let mut i = 0;
125        while i < self.len {
126            if self.data[i] != current[i] {
127                return true;
128            }
129            i += 1;
130        }
131        false
132    }
133
134    /// Check if a specific byte range changed.
135    #[inline]
136    pub fn range_changed(&self, current: &[u8], offset: usize, len: usize) -> bool {
137        let end = offset + len;
138        if end > self.len || end > current.len() {
139            return true; // Range exceeds bounds -- consider it changed
140        }
141        let mut i = offset;
142        while i < end {
143            if self.data[i] != current[i] {
144                return true;
145            }
146            i += 1;
147        }
148        false
149    }
150
151    /// Restore the snapshot data back into a mutable slice.
152    ///
153    /// Useful for rollback scenarios.
154    #[inline]
155    pub fn restore_into(&self, target: &mut [u8]) -> Result<(), ProgramError> {
156        if target.len() < self.len {
157            return Err(ProgramError::AccountDataTooSmall);
158        }
159        let mut i = 0;
160        while i < self.len {
161            target[i] = self.data[i];
162            i += 1;
163        }
164        Ok(())
165    }
166}
167
168// -- State Diff --
169
170/// A diff between two states of account data.
171pub struct StateDiff<'a> {
172    old: &'a [u8],
173    new: &'a [u8],
174    old_full_len: usize,
175    new_full_len: usize,
176}
177
178impl<'a> StateDiff<'a> {
179    /// Whether the data changed at all.
180    #[inline]
181    pub fn has_changes(&self) -> bool {
182        if self.old.len() != self.new.len() {
183            return true;
184        }
185        let mut i = 0;
186        while i < self.old.len() {
187            if self.old[i] != self.new[i] {
188                return true;
189            }
190            i += 1;
191        }
192        self.old_full_len != self.new_full_len
193    }
194
195    /// Whether the account was resized.
196    #[inline(always)]
197    pub fn was_resized(&self) -> bool {
198        self.old_full_len != self.new_full_len
199    }
200
201    /// Old data length.
202    #[inline(always)]
203    pub fn old_len(&self) -> usize {
204        self.old_full_len
205    }
206
207    /// New data length.
208    #[inline(always)]
209    pub fn new_len(&self) -> usize {
210        self.new_full_len
211    }
212
213    /// Check if a specific field (by offset and size) changed.
214    #[inline]
215    pub fn field_changed(&self, offset: usize, size: usize) -> bool {
216        let end = offset + size;
217        if end > self.old.len() || end > self.new.len() {
218            return true;
219        }
220        let mut i = offset;
221        while i < end {
222            if self.old[i] != self.new[i] {
223                return true;
224            }
225            i += 1;
226        }
227        false
228    }
229
230    /// Count the number of bytes that changed.
231    #[inline]
232    pub fn changed_byte_count(&self) -> usize {
233        let compare_len = if self.old.len() < self.new.len() {
234            self.old.len()
235        } else {
236            self.new.len()
237        };
238        let mut count = 0;
239        let mut i = 0;
240        while i < compare_len {
241            if self.old[i] != self.new[i] {
242                count += 1;
243            }
244            i += 1;
245        }
246        // Bytes beyond the shorter slice are all "changed"
247        if self.old_full_len > self.new_full_len {
248            count += self.old_full_len - self.new_full_len;
249        } else {
250            count += self.new_full_len - self.old_full_len;
251        }
252        count
253    }
254
255    /// Iterate over changed regions (runs of consecutive changed bytes).
256    ///
257    /// Returns up to `MAX_REGIONS` contiguous changed regions.
258    #[inline]
259    pub fn changed_regions<const MAX_REGIONS: usize>(&self) -> ChangedRegions<MAX_REGIONS> {
260        let compare_len = if self.old.len() < self.new.len() {
261            self.old.len()
262        } else {
263            self.new.len()
264        };
265
266        let mut regions = ChangedRegions {
267            entries: [ChangedRegion {
268                offset: 0,
269                length: 0,
270            }; MAX_REGIONS],
271            count: 0,
272        };
273
274        let mut i = 0;
275        while i < compare_len && regions.count < MAX_REGIONS {
276            if self.old[i] != self.new[i] {
277                let start = i;
278                while i < compare_len && self.old[i] != self.new[i] {
279                    i += 1;
280                }
281                regions.entries[regions.count] = ChangedRegion {
282                    offset: start,
283                    length: i - start,
284                };
285                regions.count += 1;
286            } else {
287                i += 1;
288            }
289        }
290
291        regions
292    }
293}
294
295// -- Changed Region --
296
297/// A contiguous region of changed bytes.
298#[derive(Clone, Copy)]
299pub struct ChangedRegion {
300    /// Byte offset from the start of the data.
301    pub offset: usize,
302    /// Number of consecutive changed bytes.
303    pub length: usize,
304}
305
306/// Stack-allocated list of changed regions.
307pub struct ChangedRegions<const N: usize> {
308    entries: [ChangedRegion; N],
309    count: usize,
310}
311
312impl<const N: usize> ChangedRegions<N> {
313    /// Number of changed regions.
314    #[inline(always)]
315    pub fn len(&self) -> usize {
316        self.count
317    }
318
319    /// Whether there are no changes.
320    #[inline(always)]
321    pub fn is_empty(&self) -> bool {
322        self.count == 0
323    }
324
325    /// Get a changed region by index.
326    #[inline(always)]
327    pub fn get(&self, index: usize) -> Option<&ChangedRegion> {
328        if index < self.count {
329            Some(&self.entries[index])
330        } else {
331            None
332        }
333    }
334
335    /// Iterate over changed regions.
336    #[inline]
337    pub fn iter(&self) -> ChangedRegionIter<'_> {
338        ChangedRegionIter {
339            entries: &self.entries[..self.count],
340            pos: 0,
341        }
342    }
343}
344
345/// Iterator over changed regions.
346pub struct ChangedRegionIter<'a> {
347    entries: &'a [ChangedRegion],
348    pos: usize,
349}
350
351impl<'a> Iterator for ChangedRegionIter<'a> {
352    type Item = &'a ChangedRegion;
353
354    #[inline]
355    fn next(&mut self) -> Option<Self::Item> {
356        if self.pos >= self.entries.len() {
357            return None;
358        }
359        let item = &self.entries[self.pos];
360        self.pos += 1;
361        Some(item)
362    }
363}
364
365// -- Field-Level Diff Helper --
366
367/// Build a field-level diff report for a known layout.
368///
369/// `fields` is an array of `(name, offset, size)`.
370/// Returns a bitmask where bit N is set if field N changed.
371#[inline]
372pub fn field_diff_mask(old: &[u8], new: &[u8], fields: &[(&str, usize, usize)]) -> u64 {
373    let mut mask: u64 = 0;
374    let mut i = 0;
375    while i < fields.len() && i < 64 {
376        let (_, offset, size) = fields[i];
377        let end = offset + size;
378        if end <= old.len() && end <= new.len() {
379            let mut j = offset;
380            while j < end {
381                if old[j] != new[j] {
382                    mask |= 1u64 << i;
383                    break;
384                }
385                j += 1;
386            }
387        } else {
388            mask |= 1u64 << i; // Out of bounds = changed
389        }
390        i += 1;
391    }
392    mask
393}