Skip to main content

hopper_sdk/
reader.rs

1//! # Segment-aware partial account reader
2//!
3//! Clients usually want only a few fields of a large account, not the whole
4//! blob. Hopper's schema knows every field's byte offset and canonical type,
5//! so a client can pull exactly the bytes it needs and skip the rest. This is
6//! the off-chain mirror of the on-chain segment-borrow idea: read narrow,
7//! don't deserialize what you won't touch.
8//!
9//! Competitive framing:
10//! - Quasar clients rely on Codama/Kinobi full deserialization.
11//! - Anchor clients rely on Borsh full deserialization.
12//! - Pinocchio has no client story at all.
13//!
14//! Here the reader verifies the layout_id first, then returns raw-typed
15//! accessors for any named field, backed by the same offset tables the
16//! on-chain side uses.
17
18use hopper_schema::{FieldDescriptor, LayoutManifest};
19
20use crate::fingerprint::{
21    check_against_layout, FingerprintCheck, FingerprintError, LAYOUT_ID_OFFSET,
22};
23
24/// Errors produced by the segment-aware reader.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ReaderError {
27    /// Account data was shorter than the manifest's declared `total_size`.
28    BufferTooShort {
29        /// Required length.
30        required: usize,
31        /// Actual length.
32        got: usize,
33    },
34    /// The header's layout_id did not match the expected layout's.
35    LayoutMismatch {
36        /// Expected fingerprint.
37        expected: [u8; 8],
38        /// Fingerprint actually on-chain.
39        actual: [u8; 8],
40    },
41    /// Named field not found in layout.
42    UnknownField,
43    /// Field size on the wire does not match the caller's type width.
44    SizeMismatch {
45        /// Field wire size.
46        wire: u16,
47        /// Caller-requested size.
48        requested: usize,
49    },
50    /// Fingerprint surface returned an error.
51    Fingerprint(FingerprintError),
52}
53
54impl From<FingerprintError> for ReaderError {
55    fn from(e: FingerprintError) -> Self {
56        ReaderError::Fingerprint(e)
57    }
58}
59
60/// Zero-copy segment-aware partial account reader.
61///
62/// Construct with [`SegmentReader::new`]. the layout_id is verified up front
63/// so downstream `read_*` calls can be infallible w.r.t. identity.
64#[derive(Debug)]
65pub struct SegmentReader<'a> {
66    bytes: &'a [u8],
67    layout: &'a LayoutManifest,
68}
69
70impl<'a> SegmentReader<'a> {
71    /// Bind a `LayoutManifest` to raw bytes, verifying the fingerprint.
72    pub fn new(bytes: &'a [u8], layout: &'a LayoutManifest) -> Result<Self, ReaderError> {
73        if bytes.len() < layout.total_size {
74            return Err(ReaderError::BufferTooShort {
75                required: layout.total_size,
76                got: bytes.len(),
77            });
78        }
79        match check_against_layout(bytes, layout)? {
80            FingerprintCheck::Match => Ok(Self { bytes, layout }),
81            FingerprintCheck::Mismatch { expected, actual } => {
82                Err(ReaderError::LayoutMismatch { expected, actual })
83            }
84        }
85    }
86
87    /// Bind without re-checking the fingerprint. Only use if you've already
88    /// verified identity upstream.
89    ///
90    /// # Safety
91    /// The caller promises `bytes` is a `layout`-shaped blob.
92    pub unsafe fn new_unchecked(bytes: &'a [u8], layout: &'a LayoutManifest) -> Self {
93        Self { bytes, layout }
94    }
95
96    /// Access the raw account buffer.
97    pub const fn bytes(&self) -> &'a [u8] {
98        self.bytes
99    }
100
101    /// Layout manifest this reader was constructed against.
102    pub const fn layout(&self) -> &'a LayoutManifest {
103        self.layout
104    }
105
106    /// Look up a field descriptor by name.
107    pub fn field(&self, name: &str) -> Option<&'a FieldDescriptor> {
108        let mut i = 0;
109        while i < self.layout.fields.len() {
110            if bytes_eq(self.layout.fields[i].name, name) {
111                return Some(&self.layout.fields[i]);
112            }
113            i += 1;
114        }
115        None
116    }
117
118    /// Absolute byte offset of a named field (accounts for the
119    /// 12-byte Hopper header by using `offset` as field-start; since the
120    /// manifest's `FieldDescriptor.offset` is already absolute under the
121    /// framework's convention, this is a pass-through).
122    pub fn offset_of(&self, name: &str) -> Option<usize> {
123        self.field(name).map(|f| f.offset as usize)
124    }
125
126    /// Raw byte slice for a named field.
127    pub fn read_raw(&self, name: &str) -> Result<&'a [u8], ReaderError> {
128        let f = self.field(name).ok_or(ReaderError::UnknownField)?;
129        let start = f.offset as usize;
130        let end = start + f.size as usize;
131        if end > self.bytes.len() {
132            return Err(ReaderError::BufferTooShort {
133                required: end,
134                got: self.bytes.len(),
135            });
136        }
137        Ok(&self.bytes[start..end])
138    }
139
140    /// Read a `u8` field.
141    pub fn read_u8(&self, name: &str) -> Result<u8, ReaderError> {
142        let raw = self.read_fixed(name, 1)?;
143        Ok(raw[0])
144    }
145
146    /// Read a little-endian `u16`.
147    pub fn read_u16(&self, name: &str) -> Result<u16, ReaderError> {
148        let raw = self.read_fixed(name, 2)?;
149        Ok(u16::from_le_bytes([raw[0], raw[1]]))
150    }
151
152    /// Read a little-endian `u32`.
153    pub fn read_u32(&self, name: &str) -> Result<u32, ReaderError> {
154        let raw = self.read_fixed(name, 4)?;
155        Ok(u32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]))
156    }
157
158    /// Read a little-endian `u64`.
159    pub fn read_u64(&self, name: &str) -> Result<u64, ReaderError> {
160        let raw = self.read_fixed(name, 8)?;
161        Ok(u64::from_le_bytes([
162            raw[0], raw[1], raw[2], raw[3], raw[4], raw[5], raw[6], raw[7],
163        ]))
164    }
165
166    /// Read a little-endian `u128`.
167    pub fn read_u128(&self, name: &str) -> Result<u128, ReaderError> {
168        let raw = self.read_fixed(name, 16)?;
169        let mut bytes = [0u8; 16];
170        bytes.copy_from_slice(raw);
171        Ok(u128::from_le_bytes(bytes))
172    }
173
174    /// Read a 32-byte Solana public key (Pubkey).
175    pub fn read_pubkey(&self, name: &str) -> Result<[u8; 32], ReaderError> {
176        let raw = self.read_fixed(name, 32)?;
177        let mut out = [0u8; 32];
178        out.copy_from_slice(raw);
179        Ok(out)
180    }
181
182    /// Read the layout_id from the attached buffer.
183    pub fn layout_id(&self) -> [u8; 8] {
184        let mut id = [0u8; 8];
185        id.copy_from_slice(&self.bytes[LAYOUT_ID_OFFSET..LAYOUT_ID_OFFSET + 8]);
186        id
187    }
188
189    fn read_fixed(&self, name: &str, expect: usize) -> Result<&'a [u8], ReaderError> {
190        let f = self.field(name).ok_or(ReaderError::UnknownField)?;
191        if f.size as usize != expect {
192            return Err(ReaderError::SizeMismatch {
193                wire: f.size,
194                requested: expect,
195            });
196        }
197        let start = f.offset as usize;
198        let end = start + expect;
199        if end > self.bytes.len() {
200            return Err(ReaderError::BufferTooShort {
201                required: end,
202                got: self.bytes.len(),
203            });
204        }
205        Ok(&self.bytes[start..end])
206    }
207}
208
209fn bytes_eq(a: &str, b: &str) -> bool {
210    // Explicit equal-length scan so this compiles in no_std contexts without
211    // pulling in str::eq internals.
212    let a = a.as_bytes();
213    let b = b.as_bytes();
214    if a.len() != b.len() {
215        return false;
216    }
217    let mut i = 0;
218    while i < a.len() {
219        if a[i] != b[i] {
220            return false;
221        }
222        i += 1;
223    }
224    true
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use hopper_schema::FieldIntent;
231
232    const LAYOUT_ID: [u8; 8] = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x11, 0x22];
233
234    fn fields() -> &'static [FieldDescriptor] {
235        static F: [FieldDescriptor; 3] = [
236            FieldDescriptor {
237                name: "authority",
238                canonical_type: "Pubkey",
239                size: 32,
240                offset: 16,
241                intent: FieldIntent::Authority,
242            },
243            FieldDescriptor {
244                name: "balance",
245                canonical_type: "u64",
246                size: 8,
247                offset: 48,
248                intent: FieldIntent::Balance,
249            },
250            FieldDescriptor {
251                name: "bump",
252                canonical_type: "u8",
253                size: 1,
254                offset: 56,
255                intent: FieldIntent::Bump,
256            },
257        ];
258        &F
259    }
260
261    fn manifest() -> LayoutManifest {
262        LayoutManifest {
263            name: "Vault",
264            disc: 5,
265            version: 1,
266            layout_id: LAYOUT_ID,
267            total_size: 80,
268            field_count: 3,
269            fields: fields(),
270        }
271    }
272
273    fn blob() -> [u8; 80] {
274        let mut b = [0u8; 80];
275        b[0] = 5;
276        b[1] = 1;
277        b[LAYOUT_ID_OFFSET..LAYOUT_ID_OFFSET + 8].copy_from_slice(&LAYOUT_ID);
278        // authority
279        for i in 0..32 {
280            b[16 + i] = i as u8;
281        }
282        // balance = 1_000_000
283        b[48..56].copy_from_slice(&1_000_000u64.to_le_bytes());
284        // bump
285        b[56] = 253;
286        b
287    }
288
289    #[test]
290    fn binds_and_reads() {
291        let m = manifest();
292        let b = blob();
293        let r = SegmentReader::new(&b, &m).unwrap();
294        assert_eq!(r.read_u64("balance").unwrap(), 1_000_000);
295        assert_eq!(r.read_u8("bump").unwrap(), 253);
296        let pk = r.read_pubkey("authority").unwrap();
297        assert_eq!(pk[0], 0);
298        assert_eq!(pk[31], 31);
299    }
300
301    #[test]
302    fn rejects_wrong_layout_id() {
303        let m = manifest();
304        let mut b = blob();
305        b[LAYOUT_ID_OFFSET] ^= 0xFF;
306        let err = SegmentReader::new(&b, &m).unwrap_err();
307        assert!(matches!(err, ReaderError::LayoutMismatch { .. }));
308    }
309
310    #[test]
311    fn rejects_too_short() {
312        let m = manifest();
313        let b = [0u8; 40];
314        let err = SegmentReader::new(&b, &m).unwrap_err();
315        assert!(matches!(
316            err,
317            ReaderError::BufferTooShort {
318                required: 80,
319                got: 40
320            }
321        ));
322    }
323
324    #[test]
325    fn size_mismatch_detected() {
326        let m = manifest();
327        let b = blob();
328        let r = SegmentReader::new(&b, &m).unwrap();
329        assert!(matches!(
330            r.read_u32("balance"),
331            Err(ReaderError::SizeMismatch {
332                wire: 8,
333                requested: 4
334            })
335        ));
336    }
337}