Skip to content
hopper
Get started
Migration / lazy-migration

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(...) requires mut on the same field, and cannot combine with init / init_if_needed / zero / close / realloc / sweep, with Option<..> fields, with #[composite] fields, or with from naming 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 own bind(), 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, custom constraint expressions) still evaluate against the NEW shape, so a standalone validate() on a not-yet-migrated account can fail such a constraint even though bind() (which migrates first) succeeds.
  • One from version 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). fit also 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 past required_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 optional payer = ... 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.

Lazy migration at bind | Hopper docs