Skip to main content

hopper_sdk/
diff.rs

1//! # Snapshot diff
2//!
3//! Off-chain symmetric of `hopper-core::diff`. Given the raw `before` and
4//! `after` blobs of a Hopper account, this module produces a structured
5//! field-level diff using the layout manifest.
6//!
7//! Indexers care about this because receipts only tell them *which* fields
8//! changed by index. This module answers *what they changed to*.
9
10use hopper_schema::{FieldDescriptor, LayoutManifest};
11
12#[cfg(feature = "std")]
13use alloc::vec::Vec;
14
15/// One field's before/after value, as raw bytes.
16#[derive(Debug, Clone, Copy)]
17pub struct FieldDelta<'a> {
18    /// The field that changed.
19    pub field: &'a FieldDescriptor,
20    /// Previous bytes (length `field.size`).
21    pub before: &'a [u8],
22    /// Next bytes (length `field.size`).
23    pub after: &'a [u8],
24}
25
26/// Error surface for diff operations.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum DiffError {
29    /// `before` and `after` lengths differ; resize deltas require a richer
30    /// diff type (future work. see `DiffWithResize`).
31    LengthMismatch {
32        /// Length of `before`.
33        before: usize,
34        /// Length of `after`.
35        after: usize,
36    },
37    /// Either input was shorter than the manifest's `total_size`.
38    BufferTooShort,
39}
40
41/// Compute a fixed-size diff (no resize). Returns a list of `FieldDelta`s
42///. one per changed field.
43///
44/// # Errors
45/// - `LengthMismatch` if `before.len() != after.len()`.
46/// - `BufferTooShort` if either input is shorter than `manifest.total_size`.
47#[cfg(feature = "std")]
48pub fn fixed_size_diff<'a>(
49    before: &'a [u8],
50    after: &'a [u8],
51    manifest: &'a LayoutManifest,
52) -> Result<Vec<FieldDelta<'a>>, DiffError> {
53    if before.len() != after.len() {
54        return Err(DiffError::LengthMismatch {
55            before: before.len(),
56            after: after.len(),
57        });
58    }
59    if before.len() < manifest.total_size {
60        return Err(DiffError::BufferTooShort);
61    }
62    let mut out = Vec::new();
63    let mut i = 0;
64    while i < manifest.fields.len() {
65        let f = &manifest.fields[i];
66        let start = f.offset as usize;
67        let end = start + f.size as usize;
68        if end > before.len() {
69            break;
70        }
71        if before[start..end] != after[start..end] {
72            out.push(FieldDelta {
73                field: f,
74                before: &before[start..end],
75                after: &after[start..end],
76            });
77        }
78        i += 1;
79    }
80    Ok(out)
81}
82
83/// Bitmask version. same scan but returns a `u64` whose `i`th bit is set
84/// when the `i`th field differs. Useful when comparing to the `changed_fields`
85/// mask from a receipt.
86pub fn field_change_mask(before: &[u8], after: &[u8], manifest: &LayoutManifest) -> u64 {
87    let mut mask = 0u64;
88    let common = core::cmp::min(before.len(), after.len());
89    let mut i = 0;
90    while i < manifest.fields.len() && i < 64 {
91        let f = &manifest.fields[i];
92        let start = f.offset as usize;
93        let end = start + f.size as usize;
94        if end > common {
95            break;
96        }
97        if before[start..end] != after[start..end] {
98            mask |= 1u64 << i;
99        }
100        i += 1;
101    }
102    mask
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use hopper_schema::FieldIntent;
109
110    fn fields() -> &'static [FieldDescriptor] {
111        static F: [FieldDescriptor; 2] = [
112            FieldDescriptor {
113                name: "a",
114                canonical_type: "u64",
115                size: 8,
116                offset: 0,
117                intent: FieldIntent::Counter,
118            },
119            FieldDescriptor {
120                name: "b",
121                canonical_type: "u64",
122                size: 8,
123                offset: 8,
124                intent: FieldIntent::Balance,
125            },
126        ];
127        &F
128    }
129
130    fn manifest() -> LayoutManifest {
131        LayoutManifest {
132            name: "Pair",
133            disc: 1,
134            version: 1,
135            layout_id: [0; 8],
136            total_size: 16,
137            field_count: 2,
138            fields: fields(),
139        }
140    }
141
142    #[test]
143    fn mask_detects_changed_field() {
144        let mut before = [0u8; 16];
145        let mut after = [0u8; 16];
146        before[8..16].copy_from_slice(&1u64.to_le_bytes());
147        after[8..16].copy_from_slice(&2u64.to_le_bytes());
148        let m = manifest();
149        assert_eq!(field_change_mask(&before, &after, &m), 0b10);
150    }
151
152    #[cfg(feature = "std")]
153    #[test]
154    fn fixed_size_diff_returns_deltas() {
155        let before = [0u8; 16];
156        let mut after = [0u8; 16];
157        after[0..8].copy_from_slice(&5u64.to_le_bytes());
158        let m = manifest();
159        let d = fixed_size_diff(&before, &after, &m).unwrap();
160        assert_eq!(d.len(), 1);
161        assert_eq!(d[0].field.name, "a");
162    }
163}