Skip to main content

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}