Policy guarantees
Strict, sealed, and raw modes, plus what each access tier promises.
Reference for HopperProgramPolicy intent markers, handler unsafe-code lints,
and the separate byte/lamport write policies. The intent markers do not insert
account validation or token CPI checks by themselves.
Named modes
| Mode | strict |
enforce_token_checks |
allow_unsafe |
|---|---|---|---|
HopperProgramPolicy::STRICT |
true | true | true |
HopperProgramPolicy::SEALED |
true | true | false |
HopperProgramPolicy::RAW |
false | false | true |
STRICT is the shipping default returned by HopperProgramPolicy::default_policy().
Naming is intentionally literal:
STRICTdeclares the author's intent to use typed account validation and explicit token pre-checks. Handler types and helper calls determine which checks execute. It permits explicitunsafeblocks.SEALEDmeansSTRICTplusallow_unsafe = false; handler bodies cannot contain unsafe code unless an instruction explicitly opts intounsafe_memory.RAWmeans Hopper's automatic validation/token envelope is not promised. The author owns every signer, owner, layout, PDA, token, and aliasing check.
Choose STRICT for programs using typed validation, SEALED to deny unsafe
code in handler items by default, and RAW to declare hand-validated paths.
None of these labels establishes that a program has been audited.
What each lever controls
strict
Declares the author's intent to use typed contexts (Ctx<MyAccounts>). The
macro does not enforce that every handler is typed: a raw &mut Context<'_>
handler remains raw even when strict = true. A typed handler always runs
MyAccounts::bind(ctx)? before its body, regardless of this flag. Binding
performs the checks declared by that context, including:
- signer
- mut / owner / executable / address
- duplicate-writable / signer rules
- PDA derivation
- init / realloc / close preconditions
constraint = expr
Flipping to strict = false is an intent marker: the author plans to use raw &mut Context<'_> handlers or other hand-validated paths and accepts responsibility for calling validate() where needed. Typed Ctx<T> handlers still bind. The handler's parameter type is the final word.
enforce_token_checks
Author-maintained token-check intent. The constant does not rewrite CPI code
or insert checks. For TransferChecked, BurnChecked, and ApproveChecked,
the explicit strict invocation methods provide these pre-checks:
| Invocation | Pre-check | Where |
|---|---|---|
invoke_strict() |
Authority has signer privilege, and the token account's owner field matches it |
require_authority_signed_direct and require_token_authority in crates/hopper-runtime/src/token.rs |
invoke_signed_strict(seeds) |
Token account's owner field matches authority |
require_token_authority; Solana validates the supplied PDA signer seeds during CPI |
The signed strict path does not require the PDA authority to arrive with
signer privilege. The direct strict path returns MissingRequiredSignature
when that privilege is absent; both return IncorrectAuthority on owner-field
mismatch. This field comparison does not establish token-program ownership or
validate the complete token account layout. The invoked SPL Token program
still validates the operation and its authorization. These owner-specific
helpers do not cover every valid delegate or multisig operation.
The policy constant does not rewrite or statically inspect arbitrary CPI code.
Calls that bypass the strict helpers are outside this promise and require code
review; #[instruction(..., skip_token_checks)] records an intentional
per-handler exception.
Flipping to enforce_token_checks = false changes the declared intent; it does
not remove checks from explicit helper calls. The SPL program's checks still
run whenever it is invoked.
allow_unsafe
When true (default), handler bodies can contain unsafe { ... } blocks and the hopper_unsafe_region! macro.
When false, the program macro emits #[deny(unsafe_code)] on every handler that does not carry #[instruction(N, unsafe_memory)]. Any stray unsafe { ... } fails to compile. The per-instruction override restores unsafe for a single handler without affecting the rest of the module.
The lint applies to the handler item. It does not audit called helpers,
dependencies, or other module items, and SEALED is not a proof that the
whole program contains no unsafe implementation. Ordinary Rust lint override
rules still apply.
What each policy drops
| Policy | Dropped invariant | What this means |
|---|---|---|
strict = false |
Declared intent to use typed contexts throughout | Raw handlers own their validation under either flag value. Typed-context handlers still bind. |
enforce_token_checks = false |
Declared token pre-check intent | Explicit helper checks remain unchanged; the flag does not remove or insert CPI validation. |
allow_unsafe = false |
Default permission for unsafe code in handler items | The macro adds #[deny(unsafe_code)] unless the handler opts in via #[instruction(N, unsafe_memory)]. |
#[instruction(N, unsafe_memory)] |
Program-level #[deny(unsafe_code)] for this handler only |
Raw pointer access restored for this one handler. Other handlers stay sealed. |
#[instruction(N, skip_token_checks)] |
Program-level token-check promise for this handler | Author documents why the checks are upheld elsewhere (or not needed). |
Zero-cost property
Every lever is a compile-time bool on a Copy + const struct. Readers call
HOPPER_PROGRAM_POLICY.<lever> in const context; branches that actually
consult a lever can fold to one code path during codegen. The policy value has
no runtime state, thread-local, or syscall. RAW does not remove validation
from a typed Ctx<T> handler: typed handlers still bind. A raw &mut Context
handler that deliberately omits Hopper validation can avoid that validation
cost, while the author assumes every omitted invariant.
Grep receipts
An auditor lands in the tree and wants a one-command inventory of every raw-pointer region:
grep -rn "hopper_unsafe_region!" crates/ examples/
This finds named hopper_unsafe_region! calls. It does not enumerate ordinary
unsafe blocks elsewhere. The macro expands to unsafe { ... }, so the name
provides a review hook without changing that block's codegen.
For the stricter "every unsafe region in the tree, Hopper or otherwise":
grep -rn "unsafe " crates/ examples/ tools/
Hopper's internals use unsafe for the zero-copy core (pointer casts, syscall wrappers, Pod overlays). Those regions are documented in UNSAFE_INVARIANTS.md.
Worked examples
examples/hopper-policy-vault/src/lib.rs::strict_vault,HopperProgramPolicy::STRICTfor a conventional vault.examples/hopper-policy-vault/src/lib.rs::sealed_vault::fast_sweep,SEALEDprogram with one handler opting intounsafe_memory.examples/hopper-policy-vault/src/lib.rs::raw_vault::hybrid_bump,RAWprogram demonstrating the safe -> unsafe -> safe mixed pattern inside one handler.
Growable Seq<T> tails under strict_writes
A Seq<'a, T> tail (see DYNAMIC_TAILS.md)
is an open-ended, growable list. Under #[hopper::context(strict_writes)] it is
declared with tail(<field>), which compiles to a single open-ended write
range, WriteRange::tail_from(idx, HEADER_LEN + <Layout>::<FIELD>_OFFSET) =
{ offset: TAIL_PREFIX_OFFSET, size: u32::MAX }.
What this guarantees:
| Property | Guarantee |
|---|---|
| Tail is writable and growable | The policy permits writes within the allocated tail; ordinary bounds, writability, framing, and borrow checks still apply. push/set/swap_remove use the gated ctx.tail_seq_mut::<T>(idx, off) cursor. Growth via realloc needs no range re-declaration, but still follows resize and funding requirements. |
| Fixed head stays byte-protected | The range starts past the head (offset != 0), so every head byte lies outside it. A write to any head field is refused at acquisition with Custom(0xD000 | idx). |
Writable CPI delegation under strict_writes, lamports(...) |
With the lamport dimension declared, writable delegation through validated CPI helpers requires both a whole-account data grant and lamport permission. A tail-only range fails that test. Bare strict_writes intentionally leaves writable CPI delegation and direct lamport mutation ungoverned. |
| One tail lease per acquire | Acquiring the cursor registers one segment lease over the whole tail region, not one per element. Large element counts therefore do not create per-element touch records. The instruction-wide touch log still has a finite capacity and can overflow from other distinct acquires. |
Structural rules:
- One growable tail per account. The
[count][elems]framing is the whole tail; put other dynamic data in the fixed head or a separate account. - Growth cap: 10,240 B per instruction. Growing the tail is a
realloc, so it inherits Solana'sMAX_PERMITTED_DATA_INCREASE.
The honest limit (per-element isolation):
The declared
tail_fromrange covers the entire tail region as one grant. The write policy therefore isolates the head from the tail, and the tail of one account from every other account; it does not isolate one tail element from another. Per-element / sub-range exclusion within the tail is the segment registry's job (segment_borrow): aTailSeqMutacquire takes one exclusive tail-region lease, so two live cursors over the same tail conflict (AccountBorrowFailed), but a single cursor may freely mutate any element. If you need independently borrowed sub-regions of the variable data, reach for named extension segments instead of aSeqtail.
Related
- policy.rs,
HopperProgramPolicyandHopperInstructionPolicydefinitions. - write_policy.rs,
WriteRange::tail_fromand the byte-range / lamport gate. - tail.rs,
Seq<T>cursors (TailSeq/TailSeqMut) andSeqElement. - program.rs, policy parser + handler emission.
- UNSAFE_INVARIANTS.md, framework-level unsafe inventory.
