Lazy migration at bind
Declare the previous layout version on a field and every instruction becomes a migration crank, typed, in place, measured live on devnet.
Layout migrations are usually a rollout problem: ship a dedicated migration instruction, crank every account, coordinate clients. Hopper collapses that into the account lifecycle itself. Declare the previous layout version on a context field, and every instruction that binds the context becomes a migration crank. Accounts upgrade as they are touched. No dedicated instruction, no separate rollout.
fn v1_to_v2(old: &VaultV1, new: &mut VaultV2) -> Result<(), ProgramError> {
new.authority = old.authority;
new.total = WireU64::new(old.total_u32.get() as u64); // widen
Ok(()) // unset V2 fields default to zeroed bytes
}
#[derive(Accounts)]
pub struct Touch<'info> {
pub authority: Signer<'info>,
#[account(mut, migrate(from = VaultV1, with = v1_to_v2))]
pub vault: Account<'info, VaultV2>,
}
What bind actually does
bind() probes the slot for a fully-valid VaultV1 header (the
complete disc/version/layout-id/epoch identity, never a sniff) and only
then runs the typed in-place migration
(hopper::migration::migrate_layout::<VaultV1, VaultV2, _>): typed on
both sides, one stack copy + fill(0), no (de)serialization, header
re-stamped LAST with account flags preserved, all before any validator
runs. An already-migrated account skips the probe; any other header fails
with the normal VaultV2 validation error, unchanged.
The standalone read-only validate() accepts either version without
writing: its layout-header check becomes "valid VaultV2, or fully-valid
VaultV1 whose allocation already fits VaultV2", the same set
bind() accepts. A migration error fails the instruction, so the runtime
rolls every byte back (transaction-abort atomicity).
For comparison: anchor-next's borsh Migration<A, B> design stops at a
dedicated migration instruction. Hopper's is zero-copy and ambient.
The reserved-padding pattern
The recommended forward-compat pattern: give V1 reserved padding that V2
claims. Both versions then fit one allocation and no realloc is ever
needed. The live demo below models exactly this. When V2 is genuinely
wider than V1's allocation, realloc in a prior instruction: undersized
old accounts are refused by BOTH validate() and bind().
Restrictions (all compile errors, not silent gaps)
migrate(...)requiresmuton the same field, and cannot combine withinit/init_if_needed/zero/close/realloc/sweep, withOption<..>fields, with#[composite]fields, or withfromnaming the field's own layout.- In-place only: the new shape must already fit the existing allocation.
- A context carrying a migrate field is not embeddable as a
#[composite]inner: the pre-step lives in that context's ownbind(), which an outer composite bind never invokes. Embedding would silently stop the crank, so Hopper refuses at compile time. Using it as the outer container (or standalone) is fine. - Constraints that read through the layout (
has_one, customconstraintexpressions) still evaluate against the NEW shape, so a standalonevalidate()on a not-yet-migrated account can fail such a constraint even thoughbind()(which migrates first) succeeds. - One
fromversion per field: chains (V1→V2→V3) mean the field declares only the immediately-previous version; older accounts need the intermediate crank first.
Proven, then proven live
The feature is pinned by 13 expansion tests, 7 hopper-svm integration
tests, and a compiled-SBF Mollusk end-to-end test
(examples/hopper-smoke/tests/note_migration_sbf_e2e.rs). On
2026-07-11 the crank ran against a real devnet account: instruction 6
created a version-1 Note
(B2ZyGAUw9CVqdL2tiv9yG32HwuPbpeaofTBig6UkFQrL, header verified live),
the first instruction-7 touch migrated it in place, and the second bound
it steady-state:
| Step | Live CU | Signature (devnet) |
|---|---|---|
| init_note (creates V1) | 1,656 | 4iiEejiGZxvEnTuJsabnN2w7y7A4j3jCGV2zUq5P1cHpxrpHEDM8GApg6TP644BjA2z4z6iMVehJUvim35rZX8Mm |
| touch_note: MIGRATING touch | 280 | 4HTk16eVSby5r8r68A7AaCr1ZKGRThZgS9tAbVFiQzvnHX82Jvpqz5T1imEsGPHt1osUa968J9xXoNWJajSHfcaH |
| touch_note: steady-state | 251 | 4ggJLgKPhRZZ4xbwpXuAv4Mi3TsqFr2uoNkQmaVNtWaXpNWrdQoh4X9YMPPueGKVQp4FxS3JANukASi6vPPN9GMG |
Post-state read back live: version 2, tag widened to u64 with the
value preserved (0xBEEF), touches = 2. The one-time migration premium
is 29 CU for the full typed in-place upgrade (V1 identity probe,
transform, header re-stamp), and both live numbers match the Mollusk
e2e exactly. More live measurements, with the same lab-to-cluster
parity, are on the Benchmarks page.
Hopper also keeps the explicit schema-epoch migration machinery
(#[hopper::migrate(from, to)] + hopper::layout_migrations! +
apply_pending_migrations) for orchestrated upgrades; migrate-at-bind is
the ambient complement, not a replacement.
Resizing migrations, epoch chains, and multi-hop
Three additions complete the migration story:
migrate(from = V1, with = f, resize = grow|fit, payer = <field>): the bind-time crank grows the allocation to fit the new shape, debiting the payer exactly the live-rent deficit (a well-funded account needs no payer signature).fitalso shrinks afterwards and refunds exactly the freed rent-exemption delta, never the surplus: a PDA holding user deposits keeps them (the refund rule a Quasar-style normalize-to-rent-min migration violates). Shrink is opt-in because dynamic-tail layouts store live data pastrequired_len.- Epoch chains, wired end to end. Declare the target with
#[hopper::state(schema_epoch = N)], the edges with#[hopper::migrate]layout_migrations!, and put#[account(epoch_migrate)]on the context field: a stale-epoch account heals through the chain at bind, before any validator runs;validate()accepts exactly the healable set (lagging epochs only: a from-the-future account is refused, never "migrated" down).
migrate_chain!, a typed multi-hop version chain (V1 => V2: f, V2 => V3: g): one call heals an account from ANY declared starting version, with an optionalpayer = ...arm that grows ONCE, up front, to the largest hop target.
Every migration entry point is security-gated in the runtime: a foreign-owned or read-only account is refused before the user transform reads a byte. The crank runs ahead of the per-field validators, so the gate is the first authority to look at the account.
