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}