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 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 exactly as it would be standalone, sameCustom(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_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.
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, 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).
The migration guide's composite section shows the same mapping from the Anchor side.
