Skip to main content

hopper_runtime/
layout.rs

1//! Layout contracts as runtime truth.
2//!
3//! `LayoutContract` is the central trait for Hopper's state-first architecture.
4//! It ties together discriminator, version, and layout fingerprint into a single
5//! compile-time contract that the runtime can validate before granting typed access.
6//!
7//! Layouts are not just metadata or serialization hints. They are runtime
8//! contracts that gate account access, enforce compatibility, and enable schema
9//! evolution.
10
11use crate::error::ProgramError;
12use crate::field_map::{FieldInfo, FieldMap};
13use crate::ProgramResult;
14
15// ══════════════════════════════════════════════════════════════════════
16//  HopperHeader -- the 16-byte on-chain header used by headered Hopper
17//  accounts. Compact accounts use `[disc][body]` without this header.
18// ══════════════════════════════════════════════════════════════════════
19
20/// The canonical 16-byte header at the start of a headered Hopper account.
21///
22/// The reserved header tail carries a `schema_epoch: u32` so the runtime
23/// can distinguish schema-compatible minor versions from wire-
24/// incompatible revisions without bumping the single `version` byte.
25///
26/// ```text
27/// byte 0     : disc (u8)
28/// byte 1     : version (u8)
29/// bytes 2-3  : flags (u16 LE)
30/// bytes 4-11 : layout_id (first 8 bytes of canonical wire fingerprint)
31/// bytes 12-15: schema_epoch (u32 LE), audit-added
32/// ```
33///
34/// `schema_epoch` defaults to `1` at account initialisation via
35/// [`init_header`]. Programs that publish a migration bump this
36/// field to advertise the new shape while retaining the same
37/// `disc`/`version`. Runtime header validation checks these values. Current
38/// generated headered clients compare the stored `layout_id` before decoding;
39/// compact clients use their separate size/discriminator path.
40#[repr(C, packed)]
41#[derive(Copy, Clone, Debug, PartialEq, Eq)]
42pub struct HopperHeader {
43    pub disc: u8,
44    pub version: u8,
45    pub flags: u16,
46    pub layout_id: [u8; 8],
47    /// Schema-evolution epoch. Little-endian u32. `1` for freshly
48    /// initialised headers; bumped by migration helpers.
49    pub schema_epoch: u32,
50}
51
52impl HopperHeader {
53    /// The header is always 16 bytes.
54    pub const SIZE: usize = 16;
55
56    /// Read a header from the start of a raw data slice.
57    #[inline(always)]
58    pub fn from_bytes(data: &[u8]) -> Option<&Self> {
59        if data.len() < Self::SIZE {
60            return None;
61        }
62        // SAFETY: HopperHeader is packed to alignment 1.
63        Some(unsafe { &*(data.as_ptr() as *const Self) })
64    }
65
66    /// Read a mutable header from the start of a raw data slice.
67    #[inline(always)]
68    pub fn from_bytes_mut(data: &mut [u8]) -> Option<&mut Self> {
69        if data.len() < Self::SIZE {
70            return None;
71        }
72        // SAFETY: This block is part of Hopper's reviewed zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
73        Some(unsafe { &mut *(data.as_mut_ptr() as *mut Self) })
74    }
75}
76
77// ══════════════════════════════════════════════════════════════════════
78//  LayoutInfo -- runtime-inspectable metadata snapshot
79// ══════════════════════════════════════════════════════════════════════
80
81/// Runtime metadata snapshot of an account's layout identity.
82///
83/// Returned by `AccountView::layout_info()`. Enables manager inspection,
84/// schema comparison, and version-aware loading without knowing the
85/// concrete layout type at compile time.
86#[derive(Copy, Clone, Debug, PartialEq, Eq)]
87pub struct LayoutInfo {
88    pub disc: u8,
89    pub version: u8,
90    pub flags: u16,
91    pub layout_id: [u8; 8],
92    /// Schema-evolution epoch read from the header's bytes 12..16.
93    /// A value of `0` means "legacy" (accounts created before epochs) and is
94    /// treated as equivalent to `DEFAULT_SCHEMA_EPOCH` when comparing
95    /// against `AccountLayout::SCHEMA_EPOCH`.
96    pub schema_epoch: u32,
97    pub data_len: usize,
98}
99
100impl LayoutInfo {
101    /// Read layout info from an account's raw data.
102    #[inline(always)]
103    pub fn from_data(data: &[u8]) -> Option<Self> {
104        let hdr = HopperHeader::from_bytes(data)?;
105        // Packed-struct field reads must go through a copy, reading
106        // an unaligned `u32` reference directly is undefined behaviour.
107        let schema_epoch = hdr.schema_epoch;
108        let layout_id = hdr.layout_id;
109        Some(Self {
110            disc: hdr.disc,
111            version: hdr.version,
112            flags: hdr.flags,
113            layout_id,
114            schema_epoch,
115            data_len: data.len(),
116        })
117    }
118
119    /// Whether this account matches the given layout contract.
120    #[inline(always)]
121    pub fn matches<T: LayoutContract>(&self) -> bool {
122        let schema_epoch = effective_schema_epoch(self.schema_epoch);
123        self.disc == T::DISC
124            && self.version == T::VERSION
125            && self.layout_id == T::LAYOUT_ID
126            && schema_epoch == T::SCHEMA_EPOCH
127            && self.data_len >= T::required_len()
128    }
129
130    /// Length of the account body after the Hopper header.
131    #[inline(always)]
132    pub const fn body_len(&self) -> usize {
133        self.data_len.saturating_sub(HopperHeader::SIZE)
134    }
135
136    /// Whether the account contains bytes beyond a given absolute offset.
137    #[inline(always)]
138    pub const fn has_bytes_after(&self, offset: usize) -> bool {
139        self.data_len > offset
140    }
141}
142
143// ══════════════════════════════════════════════════════════════════════
144//  LayoutContract -- the central state contract trait
145// ══════════════════════════════════════════════════════════════════════
146
147/// A compile-time layout contract binding type identity to wire format.
148///
149/// Implementors declare their discriminator, version, layout fingerprint,
150/// and wire size. The runtime uses these to validate accounts before granting
151/// typed access via `overlay` or `load`.
152///
153/// # Wire format (Hopper account header)
154///
155/// ```text
156/// byte 0   : discriminator (u8)
157/// byte 1   : version (u8)
158/// bytes 2-3: flags (u16 LE)
159/// bytes 4-11: layout_id (first 8 bytes of SHA-256 fingerprint)
160/// bytes 12-15: schema_epoch (u32 LE; zero is accepted as legacy epoch 1)
161/// ```
162///
163/// # Example
164///
165/// ```ignore
166/// impl LayoutContract for Vault {
167///     const DISC: u8 = 1;
168///     const VERSION: u8 = 1;
169///     const LAYOUT_ID: [u8; 8] = compute_layout_id("Vault", 1, "authority:[u8;32]:32,balance:LeU64:8,");
170///     const SIZE: usize = 16 + 32 + 8; // header + fields
171/// }
172/// ```
173pub trait LayoutContract: Sized + Copy + FieldMap {
174    /// Account type discriminator (byte 0 of data).
175    const DISC: u8;
176
177    /// Schema version for this layout (byte 1 of data).
178    const VERSION: u8;
179
180    /// First 8 bytes of the deterministic layout fingerprint.
181    /// Computed from `SHA-256("hopper:v1:" + name + ":" + version + ":" + field_spec)`.
182    const LAYOUT_ID: [u8; 8];
183
184    /// Total wire size in bytes (including the 16-byte header).
185    const SIZE: usize;
186
187    /// Byte offset where the typed projection begins.
188    ///
189    /// Body-only runtime layouts keep the default `HopperHeader::SIZE`, while
190    /// header-inclusive layouts set this to `0` so `AccountView::load()`
191    /// projects the full account struct.
192    const TYPE_OFFSET: usize = HopperHeader::SIZE;
193
194    /// Schema-evolution epoch expected in the Hopper header.
195    ///
196    /// Fresh accounts default to epoch 1. A stored header epoch of 0
197    /// is treated as legacy epoch 1 for backwards compatibility, but
198    /// non-default layout epochs must match exactly before typed access
199    /// is granted.
200    const SCHEMA_EPOCH: u32 = DEFAULT_SCHEMA_EPOCH;
201
202    /// Number of reserved bytes at the end of the layout. Reserved bytes
203    /// provide forward-compatible padding that future versions can claim
204    /// without a realloc.
205    const RESERVED_BYTES: usize = 0;
206
207    /// Byte offset where an extension region begins, if the layout supports one.
208    /// Extension regions allow appending variable-length data beyond the fixed
209    /// layout without breaking existing readers.
210    const EXTENSION_OFFSET: Option<usize> = None;
211
212    /// Validate a raw data slice against this contract.
213    ///
214    /// Returns `Ok(())` if the discriminator, version, layout_id, schema
215    /// epoch, and required length all match. This is the canonical "is this
216    /// account what I think it is?" check.
217    #[inline(always)]
218    fn validate_header(data: &[u8]) -> ProgramResult {
219        if data.len() < Self::required_len() {
220            return ProgramError::err_data_too_small();
221        }
222        let disc = read_disc(data);
223        if disc != Some(Self::DISC) {
224            return ProgramError::err_invalid_data();
225        }
226        let version = read_version(data);
227        if version != Some(Self::VERSION) {
228            return ProgramError::err_invalid_data();
229        }
230        if let Some(id) = read_layout_id(data) {
231            if *id != Self::LAYOUT_ID {
232                return ProgramError::err_invalid_data();
233            }
234        } else {
235            return ProgramError::err_data_too_small();
236        }
237        match read_schema_epoch(data) {
238            Some(stored) if effective_schema_epoch(stored) == Self::SCHEMA_EPOCH => {}
239            Some(_) => return ProgramError::err_invalid_data(),
240            None => return ProgramError::err_data_too_small(),
241        }
242        Ok(())
243    }
244
245    /// Byte length required to project this typed view safely.
246    #[inline(always)]
247    fn projected_len() -> usize {
248        Self::TYPE_OFFSET + core::mem::size_of::<Self>()
249    }
250
251    /// Minimum account data length required by both the wire contract and projection shape.
252    #[inline(always)]
253    fn required_len() -> usize {
254        if Self::SIZE > Self::projected_len() {
255            Self::SIZE
256        } else {
257            Self::projected_len()
258        }
259    }
260
261    /// Lightweight boolean validation helper for foreign readers and tools.
262    #[inline(always)]
263    fn validate(data: &[u8]) -> bool {
264        Self::validate_header(data).is_ok()
265    }
266
267    /// Check only the discriminator (fast path for dispatch).
268    #[inline(always)]
269    fn check_disc(data: &[u8]) -> ProgramResult {
270        match read_disc(data) {
271            Some(d) if d == Self::DISC => Ok(()),
272            _ => ProgramError::err_invalid_data(),
273        }
274    }
275
276    /// Check only the version (for migration gates).
277    #[inline(always)]
278    fn check_version(data: &[u8]) -> ProgramResult {
279        match read_version(data) {
280            Some(v) if v == Self::VERSION => Ok(()),
281            _ => ProgramError::err_invalid_data(),
282        }
283    }
284
285    /// Check whether a given version is compatible with this layout.
286    ///
287    /// The default implementation accepts only the exact version, but
288    /// implementors can override this to accept older versions for
289    /// backward-compatible migration.
290    #[inline(always)]
291    fn compatible(version: u8) -> bool {
292        version == Self::VERSION
293    }
294
295    /// Check whether the account data contains an extension region
296    /// (data beyond the fixed layout boundary).
297    #[inline(always)]
298    fn has_extension_region(data: &[u8]) -> bool {
299        match Self::EXTENSION_OFFSET {
300            Some(offset) => data.len() > offset,
301            None => false,
302        }
303    }
304
305    /// Build a `LayoutInfo` snapshot from this contract's compile-time constants.
306    #[inline(always)]
307    fn layout_info_static() -> LayoutInfo {
308        LayoutInfo {
309            disc: Self::DISC,
310            version: Self::VERSION,
311            flags: 0,
312            layout_id: Self::LAYOUT_ID,
313            schema_epoch: Self::SCHEMA_EPOCH,
314            data_len: Self::required_len(),
315        }
316    }
317
318    /// Compile-time field metadata for this layout.
319    #[inline(always)]
320    fn fields() -> &'static [FieldInfo] {
321        Self::FIELDS
322    }
323}
324
325/// Read the discriminator from account data (byte 0).
326#[inline(always)]
327pub fn read_disc(data: &[u8]) -> Option<u8> {
328    data.first().copied()
329}
330
331/// Read the version from account data (byte 1).
332#[inline(always)]
333pub fn read_version(data: &[u8]) -> Option<u8> {
334    if data.len() < 2 {
335        None
336    } else {
337        Some(data[1])
338    }
339}
340
341/// Read the 8-byte layout_id from account data (bytes 4..12).
342#[inline(always)]
343pub fn read_layout_id(data: &[u8]) -> Option<&[u8; 8]> {
344    if data.len() < 12 {
345        None
346    } else {
347        // SAFETY: bounds checked above, alignment is 1 for [u8; 8].
348        Some(unsafe { &*(data.as_ptr().add(4) as *const [u8; 8]) })
349    }
350}
351
352/// Read the flags from account data (bytes 2..4) as u16 LE.
353#[inline(always)]
354pub fn read_flags(data: &[u8]) -> Option<u16> {
355    if data.len() < 4 {
356        None
357    } else {
358        let bytes = [data[2], data[3]];
359        Some(u16::from_le_bytes(bytes))
360    }
361}
362
363/// Default schema-evolution epoch written by `init_header`.
364///
365/// Accounts initialized before schema epochs had the epoch region
366/// zeroed, so `0` is treated as "legacy, equivalent to 1" by the
367/// runtime checks that compare against an `AccountLayout::SCHEMA_EPOCH`.
368/// Freshly-initialised accounts now carry `1` so migrations can bump
369/// monotonically without any lookback.
370pub const DEFAULT_SCHEMA_EPOCH: u32 = 1;
371
372/// Convert a stored header epoch into the effective value used by
373/// runtime validation. Epoch 0 is legacy pre-epoch Hopper data and is
374/// treated as epoch 1 only for default-epoch layouts.
375#[inline(always)]
376pub const fn effective_schema_epoch(stored: u32) -> u32 {
377    if stored == 0 {
378        DEFAULT_SCHEMA_EPOCH
379    } else {
380        stored
381    }
382}
383
384/// Write a complete Hopper header to the beginning of `data`.
385///
386/// Writes disc, version, flags (zeroed), layout_id, and the
387/// audit-added `schema_epoch = 1` (bytes 12..16).
388/// Returns `Err` if `data` is shorter than 16 bytes.
389#[inline(always)]
390pub fn write_header(data: &mut [u8], disc: u8, version: u8, layout_id: &[u8; 8]) -> ProgramResult {
391    write_header_with_epoch(data, disc, version, layout_id, DEFAULT_SCHEMA_EPOCH)
392}
393
394/// Write a Hopper header with a caller-specified schema epoch.
395///
396/// Used by migration helpers that need to stamp a new epoch while
397/// preserving `disc`/`version`/`layout_id`. Regular account creation
398/// should go through [`write_header`] (which defaults the epoch to
399/// `1`) or [`init_header`].
400#[inline(always)]
401pub fn write_header_with_epoch(
402    data: &mut [u8],
403    disc: u8,
404    version: u8,
405    layout_id: &[u8; 8],
406    schema_epoch: u32,
407) -> ProgramResult {
408    if data.len() < 16 {
409        return Err(ProgramError::AccountDataTooSmall);
410    }
411    data[0] = disc;
412    data[1] = version;
413    data[2] = 0;
414    data[3] = 0;
415    data[4..12].copy_from_slice(layout_id);
416    data[12..16].copy_from_slice(&schema_epoch.to_le_bytes());
417    Ok(())
418}
419
420/// Read the `schema_epoch` field from an already-written header.
421///
422/// Returns `None` if `data` is too short. Returns the stored value
423/// verbatim, callers that want the "0 means legacy" compatibility
424/// rule should apply it themselves:
425///
426/// ```ignore
427/// let stored = read_schema_epoch(data)?;
428/// let effective = if stored == 0 { DEFAULT_SCHEMA_EPOCH } else { stored };
429/// ```
430#[inline(always)]
431pub fn read_schema_epoch(data: &[u8]) -> Option<u32> {
432    if data.len() < 16 {
433        return None;
434    }
435    Some(u32::from_le_bytes([data[12], data[13], data[14], data[15]]))
436}
437
438/// Initialize an account's header from a layout contract type.
439///
440/// Convenience wrapper that pulls disc, version, layout_id, and
441/// schema_epoch from the type.
442#[inline(always)]
443pub fn init_header<T: LayoutContract>(data: &mut [u8]) -> ProgramResult {
444    write_header_with_epoch(data, T::DISC, T::VERSION, &T::LAYOUT_ID, T::SCHEMA_EPOCH)
445}