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