Unsafe invariants
The audit ledger for raw-pointer boundaries and safety contracts.
Trust Posture
Hopper is a safety-layered framework that deliberately uses unsafe for the
operations where Rust's ownership system cannot express the invariants we need
(pointer casts onto account byte slices, zero-copy overlays, CPI invocation).
Every other layer of the framework (header validation, fingerprint checking,
tiered loading, frame borrow tracking, validation graphs) exists to make the
unsafe core as small and auditable as possible.
Design commitment: unsafe is never used for convenience. It is used only when a safe alternative would require allocation, serialization, or loss of direct, allocation-free account access.
Audit scope: the unsafe surface audited by this ledger spans the raw source
directories that own Hopper's zero-copy and backend boundary code:
crates/hopper-native/src, crates/hopper-runtime/src,
crates/hopper-solana/src, and crates/hopper-core/src. Unsafe in those
directories is limited to raw Solana/backend ABI crossings, zero-copy overlays,
Pod reads/writes, CPI assembly, and bounded in-place collection operations.
This ledger explains the major unsafe boundaries, their justifications, and the invariants that must hold. The generated CI inventory, not the manually maintained table below, is the exhaustive file/count record for a particular source revision.
CI also emits a machine-generated unsafe inventory artifact. The workflow
.github/workflows/unsafe-safety.yml runs
scripts/check-unsafe-safety-comments.py --inventory-out unsafe-inventory.md
and uploads the resulting markdown table as the unsafe-inventory artifact for
each push and pull request.
Source-pinned unsafe inventory
This generated snapshot replaces the historical table, including removed compatibility modules. Counts identify review surfaces, not a proof of soundness. Regenerate for a new executable-source revision. The scan covers all 29 public packages; the detailed explanations below focus on the account/runtime layers.
Generated by scripts/check-unsafe-safety-comments.py.
Publish-order SHA-256: b1c40b99a433651392be13f15d49c4633caf49911e56e89df3adc45b0fb70a20
Source commit: f642abad62034fd688c493991c9d123e05f03388
Source tree clean: true
Public packages: 29
Checked Rust files: 310
| Source file | Blocks | Unsafe fns | Public unsafe fns | Unsafe traits | Unsafe impls | Unsafe extern blocks |
|---|---|---|---|---|---|---|
crates/hopper-builtins/src/lib.rs |
27 | 8 | 8 | 0 | 0 | 0 |
crates/hopper-core/src/abi/boolean.rs |
0 | 0 | 0 | 0 | 4 | 0 |
crates/hopper-core/src/abi/field_ref.rs |
1 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-core/src/abi/integers.rs |
0 | 0 | 0 | 0 | 4 | 0 |
crates/hopper-core/src/abi/mod.rs |
0 | 0 | 0 | 1 | 0 | 0 |
crates/hopper-core/src/abi/typed_address.rs |
2 | 0 | 0 | 0 | 6 | 0 |
crates/hopper-core/src/account/cursor.rs |
1 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-core/src/account/header.rs |
0 | 0 | 0 | 0 | 3 | 0 |
crates/hopper-core/src/account/lifecycle.rs |
3 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-core/src/account/pod.rs |
6 | 2 | 2 | 0 | 0 | 0 |
crates/hopper-core/src/account/reader.rs |
2 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-core/src/account/registry.rs |
8 | 0 | 0 | 0 | 2 | 0 |
crates/hopper-core/src/account/segment.rs |
6 | 0 | 0 | 0 | 3 | 0 |
crates/hopper-core/src/account/verified.rs |
6 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-core/src/accounts/hopper_account.rs |
1 | 1 | 1 | 0 | 0 | 0 |
crates/hopper-core/src/accounts/program_account.rs |
1 | 1 | 1 | 0 | 0 | 0 |
crates/hopper-core/src/accounts/segmented.rs |
1 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-core/src/accounts/unchecked.rs |
1 | 1 | 1 | 0 | 0 | 0 |
crates/hopper-core/src/check/fast.rs |
3 | 1 | 0 | 0 | 0 | 0 |
crates/hopper-core/src/check/guards.rs |
1 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-core/src/check/mod.rs |
4 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-core/src/collections/fixed_vec.rs |
6 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-core/src/collections/journal.rs |
3 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-core/src/collections/packed_map.rs |
4 | 0 | 0 | 0 | 2 | 0 |
crates/hopper-core/src/collections/ring_buffer.rs |
2 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-core/src/collections/slab.rs |
5 | 0 | 0 | 0 | 2 | 0 |
crates/hopper-core/src/collections/slot_map.rs |
3 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-core/src/collections/sorted_vec.rs |
5 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-core/src/cpi/mod.rs |
8 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-core/src/event/mod.rs |
3 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-core/src/frame/mod.rs |
17 | 1 | 1 | 0 | 2 | 0 |
crates/hopper-core/src/frame/phase.rs |
6 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-core/src/manifest.rs |
4 | 0 | 0 | 0 | 4 | 0 |
crates/hopper-core/src/virtual_state/mod.rs |
5 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-macros/src/lib.rs |
2 | 1 | 1 | 0 | 6 | 0 |
crates/hopper-macros-proc/src/args.rs |
1 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-macros-proc/src/context.rs |
7 | 1 | 0 | 0 | 0 | 0 |
crates/hopper-macros-proc/src/event.rs |
1 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-macros-proc/src/pod.rs |
0 | 0 | 0 | 0 | 3 | 0 |
crates/hopper-macros-proc/src/state.rs |
0 | 0 | 0 | 0 | 6 | 0 |
crates/hopper-memo/src/lib.rs |
1 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-native/src/account_view.rs |
52 | 12 | 12 | 0 | 2 | 0 |
crates/hopper-native/src/address.rs |
1 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-native/src/borrow.rs |
5 | 2 | 2 | 0 | 0 | 0 |
crates/hopper-native/src/budget.rs |
2 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-native/src/cpi.rs |
15 | 2 | 2 | 0 | 0 | 0 |
crates/hopper-native/src/entrypoint.rs |
20 | 8 | 4 | 0 | 2 | 0 |
crates/hopper-native/src/error.rs |
1 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-native/src/hash.rs |
3 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-native/src/instruction.rs |
8 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-native/src/introspect.rs |
2 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-native/src/lazy.rs |
27 | 2 | 1 | 0 | 2 | 0 |
crates/hopper-native/src/lens.rs |
13 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-native/src/log.rs |
5 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-native/src/mem.rs |
13 | 4 | 4 | 0 | 0 | 0 |
crates/hopper-native/src/pda.rs |
19 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-native/src/pod.rs |
1 | 0 | 0 | 3 | 27 | 0 |
crates/hopper-native/src/project.rs |
10 | 3 | 3 | 2 | 13 | 0 |
crates/hopper-native/src/raw_input.rs |
99 | 7 | 7 | 0 | 0 | 0 |
crates/hopper-native/src/return_data.rs |
3 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-native/src/syscalls.rs |
1 | 2 | 0 | 0 | 0 | 0 |
crates/hopper-native/src/system.rs |
2 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-native/src/sysvar.rs |
9 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-native/src/verify.rs |
1 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-native/src/wire.rs |
0 | 0 | 0 | 0 | 12 | 0 |
crates/hopper-runtime/src/account.rs |
42 | 9 | 8 | 0 | 16 | 0 |
crates/hopper-runtime/src/account_wrappers.rs |
3 | 7 | 7 | 0 | 6 | 0 |
crates/hopper-runtime/src/address.rs |
3 | 0 | 0 | 0 | 3 | 0 |
crates/hopper-runtime/src/audit.rs |
2 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-runtime/src/behavior.rs |
2 | 0 | 0 | 0 | 2 | 0 |
crates/hopper-runtime/src/borrow.rs |
14 | 2 | 2 | 0 | 0 | 0 |
crates/hopper-runtime/src/borrow_registry.rs |
1 | 0 | 0 | 0 | 1 | 0 |
crates/hopper-runtime/src/compact.rs |
0 | 0 | 0 | 0 | 2 | 0 |
crates/hopper-runtime/src/context.rs |
12 | 4 | 4 | 0 | 2 | 0 |
crates/hopper-runtime/src/cpi.rs |
17 | 2 | 2 | 0 | 0 | 0 |
crates/hopper-runtime/src/cpi_dedup_tests.rs |
2 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-runtime/src/cpi_event.rs |
4 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-runtime/src/crypto.rs |
12 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-runtime/src/dyn_cpi.rs |
9 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-runtime/src/error.rs |
3 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-runtime/src/foreign.rs |
4 | 1 | 1 | 0 | 0 | 0 |
crates/hopper-runtime/src/instruction.rs |
5 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-runtime/src/interop.rs |
2 | 0 | 0 | 1 | 2 | 0 |
crates/hopper-runtime/src/lamports.rs |
2 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-runtime/src/layout.rs |
3 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-runtime/src/lazy.rs |
8 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-runtime/src/lib.rs |
18 | 3 | 3 | 0 | 0 | 0 |
crates/hopper-runtime/src/log.rs |
2 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-runtime/src/memory.rs |
8 | 4 | 4 | 0 | 0 | 0 |
crates/hopper-runtime/src/migrate.rs |
8 | 0 | 0 | 0 | 21 | 0 |
crates/hopper-runtime/src/native_boundary.rs |
9 | 5 | 5 | 0 | 0 | 0 |
crates/hopper-runtime/src/option_byte.rs |
2 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-runtime/src/pda.rs |
2 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-runtime/src/remaining.rs |
2 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-runtime/src/return_data.rs |
4 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-runtime/src/segment_borrow.rs |
16 | 1 | 1 | 0 | 4 | 0 |
crates/hopper-runtime/src/segment_lease.rs |
5 | 2 | 2 | 0 | 0 | 0 |
crates/hopper-runtime/src/syscall.rs |
1 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-runtime/src/syscalls.rs |
25 | 19 | 19 | 0 | 0 | 0 |
crates/hopper-runtime/src/tail.rs |
1 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-runtime/src/token.rs |
15 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-runtime/src/write_policy.rs |
5 | 0 | 0 | 0 | 1 | 0 |
crates/hopper-runtime/src/zerocopy.rs |
0 | 0 | 0 | 2 | 5 | 0 |
crates/hopper-sdk/src/reader.rs |
0 | 1 | 1 | 0 | 0 | 0 |
crates/hopper-solana/src/compute.rs |
1 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-solana/src/crypto/merkle.rs |
2 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-solana/src/mint.rs |
2 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-solana/src/token.rs |
2 | 0 | 0 | 0 | 0 | 0 |
crates/hopper-svm/src/lib.rs |
4 | 0 | 0 | 0 | 0 | 0 |
src/__macro_support.rs |
1 | 0 | 0 | 0 | 0 | 0 |
src/receipts.rs |
2 | 0 | 0 | 0 | 0 | 0 |
Trust Summary
Hopper's unsafe surface is deliberately narrow and follows three foundational rules:
- All overlay targets are alignment-1, enforced by the type system. The
Podmarker is implemented only for alignment-1 types (u8,i8,[u8; N], and macro-authored wire/layout types), so every reference-returning overlay API (load,segment_ref,raw_ref,pod_from_bytes, …) bounds onPodand cannot be instantiated with a native multi-byte integer,segment_ref::<u64>is a compile error (seetests/hopper-trybuild/tests/ui/fail/*_u64_rejected.rs). Native scalars are read by value via the separateValuePodmarker andread_unaligned/Context::read_data, never as&u64. The two internal header reads that previously used aligned casts (AccountView::header_u32, the fast-entrypoint length prefix) now usecore::ptr::read_unaligned. No pointer cast in the codebase produces a reference withalign > 1; this eliminates alignment UB entirely. - All casts are bounds-checked. Every
pod_from_bytes/overlay_at/read_unalignedcall is preceded by a length check againstT::SIZEor explicit offset arithmetic. - Aliasing is structurally prevented. Mutable borrows flow through
&mut self(compile-time) or frame-level bitmask tracking (runtime). No two mutable references can alias the same account data.
What tests prove it
| Invariant | Focused suite | Command |
|---|---|---|
| Pod boundary rejection | tests/unsafe_boundary_tests.rs |
cargo test --test unsafe_boundary_tests |
| Overlay checked/unchecked parity | tests/overlay_equivalence_tests.rs |
cargo test --test overlay_equivalence_tests |
| Compat regression and receipt wire format | tests/compat_regression_tests.rs |
cargo test --test compat_regression_tests |
| Property-based ABI roundtrip | tests/property_tests.rs |
cargo test --test property_tests |
| CPI guard, collections, registry, validation | tests/trust_tests.rs |
cargo test --test trust_tests |
The suites are the durable evidence; their case counts move as regressions are added and are intentionally not frozen here.
What callers must guarantee
| API | Caller Obligation |
|---|---|
unsafe impl Pod for T |
T is #[repr(C)] or #[repr(transparent)], all fields are [u8; N] or Pod, align_of::<T>() == 1 |
cast_unchecked / cast_unchecked_mut |
data.len() >= size_of::<T>(). No concurrent aliasing. |
hopper_layout! load_unchecked |
Account data is valid for the layout. Caller accepts all risk. |
MaybeUninit transmute in CPI builders |
All ACCTS slots initialized via add_account() before invoke() |
Global Guarantees
#![deny(unsafe_op_in_unsafe_fn)]-- enforced inhopper-coreandhopper-solana. All unsafe operations must be explicitly wrapped even insideunsafe fn.- Pod trait --
unsafe trait Pod: Copy + Sizedrequiresalign_of == 1and all bit patterns valid. Everyunsafe impl Podis for types whose fields are[u8; N]or nested Pod types under#[repr(C)]/#[repr(transparent)]. - All pointer casts target align-1 types. No pointer cast in the codebase produces a reference to a type with alignment > 1.
hopper-core::abi
Wire types (integers.rs, boolean.rs)
| Line(s) | Construct | Invariant |
|---|---|---|
unsafe impl WireType |
Trait impl per wire type | Type is #[repr(transparent)] over [u8; N], align == 1, size == N (compile-time asserted). All bit patterns valid. |
unsafe impl Pod |
Trait impl per wire type | Same as above. |
typed_address.rs
| Line | Construct | Invariant |
|---|---|---|
| 61 | unsafe impl Pod for TypedAddress<T> |
#[repr(transparent)] over [u8; 32]. PhantomData is ZST. Size == 32, align == 1 (compile-time asserted). |
| 99 | &*(account.address() as *const Address as *const [u8; 32]) |
hopper_native::Address is [u8; 32] (same repr). Read-only, no-alloc. |
| 198 | unsafe impl Pod for UntypedAddress |
#[repr(transparent)] over [u8; 32]. |
field_ref.rs
| Line | Construct | Invariant |
|---|---|---|
| 88 | &*(self.data.as_ptr() as *const [u8; 32]) |
Slice length checked ≥ 32 before cast. Target type is [u8; 32], align 1. |
hopper-core::account
pod.rs
| Line | Construct | Invariant |
|---|---|---|
| 13 | pub unsafe trait Pod |
Marker trait. Implementors guarantee align-1, all bit patterns valid. |
| 16-17 | unsafe impl Pod for u8 / [u8; 32] |
Trivially safe. |
| 39 | pod_from_bytes: &*(data.as_ptr() as *const T) |
Size checked: data.len() >= T::SIZE. T: Pod guarantees align-1. No aliasing: immutable borrow. |
| 54 | pod_from_bytes_mut: &mut *(data.as_mut_ptr() as *mut T) |
Size checked. T: Pod. Caller must ensure exclusive access. |
| 64 | pod_read: read_unaligned |
Size checked. T: Pod. Returns by value, no alias concern. |
| 74 | pod_write: write_unaligned |
Size checked. T: Pod. Caller must hold &mut [u8]. |
header.rs
| Line | Construct | Invariant |
|---|---|---|
| 49 | unsafe impl Pod for AccountHeader |
#[repr(C)] of all byte-array fields. Size == 16, align == 1 (asserted). |
verified.rs
All checked core Pod casts and verified overlay constructors enforce
FixedLayout::SIZE == size_of::<T>() using a framework-owned const assertion.
The compatibility _SIZE_IS_HONEST associated constant is overridable in safe
Rust and is not trusted by internal pointer arithmetic. Collections check the
same equality independently and additionally reject zero-sized elements.
Segment slices validate count against capacity, descriptor element size against
the type, and checked region bounds before exposing elements.
Runtime AccountView typed loads independently bound offset + size_of::<T>()
with checked arithmetic. Safe custom layout implementations can override both
validation and size methods, so neither is relied upon for pointer safety.
Compact initialization checks the real head range before stamping the
discriminator, and rejects overlapping or overflowing tail-prefix offsets.
| Line | Construct | Invariant |
|---|---|---|
| 36 | VerifiedAccount::get(): &*(data.as_ptr() as *const T) |
Size validated at construction (data.len() >= T::SIZE). T: Pod. Immutable. |
| 99 | overlay_at: &*(data.as_ptr().add(offset) as *const U) |
Bounds checked: offset + U::SIZE <= data.len(). U: Pod. |
| 126 | VerifiedAccountMut::get() |
Same as VerifiedAccount::get(). |
| 133 | get_mut(): &mut *(data.as_mut_ptr() as *mut T) |
Size validated. Exclusive access via &mut self. |
| 180 | overlay_at (mut variant) |
Bounds checked. |
| 190 | overlay_at_mut |
Bounds checked. Exclusive access via &mut self. |
VerifiedAccount and VerifiedAccountMut are size-checked overlay wrappers.
Their public constructors establish sufficient byte length, not account
ownership, header validity, signer authority, PDA derivation, or write policy.
Those checks belong to the loader or caller. They may return &T / &mut T, &[u8], &mut [u8], or
secondary overlays, but every returned reference is tied to &self or
&mut self. The wrapper itself owns either the Hopper borrow guard or a
size-checked raw slice, so the reference cannot outlive the wrapper and
cannot be held after the guard is dropped. This is intentionally distinct from
the HopperRefOnly field-access path, where naked raw references are rejected
at the macro boundary.
The compile-fail fixture
tests/hopper-trybuild/tests/ui/fail/verified_ref_outlives_wrapper.rs locks in
this lifetime boundary: attempting to return a VerifiedAccount::get() result
after the wrapper goes out of scope fails to compile.
reader.rs
| Line | Construct | Invariant |
|---|---|---|
| 42 | Header overlay cast | Data length checked ≥ HEADER_LEN at construction. |
| 114 | Address overlay at offset | Bounds checked. |
segment.rs
| Line | Construct | Invariant |
|---|---|---|
| 37 | unsafe impl Pod for SegmentDescriptor |
#[repr(C)], all byte fields. |
| 147, 175, 246, 257, 310, 321 | Pointer offset casts | All bounds-checked before cast. Target types are Pod (align-1). |
registry.rs
| Line | Construct | Invariant |
|---|---|---|
| 226, 236, 268, 320, 418, 439, 449, 489 | Pointer offset casts | All preceded by bounds checks against self.data.len(). Target types: SegmentEntry (Pod, align-1), or generic T: Pod. |
cursor.rs
| Line | Construct | Invariant |
|---|---|---|
| 135 | Address cast at cursor position | Position + 32 <= data.len() checked. |
lifecycle.rs
No raw unsafe blocks. Uses hopper_runtime::AccountView safe APIs.
hopper-core::check
mod.rs
| Line | Construct | Invariant |
|---|---|---|
| 142 | keys_eq_fast: read_unaligned x 4 |
Input is &[u8; 32], always valid for u64 reads at offsets 0/8/16/24. |
| 159 | is_zero_address: read_unaligned x 4 |
Same as above. |
| 179 | Address cast in check_has_one |
hopper_native::Address is [u8; 32]. |
| 239 | borrow_unchecked() in check_account |
Immutable borrow for validation only. No conflicting mutable borrows at this point (called before execution phase). |
| 353 | borrow_unchecked() in check_discriminator (via macro) |
Same pattern. |
| 405 | account.owner() in check_owner_multi |
AccountView's unsafe owner() reads the owner field. No alias concern (read-only). |
fast.rs
| Line | Construct | Invariant |
|---|---|---|
| 71-82 | read_account_header |
Reads first 4 bytes of RuntimeAccount via pointer dereference. Relies on AccountView being #[repr(C)] with first field = pointer to RuntimeAccount base. Gated to target_os = "solana" only. Preconditions: SVM guarantees valid input buffer layout. |
| 103 | Call to read_account_header |
Within check_account_fast, called on SVM-provided AccountView. |
modifier.rs
| Line | Construct | Invariant |
|---|---|---|
| 160 | borrow_unchecked() in Account<T>::from_account |
Owner check passed. Frame-level borrow tracking prevents conflicting mutable borrows. |
| 179 | borrow_unchecked_mut() in AccountMut<T>::from_account |
Owner + writable checks passed. Caller ensures exclusive access at frame level. |
hopper-core::cpi
mod.rs
| Line | Construct | Invariant |
|---|---|---|
| 58, 207 | MaybeUninit::uninit().assume_init() (array of MaybeUninit) |
Creating an array of MaybeUninit<&AccountView> from uninit is sound: MaybeUninit<T> does not require initialization. Added slots are initialized via add_account before invoke. |
| 76, 224 | Address cast from AccountView::address() |
Address is [u8; 32]. Read-only cast. |
| 122, 260 | View transmute from MaybeUninit array |
All ACCTS slots initialized via add_account (enforced by debug_assert_eq!(acct_cursor, ACCTS)). The transmute from [MaybeUninit<&T>; N] to [&T; N] is sound when all N elements are initialized. |
| 128, 265 | core::mem::zeroed() for InstructionAccount array |
InstructionAccount has no invalid bit patterns (contains &[u8; 32] pointer + 2 bools). Zeroed pointers are overwritten before use. |
| 150, 154, 285, 287 | core::mem::zeroed() for Signer/Seed buffers |
Same pattern. All used slots are written before invoke_signed_unchecked. |
hopper-core::collections
All collections follow the same pattern: bounds-checked pointer arithmetic on &[u8] / &mut [u8] slices, with target types that are Pod (align-1).
| Module | Pattern | Invariant |
|---|---|---|
fixed_vec |
read_unaligned, overlay casts |
Count/capacity validated. Offset arithmetic checked against data.len(). |
ring_buffer |
write_unaligned, overlay casts |
Head/count maintained modulo capacity. Offsets checked. |
slot_map |
Overlay casts with generation counter | Slot index validated. |
bit_set |
None (all byte-level) | N/A |
sorted_vec |
read_unaligned, write_unaligned, copy_within |
Count validated, offsets checked. copy_within uses ptr::copy for overlapping regions. |
journal |
write_unaligned, read_unaligned |
Cursor wraps within capacity. Bounds checked. |
slab |
Offset casts, read_unaligned |
Bitmap allocation tracking. Slot offset validated against data length. |
packed_map |
read_unaligned, write_unaligned |
Count validated, entry size arithmetic checked. |
hopper-core::frame
phase.rs
| Line | Construct | Invariant |
|---|---|---|
borrow_mut |
borrow_unchecked_mut() via ExecutionContext |
Runtime borrow tracking via u64 bitmask (mutable_borrows). Each bit corresponds to an account index. AccountBorrowFailed returned on double-mutable-borrow. |
borrow |
borrow_unchecked() |
Immutable borrow. No conflict tracking needed (follows Rust's shared-borrow model). |
hopper-macros
hopper_layout!
| Construct | Invariant |
|---|---|
unsafe impl Pod for $name |
Generated struct is #[repr(C)] over alignment-1 fields. Compile-time assertions enforce size_of == LEN and align_of == 1. |
borrow_unchecked() / borrow_unchecked_mut() in load functions |
Protected by tiered validation: T1 checks owner + disc + version + layout_id + size before borrow. T2 checks owner + layout_id + size. |
load_unchecked |
Explicitly marked unsafe fn. Caller assumes all risk. |
load_unverified |
Size checked. Returns overlay even without full validation (tier 5 for indexers). |
hopper_check!
| Construct | Invariant |
|---|---|
borrow_unchecked() in disc/size arms |
Immutable borrow for validation reads. Called during resolve/validate phase (before any mutable borrows). |
hopper-solana
token.rs, mint.rs
| Line | Construct | Invariant |
|---|---|---|
| All pointer casts | Data length >= TOKEN_ACCOUNT_LEN or MINT_LEN checked before cast. Target: Address (align 1). |
cpi_guard.rs
| Line | Construct | Invariant |
|---|---|---|
| 71 | instructions_sysvar.borrow_unchecked() |
Used to read the Instructions sysvar. Immutable, read-only. |
typed_cpi.rs
| Line | Construct | Invariant |
|---|---|---|
| 298-299 | borrow_unchecked() in checked_token_transfer |
Read-only borrows to compare mint fields before CPI. No conflicting mutable access at this point. |
Audit Checklist
For any new unsafe added to the codebase, verify:
- Bounds check precedes every pointer offset/cast
- Target type is Pod (align-1, all bits valid)
-
// SAFETY:comment present and accurate - Mutable borrows tracked by frame bitmask or exclusive
&mutaccess - No UB on the off-chain (non-SVM) path
-
target_os = "solana"gate if relying on SVM runtime layout
Unsafe Review Checklist (for auditors)
When reviewing Hopper code (or code that depends on Hopper), walk through
these questions for every unsafe block:
Is the target type alignment-1? Every Pod type in Hopper is
#[repr(C)]or#[repr(transparent)]with all fields being[u8; N]or nested Pod types. If a new type is introduced, verifyalign_of == 1with a compile-time assertion.Is the slice length checked before the cast? Every
pod_from_bytes,overlay_at, and manual pointer cast must be preceded bydata.len() >= T::SIZEor equivalent bounds arithmetic.Is aliasing structurally prevented? Mutable access must flow through either
&mut self(compile-time) or the frame-level borrow bitmask (runtime). No two mutable references should be able to alias the same account data within a single instruction.Does it work off-chain? Code gated to
target_os = "solana"may assume SVM account layout. Verify that the non-SVM path either provides equivalent safety or is unreachable.Is the
// SAFETY:comment accurate and complete? It must state the precondition, why it holds, and what would go wrong if it didn't.Are MaybeUninit uses fully initialized before read? CPI builders use
MaybeUninitarrays. Verify thatadd_account()is called for every slot beforeinvoke().Does the test suite cover the boundary? Each unsafe boundary should have at least one test that exercises the happy path and one that exercises the rejection path (wrong size, wrong alignment, etc.).
Test Coverage by Danger Zone
Every module with unsafe blocks has corresponding tests that exercise the
invariant boundaries. This table maps each risk area to its test coverage.
| Module | Risk | Key Invariant | Test Coverage |
|---|---|---|---|
abi::integers |
Wire type soundness | align == 1, size == WIRE_SIZE |
Compile-time assertions + prop_wire_* property tests |
abi::typed_address |
Address cast soundness | Address is [u8; 32], read-only |
prop_typed_address_* property tests |
abi::fingerprint |
Deterministic hashing | SHA-256 prefix must change with schema | fingerprint_* golden tests in trust_tests |
account::pod |
Overlay cast bounds | data.len() >= T::SIZE before cast |
prop_pod_* + compile-time size_of assertions |
account::segment |
Segment offset math | Bounds checked before every cast | segment_* trust tests + property tests |
account::registry |
Registry pointer offset | All offsets validated against data.len() |
registry_* trust tests |
check::mod |
Sysvar instruction parsing | Offset table + per-ix layout fidelity | cpi_guard_* + sysvar_parse_* golden tests (with 0/1/N account metas) |
check::fast |
RuntimeAccount header read | SVM-only, gated to target_os = "solana" |
Relies on SVM runtime guarantees; untestable off-chain |
cpi::mod |
MaybeUninit transmute | All ACCTS slots initialized before transmute |
debug_assert_eq!(acct_cursor, ACCTS) + off-chain no-op path |
cpi::mod |
CPI builder zeroed data | InstructionAccount overwritten before invoke |
Off-chain path returns Ok(()), SVM path exercises full path |
collections::journal |
Circular wrap + copy_nonoverlapping |
Head wraps within capacity, bounds checked | journal_* trust tests: strict/circular, wrap-many, ordering, latest, out-of-bounds |
collections::slab |
Bitmap + offset arithmetic | Slot index validated, bounds checked | slab_* trust tests: alloc/free cycle, double-free reject, full/realloc |
collections::fixed_vec |
read_unaligned overlay |
Count/capacity validated | fixed_vec_* unit tests |
collections::ring_buffer |
write_unaligned overlay |
Head/count modulo capacity | ring_buffer_* unit tests |
collections::sorted_vec |
ptr::copy for insert/remove |
Count validated, offsets checked | sorted_vec_* trust + property tests |
frame::phase |
Borrow tracking bitmask | u64 bitmask prevents double-mutable-borrow | frame_* property tests |
hopper-macros |
hopper_layout! Pod derivation |
Compile-time size_of == LEN, align_of == 1 |
Every generated type gets static assertions; used in all test layouts |
hopper-solana::token |
Token account overlay | data.len() >= TOKEN_ACCOUNT_LEN checked |
token_* integration tests |
hopper-solana::cpi_guard |
Instructions sysvar borrow | Immutable read for validation | cpi_guard_* trust tests covering all guard variants |
receipt |
Fingerprint hashing of account data | FNV-1a deterministic, before/after tracked | receipt_* trust tests + prop_receipt_* property tests |
Boundary Test Files
The following dedicated test files exercise unsafe boundaries directly:
tests/unsafe_boundary_tests.rs- Pod from undersized/empty/oversized buffers, VerifiedAccount rejection, overlay-at OOB rejection,usize::MAXoverflow check, header wire layout verification, segment descriptor boundary conditions, wire type roundtrips, unchecked cast parity.tests/overlay_equivalence_tests.rs-pod_from_bytesvspod_readvalue equivalence,VerifiedAccount::get()vs raw pod parity,overlay_atvs manual slice pod parity,cast_uncheckedvs checked parity, mutable write-through equivalence, wire type overlay vs raw bytes, header overlay vs constructor.
Internal Hopper safety-review response (2026-04)
An internal safety review flagged four specific unsound or permissive
surfaces. Earlier revisions incorrectly called that review an independent
"Hopper Safety Audit" and linked an absent docs/Hopper Safety Audit.docx.
No independent third-party audit was performed. This section records the
internal findings, the action taken on each, and the invariants the fixes now
enforce.
Finding 1. hopper-core::frame::{segment_ref, segment_mut, segment_mut_unchecked} returned naked references to T after dropping the backing byte-slice borrow
Fix landed: crates/hopper-core/src/frame/mod.rs
now returns hopper_runtime::Ref<'_, T> / RefMut<'_, T> projected through
the live byte-slice guard via Ref::project / RefMut::project. The
returned guard owns the account's borrow state byte. it is released
only when the typed reference drops, not when the function returns.
Invariant enforced: borrow state byte always matches the set of live
Ref<T> / RefMut<T> guards returned from Frame.
Regression tests: frame::audit_tests::frame_segment_mut_writes_through_ref_mut,
frame::audit_tests::frame_segment_ref_returns_live_guard.
Finding 2. T: Copy bound on hot-path access was too loose
bool, char, &T, NonZeroU64, and padded #[repr(C)] structs all
satisfy Copy but are not safe to overlay on arbitrary bytes.
Fix landed: the canonical Pod
trait now lives at the substrate layer (hopper_native::Pod)
with the contract documented as four explicit obligations (all bit
patterns valid, alignment-1, no padding, no interior pointers). hopper-runtime
re-exports it (hopper_runtime::Pod) when the native backend is active;
hopper-core re-exports it as hopper_core::account::Pod. Every
hot-path access API tightened from T: Copy to T: Pod:
hopper_native::AccountView::{segment_ref, segment_mut, segment_ref_unchecked, segment_mut_unchecked, raw_ref, raw_mut}hopper_runtime::AccountView::{segment_ref, segment_mut, segment_ref_const, segment_mut_const, segment_ref_typed, segment_mut_typed, raw_ref, raw_mut}hopper_runtime::Context::{segment_ref, segment_mut, segment_ref_const, segment_mut_const, segment_ref_typed, segment_mut_typed, raw_ref, raw_mut, raw_unchecked, read_data}hopper_core::frame::Frame::{segment_ref, segment_mut, segment_mut_unchecked}- Macro-generated
__SegTescapes from#[hopper::context]
Finding 3. Projectable trait too permissive (Copy + 'static)
Fix landed: crates/hopper-native/src/project.rs
now documents Projectable as the Tier-C unsafe escape hatch kept for
backward compatibility with already-published programs. A strengthened
SafeProjectable trait + project_safe / project_safe_mut helpers reject
zero-sized overlays at compile time and steer all new code toward the
Pod-bounded path. hopper-native::lens::read_field_pod added as a
drop-in Pod-bounded replacement for read_field.
Finding 4. CLI/IDL/DX gaps
Fix landed: #[hopper::pod] standalone attribute macro (crates/hopper-macros-proc/src/pod.rs)
lets any #[repr(C)] struct opt into the full contract without the
#[hopper::state] header/layout_id/schema machinery. CLI already has
hopper compile --emit rust, hopper inspect, hopper explain, hopper client gen --ts, hopper client gen --kt, hopper manager … wiring
(see tools/hopper-cli).
Compile-time fences
#[hopper::state] now emits three additional const _: () = assert!(...)
fences on every generated layout:
| Fence | What it catches |
|---|---|
align_of::<T>() == 1 |
#[repr(C)] struct with a non-alignment-1 field slipped in (e.g. raw u64 instead of WireU64) |
size_of::<T>() == sum of field sizes |
Compiler-inserted padding between fields |
size_of::<T>() > 0 |
Zero-sized overlay (projects to a dangling pointer) |
DISC != 0 |
Zero discriminator cannot be distinguished from an uninitialized buffer |
All four fire at type-check time. Malformed layouts never reach link.
Const-generic TypedSegment<T, const OFFSET: u32> (audit innovation item)
New crates/hopper-runtime/src/segment.rs
introduces a zero-sized TypedSegment marker that bakes both the overlay
type and the body offset into the type system. AccountView::segment_ref_typed
/ segment_mut_typed and Context::segment_ref_typed / segment_mut_typed
take such a marker and lower to ptr + literal_offset SBF with a literal
size bounds check. A compile-time const _: () proves the marker is
zero-sized so passing it around is free.
Coordination with live borrow state (audit appendix)
Three Hopper-runtime regression tests lock in the cross-path coordination the audit wanted proven:
live_load_blocks_segment_mut.account.load::<T>()+ subsequentsegment_mutrejected via the native state byte.live_load_mut_blocks_segment_ref. exclusiveload_mutrejects a concurrentsegment_refeven though they use different registries.every_access_path_is_tracked. walks every safe access method and asserts each one blocks a conflicting follow-up.
These, together with the Frame audit regression tests, mean every safe access path in Hopper is now covered by at least one regression test that fails loudly if the coordination breaks.
tests/compat_regression_tests.rs- Append-safe addition detection, forbidden field rename/resize, field removal as breaking,compare_fieldsreport accuracy,is_backward_readable/requires_migrationcorrectness, receipt wire format encode/decode roundtrip, Phase/CompatImpact enum roundtrips, segment/field mask roundtrip, reserved byte verification.
Post-Audit Closure
Last focused verification refresh: 2026-05-25. Current proof lanes are listed in the Verification section; avoid treating this document as a frozen workspace-wide test-count snapshot.
This section enumerates every item in the April 2026 internal safety review and points at the source-of-truth closure in the current tree. It is the ground truth the audit will be compared against on re-review.
Must-fix (5 of 5. DONE)
| # | Audit item | Closure |
|---|---|---|
| M1 | Reject malformed duplicate-account indices | crates/hopper-native/src/raw_input.rs:16-46, 112-114, 176-179 (malformed_duplicate_marker trap on any forward/self-ref); lazy.rs:231-233 mirrors for lazy parse; D3 fuzz target continuously adversarial-tests the invariant |
| M2 | RAII segment leases | crates/hopper-runtime/src/segment_lease.rs (SegmentLease/SegRef/SegRefMut with Drop); integrated into Frame::segment_ref/segment_mut at crates/hopper-core/src/frame/mod.rs:207-300; regression tests in trust_tests.rs |
| M3 | Canonical wire-fingerprint layout identity | crates/hopper-macros-proc/src/state.rs:373-467. canonical_wire_stem + hopper:wire:v2 descriptor, SHA-256-hashed; spelling-drift regression tests at state.rs:515-568 |
| M4 | Field-level Pod proof at macro expansion | crates/hopper-macros-proc/src/pod.rs and src/state.rs now emit a __FieldPodProof<T: bytemuck::Pod + Zeroable> marker per field. a bare unsafe impl bytemuck::Pod is a rubber stamp that does not check fields, so this closes the hole rubber stamps left. Every field type is forced through the trait bound at expansion time |
| M5 | Compile-fail doctests for negative proof | crates/hopper-runtime/src/pod.rs carries 3 compile-fail doctests for non-Pod overlays; at the 2026-08-16 source snapshot the root tests/ui.rs suite has 17 compile-fail fixtures under tests/compile_fail/, while tests/hopper-trybuild/tests/ui/ has 14 fail fixtures and 6 pass fixtures. Treat these as dated counts; the commands in D2 are the evidence. |
Should-fix (4 of 4. DONE)
| # | Audit item | Closure |
|---|---|---|
| S1 | Address fingerprint collision safety | crates/hopper-runtime/src/segment_borrow.rs:45-67. fast-path 8-byte compare + full-address fallback at line 197, 212-213 |
| S2 | Retire Projectable/SafeProjectable split |
crates/hopper-native/Cargo.toml legacy-projectable feature; SafeProjectable marked Tier-C; ZeroCopy is the unified modern surface |
| S3 | Tighten close / close_to preconditions |
crates/hopper-runtime/src/account.rs:764-783 (writable + owner + dest-writable checks); crates/hopper-native/src/account_view.rs:389-415 (System Program ID constant + doc clarity) |
| S4 | Fix stale T: Copy docs where code requires T: Pod |
Audited across crates/; all zero-copy signature docs now say T: Pod (see crates/hopper-runtime/src/context.rs:229-244, account.rs:182-187) |
Structural (3 of 4 DONE; 1 deferred with rationale below)
| # | Audit item | Status |
|---|---|---|
| ST1 | Unify trait model -> ZeroCopy -> WireLayout -> AccountLayout |
DONE. crates/hopper-runtime/src/zerocopy.rs defines the three-tier stack; blanket impls make every LayoutContract automatically an AccountLayout |
| ST2 | Declarative account constraints | DONE (parser + validation + lifecycle). crates/hopper-macros-proc/src/context.rs parses init/init_if_needed/zero/close/realloc/realloc_payer/realloc_zero/payer/space/seeds/bump/has_one/owner/address/constraint, emits ordered validation, and generates lifecycle helpers. Signer<'info>, Account<'info, T>, and the other public typed wrappers are shipped; they are not deferred work. |
| ST3 | Schema epoch in header + wire fingerprinting | DONE. HopperHeader::schema_epoch: u32 at bytes 12-15; AccountLayout::WIRE_FINGERPRINT: u64 constant |
| ST4 | hopper compile beyond --emit rust |
DONE. hopper compile --emit routes Rust preview, TypeScript, Kotlin, Python, Go, C, Rust client, IDL, Codama, and schema output through the shared manifest-source path |
DX (1 of 4 DONE; 3 documented)
| # | Audit item | Status |
|---|---|---|
| DX1 | End-to-end build/test/deploy CLI |
DONE. tools/hopper-cli/src/cmd/lifecycle.rs:86-246 |
| DX2 | Cleaner generated access surfaces | DONE. #[hopper::state] now emits {FIELD}_ABS_OFFSET: u32 inherent consts that fold HEADER_LEN + offset; callers pass them to typed-segment escapes directly without arithmetic boilerplate. Regression tests at examples/hopper-proc-vault/src/lib.rs:abs_offset_tests |
| DX3 | Authored-source output pipeline | DONE for the shipped Rust-authoring path. Source-derived manifest emission feeds IDL, Codama, schema, and client outputs through hopper compile --emit. This is not a claim that Hopper implements a separate authored programming language. |
| DX4 | Canonical account/context syntax with full PDA/init/realloc/close/payer/space | DONE via ST2 closure. every audit-listed attribute now lowers through #[hopper::context] |
Docs and tests (2 of 4 DONE; 2 documented)
| # | Audit item | Status |
|---|---|---|
| D1 | Canonical unsafe-invariants document | DONE. this file |
| D2 | Compile-fail coverage | DONE. Run cargo test --test ui --features proc-macros --locked and cargo test -p hopper-trybuild --locked. The dated 2026-08-16 source snapshot contains 17 root fail fixtures plus 14 fail and 6 pass trybuild fixtures; the commands, not the rolling counts, are the release evidence. |
| D3 | Fuzzing low-level loaders/parsers | DONE. fuzz/ has 5 targets at the 2026-08-16 snapshot: fuzz_instruction_frame, fuzz_decode_header, fuzz_decode_segments, fuzz_pod_overlay, and fuzz_account_view_load. The safe bounds-checked parse_instruction_frame_checked parser has its own regression tests. |
| D4 | Program measurement fixtures | Compiled ELF, account-state, rollback, and dated devnet checks; see program measurements. |
Innovations (5 of 5 DONE)
| # | Audit innovation | Status |
|---|---|---|
| I1 | Borrow stack with typed leases | DONE. SegmentLease / SegRef / SegRefMut RAII stack in crates/hopper-runtime/src/segment_lease.rs |
| I2 | Generated typed-segment tokens everywhere | DONE. {FIELD}_OFFSET, {FIELD}_ABS_OFFSET, {FIELD}_SIZE, {FIELD}_TYPE const emission from #[hopper::state]; #[hopper::context] consumes both |
| I3 | Manifest-backed foreign account lenses | DONE. crates/hopper-runtime/src/foreign.rs. ForeignManifest + ForeignLens<T> with four-step verification (owner / disc / wire_fp / schema_epoch range) |
| I4 | Schema epoch with in-place migration helpers | DONE. #[hopper::migrate(from, to)] proc macro in crates/hopper-macros-proc/src/migrate.rs + hopper::layout_migrations! composition macro + apply_pending_migrations runtime in crates/hopper-runtime/src/migrate.rs. 8 integration tests in tests/migrate_integration.rs |
| I5 | Hybrid serialization (fixed body + typed dynamic tail) | DONE. #[hopper::state(dynamic_tail = T)] + TailCodec trait (Borsh-subset) in crates/hopper-runtime/src/tail.rs. 12 codec + 8 integration tests |
Follow-up design closure
On top of the original audit, a follow-up design pass called for the
Jiminy-replacement safety surface, the hopper verify ABI-integrity command,
and client-side layout verification. All three are now in-tree:
| Design item | Closure |
|---|---|
require! / require_eq! |
crates/hopper-runtime/src/lib.rs |
require_neq! |
crates/hopper-runtime/src/lib.rs |
require_keys_eq! / require_keys_neq! (Jiminy-familiar) |
crates/hopper-runtime/src/lib.rs |
require_gte! / require_gt! |
crates/hopper-runtime/src/lib.rs |
check_signer / check_owner / check_writable free fns |
crates/hopper-core/src/check/mod.rs (pre-existing) |
check_program(account, program_id) free fn |
crates/hopper-core/src/check/mod.rs (added post-audit) |
checked_mul_div / checked_mul_div_ceil safe math |
crates/hopper-core/src/math/mod.rs (pre-existing) |
hopper verify CLI |
tools/hopper-cli/src/cmd/verify.rs (manifest integrity + binary scan) |
#[used] LAYOUT_ID anchor |
crates/hopper-macros-proc/src/state.rs emits per-layout static |
Client-side assertLayoutId(data, hex) |
crates/hopper-schema/src/clientgen.rs TS generator |
Per-layout assert{Name}Layout(data) helpers |
Same generator, paired with {NAME}_LAYOUT_ID const |
hopper init scaffold |
tools/hopper-cli/src/cmd/lifecycle.rs::cmd_init (pre-existing) |
| Rust off-chain client generator | crates/hopper-schema/src/rust_client.rs - RsClientGen emits ClientError, assert_{name}_layout, decode_{name}, {Ix}_ix builders; wired as hopper compile --emit rust-client; 9 regression tests |
| Token CPI signer-pre-check default | crates/hopper-runtime/src/token.rs - Transfer/MintTo/Burn/CloseAccount/Approve/Revoke invoke() runs require_authority_signed_direct before the CPI so a missing signer surfaces MissingRequiredSignature instead of an opaque CPI error |
SPL *Checked builders (Token-2022 extension safety) |
crates/hopper-runtime/src/token.rs - TransferChecked (idx 12), ApproveChecked (idx 13), MintToChecked (idx 14), BurnChecked (idx 15) carry a decimals: u8 byte the SPL token program validates against the mint's stored decimals. Every invoke() applies the same signer pre-check. 7 wire-format regression tests lock the byte layout |
| Deprecation of unchecked token builders | crates/hopper-runtime/src/token.rs + crates/hopper-solana/src/typed_cpi.rs - Transfer, MintTo, Burn, Approve structs and their token_* wrapper functions marked #[deprecated(note = "use *Checked for Token-2022 safety")]. New token_transfer_checked, token_transfer_checked_signed, token_mint_to_checked, token_mint_to_checked_signed, token_burn_checked, token_burn_checked_signed, token_approve_checked free functions expose the safe path in hopper-solana |
| Typed-context validation binding (audit final-API Step 7) | crates/hopper-macros-proc/src/program.rs::handler_invocation emits <ContextSpec>::bind(ctx)? or bind_with_args(...) for typed Ctx<Spec> handlers before calling their body. Binding performs the checks declared by that context. Raw &mut Context handlers receive the raw context and own their validation, regardless of the strict intent flag. |
Sealed ZeroCopy trait (audit final-API Step 5) |
crates/hopper-runtime/src/zerocopy.rs::__sealed::HopperZeroCopySealed gates the blanket ZeroCopy impl. Every Hopper-authored surface stamps the seal: #[hopper::pod], #[hopper::state], hopper_layout! (both forms), and every primitive wire type (Wire{U,I}{8,16,32,64,128}, WireBool, TypedAddress<T>, UntypedAddress, AccountHeader, SegmentDescriptor, Address, plus the u8/u64/[u8; N] framework primitives). A user bypassing the macros with a bare unsafe impl Pod for Foo does not pick up ZeroCopy automatically. They would have to explicitly name the doc-hidden __sealed::HopperZeroCopySealed path, which signals the opt-out is deliberate |
| Canonical segment-access path documented | crates/hopper-runtime/src/context.rs - rustdoc table on Context::segment_ref points callers to segment_ref_typed as the compile-time-offset canonical; runtime-offset variant stays available for dynamic iteration |
| Compile-proven borrow-guard constraint (audit Finding 2) | HopperRefOnly in crates/hopper-runtime/src/ref_only.rs is a sealed marker trait implemented only by Ref, RefMut, SegRef, SegRefMut. An API bounded by G: HopperRefOnly rejects naked &T / &mut T at compile time. The seal lives in a private sealed module so downstream crates cannot stamp the marker onto arbitrary types. Closure test: tests/compile_fail/ref_only_rejects_raw_ref.rs captures rustc's error[E0277]: the trait bound '&mut u64: HopperRefOnly' is not satisfied verbatim |
Policy-driven zero-copy runtime (strict / sealed / raw) |
HopperProgramPolicy in crates/hopper-runtime/src/policy.rs ships three named modes (STRICT, SEALED, RAW) plus custom lever-by-lever overrides. #[hopper::program(strict)] / (raw) / (sealed) / (allow_unsafe = false, ...) parses the attribute in crates/hopper-macros-proc/src/program.rs::parse_program_policy and emits pub const HOPPER_PROGRAM_POLICY: HopperProgramPolicy = ...; inside the module. When allow_unsafe = false, every handler gets #[deny(unsafe_code)] unless it opts back in via #[instruction(N, unsafe_memory)], per-handler const <HANDLER>_POLICY: HopperInstructionPolicy captures the override. Three-mode demonstration: examples/hopper-policy-vault/src/lib.rs with compile-time assert! blocks that lock the emitted constants to the named policies |
Canonical raw-pointer escape hatch on Context |
crates/hopper-runtime/src/context.rs::Context::as_mut_ptr and as_ptr expose the explicit unsafe fn / safe-to-obtain pointer primitive the audit names. as_mut_ptr requires require_writable; as_ptr requires check_borrow. Both yield pointers into the loader-provided per-account buffer, transferring alias-safety to the caller as documented. Exercised by examples/hopper-policy-vault::raw_vault::raw_pointer_reset, which writes directly to a field at a compile-computed offset in raw mode |
Explicit token-authority pre-checks (enforce_token_checks is intent) |
crates/hopper-runtime/src/token.rs::require_token_authority compares the token account's owner field at bytes [32..64] with the authority address; mismatch returns IncorrectAuthority. It does not establish token-program ownership, validate the complete layout, or check signer privilege. TransferChecked, BurnChecked, and ApproveChecked call it from invoke_strict() and invoke_signed_strict(). The direct strict methods also require signer privilege and return MissingRequiredSignature when absent. Signed strict methods pass PDA seeds for CPI runtime validation. The policy constant does not insert these calls. Owner-specific strict methods do not cover every valid delegate or multisig operation. The existing token-authority tests cover matching owner, mismatch, and short buffer cases. |
Audit Provability
The three audit findings that remained open after the enforcement pass asked for invariants that are proven as well as enforced. Each row below names the exact file the auditor greps, what they see, and the compile-time failure mode a bypass produces:
| Audit finding | Grep target | What an auditor sees | Bypass failure mode |
|---|---|---|---|
| F1: provable single access path | AccountView.*data_ptr_unchecked|borrow_unchecked |
Every slice-returning accessor on hopper_native::AccountView is pub unsafe fn (borrow_unchecked, borrow_unchecked_mut) or explicitly low-level raw pointer (data_ptr_unchecked) consumed by same-crate internals and the documented raw-pointer escape hatches in hopper-runtime. Safe paths (try_borrow, try_borrow_mut, segment_ref, segment_mut) return Ref / RefMut with native borrow-state tracking |
Any call to borrow_unchecked* requires an unsafe block visible in the caller's source; obtaining a raw pointer is spelled _unchecked and dereferencing it remains unsafe |
| F2: compile-proven borrow safety | HopperRefOnly |
Eight impls total (four sealed-trait impls, four marker-trait impls), all visible in crates/hopper-runtime/src/ref_only.rs. No macro expansion, no derive. The compile-fail fixture tests/compile_fail/ref_only_rejects_raw_ref.rs is the end-to-end proof |
Raw reference at the call site: error[E0277]: the trait bound '&mut u64: HopperRefOnly' is not satisfied |
| F3: entrypoint and execution | Compiled program fixtures | Exact source and ELF hashes accompany the tested program. | Reject unexpected state or failed validation; rerun after source changes. |
hopper-native Unsafe Surface (post-audit supplement, R10)
The original audit scope centred on hopper-core, hopper-runtime, and
hopper-solana. The hopper-native crate (raw substrate, loader parsing,
syscall wrappers) was reviewed during the audit closure pass but its unsafe
surface was not enumerated in this document at the same level of rigour. This
section closes that gap and lists every unsafe entry point in hopper-native
with its invariants and test coverage pointer. Paired with the existing table
above, this makes UNSAFE_INVARIANTS.md the complete ground-truth inventory for
auditors.
hopper-native/src/account_view.rs
| Entry point | Kind | Invariant | Test coverage |
|---|---|---|---|
AccountView::new_unchecked(ptr) |
pub const unsafe fn |
ptr must point at a valid RuntimeAccount followed by at least data_len bytes, and stay dereferenceable for the lifetime of the view. Before any safe resize, the resize_delta slot must hold the data length from instruction entry. The entrypoint parsers (raw_input::deserialize_accounts, lazy::parse_one_account) establish both |
crates/hopper-native/tests/unsafe_accessors.rs::new_unchecked_views_the_header_and_data_it_was_given; crates/hopper-native/tests/loader_input_conformance.rs builds views through deserialize_accounts |
AccountView::owner(&self) |
pub unsafe fn |
Returns &Address into the input buffer. The reference is invalid once the account is assigned or closed; the caller must not mutate the owner while it is live |
crates/hopper-native/tests/unsafe_accessors.rs::owner_reads_the_header_and_assign_replaces_it |
AccountView::assign(&self, new_owner) |
pub unsafe fn |
The account must be writable and the transfer must be authorized by the current owner program. Writes the 32-byte owner slot and nothing else | crates/hopper-native/tests/unsafe_accessors.rs::owner_reads_the_header_and_assign_replaces_it |
AccountView::borrow_unchecked(&self) / borrow_unchecked_mut(&self) |
pub unsafe fn |
No conflicting borrow may be live. The slice is the same region the checked borrow returns, and the borrow_state byte is neither read nor written |
crates/hopper-native/tests/unsafe_accessors.rs::borrow_unchecked_is_the_checked_region_and_leaves_the_borrow_state_alone |
AccountView::segment_ref_unchecked::<T>(offset) / segment_mut_unchecked::<T>(offset) |
pub unsafe fn |
offset + size_of::<T>() must not overflow and must be <= data_len; no conflicting borrow of the range may be live; for the mutable form the account must be writable. No bounds, size, writable, or borrow check runs. The checked segment_ref and segment_mut run all four |
crates/hopper-native/tests/unsafe_accessors.rs::unchecked_segments_address_the_same_bytes_as_the_checked_ones, crates/hopper-native/tests/unsafe_accessors.rs::checked_segments_refuse_what_the_unchecked_contract_assumes |
AccountView::raw_ref::<T>() / raw_mut::<T>() |
pub unsafe fn |
T: Pod. The call goes through the checked segment path, so it is bounds checked and borrow tracked; it is unsafe because it overlays T with no layout or discriminator check |
crates/hopper-native/tests/unsafe_accessors.rs::raw_ref_and_raw_mut_are_bounds_checked_and_borrow_tracked |
AccountView::resize_unchecked(new_len) |
pub unsafe fn |
The account must be writable, no data borrow may be live, and the growth over the entry length must be at most MAX_PERMITTED_DATA_INCREASE. Sets the length only: the caller zero-fills any grown region |
crates/hopper-native/tests/unsafe_accessors.rs::resize_unchecked_sets_the_length_and_nothing_else |
AccountView::close_unchecked() |
pub unsafe fn |
No borrow of the account may be live. Sets lamports and data length to zero and the owner to the System Program. It moves no lamports to a recipient and does not zero the data; the checked close zeroes the data and refuses a live borrow |
crates/hopper-native/tests/unsafe_accessors.rs::close_unchecked_clears_lamports_length_and_owner_without_touching_data |
hopper-native/src/raw_input.rs
| Entry point | Kind | Invariant | Test coverage |
|---|---|---|---|
deserialize_accounts::<MAX>() |
pub unsafe fn |
input must be the pointer the Solana loader passes to the BPF entrypoint. Walks marker bytes and canonical RuntimeAccount frames with strict alignment |
parse_instruction_frame_checked tests (lines 475-543 in raw_input.rs) mirror the same parser and cover malformed, forward-reference, self-reference, and EOF inputs; Kani raw_input::kani_proofs (CI lane kani-native-rawinput-proofs, scripts/kani-native-rawinput.{sh,ps1}): stride_lemma_matches_checked_align_offset_formula proves the next_record_offset stride over the full loader-reachable range, and the differential_* harnesses prove the fused walk's slot pointers/count/instruction tail equal the safe oracle's for N <= 3 symbolic frames (including the skip-only tail beyond MAX) |
deserialize_accounts_fast::<MAX>() |
pub unsafe fn |
Same as above, plus the caller must be running on a cluster where SIMD-0321 (the r2 instruction-data pointer) is active and pass the r2-derived data. Reached only through fast_entrypoint! built with the simd-0321 cargo feature, which also null-checks r2 and falls back to the scanning parser when the gate is inactive |
Same harness as eager variant; Kani differential_fast_walk_two_accounts + trap_fires_in_fast_walk_{dl0,dl_nonzero} |
scan_instruction_frame() |
pub unsafe fn |
Input must be a valid BPF buffer. Returns the (program_id, data) pair without claiming any account slots; caller responsible for the subsequent scan | parse_instruction_frame_checked tests; Kani differential_scan_frame_two_accounts + trap_fires_in_scan_frame_{dl0,dl_nonzero} |
malformed_duplicate_marker(marker, slot) |
fn(..) -> ! |
Never returns. On-chain: calls sol_panic_. Off-chain: panics. Exists to close the pre-audit "Must-Fix #1" where an attacker-supplied forward duplicate reference fell through to account zero |
Covered by the forward-reference and self-reference fixtures in the checked-parser tests; Kani trap_fires_* should_panic harnesses run against buffers sized exactly to the encoded frame (any access one byte past it is a CBMC violation) and prove, per harness, that the trap is reachable (existential) and that no path in the assumed malformed-marker space has a non-panic failure (no OOB read/write, no invalid write, no overflow); the concrete trap_slot_zero_marker_* sub-harnesses are deterministic (single path), so their trap verdicts are universal for those marker values; oracle_rejects_exactly_the_malformed_markers (assert-based) proves the safe parser rejects every malformed marker universally. "The fused walk traps on every malformed marker on every path" is not machine-checked by any single harness; it follows from the combination spelled out in the family (c) comment in raw_input.rs |
hopper-native/src/lazy.rs
| Entry point | Kind | Invariant | Test coverage |
|---|---|---|---|
lazy_deserialize(input) |
pub unsafe fn |
Same input contract as deserialize_accounts. Returns a LazyContext whose cursor points at the next unparsed account frame; no account is materialized yet |
The test module of crates/hopper-native/src/lazy.rs: crates/hopper-native/src/lazy.rs::agrees_with_duplicates, crates/hopper-native/src/lazy.rs::agrees_every_data_len_residue, crates/hopper-native/src/lazy.rs::agrees_exactly_max_254_accounts compare it with the eager parser |
LazyContext::parse_one_account() |
unsafe fn (private) |
The cursor must point at a duplicate marker or an account frame inside the input buffer, and at least one account must remain. next_account, skip, and drain_remaining check the remaining count before calling it |
crates/hopper-native/src/lazy.rs::skip_past_end_errors, crates/hopper-native/src/lazy.rs::drain_with_duplicates_aliases_canonical |
hopper-native/src/pda.rs
| Entry point | Kind | Invariant | Test coverage |
|---|---|---|---|
create_program_address syscall |
Seed-slice ABI and output buffer | Seed count/length bounds are checked; SBF slice descriptors have the asserted 16-byte layout; the output has space for 32 bytes | bench/framework-comparison/verifier/tests/pda_boundaries_sbf.rs |
Inline unsafe in verify_program_address |
Bounded descriptor array and hash output | Only the initialized descriptor prefix is exposed to the synchronous SHA-256 syscall; inputs remain live and the valid call fills the 32-byte output before it is read | bench/framework-comparison/verifier/tests/pda_boundaries_sbf.rs |
Inline unsafe in based_try_find_program_address |
Canonical descending bump search | Each iteration creates an immutable bump byte, writes its descriptor, then borrows the initialized prefix for hashing. No shared bump reference is reused after mutation. The hash is initialized before curve validation or return | bench/framework-comparison/verifier/tests/canonical_pda_sbf.rs and pda_boundaries_sbf.rs |
Inline unsafe in find_bump_for_address |
Hash-match search without curve validation | Uses the same per-iteration descriptor lifetime. Address equality alone proves neither off-curve membership nor canonicality; the caller must establish the required provenance separately | bench/framework-comparison/verifier/tests/pda_boundaries_sbf.rs |
hopper-native/src/mem.rs
| Entry point | Kind | Invariant | Test coverage |
|---|---|---|---|
memcpy(dst, src, n) |
pub unsafe fn |
dst and src must be valid for n bytes and must not overlap. sol_memcpy_ on chain, core::ptr::copy_nonoverlapping off chain |
crates/hopper-native/tests/mem_primitives.rs::memcpy_copies_exactly_n_bytes |
memmove(dst, src, n) |
pub unsafe fn |
Both regions valid for n bytes; they may overlap |
crates/hopper-native/tests/mem_primitives.rs::memmove_is_correct_when_the_regions_overlap |
memset(dst, byte, n) |
pub unsafe fn |
dst valid for n bytes |
crates/hopper-native/tests/mem_primitives.rs::memset_fills_exactly_n_bytes |
memcmp(a, b, n) |
pub unsafe fn returning Ordering |
Both pointers valid for n bytes |
crates/hopper-native/tests/mem_primitives.rs::memcmp_orders_by_the_first_differing_byte |
hopper-native/src/cpi.rs (borrow-conflict invariant, expanded)
As of R7, cpi::invoke_unchecked and cpi::invoke_signed_unchecked now carry
an explicit seven-item invariant list in their # Safety doc blocks. The
inventory is:
- No aliasing borrows into any account in
accountsfor the call's duration. accountscorresponds to real accounts from the program entrypoint (address + signer/writable flags).- Writability and signer flags match the
instruction's declared requirements. - Duplicate accounts impose caller responsibility for post-CPI re-borrow discipline.
instruction.program_id/accounts/datapointers are valid for the call's lifetime.- (signed variant) Every
Signerseed derivation hashes to a signer address inaccounts. - (signed variant) Seed slice lifetimes exceed the call duration.
Checked variants (invoke, invoke_signed) enforce 2-4 and 6 before routing
to the unchecked path; the typical caller should reach for those unless a CU
measurement justifies bypassing validation.
Post-CPI validation is deliberately split by handle type:
ForeignLens<T>holds its account-data borrow for the lens lifetime, so every safe CPI tier refuses a writable hand-off until that lens is dropped.ExternalAccount<T>repeats the adapter's owner, discriminator, length, and custom validation before every safe data, view, resolve, explain, or checked- proof issuance path.InterfaceAccount<T>repeats owner-set and interface-layout validation before every typed load or resolution. A changed owner fails withIncorrectProgramIdbefore bytes are interpreted.Account<T>andInitAccount<T>are entry/lifecycle-bound roles. Generated validation initially pinsAccount<T>to the executing program and later typed loads recheck the Hopper header, but the wrappers do not retain an owner witness.Derefandas_account()deliberately expose the lower-level raw view. Solana prevents a foreign CPI from reassigning caller-owned state, so code that deliberately closes, reassigns, and recreates an account must stop using the old wrapper.SystemAccountis also a point-in-time role and exposes no system-typed data. Recheck ownership after an intentional System Assign before relying on the role again.ExternalChecked<T, P>revalidates the adapter's base owner/type contract on later view access, but proof-specific mint, authority, freshness, or other business facts are point-in-time evidence. They must not cross a CPI that can mutate the proven account; callers re-runchecked()afterward. The unsafe unchecked CPI tier cannot enforce this rule.
Verification
cargo check --workspace --all-targets
cargo test --workspace --no-fail-fast
cargo test --test ui --features proc-macros --locked
cargo test -p hopper-trybuild --locked
cargo test --test require_macros # 16 guard-macro tests pass
cargo test --test migrate_integration --features proc-macros # 8 migration-chain tests pass
cargo test --test hybrid_tail_integration --features proc-macros # 8 dynamic-tail tests pass
cargo test -p hopper-schema rust_client # 9 Rust-client-gen tests pass
cd fuzz && cargo check # fuzz crate structure compiles
cargo build-sbf --manifest-path examples/hopper-parity-vault/Cargo.toml # 15 KiB .so
cargo build-sbf --manifest-path examples/hopper-token-2022-vault/Cargo.toml # 20 KiB .so
cargo run -p hopper-cli -- verify @examples/sample-manifest.json # manifest integrity + binary scan
cargo run -p hopper-cli -- compile --emit rust-client @examples/sample-manifest.json # Rust client emits
September 26 lifecycle correction (0.4.2)
Host borrow tracking did not exercise the former unchecked SBF segment path.
Segment guards now retain the canonical native borrow as well as their range
lease. Compiled v0/v3 regressions test conflicts through a second registry,
whole-account access, resize, close, and checked CPI. Explicit disjoint splits
retain one whole-account exclusive borrow. Construction is crate-private so
unchecked offsets cannot reach the safe all_mut and get_mut methods.
Close operations preflight all applicable local checks before changing data or balances. Native borrow mappings transfer the same lease; a panicking mapping closure still drops the original guard during host unwinding. This targeted review and the generated inventory are not an independent security audit.
