Skip to content
hopper
Get started
Model / composite-contexts

Composite contexts

Nest one accounts context inside another with Anchor-style flattening, and write-sets, events, and touch maps that compose across the boundary.

This reference covers Hopper 0.5.0, published on crates.io. See the release status for package verification, dated benchmarks, and remaining audit work.

Composite contexts let an accounts struct embed a reusable validation group. Hopper flattens those groups into the instruction account list and composes outer options with inner declarations. Macro-declared inner ranges feed the outer runtime gate when mutation stays on Hopper-tracked typed paths.

The shape

Hopper uses an explicit #[composite] marker where Anchor infers embedding from any non-wrapper field type. Hopper refuses to guess. Say so in the accounts declaration; the restrictions below define the supported scope.

// Anchor
#[derive(Accounts)]
pub struct Operate<'info> {
    pub payer: Signer<'info>,
    pub check: VaultCheck<'info>,      // composite, inferred
    #[account(mut)]
    pub tail: Account<'info, Vault>,
}

// Hopper
#[derive(Accounts)]
pub struct Operate<'info> {
    pub payer: Signer<'info>,
    #[composite]
    pub check: VaultCheck<'info>,      // composite, declared
    #[account(mut)]
    pub tail: Account<'info, Vault>,
}

Slots flatten in declaration order exactly like Anchor (payer, check.authority, check.vault, tail), ctx.bumps.check nests the inner context's bumps, and clients pass the same flat account list. ACCOUNT_COUNT sums as a const expression, the composite-free lowering stays byte-identical when no composite is present, and the schema metadata splices the inner context's descriptors per flattened slot with a const assert pinning descriptor count == ACCOUNT_COUNT.

v2: options compose across the boundary

The interesting property is what happens to the container's options:

  • strict_writes (and its lamports(...) dimension) compiles a compile-time-composed write-set: outer leaves at flattened const-expr indices, and each inner context's declared mut / mut(seg) structure spliced in with rebased indices. An inner segment lease is enforced from the outer gate as it would be standalone, with the same Custom(0xD000|idx) refusals. Equality tests pin the generated manifest ranges to the runtime policy constants. Raw and unchecked paths remain outside that scoped guarantee.
  • event_cpi's two synthetic slots trail the flattened set at const-expr indices.
  • emit_touch_map records land at flattened slots.
  • auto_lifecycle works: every lifecycle sibling-role lookup (init payer/system_program, close/sweep targets, realloc payer, Metaplex roles) is rebased composite-aware.

Expansion tests plus hopper-svm integration suites cover both v1 and v2 (tests/composite_contexts_integration.rs, tests/composite_options_integration.rs in the framework repo): wrong inner owner fails, post-composite fields resolve to the right flattened slot, an in-range inner segment write lands while an out-of-range one is refused with Custom(0xD000|2), event capture is byte-exact with the trailing slots after the flattened set, and touch-map write records land at the flattened slots.

Restrictions (compile errors with actionable text)

  • No Option<Composite>, and no #[account(...)] constraints on the composite field itself.
  • The INNER context must stay a plain validation context: no #[instruction(...)] args, no strict_writes / lamports(...) / emit_touch_map / event_cpi options of its own, no lifecycle or migrate(...), no nested #[composite].
  • lamports(...) on the outer can only name the outer's own leaf fields. An account inside an embedded context cannot be granted lamport permission from the outer. Flatten the inner context if one of its accounts must move lamports.
  • A context carrying a migrate(...) field is not embeddable as an inner (see Lazy migration at bind for why: the crank lives in the inner's own bind(), which an outer composite bind never invokes; Hopper refuses rather than silently stopping the crank).
  • Touch-map completeness still requires a non-partial map and mutation through Hopper-tracked APIs. Raw escape hatches require separate denial or review.

See program migration for the broader account and handler checklist.