hopper_runtime/foreign.rs
1//! Manifest-backed foreign-account lenses.
2//!
3//! The Hopper Safety Audit (page 14, "Manifest-backed foreign account
4//! lenses") proposed a verifiable cross-program read API as the next
5//! step beyond ad-hoc offset-based foreign reads. This module
6//! implements it.
7//!
8//! # Problem
9//!
10//! Today, reading a field from an account owned by a *different* program
11//! either imports the foreign program's crate (tight coupling, forces
12//! version-lock) or reads raw bytes by hand-maintained offset
13//! (no ABI-drift detection. if the foreign program changes its layout,
14//! silent misreads result).
15//!
16//! # Design
17//!
18//! A `ForeignManifest` is an opaque witness (supplied by the caller)
19//! that carries the foreign program's `wire_fp64` hash plus the layout
20//! discriminator it expects for a particular `T: AccountLayout`. When
21//! `ctx.foreign::<T>(idx, &manifest)?` is called:
22//!
23//! 1. The account's owner must match `manifest.program_id`
24//! 2. The account's header discriminator must match `T::DISC` and
25//! `manifest.expected_disc`
26//! 3. The header's `wire_fp64` must match `T::WIRE_FINGERPRINT` and
27//! `manifest.expected_wire_fp`
28//! 4. `schema_epoch` must fall in `manifest.supported_epochs`
29//!
30//! Only after all four pass does the lens expose field access. Any
31//! mismatch returns `ProgramError::InvalidAccountData`. never silent
32//! mis-reads, never UB.
33//!
34//! # Manifest sourcing
35//!
36//! Hopper does not fetch manifests from RPC inside a program (that
37//! would be round-trip CPI with no caching story). Manifests are
38//! caller-supplied, typically from:
39//!
40//! - An embedded `const ForeignManifest` authored when the program was
41//! built (works when the foreign program's ABI is known at build time)
42//! - A manifest account located at the canonical manifest PDA
43//! (`find_program_address(&[MANIFEST_SEED], &foreign_program_id)`)
44//! whose payload has already been verified by a prior instruction
45//! - A Hopper-authored IDL that emits manifest constants as part of
46//! its client-generation output
47
48use crate::account::AccountView;
49use crate::address::Address;
50use crate::borrow::Ref;
51use crate::error::ProgramError;
52use crate::layout::{HopperHeader, LayoutContract};
53use crate::zerocopy::{AccountLayout, ZeroCopy};
54
55/// Opaque witness to a foreign program's layout ABI.
56///
57/// Callers construct this once per foreign program they want to read
58/// from, typically as a `const` from build-time-embedded metadata or
59/// from the foreign program's Hopper manifest account.
60#[derive(Clone, Debug, PartialEq, Eq)]
61pub struct ForeignManifest {
62 /// Owner program that authored the layout. The account's owner
63 /// must match this address exactly.
64 pub program_id: Address,
65 /// Discriminator byte the foreign layout expects.
66 pub expected_disc: u8,
67 /// Canonical wire-fingerprint hash from the foreign program's
68 /// schema manifest. Matches `AccountLayout::WIRE_FINGERPRINT` on
69 /// the reader side.
70 pub expected_wire_fp: u64,
71 /// Inclusive range of `schema_epoch` values the reader supports.
72 /// Accounts outside this range fail verification. the caller can
73 /// then fall back to a migration path or a different manifest.
74 pub supported_epochs: core::ops::RangeInclusive<u32>,
75}
76
77impl ForeignManifest {
78 /// Build a single-epoch manifest covering `expected_wire_fp` for
79 /// `program_id` at exactly the given schema epoch.
80 pub const fn single_epoch(
81 program_id: Address,
82 expected_disc: u8,
83 expected_wire_fp: u64,
84 epoch: u32,
85 ) -> Self {
86 Self {
87 program_id,
88 expected_disc,
89 expected_wire_fp,
90 supported_epochs: epoch..=epoch,
91 }
92 }
93}
94
95/// A verified read-only handle into a foreign account.
96///
97/// `ForeignLens<'a, T>` borrows the underlying account data for its
98/// lifetime. Field access (`.get()`, `.field::<F, OFFSET>()`) performs
99/// only pointer arithmetic. no further verification, because all
100/// cross-program invariants were pinned at construction.
101pub struct ForeignLens<'a, T: AccountLayout + LayoutContract> {
102 inner: Ref<'a, T>,
103}
104
105impl<'a, T: AccountLayout + LayoutContract> ForeignLens<'a, T> {
106 /// Verify a foreign account against the supplied manifest and, on
107 /// success, return a read-only lens into its body.
108 ///
109 /// The four verification steps correspond one-to-one with the
110 /// audit's page-14 requirements:
111 ///
112 /// 1. owner match
113 /// 2. discriminator match (both `T::DISC` *and* `manifest.expected_disc`)
114 /// 3. wire-fingerprint match
115 /// 4. schema_epoch in supported range
116 #[inline]
117 pub fn open(
118 account: &'a AccountView,
119 manifest: &ForeignManifest,
120 ) -> Result<Self, ProgramError> {
121 // 1. Owner match. `check_owned_by` compares address bytes.
122 account.check_owned_by(&manifest.program_id)?;
123
124 // 2-4. Header inspection. must happen behind a byte borrow
125 // so the data can't mutate underneath us. We use the same
126 // load path authored accounts use, which verifies the
127 // discriminator too. That closes #2.
128 let loaded: Ref<'a, T> = account.load::<T>()?;
129 if <T as AccountLayout>::DISC != manifest.expected_disc {
130 return Err(ProgramError::InvalidAccountData);
131 }
132
133 // Re-read the header bytes directly so we can match the
134 // manifest's wire-fingerprint and epoch fields. The load
135 // above already verified disc/version, so this step only
136 // checks the manifest-specific fields. HopperHeader is
137 // `#[repr(C, packed)]` at 16 bytes. `from_bytes` returns a
138 // properly bounds-checked reference without touching unaligned
139 // primitives (we copy packed fields out by value below).
140 let data = account.try_borrow()?;
141 let header = HopperHeader::from_bytes(&data).ok_or(ProgramError::AccountDataTooSmall)?;
142 // Packed-field reads must go through a local copy.
143 let layout_id = header.layout_id;
144 let schema_epoch = header.schema_epoch;
145 let actual_wire_fp = u64::from_le_bytes(layout_id);
146 if actual_wire_fp != manifest.expected_wire_fp {
147 return Err(ProgramError::InvalidAccountData);
148 }
149 if actual_wire_fp != <T as AccountLayout>::WIRE_FINGERPRINT {
150 return Err(ProgramError::InvalidAccountData);
151 }
152 if !manifest.supported_epochs.contains(&schema_epoch) {
153 return Err(ProgramError::InvalidAccountData);
154 }
155
156 // Explicit drop so the re-borrow guard releases before we
157 // hand out `loaded`, which already pins its own guard.
158 drop(data);
159
160 Ok(Self { inner: loaded })
161 }
162
163 /// The full verified layout. Field access through this path is
164 /// zero-cost; no further checks fire.
165 #[inline(always)]
166 pub fn get(&self) -> &T {
167 &self.inner
168 }
169
170 /// Project a typed field by byte offset. Returns a pointer-cast
171 /// reference with the lens's lifetime.
172 ///
173 /// `OFFSET` must be the field's offset *within the layout body*
174 /// (i.e. already past the 16-byte Hopper header). Callers should
175 /// prefer the auto-emitted `{FIELD}_OFFSET` constants from
176 /// `#[hopper::state]`.
177 #[inline(always)]
178 pub fn field<F: ZeroCopy, const OFFSET: usize>(&self) -> Result<&F, ProgramError> {
179 let body_size = core::mem::size_of::<T>();
180 let field_size = core::mem::size_of::<F>();
181 if OFFSET
182 .checked_add(field_size)
183 .map(|end| end > body_size)
184 .unwrap_or(true)
185 {
186 return Err(ProgramError::AccountDataTooSmall);
187 }
188 // SAFETY: We checked the byte range lies entirely inside the
189 // body. The layout is `Pod` (from `T: AccountLayout: ZeroCopy`),
190 // so every byte pattern is valid for `F: ZeroCopy`. The
191 // returned reference inherits the lens's lifetime and thus
192 // cannot outlive the underlying borrow guard.
193 // `Ref<T>` derefs to `T`; take the address via `&*`.
194 let layout_ref: &T = &*self.inner;
195 // 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.
196 unsafe {
197 let base = layout_ref as *const T as *const u8;
198 let field_ptr = base.add(OFFSET) as *const F;
199 Ok(&*field_ptr)
200 }
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn manifest_single_epoch_is_inclusive_single_value() {
210 let program = Address::new_from_array([7u8; 32]);
211 let m = ForeignManifest::single_epoch(program, 42, 0xDEAD_BEEF_1234_5678, 3);
212 assert!(m.supported_epochs.contains(&3));
213 assert!(!m.supported_epochs.contains(&2));
214 assert!(!m.supported_epochs.contains(&4));
215 assert_eq!(m.expected_disc, 42);
216 assert_eq!(m.expected_wire_fp, 0xDEAD_BEEF_1234_5678);
217 }
218
219 #[test]
220 fn manifest_range_spans_inclusive() {
221 let program = Address::new_from_array([0u8; 32]);
222 let m = ForeignManifest {
223 program_id: program,
224 expected_disc: 1,
225 expected_wire_fp: 0,
226 supported_epochs: 2..=5,
227 };
228 for ok in [2u32, 3, 4, 5] {
229 assert!(m.supported_epochs.contains(&ok), "{ok}");
230 }
231 for fail in [0u32, 1, 6, 100] {
232 assert!(!m.supported_epochs.contains(&fail), "{fail}");
233 }
234 }
235}