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