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)
142 .ok_or(ProgramError::AccountDataTooSmall)?;
143 // Packed-field reads must go through a local copy.
144 let layout_id = header.layout_id;
145 let schema_epoch = header.schema_epoch;
146 let actual_wire_fp = u64::from_le_bytes(layout_id);
147 if actual_wire_fp != manifest.expected_wire_fp {
148 return Err(ProgramError::InvalidAccountData);
149 }
150 if actual_wire_fp != <T as AccountLayout>::WIRE_FINGERPRINT {
151 return Err(ProgramError::InvalidAccountData);
152 }
153 if !manifest.supported_epochs.contains(&schema_epoch) {
154 return Err(ProgramError::InvalidAccountData);
155 }
156
157 // Explicit drop so the re-borrow guard releases before we
158 // hand out `loaded`, which already pins its own guard.
159 drop(data);
160
161 Ok(Self { inner: loaded })
162 }
163
164 /// The full verified layout. Field access through this path is
165 /// zero-cost; no further checks fire.
166 #[inline(always)]
167 pub fn get(&self) -> &T {
168 &self.inner
169 }
170
171 /// Project a typed field by byte offset. Returns a pointer-cast
172 /// reference with the lens's lifetime.
173 ///
174 /// `OFFSET` must be the field's offset *within the layout body*
175 /// (i.e. already past the 16-byte Hopper header). Callers should
176 /// prefer the auto-emitted `{FIELD}_OFFSET` constants from
177 /// `#[hopper::state]`.
178 #[inline(always)]
179 pub fn field<F: ZeroCopy, const OFFSET: usize>(&self) -> Result<&F, ProgramError> {
180 let body_size = core::mem::size_of::<T>();
181 let field_size = core::mem::size_of::<F>();
182 if OFFSET.checked_add(field_size).map(|end| end > body_size).unwrap_or(true) {
183 return Err(ProgramError::AccountDataTooSmall);
184 }
185 // SAFETY: We checked the byte range lies entirely inside the
186 // body. The layout is `Pod` (from `T: AccountLayout: ZeroCopy`),
187 // so every byte pattern is valid for `F: ZeroCopy`. The
188 // returned reference inherits the lens's lifetime and thus
189 // cannot outlive the underlying borrow guard.
190 // `Ref<T>` derefs to `T`; take the address via `&*`.
191 let layout_ref: &T = &*self.inner;
192 unsafe {
193 let base = layout_ref as *const T as *const u8;
194 let field_ptr = base.add(OFFSET) as *const F;
195 Ok(&*field_ptr)
196 }
197 }
198}
199
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn manifest_single_epoch_is_inclusive_single_value() {
207 let program = Address::new_from_array([7u8; 32]);
208 let m = ForeignManifest::single_epoch(program, 42, 0xDEAD_BEEF_1234_5678, 3);
209 assert!(m.supported_epochs.contains(&3));
210 assert!(!m.supported_epochs.contains(&2));
211 assert!(!m.supported_epochs.contains(&4));
212 assert_eq!(m.expected_disc, 42);
213 assert_eq!(m.expected_wire_fp, 0xDEAD_BEEF_1234_5678);
214 }
215
216 #[test]
217 fn manifest_range_spans_inclusive() {
218 let program = Address::new_from_array([0u8; 32]);
219 let m = ForeignManifest {
220 program_id: program,
221 expected_disc: 1,
222 expected_wire_fp: 0,
223 supported_epochs: 2..=5,
224 };
225 for ok in [2u32, 3, 4, 5] {
226 assert!(m.supported_epochs.contains(&ok), "{ok}");
227 }
228 for fail in [0u32, 1, 6, 100] {
229 assert!(!m.supported_epochs.contains(&fail), "{fail}");
230 }
231 }
232}