Composite contexts
Nest one accounts context inside another with Anchor-style flattening, and write-sets, events, and touch maps that compose across the boundary.
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 itslamports(...)dimension) compiles a compile-time-composed write-set: outer leaves at flattened const-expr indices, and each inner context's declaredmut/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 sameCustom(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_maprecords land at flattened slots.auto_lifecycleworks: 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, nostrict_writes/lamports(...)/emit_touch_map/event_cpioptions of its own, no lifecycle ormigrate(...), 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 ownbind(), 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.
