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/// A layout whose `#[bump]`-marked field stores its PDA bump.
31///
32/// Implemented by `#[hopper::state]` for every layout that marks a field
33/// with `#[bump]`. `#[derive(Accounts)]`'s `init` helper writes the
34/// canonical bump it just signed the creation with into this byte, so an
35/// account created by Hopper always stores a canonical bump, and
36/// `bump = stored` then verifies the PDA with one hash.
37pub trait StoredBump {
38    /// Account-absolute offset of the bump byte.
39    const BUMP_ABS_OFFSET: usize;
40}
41
42/// Selector for [`StoredBump`] in generated code: `(&BumpProbe::<T>(..))
43/// .write_stored_bump(..)` resolves to the writing impl when `T:
44/// StoredBump` and to the no-op otherwise, with no trait bound on `T`.
45#[doc(hidden)]
46pub struct BumpProbe<T>(pub core::marker::PhantomData<T>);
47
48#[doc(hidden)]
49pub trait WriteStoredBump {
50    /// Write `bump` into the layout's bump byte of `data`.
51    fn write_stored_bump_into(&self, data: &mut [u8], bump: u8) -> ProgramResult;
52
53    /// Borrow `account` mutably and write `bump` into its bump byte. The
54    /// borrow is taken here, inside the marked-layout impl only, so an
55    /// unmarked layout's `init` pays nothing for the probe.
56    fn write_stored_bump(
57        &self,
58        account: &crate::account::AccountView<'_>,
59        bump: u8,
60    ) -> ProgramResult;
61}
62
63impl<T: StoredBump> WriteStoredBump for BumpProbe<T> {
64    #[inline(always)]
65    fn write_stored_bump_into(&self, data: &mut [u8], bump: u8) -> ProgramResult {
66        match data.get_mut(T::BUMP_ABS_OFFSET) {
67            Some(slot) => {
68                *slot = bump;
69                Ok(())
70            }
71            None => Err(ProgramError::AccountDataTooSmall),
72        }
73    }
74
75    #[inline(always)]
76    fn write_stored_bump(
77        &self,
78        account: &crate::account::AccountView<'_>,
79        bump: u8,
80    ) -> ProgramResult {
81        let mut data = account.try_borrow_mut()?;
82        self.write_stored_bump_into(&mut data, bump)
83    }
84}
85
86#[doc(hidden)]
87pub trait NoStoredBump {
88    #[inline(always)]
89    fn write_stored_bump_into(&self, _data: &mut [u8], _bump: u8) -> ProgramResult {
90        Ok(())
91    }
92
93    #[inline(always)]
94    fn write_stored_bump(
95        &self,
96        _account: &crate::account::AccountView<'_>,
97        _bump: u8,
98    ) -> ProgramResult {
99        Ok(())
100    }
101}
102
103impl<T> NoStoredBump for &BumpProbe<T> {}
104
105/// A layout with value rules: `#[check(..)]` on a field of a
106/// `#[hopper::state]` struct implements this.
107pub trait FieldRules {
108    /// Check every rule against this value and return the first failure.
109    fn check_rules(&self) -> ProgramResult;
110
111    /// Load the layout from `account` and check its rules.
112    fn check_account(account: &crate::account::AccountView<'_>) -> ProgramResult;
113}
114
115/// Selector for [`FieldRules`] in generated code:
116/// `(&RulesProbe::<T>(..)).check_account_rules(view)` resolves to the
117/// checking impl when `T: FieldRules` and to the no-op otherwise, with no
118/// trait bound on `T`, so a layout without rules pays nothing.
119#[doc(hidden)]
120pub struct RulesProbe<T>(pub core::marker::PhantomData<T>);
121
122#[doc(hidden)]
123pub trait CheckFieldRules {
124    /// Load the layout from `account` and check its rules.
125    fn check_account_rules(&self, account: &crate::account::AccountView<'_>) -> ProgramResult;
126}
127
128impl<T: FieldRules> CheckFieldRules for RulesProbe<T> {
129    #[inline(always)]
130    fn check_account_rules(&self, account: &crate::account::AccountView<'_>) -> ProgramResult {
131        T::check_account(account)
132    }
133}
134
135#[doc(hidden)]
136pub trait NoFieldRules {
137    #[inline(always)]
138    fn check_account_rules(&self, _account: &crate::account::AccountView<'_>) -> ProgramResult {
139        Ok(())
140    }
141}
142
143impl<T> NoFieldRules for &RulesProbe<T> {}
144
145// ══════════════════════════════════════════════════════════════════════
146//  HopperHeader -- the 16-byte on-chain header used by headered Hopper
147//  accounts. Compact accounts use `[disc][body]` without this header.
148// ══════════════════════════════════════════════════════════════════════
149
150/// The canonical 16-byte header at the start of a headered Hopper account.
151///
152/// The reserved header tail carries a `schema_epoch: u32` so the runtime
153/// can distinguish schema-compatible minor versions from wire-
154/// incompatible revisions without bumping the single `version` byte.
155///
156/// ```text
157/// byte 0     : disc (u8)
158/// byte 1     : version (u8)
159/// bytes 2-3  : flags (u16 LE)
160/// bytes 4-11 : layout_id (first 8 bytes of canonical wire fingerprint)
161/// bytes 12-15: schema_epoch (u32 LE), audit-added
162/// ```
163///
164/// `schema_epoch` defaults to `1` at account initialisation via
165/// [`init_header`]. Programs that publish a migration bump this
166/// field to advertise the new shape while retaining the same
167/// `disc`/`version`. Runtime header validation checks these values. Current
168/// generated headered clients compare the stored `layout_id` before decoding;
169/// compact clients use their separate size/discriminator path.
170#[repr(C, packed)]
171#[derive(Copy, Clone, Debug, PartialEq, Eq)]
172pub struct HopperHeader {
173    pub disc: u8,
174    pub version: u8,
175    pub flags: u16,
176    pub layout_id: [u8; 8],
177    /// Schema-evolution epoch. Little-endian u32. `1` for freshly
178    /// initialised headers; bumped by migration helpers.
179    pub schema_epoch: u32,
180}
181
182impl HopperHeader {
183    /// The header is always 16 bytes.
184    pub const SIZE: usize = 16;
185
186    /// Read a header from the start of a raw data slice.
187    #[inline(always)]
188    pub fn from_bytes(data: &[u8]) -> Option<&Self> {
189        if data.len() < Self::SIZE {
190            return None;
191        }
192        // SAFETY: HopperHeader is packed to alignment 1.
193        Some(unsafe { &*(data.as_ptr() as *const Self) })
194    }
195
196    /// Read a mutable header from the start of a raw data slice.
197    #[inline(always)]
198    pub fn from_bytes_mut(data: &mut [u8]) -> Option<&mut Self> {
199        if data.len() < Self::SIZE {
200            return None;
201        }
202        // SAFETY: `data.len() >= Self::SIZE` was checked above;
203        // `HopperHeader` is `repr(C, packed)`, so it has alignment 1 and no
204        // padding, and every bit pattern is valid.
205        Some(unsafe { &mut *(data.as_mut_ptr() as *mut Self) })
206    }
207}
208
209// ══════════════════════════════════════════════════════════════════════
210//  LayoutInfo -- runtime-inspectable metadata snapshot
211// ══════════════════════════════════════════════════════════════════════
212
213/// Runtime metadata snapshot of an account's layout identity.
214///
215/// Returned by `AccountView::layout_info()`. Enables manager inspection,
216/// schema comparison, and version-aware loading without knowing the
217/// concrete layout type at compile time.
218#[derive(Copy, Clone, Debug, PartialEq, Eq)]
219pub struct LayoutInfo {
220    pub disc: u8,
221    pub version: u8,
222    pub flags: u16,
223    pub layout_id: [u8; 8],
224    /// Schema-evolution epoch read from the header's bytes 12..16.
225    /// A value of `0` means "legacy" (accounts created before epochs) and is
226    /// treated as equivalent to `DEFAULT_SCHEMA_EPOCH` when comparing
227    /// against `AccountLayout::SCHEMA_EPOCH`.
228    pub schema_epoch: u32,
229    pub data_len: usize,
230}
231
232impl LayoutInfo {
233    /// Read layout info from an account's raw data.
234    #[inline(always)]
235    pub fn from_data(data: &[u8]) -> Option<Self> {
236        let hdr = HopperHeader::from_bytes(data)?;
237        // Packed-struct field reads must go through a copy, reading
238        // an unaligned `u32` reference directly is undefined behaviour.
239        let schema_epoch = hdr.schema_epoch;
240        let layout_id = hdr.layout_id;
241        Some(Self {
242            disc: hdr.disc,
243            version: hdr.version,
244            flags: hdr.flags,
245            layout_id,
246            schema_epoch,
247            data_len: data.len(),
248        })
249    }
250
251    /// Whether this account matches the given layout contract.
252    #[inline(always)]
253    pub fn matches<T: LayoutContract>(&self) -> bool {
254        let schema_epoch = effective_schema_epoch(self.schema_epoch);
255        self.disc == T::DISC
256            && self.version == T::VERSION
257            && self.layout_id == T::LAYOUT_ID
258            && schema_epoch == T::SCHEMA_EPOCH
259            && self.data_len >= T::required_len()
260    }
261
262    /// Length of the account body after the Hopper header.
263    #[inline(always)]
264    pub const fn body_len(&self) -> usize {
265        self.data_len.saturating_sub(HopperHeader::SIZE)
266    }
267
268    /// Whether the account contains bytes beyond a given absolute offset.
269    #[inline(always)]
270    pub const fn has_bytes_after(&self, offset: usize) -> bool {
271        self.data_len > offset
272    }
273}
274
275// ══════════════════════════════════════════════════════════════════════
276//  LayoutContract -- the central state contract trait
277// ══════════════════════════════════════════════════════════════════════
278
279/// A compile-time layout contract binding type identity to wire format.
280///
281/// Implementors declare their discriminator, version, layout fingerprint,
282/// and wire size. The runtime uses these to validate accounts before granting
283/// typed access via `overlay` or `load`.
284///
285/// # Wire format (Hopper account header)
286///
287/// ```text
288/// byte 0   : discriminator (u8)
289/// byte 1   : version (u8)
290/// bytes 2-3: flags (u16 LE)
291/// bytes 4-11: layout_id (first 8 bytes of SHA-256 fingerprint)
292/// bytes 12-15: schema_epoch (u32 LE; zero is accepted as legacy epoch 1)
293/// ```
294///
295/// # Example
296///
297/// ```ignore
298/// impl LayoutContract for Vault {
299///     const DISC: u8 = 1;
300///     const VERSION: u8 = 1;
301///     const LAYOUT_ID: [u8; 8] = compute_layout_id("Vault", 1, "authority:[u8;32]:32,balance:LeU64:8,");
302///     const SIZE: usize = 16 + 32 + 8; // header + fields
303/// }
304/// ```
305pub trait LayoutContract: Sized + Copy + FieldMap {
306    /// Account type discriminator (byte 0 of data).
307    const DISC: u8;
308
309    /// Schema version for this layout (byte 1 of data).
310    const VERSION: u8;
311
312    /// First 8 bytes of the deterministic layout fingerprint.
313    /// Computed from `SHA-256("hopper:v1:" + name + ":" + version + ":" + field_spec)`.
314    const LAYOUT_ID: [u8; 8];
315
316    /// Total wire size in bytes (including the 16-byte header).
317    const SIZE: usize;
318
319    /// Byte offset where the typed projection begins.
320    ///
321    /// Body-only runtime layouts keep the default `HopperHeader::SIZE`, while
322    /// header-inclusive layouts set this to `0` so `AccountView::load()`
323    /// projects the full account struct.
324    const TYPE_OFFSET: usize = HopperHeader::SIZE;
325
326    /// Schema-evolution epoch expected in the Hopper header.
327    ///
328    /// Fresh accounts default to epoch 1. A stored header epoch of 0
329    /// is treated as legacy epoch 1 for backwards compatibility, but
330    /// non-default layout epochs must match exactly before typed access
331    /// is granted.
332    const SCHEMA_EPOCH: u32 = DEFAULT_SCHEMA_EPOCH;
333
334    /// Number of reserved bytes at the end of the layout. Reserved bytes
335    /// provide forward-compatible padding that future versions can claim
336    /// without a realloc.
337    const RESERVED_BYTES: usize = 0;
338
339    /// Byte offset where an extension region begins, if the layout supports one.
340    /// Extension regions allow appending variable-length data beyond the fixed
341    /// layout without breaking existing readers.
342    const EXTENSION_OFFSET: Option<usize> = None;
343
344    /// Validate a raw data slice against this contract.
345    ///
346    /// Returns `Ok(())` if the discriminator, version, layout_id, schema
347    /// epoch, and required length all match. This is the canonical "is this
348    /// account what I think it is?" check.
349    #[inline(always)]
350    fn validate_header(data: &[u8]) -> ProgramResult {
351        if data.len() < Self::required_len() {
352            return ProgramError::err_data_too_small();
353        }
354        let disc = read_disc(data);
355        if disc != Some(Self::DISC) {
356            return ProgramError::err_invalid_data();
357        }
358        let version = read_version(data);
359        if version != Some(Self::VERSION) {
360            return ProgramError::err_invalid_data();
361        }
362        if let Some(id) = read_layout_id(data) {
363            if *id != Self::LAYOUT_ID {
364                return ProgramError::err_invalid_data();
365            }
366        } else {
367            return ProgramError::err_data_too_small();
368        }
369        match read_schema_epoch(data) {
370            Some(stored) if effective_schema_epoch(stored) == Self::SCHEMA_EPOCH => {}
371            Some(_) => return ProgramError::err_invalid_data(),
372            None => return ProgramError::err_data_too_small(),
373        }
374        Ok(())
375    }
376
377    /// Byte length required to project this typed view safely.
378    #[inline(always)]
379    fn projected_len() -> usize {
380        Self::TYPE_OFFSET + core::mem::size_of::<Self>()
381    }
382
383    /// Minimum account data length required by both the wire contract and projection shape.
384    #[inline(always)]
385    fn required_len() -> usize {
386        if Self::SIZE > Self::projected_len() {
387            Self::SIZE
388        } else {
389            Self::projected_len()
390        }
391    }
392
393    /// Lightweight boolean validation helper for foreign readers and tools.
394    #[inline(always)]
395    fn validate(data: &[u8]) -> bool {
396        Self::validate_header(data).is_ok()
397    }
398
399    /// Check only the discriminator (fast path for dispatch).
400    #[inline(always)]
401    fn check_disc(data: &[u8]) -> ProgramResult {
402        match read_disc(data) {
403            Some(d) if d == Self::DISC => Ok(()),
404            _ => ProgramError::err_invalid_data(),
405        }
406    }
407
408    /// Check only the version (for migration gates).
409    #[inline(always)]
410    fn check_version(data: &[u8]) -> ProgramResult {
411        match read_version(data) {
412            Some(v) if v == Self::VERSION => Ok(()),
413            _ => ProgramError::err_invalid_data(),
414        }
415    }
416
417    /// Check whether a given version is compatible with this layout.
418    ///
419    /// The default implementation accepts only the exact version, but
420    /// implementors can override this to accept older versions for
421    /// backward-compatible migration.
422    #[inline(always)]
423    fn compatible(version: u8) -> bool {
424        version == Self::VERSION
425    }
426
427    /// Check whether the account data contains an extension region
428    /// (data beyond the fixed layout boundary).
429    #[inline(always)]
430    fn has_extension_region(data: &[u8]) -> bool {
431        match Self::EXTENSION_OFFSET {
432            Some(offset) => data.len() > offset,
433            None => false,
434        }
435    }
436
437    /// Build a `LayoutInfo` snapshot from this contract's compile-time constants.
438    #[inline(always)]
439    fn layout_info_static() -> LayoutInfo {
440        LayoutInfo {
441            disc: Self::DISC,
442            version: Self::VERSION,
443            flags: 0,
444            layout_id: Self::LAYOUT_ID,
445            schema_epoch: Self::SCHEMA_EPOCH,
446            data_len: Self::required_len(),
447        }
448    }
449
450    /// Compile-time field metadata for this layout.
451    #[inline(always)]
452    fn fields() -> &'static [FieldInfo] {
453        Self::FIELDS
454    }
455}
456
457/// Read the discriminator from account data (byte 0).
458#[inline(always)]
459pub fn read_disc(data: &[u8]) -> Option<u8> {
460    data.first().copied()
461}
462
463/// Read the version from account data (byte 1).
464#[inline(always)]
465pub fn read_version(data: &[u8]) -> Option<u8> {
466    if data.len() < 2 {
467        None
468    } else {
469        Some(data[1])
470    }
471}
472
473/// Read the 8-byte layout_id from account data (bytes 4..12).
474#[inline(always)]
475pub fn read_layout_id(data: &[u8]) -> Option<&[u8; 8]> {
476    if data.len() < 12 {
477        None
478    } else {
479        // SAFETY: bounds checked above, alignment is 1 for [u8; 8].
480        Some(unsafe { &*(data.as_ptr().add(4) as *const [u8; 8]) })
481    }
482}
483
484/// Read the flags from account data (bytes 2..4) as u16 LE.
485#[inline(always)]
486pub fn read_flags(data: &[u8]) -> Option<u16> {
487    if data.len() < 4 {
488        None
489    } else {
490        let bytes = [data[2], data[3]];
491        Some(u16::from_le_bytes(bytes))
492    }
493}
494
495/// Default schema-evolution epoch written by `init_header`.
496///
497/// Accounts initialized before schema epochs had the epoch region
498/// zeroed, so `0` is treated as "legacy, equivalent to 1" by the
499/// runtime checks that compare against an `AccountLayout::SCHEMA_EPOCH`.
500/// Freshly-initialised accounts now carry `1` so migrations can bump
501/// monotonically without any lookback.
502pub const DEFAULT_SCHEMA_EPOCH: u32 = 1;
503
504/// Convert a stored header epoch into the effective value used by
505/// runtime validation. Epoch 0 is legacy pre-epoch Hopper data and is
506/// treated as epoch 1 only for default-epoch layouts.
507#[inline(always)]
508pub const fn effective_schema_epoch(stored: u32) -> u32 {
509    if stored == 0 {
510        DEFAULT_SCHEMA_EPOCH
511    } else {
512        stored
513    }
514}
515
516/// Write a complete Hopper header to the beginning of `data`.
517///
518/// Writes disc, version, flags (zeroed), layout_id, and the
519/// audit-added `schema_epoch = 1` (bytes 12..16).
520/// Returns `Err` if `data` is shorter than 16 bytes.
521#[inline(always)]
522pub fn write_header(data: &mut [u8], disc: u8, version: u8, layout_id: &[u8; 8]) -> ProgramResult {
523    write_header_with_epoch(data, disc, version, layout_id, DEFAULT_SCHEMA_EPOCH)
524}
525
526/// Write a Hopper header with a caller-specified schema epoch.
527///
528/// Used by migration helpers that need to stamp a new epoch while
529/// preserving `disc`/`version`/`layout_id`. Regular account creation
530/// should go through [`write_header`] (which defaults the epoch to
531/// `1`) or [`init_header`].
532#[inline(always)]
533pub fn write_header_with_epoch(
534    data: &mut [u8],
535    disc: u8,
536    version: u8,
537    layout_id: &[u8; 8],
538    schema_epoch: u32,
539) -> ProgramResult {
540    if data.len() < 16 {
541        return Err(ProgramError::AccountDataTooSmall);
542    }
543    data[0] = disc;
544    data[1] = version;
545    data[2] = 0;
546    data[3] = 0;
547    data[4..12].copy_from_slice(layout_id);
548    data[12..16].copy_from_slice(&schema_epoch.to_le_bytes());
549    Ok(())
550}
551
552/// Read the `schema_epoch` field from an already-written header.
553///
554/// Returns `None` if `data` is too short. Returns the stored value
555/// verbatim, callers that want the "0 means legacy" compatibility
556/// rule should apply it themselves:
557///
558/// ```ignore
559/// let stored = read_schema_epoch(data)?;
560/// let effective = if stored == 0 { DEFAULT_SCHEMA_EPOCH } else { stored };
561/// ```
562#[inline(always)]
563pub fn read_schema_epoch(data: &[u8]) -> Option<u32> {
564    if data.len() < 16 {
565        return None;
566    }
567    Some(u32::from_le_bytes([data[12], data[13], data[14], data[15]]))
568}
569
570/// Initialize an account's header from a layout contract type.
571///
572/// Convenience wrapper that pulls disc, version, layout_id, and
573/// schema_epoch from the type.
574#[inline(always)]
575pub fn init_header<T: LayoutContract>(data: &mut [u8]) -> ProgramResult {
576    write_header_with_epoch(data, T::DISC, T::VERSION, &T::LAYOUT_ID, T::SCHEMA_EPOCH)
577}