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 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.
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.
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:
- All overlay targets are alignment-1. No pointer cast in the codebase produces a reference with
align > 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 | 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
#![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
| 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
&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 (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::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.
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
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).
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
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 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 carries 3 compile-fail doctests for non-Pod overlays; the root tests/ui.rs suite has 17 compile-fail fixtures under tests/compile_fail/; tests/hopper-trybuild/tests/ui/ adds 6 generated macro-DX fixtures covering minimal state expansion, Pod alignment accept/reject, #[state] Copy enforcement, #[program] crank handler arity, and verified-ref lifetime rejection |
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 | 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 |
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-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. The current UI proof lanes are cargo test --test ui --features proc-macros --locked for 17 root compile-fail fixtures, plus cargo test -p hopper-trybuild --locked for 2 pass fixtures and 4 compile-fail fixtures under tests/hopper-trybuild/tests/ui/. Together they pin Pod validity, state constraints, dynamic tails, tiny profile restrictions, borrow guards, generated state/program diagnostics, and prelude ergonomics. |
| 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 |
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 |
| 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 = 431 CU, counter_access = 551 CU, deposit = 1669 CU, withdraw = 453 CU, binary 7.53 KiB; Anza Pinocchio authorize = 2512 CU, counter_access = 2539 CU, deposit = 3856 CU, withdraw = 2548 CU, binary 7.73 KiB; Quasar deposit = 1767 CU, withdraw = 603 CU, binary 6.27 KiB. Quasar's upstream vault does not implement authorize / counter_access, so those cells are n/a. 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:
- 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.
Verification
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 --locked # 4 trybuild tests, 17 compile-fail fixtures
cargo test -p hopper-trybuild --locked # 2 trybuild tests, 6 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
