# Unsafe Invariants Ledger - Trust Posture Document
## 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
the zero-copy property that makes Hopper competitive.
**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.
Every `unsafe` block in Hopper, its justification, and the invariants
that must hold. Organized by module boundary.
## Current Unsafe Source Inventory
This inventory is generated from the current source tree by scanning for real
non-rustdoc `unsafe {` blocks and `pub unsafe fn` identifiers in the audited
source directories. Every listed unsafe block must have a nearby `// SAFETY:`
comment, and every public unsafe function must carry a rustdoc `# Safety`
section. The CI helper `scripts/check-unsafe-safety-comments.py` enforces both
requirements.
| Source file | Unsafe blocks | Public unsafe functions |
|---|---:|---|
| `crates/hopper-core/src/abi/field_ref.rs` | 1 | - |
| `crates/hopper-core/src/abi/typed_address.rs` | 2 | - |
| `crates/hopper-core/src/account/cursor.rs` | 1 | - |
| `crates/hopper-core/src/account/pod.rs` | 6 | `cast_unchecked`, `cast_unchecked_mut` |
| `crates/hopper-core/src/account/reader.rs` | 2 | - |
| `crates/hopper-core/src/account/registry.rs` | 8 | - |
| `crates/hopper-core/src/account/segment.rs` | 6 | - |
| `crates/hopper-core/src/account/verified.rs` | 6 | - |
| `crates/hopper-core/src/accounts/hopper_account.rs` | 1 | `owner` |
| `crates/hopper-core/src/accounts/program_account.rs` | 1 | `owner` |
| `crates/hopper-core/src/accounts/segmented.rs` | 1 | - |
| `crates/hopper-core/src/accounts/unchecked.rs` | 1 | `owner` |
| `crates/hopper-core/src/check/fast.rs` | 3 | - |
| `crates/hopper-core/src/check/guards.rs` | 1 | - |
| `crates/hopper-core/src/check/mod.rs` | 4 | - |
| `crates/hopper-core/src/collections/fixed_vec.rs` | 6 | - |
| `crates/hopper-core/src/collections/journal.rs` | 3 | - |
| `crates/hopper-core/src/collections/packed_map.rs` | 4 | - |
| `crates/hopper-core/src/collections/ring_buffer.rs` | 2 | - |
| `crates/hopper-core/src/collections/slab.rs` | 5 | - |
| `crates/hopper-core/src/collections/slot_map.rs` | 3 | - |
| `crates/hopper-core/src/collections/sorted_vec.rs` | 5 | - |
| `crates/hopper-core/src/cpi/mod.rs` | 10 | - |
| `crates/hopper-core/src/event/mod.rs` | 8 | - |
| `crates/hopper-core/src/frame/mod.rs` | 17 | `segment_mut_unchecked` |
| `crates/hopper-core/src/virtual_state/mod.rs` | 2 | - |
| `crates/hopper-native/src/account_view.rs` | 52 | `owner`, `assign`, `borrow_unchecked`, `borrow_unchecked_mut`, `segment_ref_unchecked`, `segment_mut_unchecked`, `raw_ref`, `raw_mut`, `resize_unchecked`, `close_unchecked` |
| `crates/hopper-native/src/address.rs` | 1 | - |
| `crates/hopper-native/src/batch.rs` | 1 | - |
| `crates/hopper-native/src/borrow.rs` | 3 | `from_raw_parts`, `from_raw_parts` |
| `crates/hopper-native/src/budget.rs` | 3 | - |
| `crates/hopper-native/src/cpi.rs` | 9 | `invoke_unchecked`, `invoke_signed_unchecked` |
| `crates/hopper-native/src/entrypoint.rs` | 11 | `process_entrypoint` |
| `crates/hopper-native/src/hash.rs` | 2 | - |
| `crates/hopper-native/src/instruction.rs` | 5 | - |
| `crates/hopper-native/src/introspect.rs` | 2 | - |
| `crates/hopper-native/src/lazy.rs` | 9 | `lazy_deserialize` |
| `crates/hopper-native/src/lens.rs` | 13 | - |
| `crates/hopper-native/src/log.rs` | 5 | - |
| `crates/hopper-native/src/mem.rs` | 13 | `memcpy`, `memmove`, `memset`, `memcmp` |
| `crates/hopper-native/src/pda.rs` | 21 | - |
| `crates/hopper-native/src/project.rs` | 8 | `project_safe_mut`, `project_mut`, `project_hopper_mut` |
| `crates/hopper-native/src/raw_input.rs` | 33 | `deserialize_accounts`, `deserialize_accounts_fast`, `scan_instruction_frame` |
| `crates/hopper-native/src/return_data.rs` | 2 | - |
| `crates/hopper-native/src/system.rs` | 1 | - |
| `crates/hopper-native/src/sysvar.rs` | 3 | - |
| `crates/hopper-native/src/token.rs` | 1 | - |
| `crates/hopper-native/src/verify.rs` | 1 | - |
| `crates/hopper-runtime/src/account.rs` | 26 | `owner`, `raw_ref`, `raw_mut`, `assign`, `borrow_unchecked`, `borrow_unchecked_mut`, `resize_unchecked`, `close_unchecked` |
| `crates/hopper-runtime/src/account_wrappers.rs` | 0 | `new_unchecked`, `new_unchecked`, `new_unchecked` |
| `crates/hopper-runtime/src/address.rs` | 1 | - |
| `crates/hopper-runtime/src/audit.rs` | 2 | - |
| `crates/hopper-runtime/src/borrow.rs` | 13 | `project`, `project` |
| `crates/hopper-runtime/src/borrow_registry.rs` | 2 | - |
| `crates/hopper-runtime/src/compat/mod.rs` | 1 | - |
| `crates/hopper-runtime/src/compat/native.rs` | 9 | `wrap_account_slice`, `account_owner`, `assign`, `process_entrypoint` |
| `crates/hopper-runtime/src/compat/pinocchio.rs` | 17 | `wrap_account_slice`, `account_owner`, `assign`, `process_entrypoint` |
| `crates/hopper-runtime/src/compat/solana_program.rs` | 16 | `borrow_unchecked`, `borrow_unchecked_mut`, `close_unchecked`, `wrap_account_slice`, `account_owner`, `assign`, `process_entrypoint` |
| `crates/hopper-runtime/src/context.rs` | 4 | `raw_ref`, `raw_mut`, `raw_unchecked`, `as_mut_ptr` |
| `crates/hopper-runtime/src/cpi.rs` | 12 | `invoke_unchecked`, `invoke_signed_unchecked` |
| `crates/hopper-runtime/src/dyn_cpi.rs` | 1 | - |
| `crates/hopper-runtime/src/foreign.rs` | 1 | - |
| `crates/hopper-runtime/src/instruction.rs` | 4 | - |
| `crates/hopper-runtime/src/interop.rs` | 2 | - |
| `crates/hopper-runtime/src/layout.rs` | 3 | - |
| `crates/hopper-runtime/src/lib.rs` | 11 | - |
| `crates/hopper-runtime/src/log.rs` | 4 | - |
| `crates/hopper-runtime/src/option_byte.rs` | 2 | - |
| `crates/hopper-runtime/src/pda.rs` | 7 | - |
| `crates/hopper-runtime/src/segment_lease.rs` | 1 | `new` |
| `crates/hopper-runtime/src/syscall.rs` | 2 | - |
| `crates/hopper-runtime/src/syscalls.rs` | 7 | `sol_log_data`, `sol_sha256` |
| `crates/hopper-runtime/src/token.rs` | 10 | - |
| `crates/hopper-solana/src/compute.rs` | 1 | - |
| `crates/hopper-solana/src/crypto/merkle.rs` | 2 | - |
| `crates/hopper-solana/src/mint.rs` | 2 | - |
| `crates/hopper-solana/src/token.rs` | 2 | - |
## Trust Summary
Hopper's unsafe surface is deliberately narrow and follows three foundational rules:
1. **All overlay targets are alignment-1.** No pointer cast in the codebase produces a reference with `align > 1`. This eliminates alignment UB entirely.
2. **All casts are bounds-checked.** Every `pod_from_bytes` / `overlay_at` / `read_unaligned` call is preceded by a length check against `T::SIZE` or explicit offset arithmetic.
3. **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 | Test Suite | File |
|---|---|---|
| Pod boundary rejection | 38 tests | `tests/unsafe_boundary_tests.rs` |
| Overlay checked/unchecked parity | 24 tests | `tests/overlay_equivalence_tests.rs` |
| Compat regression & receipt wire format | 26 tests | `tests/compat_regression_tests.rs` |
| Property-based ABI roundtrip | 36 tests | `tests/property_tests.rs` |
| CPI guard, collections, registry, validation | 96 tests | `tests/trust_tests.rs` |
### 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
1. **`#![deny(unsafe_op_in_unsafe_fn)]`** -- enforced in `hopper-core` and `hopper-solana`. All unsafe operations must be explicitly wrapped even inside `unsafe fn`.
2. **Pod trait** -- `unsafe trait Pod: Copy + Sized` requires `align_of == 1` and all bit patterns valid. Every `unsafe impl Pod` is for types whose fields are `[u8; N]` or nested Pod types under `#[repr(C)]`/`#[repr(transparent)]`.
3. **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`
| 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 proof wrappers, not ordinary raw
account accessors. 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
pre-validated raw slice, so the reference cannot outlive the proof object 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 `&mut` access
- [ ] 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:
1. **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, verify `align_of == 1`
with a compile-time assertion.
2. **Is the slice length checked before the cast?** Every `pod_from_bytes`,
`overlay_at`, and manual pointer cast must be preceded by
`data.len() >= T::SIZE` or equivalent bounds arithmetic.
3. **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.
4. **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.
5. **Is the `// SAFETY:` comment accurate and complete?** It must state
the precondition, why it holds, and what would go wrong if it didn't.
6. **Are MaybeUninit uses fully initialized before read?** CPI builders
use `MaybeUninit` arrays. Verify that `add_account()` is called for
every slot before `invoke()`.
7. **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 (12 tests covering all guard variants) |
| `receipt` | Fingerprint hashing of account data | FNV-1a deterministic, before/after tracked | `receipt_*` trust tests (12 tests) + `prop_receipt_*` property tests (9 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::MAX` overflow check, header wire layout verification, segment descriptor boundary conditions, wire type roundtrips, unchecked cast parity.
- **`tests/overlay_equivalence_tests.rs`** - `pod_from_bytes` vs `pod_read` value equivalence, `VerifiedAccount::get()` vs raw pod parity, `overlay_at` vs manual slice pod parity, `cast_unchecked` vs checked parity, mutable write-through equivalence, wire type overlay vs raw bytes, header overlay vs constructor.
---
## Hopper Safety Audit Response (2026-04)
The independent **Hopper Safety Audit** (see `docs/Hopper Safety Audit.docx`)
flagged four specific unsound or permissive surfaces. This section records
the action taken on each finding and the invariants the fix now enforces.
### 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](../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`](../crates/hopper-runtime/src/pod.rs)
trait now lives at the substrate layer ([`hopper_native::Pod`](../crates/hopper-native/src/pod.rs))
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 `__SegT` escapes from `#[hopper::context]`
### Finding 3. `Projectable` trait too permissive (`Copy + 'static`)
**Fix landed:** [crates/hopper-native/src/project.rs](../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](../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](../tools/hopper-cli)).
### New compile-time fences (Quasar-inspired hardening)
`#[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](../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>()` + subsequent
`segment_mut` rejected via the native state byte.
- `live_load_mut_blocks_segment_ref`. exclusive `load_mut` rejects a
concurrent `segment_ref` even 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_fields` report accuracy, `is_backward_readable` / `requires_migration` correctness, receipt wire format encode/decode roundtrip, Phase/CompatImpact enum roundtrips, segment/field mask roundtrip, reserved byte verification.
---
# Post-Audit Closure
**Last verified: 2026-04-20. 647 workspace tests pass, zero failures.**
This section enumerates every item in the `docs/Hopper Safety Audit.docx`
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:33-92` (3 doctests); `tests/compile_fail/pod_*.rs` (5 trybuild fixtures covering `bool`, `char`, reference, missing repr, padded) |
## 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 (2 of 4 DONE; 2 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 | Anchor-grade declarative account constraints | **DONE (parser + validation + lifecycle)**. `crates/hopper-macros-proc/src/context.rs` now parses `init/zero/close/realloc/realloc_payer/realloc_zero/payer/space/seeds/bump/has_one/owner/address/constraint`; emits ordered validation per audit page 12; generates `init_{field}`/`close_{field}`/`realloc_{field}` lifecycle helpers. Deferred: typed wrappers `Signer<'info>`/`Account<T>` (attribute-directed lowering is functionally equivalent today) |
| 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` | **DEFERRED**. existing `hopper client gen --ts` / `--kt` already emit those targets through separate code paths (`TsClientGen`, `KtClientGen`); unifying them under `--emit` is a CLI refactor that doesn't touch the safety story |
## 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-language compile pipeline end-to-end | **DEFERRED**. requires ST4 plus manifest->IDL->client unification; orthogonal to the safety audit |
| 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**. 12 trybuild fixtures in `tests/compile_fail/`: 5 Pod cases (bool/char/reference/missing-repr/padded), 5 state-constraint cases (init_no_payer/init_no_space/seeds_no_bump/realloc_no_payer/realloc_no_zero), `pod_vec_field` (heap types rejected), `zerocopy_seal_required` (proof that bypassing `#[hopper::pod]` cannot earn `ZeroCopy`). Wired via `tests/ui.rs` |
| D3 | Fuzzing low-level loaders/parsers | **DONE**. `fuzz/` crate with 4 targets (`fuzz_instruction_frame`, `fuzz_decode_header`, `fuzz_decode_segments`, `fuzz_pod_overlay`) + new safe bounds-checked parser `parse_instruction_frame_checked` in `raw_input.rs` with 7 regression tests |
| D4 | Benchmark suite across frameworks | **DONE as a sibling product**. The `hopper-bench` repo owns primitive benchmarks, cross-framework parity vaults, competitor locks, raw logs, and CI thresholds; this framework repo keeps release docs and lightweight result snapshots only. |
## 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 |
## Winning-architecture design closure
On top of the original audit, a follow-up design pass
(`we're designing the winning architecture.rs`, 3000+ line doc) 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` |
| Validation auto-injection (audit final-API Step 7) | `crates/hopper-macros-proc/src/program.rs:184` - every `#[hopper::program]` dispatcher emits `<ContextSpec>::bind(ctx)?`, which runs the context's `validate(ctx)?` before handing the bound context to the user's handler body. Users cannot reach the handler without the full signer/mut/owner/address/PDA/layout/has_one/constraint gauntlet passing first |
| 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) | `crates/hopper-runtime/src/ref_only.rs::HopperRefOnly` 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`) | `crates/hopper-runtime/src/policy.rs::HopperProgramPolicy` 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 |
| Token-authority ownership pre-check (`enforce_token_checks`) | `crates/hopper-runtime/src/token.rs::require_token_authority` verifies the SPL TokenAccount's `owner` field (bytes `[32..64]`) matches the authority's address before any CPI. Wired into `TransferChecked::invoke_strict`, `TransferChecked::invoke_signed_strict`, `BurnChecked::invoke_strict`, `BurnChecked::invoke_signed_strict`, `ApproveChecked::invoke_strict`, `ApproveChecked::invoke_signed_strict`. Handlers inside `#[hopper::program(enforce_token_checks = true)]` use the `*_strict` variants to get the attacker-passes-correct-pubkey-but-wrong-signer exploit class closed with a `ProgramError::IncorrectAuthority` before the CPI instead of an opaque SPL failure. Three regression tests in `token::tests` pin the accept/reject/short-buffer paths |
## Audit Provability
The three audit findings that remained open after the enforcement pass asked for *provable* invariants, not just enforced ones. 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 minimal | sibling `hopper-bench` parity artifacts | Hopper `authorize = 432 CU` vs Quasar `585 CU`; `deposit = 1651 CU` vs `1768`; `binary_size = 7.62 KiB` vs `8.36`. The older Quasar-authored "Pinocchio-style" column is excluded from release claims. Methodology is owned by the benchmark repo: pinned toolchain, equivalent-logic rule, shared vault contract, seed count, and exact command line | Any regression is caught by the sibling parity runner, which records a `cu_delta` vs the Hopper baseline; the safety-correctness gate (`unsigned_withdraw_rejected`) excludes any framework that trades safety for speed |
## 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 unsafe fn` | `ptr` must point at a valid `RuntimeAccount` inside the loader-provided BPF input buffer and must remain dereferenceable for the lifetime of the returned view. Only constructed by `raw_input::deserialize_accounts` and `lazy::parse_one_account`, both of which consume a bounds-checked cursor | `tests/parse_harness.rs` exercises the construction via `deserialize_accounts` end-to-end with deterministic fixtures |
| `AccountView::owner(&self)` | `pub unsafe fn` | Returns `&Address` into the BPF input buffer. Caller must not construct a mutable borrow of the same account's header concurrently. Hopper's safe helpers (`read_owner`, `owned_by`, `check_owner`) copy the bytes and drop the reference before returning | `account_view::tests::owner_readback` (same-crate) |
| `AccountView::assign(&self, new_owner)` | `pub unsafe fn` | Account must be writable AND the program must own the account. Writes 32 bytes into the header owner slot | `close_flow_tests::owner_zeroed_on_close` |
| `AccountView::borrow_unchecked(&self)` / `borrow_unchecked_mut(&self)` | `pub unsafe fn` | Caller must ensure no conflicting borrow via the safe API is live. Bypasses the 1-byte `borrow_state` field | `account_view::tests::unchecked_borrow_roundtrip`, `unsafe_boundary_tests.rs::unchecked_mut_conflicts` (compile-fail check) |
| `AccountView::segment_ref_unchecked(offset, size)` / `segment_mut_unchecked(offset, size)` | `pub unsafe fn` | `offset + size` must be `<= data_len`. Caller owns alignment and aliasing for the returned slice. Safe variants (`segment_ref`, `segment_mut`) bounds-check and route through `SegmentBorrowRegistry` | `segment_bounds_tests.rs` covers undersized, oversized, and overlapping borrows |
| `AccountView::raw_ref::<T>()` / `raw_mut::<T>()` | `pub unsafe fn` | `T: Pod`, `size_of::<T>() <= data_len`, no concurrent borrow. These are the Tier C hot-path accessors | `pod_tier_tests.rs::raw_ref_matches_safe_overlay` (equivalence) |
| `AccountView::resize_unchecked(new_len)` | `pub unsafe fn` | `new_len <= MAX_PERMITTED_DATA_INCREASE + original_len`; caller has no live borrows into the data slice | `realloc_tests.rs::resize_growth_and_shrink` |
| `AccountView::close_unchecked(dest)` | `pub unsafe fn` | Caller holds no live borrows into the closing account; writes `CLOSE_SENTINEL` into discriminator slot after transferring lamports | `close_flow_tests::close_sentinel_present` |
### `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 |
| `deserialize_accounts_fast::<MAX>()` | `pub unsafe fn` | Same as above, plus the caller must be running on SVM ≥ 1.17 with the two-register entrypoint convention. Hopper's `fast_entrypoint!` macro is the only well-typed caller | Same harness as eager variant |
| `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 |
| `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 |
### `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 accounts are materialised yet | `lazy_tests.rs` exercises single-account, multi-account, and duplicate-account partial scans |
| `LazyContext::parse_one_account()` | `unsafe fn` (crate-private) | Cursor must point at a valid marker or canonical frame boundary. Precondition checked by `advance_cursor` in the caller | Covered transitively by `lazy_tests.rs` |
| `LazyContext::advance_cursor()` / `advance_non_dup_cursor()` | `unsafe fn` (crate-private) | Cursor must be inside the BPF buffer; advances by the account frame's declared size | Same |
### `hopper-native/src/pda.rs`
| Entry point | Kind | Invariant | Test coverage |
|---|---|---|---|
| Inline `unsafe` in `verify_program_address` | Bounded seed array construction from `MaybeUninit` | All `MaybeUninit` slots are written before the slice is exposed to `sol_sha256`; `assume_init_ref` is called only after every slot is initialized | `pda_tests.rs::verify_program_address_sha256_only` |
| Inline `unsafe` in `based_try_find_program_address` (3 blocks) | Bump iteration with per-iteration seed mutation | Each iteration writes a fresh bump byte into the last seed slot before the sha256 call. Safety comment at the top of the loop documents that the slot is always overwritten | `pda_tests.rs::bump_iteration_exhaustive` |
| Inline `unsafe` in `find_bump_for_address` (3 blocks) | Same pattern as `based_try_find_program_address` but skips `sol_curve_validate_point`. Safe because PDAs are off-curve by construction and the check compares the resulting address to a known-on-chain PDA | `pda_tests.rs::find_bump_skips_curve_validate` |
### `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; regions must not overlap. Dispatches to `sol_memcpy_` on-chain and `core::ptr::copy_nonoverlapping` off-chain | `mem_tests.rs::memcpy_roundtrip` |
| `memmove(dst, src, n)` | `pub unsafe fn` | Regions may overlap | `mem_tests.rs::memmove_overlapping_slices` |
| `memset(dst, byte, n)` | `pub unsafe fn` | `dst` valid for `n` bytes | `mem_tests.rs::memset_zero` |
| `memcmp(a, b, n, result)` | `pub unsafe fn` | Both pointers valid for `n` bytes; `result` writable for one `i32` | `mem_tests.rs::memcmp_equal_and_nonequal` |
### `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:
1. No aliasing borrows into any account in `accounts` for the call's duration.
2. `accounts` corresponds to real accounts from the program entrypoint (address + signer/writable flags).
3. Writability and signer flags match the `instruction`'s declared requirements.
4. Duplicate accounts impose caller responsibility for post-CPI re-borrow discipline.
5. `instruction.program_id` / `accounts` / `data` pointers are valid for the call's lifetime.
6. (signed variant) Every `Signer` seed derivation hashes to a signer address in `accounts`.
7. (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.
## Verification
```bash
cargo check --workspace --all-targets # green (pre-existing deprecation warnings only)
cargo test --workspace --no-fail-fast # 740 passed, 0 failed, 133 ignored
cargo test --test ui --features proc-macros # 2 trybuild tests, 10 fixtures, all pass
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
```