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.

Anchor has let one accounts struct embed another for years; until 2026-07-10 Hopper could not. This page documents the catch-up (spelled plainly as catch-up, because that is what closing the last Anchor-parity gap is) and then the part that is not: since composite v2, the container's options compose across the nesting boundary, so published-equals-enforced write-sets survive nesting.

The shape

Hopper uses an explicit #[composite] marker where Anchor infers embedding from any non-wrapper field type. Hopper refuses to guess. Say so when comparing; the detection rule is the one visible difference.

// 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 (zero cost when you don't nest), 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 exactly as it would be standalone, same Custom(0xD000|idx) refusals. Composed == published == enforced, proven by an equality test.
  • 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.

Proven by expansion tests plus hopper-svm integration suites for 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).

The migration guide's composite section shows the same mapping from the Anchor side.

Composite contexts | Hopper docs