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 self.disc == T::DISC
126 && self.version == T::VERSION
127 && self.layout_id == T::LAYOUT_ID
128 && self.data_len >= T::required_len()
129 }
130
131 /// Length of the account body after the Hopper header.
132 #[inline(always)]
133 pub const fn body_len(&self) -> usize {
134 self.data_len.saturating_sub(HopperHeader::SIZE)
135 }
136
137 /// Whether the account contains bytes beyond a given absolute offset.
138 #[inline(always)]
139 pub const fn has_bytes_after(&self, offset: usize) -> bool {
140 self.data_len > offset
141 }
142}
143
144// ══════════════════════════════════════════════════════════════════════
145// LayoutContract -- the central state contract trait
146// ══════════════════════════════════════════════════════════════════════
147
148/// A compile-time layout contract binding type identity to wire format.
149///
150/// Implementors declare their discriminator, version, layout fingerprint,
151/// and wire size. The runtime uses these to validate accounts before granting
152/// typed access via `overlay` or `load`.
153///
154/// # Wire format (Hopper account header)
155///
156/// ```text
157/// byte 0 : discriminator (u8)
158/// byte 1 : version (u8)
159/// bytes 2-3: flags (u16 LE)
160/// bytes 4-11: layout_id (first 8 bytes of SHA-256 fingerprint)
161/// bytes 12-15: reserved
162/// ```
163///
164/// # Example
165///
166/// ```ignore
167/// impl LayoutContract for Vault {
168/// const DISC: u8 = 1;
169/// const VERSION: u8 = 1;
170/// const LAYOUT_ID: [u8; 8] = compute_layout_id("Vault", 1, "authority:[u8;32]:32,balance:LeU64:8,");
171/// const SIZE: usize = 16 + 32 + 8; // header + fields
172/// }
173/// ```
174pub trait LayoutContract: Sized + Copy + FieldMap {
175 /// Account type discriminator (byte 0 of data).
176 const DISC: u8;
177
178 /// Schema version for this layout (byte 1 of data).
179 const VERSION: u8;
180
181 /// First 8 bytes of the deterministic layout fingerprint.
182 /// Computed from `SHA-256("hopper:v1:" + name + ":" + version + ":" + field_spec)`.
183 const LAYOUT_ID: [u8; 8];
184
185 /// Total wire size in bytes (including the 16-byte header).
186 const SIZE: usize;
187
188 /// Byte offset where the typed projection begins.
189 ///
190 /// Body-only runtime layouts keep the default `HopperHeader::SIZE`, while
191 /// header-inclusive layouts set this to `0` so `AccountView::load()`
192 /// projects the full account struct.
193 const TYPE_OFFSET: usize = HopperHeader::SIZE;
194
195 /// Number of reserved bytes at the end of the layout. Reserved bytes
196 /// provide forward-compatible padding that future versions can claim
197 /// without a realloc.
198 const RESERVED_BYTES: usize = 0;
199
200 /// Byte offset where an extension region begins, if the layout supports one.
201 /// Extension regions allow appending variable-length data beyond the fixed
202 /// layout without breaking existing readers.
203 const EXTENSION_OFFSET: Option<usize> = None;
204
205 /// Validate a raw data slice against this contract.
206 ///
207 /// Returns `Ok(())` if the discriminator, version, and layout_id all match.
208 /// This is the canonical "is this account what I think it is?" check.
209 #[inline(always)]
210 fn validate_header(data: &[u8]) -> ProgramResult {
211 if data.len() < Self::required_len() {
212 return ProgramError::err_data_too_small();
213 }
214 let disc = read_disc(data);
215 if disc != Some(Self::DISC) {
216 return ProgramError::err_invalid_data();
217 }
218 let version = read_version(data);
219 if version != Some(Self::VERSION) {
220 return ProgramError::err_invalid_data();
221 }
222 if let Some(id) = read_layout_id(data) {
223 if *id != Self::LAYOUT_ID {
224 return ProgramError::err_invalid_data();
225 }
226 } else {
227 return ProgramError::err_data_too_small();
228 }
229 Ok(())
230 }
231
232 /// Byte length required to project this typed view safely.
233 #[inline(always)]
234 fn projected_len() -> usize {
235 Self::TYPE_OFFSET + core::mem::size_of::<Self>()
236 }
237
238 /// Minimum account data length required by both the wire contract and projection shape.
239 #[inline(always)]
240 fn required_len() -> usize {
241 if Self::SIZE > Self::projected_len() {
242 Self::SIZE
243 } else {
244 Self::projected_len()
245 }
246 }
247
248 /// Lightweight boolean validation helper for foreign readers and tools.
249 #[inline(always)]
250 fn validate(data: &[u8]) -> bool {
251 Self::validate_header(data).is_ok()
252 }
253
254 /// Check only the discriminator (fast path for dispatch).
255 #[inline(always)]
256 fn check_disc(data: &[u8]) -> ProgramResult {
257 match read_disc(data) {
258 Some(d) if d == Self::DISC => Ok(()),
259 _ => ProgramError::err_invalid_data(),
260 }
261 }
262
263 /// Check only the version (for migration gates).
264 #[inline(always)]
265 fn check_version(data: &[u8]) -> ProgramResult {
266 match read_version(data) {
267 Some(v) if v == Self::VERSION => Ok(()),
268 _ => ProgramError::err_invalid_data(),
269 }
270 }
271
272 /// Check whether a given version is compatible with this layout.
273 ///
274 /// The default implementation accepts only the exact version, but
275 /// implementors can override this to accept older versions for
276 /// backward-compatible migration.
277 #[inline(always)]
278 fn compatible(version: u8) -> bool {
279 version == Self::VERSION
280 }
281
282 /// Check whether the account data contains an extension region
283 /// (data beyond the fixed layout boundary).
284 #[inline(always)]
285 fn has_extension_region(data: &[u8]) -> bool {
286 match Self::EXTENSION_OFFSET {
287 Some(offset) => data.len() > offset,
288 None => false,
289 }
290 }
291
292 /// Build a `LayoutInfo` snapshot from this contract's compile-time constants.
293 #[inline(always)]
294 fn layout_info_static() -> LayoutInfo {
295 LayoutInfo {
296 disc: Self::DISC,
297 version: Self::VERSION,
298 flags: 0,
299 layout_id: Self::LAYOUT_ID,
300 schema_epoch: DEFAULT_SCHEMA_EPOCH,
301 data_len: Self::required_len(),
302 }
303 }
304
305 /// Compile-time field metadata for this layout.
306 #[inline(always)]
307 fn fields() -> &'static [FieldInfo] {
308 Self::FIELDS
309 }
310}
311
312/// Read the discriminator from account data (byte 0).
313#[inline(always)]
314pub fn read_disc(data: &[u8]) -> Option<u8> {
315 data.first().copied()
316}
317
318/// Read the version from account data (byte 1).
319#[inline(always)]
320pub fn read_version(data: &[u8]) -> Option<u8> {
321 if data.len() < 2 {
322 None
323 } else {
324 Some(data[1])
325 }
326}
327
328/// Read the 8-byte layout_id from account data (bytes 4..12).
329#[inline(always)]
330pub fn read_layout_id(data: &[u8]) -> Option<&[u8; 8]> {
331 if data.len() < 12 {
332 None
333 } else {
334 // SAFETY: bounds checked above, alignment is 1 for [u8; 8].
335 Some(unsafe { &*(data.as_ptr().add(4) as *const [u8; 8]) })
336 }
337}
338
339/// Read the flags from account data (bytes 2..4) as u16 LE.
340#[inline(always)]
341pub fn read_flags(data: &[u8]) -> Option<u16> {
342 if data.len() < 4 {
343 None
344 } else {
345 let bytes = [data[2], data[3]];
346 Some(u16::from_le_bytes(bytes))
347 }
348}
349
350/// Default schema-evolution epoch written by `init_header`.
351///
352/// Accounts initialised by pre-audit Hopper had the epoch region
353/// zeroed, so `0` is treated as "legacy, equivalent to 1" by the
354/// runtime checks that compare against an `AccountLayout::SCHEMA_EPOCH`.
355/// Freshly-initialised accounts now carry `1` so migrations can bump
356/// monotonically without any lookback.
357pub const DEFAULT_SCHEMA_EPOCH: u32 = 1;
358
359/// Write a complete Hopper header to the beginning of `data`.
360///
361/// Writes disc, version, flags (zeroed), layout_id, and the
362/// audit-added `schema_epoch = 1` (bytes 12..16).
363/// Returns `Err` if `data` is shorter than 16 bytes.
364#[inline(always)]
365pub fn write_header(data: &mut [u8], disc: u8, version: u8, layout_id: &[u8; 8]) -> ProgramResult {
366 write_header_with_epoch(data, disc, version, layout_id, DEFAULT_SCHEMA_EPOCH)
367}
368
369/// Write a Hopper header with a caller-specified schema epoch.
370///
371/// Used by migration helpers that need to stamp a new epoch while
372/// preserving `disc`/`version`/`layout_id`. Regular account creation
373/// should go through [`write_header`] (which defaults the epoch to
374/// `1`) or [`init_header`].
375#[inline(always)]
376pub fn write_header_with_epoch(
377 data: &mut [u8],
378 disc: u8,
379 version: u8,
380 layout_id: &[u8; 8],
381 schema_epoch: u32,
382) -> ProgramResult {
383 if data.len() < 16 {
384 return Err(ProgramError::AccountDataTooSmall);
385 }
386 data[0] = disc;
387 data[1] = version;
388 data[2] = 0;
389 data[3] = 0;
390 data[4..12].copy_from_slice(layout_id);
391 data[12..16].copy_from_slice(&schema_epoch.to_le_bytes());
392 Ok(())
393}
394
395/// Read the `schema_epoch` field from an already-written header.
396///
397/// Returns `None` if `data` is too short. Returns the stored value
398/// verbatim, callers that want the "0 means legacy" compatibility
399/// rule should apply it themselves:
400///
401/// ```ignore
402/// let stored = read_schema_epoch(data)?;
403/// let effective = if stored == 0 { DEFAULT_SCHEMA_EPOCH } else { stored };
404/// ```
405#[inline(always)]
406pub fn read_schema_epoch(data: &[u8]) -> Option<u32> {
407 if data.len() < 16 {
408 return None;
409 }
410 Some(u32::from_le_bytes([data[12], data[13], data[14], data[15]]))
411}
412
413/// Initialize an account's header from a layout contract type.
414///
415/// Convenience wrapper that pulls disc, version, and layout_id from
416/// the type and stamps `schema_epoch = DEFAULT_SCHEMA_EPOCH`.
417#[inline(always)]
418pub fn init_header<T: LayoutContract>(data: &mut [u8]) -> ProgramResult {
419 write_header(data, T::DISC, T::VERSION, &T::LAYOUT_ID)
420}