Skip to main content

hopper_runtime/
migrate.rs

1//! Schema-epoch in-place migration runtime.
2//!
3//! Schema epochs and in-place migration helpers use the header's
4//! `schema_epoch: u32`, which
5//! lets accounts self-identify the ABI version they were written in.
6//! When a program later loads an account written at an older epoch,
7//! the runtime consults a declared migration chain, applies each edge
8//! in sequence atomically with a `schema_epoch` bump, and only then
9//! hands the caller a typed `Ref<'_, T>` of the current shape.
10//!
11//! # Design rules
12//!
13//! * **In-place**. no allocation, no CPI. Migration rewrites the
14//!   account body (within its existing byte range) and the 16-byte
15//!   Hopper header.
16//! * **Atomic per edge under transaction-abort semantics.** Each
17//!   edge bumps the header's `schema_epoch` only after its body
18//!   mutation fully succeeded, so a *completed* edge is always
19//!   consistent. A migrator that errors after partially writing the
20//!   body, however, leaves a hybrid body under the old epoch. The
21//!   returned error **must** propagate to instruction failure (the
22//!   Solana runtime then rolls every byte back). Callers must not
23//!   swallow migration errors and continue using the account.
24//! * **Idempotent**. re-running an already-applied edge is a no-op
25//!   (the header epoch mismatch returns `MigrationMismatch`).
26//! * **Deterministic**. edges are applied in strict
27// ---------------------------------------------------------------------
28
29use crate::account::AccountView;
30use crate::address::Address;
31use crate::error::ProgramError;
32use crate::layout::{HopperHeader, LayoutContract};
33use crate::zerocopy::AccountLayout;
34
35/// The migration security gate: every migration entry point checks the
36/// account is **writable** and **owned by the executing program** before
37/// a user transform reads a byte.
38///
39/// Rationale (the crank-before-validators fix): the context macro runs
40/// lazy-migration pre-steps in `bind()` BEFORE the per-field validators
41/// so the validators see the upgraded account. That ordering is correct,
42/// but it means the migration used to execute a user transform over an
43/// account nobody had checked yet, a foreign-owned account whose bytes
44/// happen to validate as an `Old` header would have its transform run
45/// (and, if the caller swallowed the eventual bind error, its writes
46/// kept). Baking the check into the runtime entry points protects every
47/// caller, systems-mode included, not just the macro crank.
48#[inline(always)]
49fn check_migratable(account: &AccountView<'_>, program_id: &Address) -> Result<(), ProgramError> {
50    if !account.is_writable() {
51        return Err(ProgramError::InvalidAccountData);
52    }
53    if !account.owned_by(program_id) {
54        return Err(ProgramError::IncorrectProgramId);
55    }
56    Ok(())
57}
58
59/// One step in a layout's migration chain.
60///
61/// An edge takes the raw account *body* (the bytes after the 16-byte
62/// Hopper header), mutates them in place to match the new epoch's
63/// shape, and returns `Ok(())` on success. The runtime then atomically
64/// bumps the header's `schema_epoch` to `to_epoch` under the same
65/// mutable borrow.
66///
67/// Migration functions must not call CPIs (no CreateAccount, no
68/// Transfer) and must not resize the account (use `realloc` for that
69/// separately). They may read and write arbitrary bytes within the
70/// body, which is why the signature takes `&mut [u8]`. `ZeroCopy`
71/// safety has deliberately been stepped out of because the user is
72/// explicitly translating between two different byte layouts.
73#[derive(Clone, Copy)]
74pub struct MigrationEdge {
75    /// Epoch the body is expected to be in before this edge runs.
76    pub from_epoch: u32,
77    /// Epoch the body will be in after this edge runs successfully.
78    pub to_epoch: u32,
79    /// In-place mutator. Called exactly once per upgrade sequence.
80    pub migrator: fn(body: &mut [u8]) -> Result<(), ProgramError>,
81}
82
83impl MigrationEdge {
84    /// Reject edges that would decrement or stay at the same epoch .
85    /// migrations always move forward.
86    pub const fn is_forward(&self) -> bool {
87        self.to_epoch > self.from_epoch
88    }
89}
90
91/// Layouts opt into in-place migration by providing a `MIGRATIONS`
92/// constant. The default (empty slice) means "no migrations declared"
93/// and any mismatch between header and `AccountLayout::SCHEMA_EPOCH`
94/// is a hard failure.
95///
96/// The trait is sealed-by-convention: downstream crates should
97/// express migrations via the `#[hopper::migrate(...)]` attribute
98/// macro and the `hopper::layout_migrations!` composition helper,
99/// never by hand-writing `impl LayoutMigration for T`.
100pub trait LayoutMigration {
101    /// Ordered migration chain. `MIGRATIONS[i].to_epoch ==
102    /// MIGRATIONS[i + 1].from_epoch` must hold for every adjacent
103    /// pair, and the whole chain must be strictly monotonic.
104    const MIGRATIONS: &'static [MigrationEdge];
105}
106
107// No blanket impl. stable Rust doesn't allow specialization, so a
108// blanket `impl<T: AccountLayout> LayoutMigration for T` would lock
109// out user opt-ins. Types without migrations simply never implement
110// `LayoutMigration` and are therefore ineligible for
111// `apply_pending_migrations::<T>`. which is the correct behaviour:
112// you opt in to in-place migration by declaring a chain.
113
114/// Apply all pending migrations needed to bring the account at
115/// `current_epoch` up to `AccountLayout::SCHEMA_EPOCH`.
116///
117/// Returns `Ok(applied_count)` if everything up-migrated cleanly.
118/// Returns `Err(MigrationMismatch)` if the declared chain is
119/// incomplete, non-monotonic, or doesn't start at `current_epoch`.
120/// Returns `Err(MigrationRejected)` if a user migrator function
121/// returned an error.
122#[inline]
123pub fn apply_pending_migrations<T>(
124    account: &AccountView<'_>,
125    program_id: &Address,
126    current_epoch: u32,
127) -> Result<u32, ProgramError>
128where
129    T: AccountLayout + LayoutContract + LayoutMigration,
130{
131    check_migratable(account, program_id)?;
132    let target_epoch = <T as AccountLayout>::SCHEMA_EPOCH;
133    if current_epoch == target_epoch {
134        return Ok(0);
135    }
136    if current_epoch > target_epoch {
137        // Account is from a FUTURE epoch. forward-compatibility is
138        // out of scope for in-place migration. Caller must refuse
139        // or route to a different program.
140        return Err(ProgramError::InvalidAccountData);
141    }
142
143    let edges = <T as LayoutMigration>::MIGRATIONS;
144    let mut applied = 0u32;
145    let mut epoch = current_epoch;
146
147    // Single mutable borrow across the whole chain. atomicity per
148    // edge is maintained by rewriting the header's schema_epoch byte
149    // range before the borrow is released.
150    let mut data = account.try_borrow_mut_ungated()?;
151    let header_len = core::mem::size_of::<HopperHeader>();
152    if data.len() < header_len {
153        return Err(ProgramError::AccountDataTooSmall);
154    }
155
156    while epoch < target_epoch {
157        let edge = find_edge(edges, epoch)?;
158        // A declared edge must not overshoot the layout's current
159        // epoch: stamping the header past SCHEMA_EPOCH would mark the
160        // account as from-the-future and make every subsequent typed
161        // load refuse it, silent corruption from a misdeclared chain.
162        // Refuse before touching a byte.
163        if edge.to_epoch > target_epoch {
164            return Err(ProgramError::InvalidAccountData);
165        }
166        let (header_bytes, body_bytes) = data.split_at_mut(header_len);
167        // Step 1: mutate the body.
168        (edge.migrator)(body_bytes)?;
169        // Step 2: atomically bump the header's schema_epoch field.
170        // Header layout is `#[repr(C, packed)]`: bytes 12..16 are
171        // `schema_epoch: u32 LE` per `layout.rs`.
172        let new_epoch_bytes = edge.to_epoch.to_le_bytes();
173        header_bytes[12..16].copy_from_slice(&new_epoch_bytes);
174        epoch = edge.to_epoch;
175        applied += 1;
176    }
177
178    Ok(applied)
179}
180
181/// Typed, in-place, cross-VERSION layout migration: `Old` → `New`.
182///
183/// The epoch machinery above evolves an account *within* one layout
184/// version through raw `&mut [u8]` edges. This is the other half of the
185/// versioning story: the account's layout **version byte** changes
186/// (`#[hopper::state(version = 1)]` → `version = 2`), the wire
187/// fingerprint changes with the field set, and the transform is
188/// **typed on both sides**, no hand-offsetting bytes:
189///
190/// ```ignore
191/// hopper_runtime::migrate::migrate_layout::<VaultV1, VaultV2, _>(
192///     account,
193///     program_id,
194///     |old, new| {
195///         new.authority = old.authority;
196///         // Widen the counter; every other V2 field keeps its
197///         // deterministic all-zero default.
198///         new.total = WireU64::new(old.total_u32.get() as u64);
199///         Ok(())
200///     },
201/// )?;
202/// ```
203///
204/// Contrast with anchor-next's `Migration` account shape, which
205/// deserializes the old form and RESERIALIZES the new one through
206/// borsh. Here both shapes are zero-copy overlays of the same buffer:
207/// one stack copy of `Old` (so the transform can still read it after
208/// the buffer is re-purposed), one `fill(0)` of the `New` span, no
209/// (de)serialization, no heap.
210///
211/// # Sequence
212///
213/// 1. `Old::validate_header`, the full identity check (disc, version,
214///    layout_id, epoch). An already-migrated account no longer matches
215///    `Old` and is refused, which is the idempotence rule: migrate
216///    exactly once, route repeat calls to the `New` load path.
217/// 2. The `New` shape must FIT the existing allocation
218///    (`required_len`); resizing is `realloc`'s job, done separately
219///    BEFORE migrating when `New` is larger.
220/// 3. `Old` is copied to the stack, the `New` span is zeroed (so every
221///    field the transform does not set has the framework's canonical
222///    all-zero default, stale `Old` bytes never leak through), and the
223///    typed transform fills `New` from the copy.
224/// 4. Only after the transform returns `Ok` is the header re-stamped,
225///    `New`'s disc/version/layout_id/schema-epoch, with the header's
226///    FLAGS bytes preserved (flags are account state, not layout
227///    identity). A transform error therefore leaves the header on
228///    `Old`, same transaction-abort atomicity contract as the epoch
229///    edges above: the error must propagate to instruction failure so
230///    the runtime rolls the partially-written body back.
231///
232/// # Guard rails (all refuse before touching a byte)
233///
234/// * `New::DISC == Old::DISC`, a migration must not repurpose the
235///   account kind; both consts are known at monomorphization, so the
236///   check folds away when it passes.
237/// * `New::VERSION > Old::VERSION`, versions only move forward
238///   (also const-folded).
239/// * The account is **writable** and **owned by `program_id`**, the
240///   crank runs at bind BEFORE the per-field validators (so validators
241///   see the upgraded account), which means this function is the first
242///   authority to look at the account. A user transform must never run
243///   over another program's bytes, however plausibly they parse as
244///   `Old`.
245#[inline]
246pub fn migrate_layout<Old, New, F>(
247    account: &AccountView<'_>,
248    program_id: &Address,
249    transform: F,
250) -> Result<(), ProgramError>
251where
252    Old: LayoutContract + crate::Pod,
253    New: LayoutContract + crate::Pod,
254    F: FnOnce(&Old, &mut New) -> Result<(), ProgramError>,
255{
256    // Const-foldable direction guards: same account kind, strictly
257    // forward version. (Written as runtime `if`s so they work on every
258    // toolchain; the comparisons are monomorphized constants and the
259    // passing branch compiles to nothing.)
260    if New::DISC != Old::DISC {
261        return Err(ProgramError::InvalidAccountData);
262    }
263    if New::VERSION <= Old::VERSION {
264        return Err(ProgramError::InvalidAccountData);
265    }
266    check_migratable(account, program_id)?;
267
268    let mut data = account.try_borrow_mut_ungated()?;
269    Old::validate_header(&data)?;
270    if data.len() < New::required_len() {
271        // In-place only: a larger New needs `realloc` FIRST. Refusing
272        // here (before any write) keeps the account a valid Old.
273        return Err(ProgramError::AccountDataTooSmall);
274    }
275
276    // Stack-copy the old shape so the transform can read it after the
277    // buffer below is re-purposed as `New`.
278    // SAFETY: `validate_header` proved the buffer holds a valid `Old`
279    // at TYPE_OFFSET with at least `required_len()` bytes; `Old: Pod`
280    // makes any bit pattern valid, and `read_unaligned` lifts the
281    // bytes without an alignment requirement.
282    let old: Old =
283        unsafe { core::ptr::read_unaligned((*data).as_ptr().add(Old::TYPE_OFFSET) as *const Old) };
284
285    // Deterministic defaults: zero the New span so unset fields carry
286    // the framework's canonical empty value rather than stale bytes.
287    let new_start = New::TYPE_OFFSET;
288    let new_end = new_start + core::mem::size_of::<New>();
289    data[new_start..new_end].fill(0);
290
291    // SAFETY: the length check above proved `new_end <= data.len()`;
292    // `New: Pod` accepts the all-zero pattern just written; Hopper
293    // layout types are wire-aligned (align 1 by construction, same
294    // contract `load_mut` relies on when it projects at TYPE_OFFSET).
295    let new: &mut New = unsafe { &mut *(data.as_bytes_mut_ptr().add(new_start) as *mut New) };
296    transform(&old, new)?;
297
298    // Success: re-stamp the header as New, LAST, so an erroring
299    // transform leaves the header on Old (see atomicity note above).
300    // Flags are preserved: they describe the account, not the layout.
301    let flags = crate::layout::read_flags(&data).unwrap_or(0);
302    crate::layout::write_header_with_epoch(
303        &mut data,
304        New::DISC,
305        New::VERSION,
306        &New::LAYOUT_ID,
307        New::SCHEMA_EPOCH,
308    )?;
309    data[2..4].copy_from_slice(&flags.to_le_bytes());
310    Ok(())
311}
312
313/// [`migrate_layout`] that **resizes the account to fit the new shape**,
314/// with a payer-funded rent top-up, the one migration capability the
315/// in-place form defers to a separate `realloc`.
316///
317/// # Sequence
318///
319/// 1. The same const direction guards and the writable+owner gate.
320/// 2. **Grow first** (when the allocation is smaller than
321///    `New::required_len()`): compute the rent-exempt minimum for the
322///    grown size from the LIVE rent sysvar, and when the account's
323///    balance falls short, debit exactly the deficit from `payer`
324///    (which must then be writable and a signer, a well-funded account
325///    needs no payer at all). Growth is capped by Solana's
326///    `MAX_PERMITTED_DATA_INCREASE` (10,240 bytes per instruction),
327///    enforced by `resize`.
328/// 3. The typed in-place migration ([`migrate_layout`]), which
329///    re-verifies the `Old` identity under its own borrow.
330/// 4. **Shrink last, opt-in** (`shrink_to_fit`, when the allocation
331///    exceeds `New::required_len()` after migrating): resize down and
332///    refund **exactly the freed rent-exemption delta** to `payer`,
333///    doubly capped, never more than
334///    `minimum_balance(old_len) - minimum_balance(new_len)`, and never
335///    taking the account below its new minimum.
336///
337/// # The refund rule (why the cap is the point)
338///
339/// Quasar's `Migration<From, To>` normalizes the migrated account's
340/// balance to the new rent minimum and pays the WHOLE difference to the
341/// payer (`quasar account.rs:117-125`), run it on a PDA that holds user
342/// deposits and the deposits leave with the payer. Hopper refunds only
343/// the rent requirement the shrink actually freed; every other lamport
344/// stays where it was.
345///
346/// # The shrink hazard (why it is opt-in)
347///
348/// `New::required_len()` covers the fixed shape. A layout with a dynamic
349/// tail (`raw_tail`, `Seq<T>`, `TailStr`/`TailBytes`) stores live data
350/// PAST that length, shrinking to fit would truncate it. Pass
351/// `shrink_to_fit = false` (the context macro's default; `resize = fit`
352/// opts in) unless the layout is tail-free.
353#[inline]
354pub fn migrate_layout_resizing<Old, New, F>(
355    account: &AccountView<'_>,
356    payer: &AccountView<'_>,
357    program_id: &Address,
358    shrink_to_fit: bool,
359    transform: F,
360) -> Result<(), ProgramError>
361where
362    Old: LayoutContract + crate::Pod,
363    New: LayoutContract + crate::Pod,
364    F: FnOnce(&Old, &mut New) -> Result<(), ProgramError>,
365{
366    if New::DISC != Old::DISC {
367        return Err(ProgramError::InvalidAccountData);
368    }
369    if New::VERSION <= Old::VERSION {
370        return Err(ProgramError::InvalidAccountData);
371    }
372    check_migratable(account, program_id)?;
373
374    let new_required = New::required_len();
375
376    // Grow BEFORE migrating: the Old body must stay intact for the
377    // transform, and migrate_layout refuses a too-small New span.
378    ensure_fits_with_rent(account, payer, program_id, new_required)?;
379
380    migrate_layout::<Old, New, F>(account, program_id, transform)?;
381
382    // Shrink AFTER migrating (never before the transform reads Old).
383    if shrink_to_fit && account.data_len() > new_required {
384        let min_old = crate::rent::minimum_balance_live(account.data_len())?;
385        let min_new = crate::rent::minimum_balance_live(new_required)?;
386        drop(account.try_borrow_mut_ungated()?);
387        account.resize(new_required)?;
388        // Refund exactly the freed rent delta; see the refund rule in
389        // the doc above. Both caps matter: `delta` keeps deposits and
390        // surplus with the account; `above_min` keeps an under-funded
391        // account from being drained below its new minimum.
392        let delta = min_old.saturating_sub(min_new);
393        let above_min = account.lamports().saturating_sub(min_new);
394        let refund = if delta < above_min { delta } else { above_min };
395        if refund > 0 {
396            if !payer.is_writable() {
397                return Err(ProgramError::InvalidAccountData);
398            }
399            let account_after = account.lamports() - refund;
400            let payer_after = payer
401                .lamports()
402                .checked_add(refund)
403                .ok_or(ProgramError::ArithmeticOverflow)?;
404            account.try_set_lamports(account_after)?;
405            payer.try_set_lamports(payer_after)?;
406        }
407    }
408    Ok(())
409}
410
411/// Grow `account` to at least `min_len` bytes, topping up the
412/// rent-exempt minimum (from the LIVE rent sysvar) out of `payer` when
413/// the account's balance falls short, the payer must then be writable
414/// and a signer; a well-funded account needs no payer at all. A no-op
415/// when the allocation already fits. Growth is capped by Solana's
416/// `MAX_PERMITTED_DATA_INCREASE`, enforced by `resize`.
417///
418/// The building block behind [`migrate_layout_resizing`]'s grow phase
419/// and [`migrate_chain!`](crate::migrate_chain)'s single up-front grow.
420pub fn ensure_fits_with_rent(
421    account: &AccountView<'_>,
422    payer: &AccountView<'_>,
423    program_id: &Address,
424    min_len: usize,
425) -> Result<(), ProgramError> {
426    check_migratable(account, program_id)?;
427    if account.data_len() >= min_len {
428        return Ok(());
429    }
430    let rent_needed = crate::rent::minimum_balance_live(min_len)?;
431    let deficit = rent_needed.saturating_sub(account.lamports());
432    if deficit > 0 {
433        if !payer.is_writable() {
434            return Err(ProgramError::InvalidAccountData);
435        }
436        if !payer.is_signer() {
437            return Err(ProgramError::MissingRequiredSignature);
438        }
439    }
440    // Fail fast on outstanding data borrows before the length moves.
441    drop(account.try_borrow_mut_ungated()?);
442    account.resize(min_len)?;
443    if deficit > 0 {
444        let payer_after = payer
445            .lamports()
446            .checked_sub(deficit)
447            .ok_or(ProgramError::InsufficientFunds)?;
448        let account_after = account
449            .lamports()
450            .checked_add(deficit)
451            .ok_or(ProgramError::ArithmeticOverflow)?;
452        payer.try_set_lamports(payer_after)?;
453        account.try_set_lamports(account_after)?;
454    }
455    Ok(())
456}
457
458/// Header identity check for an **epoch-migration candidate**: disc,
459/// version, and layout id must match `T` exactly and the allocation
460/// must fit `T`, while the stored schema epoch may LAG
461/// `T::SCHEMA_EPOCH`, that lag is exactly what the epoch chain heals,
462/// but may never exceed it (a from-the-future account is refused, never
463/// "migrated"). Returns the stored EFFECTIVE epoch (a pre-epoch zero
464/// header reads as epoch 1).
465///
466/// This is the shared acceptance predicate behind
467/// `#[account(epoch_migrate)]`: the read-only `validate()` surface uses
468/// it to accept a stale-epoch account that `bind()` can heal, and
469/// bind's crank uses it to decide whether to run
470/// [`apply_pending_migrations`], one function, two surfaces, so the
471/// would-bind parity can never drift.
472pub fn validate_header_for_epoch_migration<T: LayoutContract>(
473    data: &[u8],
474) -> Result<u32, ProgramError> {
475    use crate::layout::{
476        effective_schema_epoch, read_disc, read_layout_id, read_schema_epoch, read_version,
477    };
478    if data.len() < T::required_len() {
479        return Err(ProgramError::AccountDataTooSmall);
480    }
481    if read_disc(data) != Some(T::DISC)
482        || read_version(data) != Some(T::VERSION)
483        || read_layout_id(data) != Some(&T::LAYOUT_ID)
484    {
485        return Err(ProgramError::InvalidAccountData);
486    }
487    let stored = read_schema_epoch(data).ok_or(ProgramError::InvalidAccountData)?;
488    let effective = effective_schema_epoch(stored);
489    if effective > T::SCHEMA_EPOCH {
490        return Err(ProgramError::InvalidAccountData);
491    }
492    Ok(effective)
493}
494
495/// Typed multi-hop layout migration: probe-and-migrate each declared
496/// hop in declaration order, so ONE call heals an account from ANY
497/// declared starting version to the newest, the chain Quasar's
498/// pairwise `Migration<From, To>` cannot express in one instruction.
499///
500/// ```ignore
501/// // In place (every hop's target must already fit the allocation):
502/// let hops = hopper::migrate_chain!(account, ctx.program_id(), {
503///     VaultV1 => VaultV2: widen,
504///     VaultV2 => VaultV3: add_flag,
505/// });
506///
507/// // With ONE up-front grow to the largest hop target, rent topped up
508/// // from `payer` (see `ensure_fits_with_rent`):
509/// let hops = hopper::migrate_chain!(account, ctx.program_id(), payer = payer_view, {
510///     VaultV1 => VaultV2: widen,
511///     VaultV2 => VaultV3: add_flag,
512/// });
513/// ```
514///
515/// Each hop probes the header for a fully-valid `$old` identity and,
516/// only then, runs [`migrate_layout`] (which re-verifies under its own
517/// borrow and carries the owner+writable security gate). An account
518/// already at a later hop's source version simply skips the earlier
519/// hops; an account matching NO hop is left untouched and the chain
520/// returns `0`, the caller's subsequent typed load rejects foreign
521/// layouts exactly as before, so the chain is a healing pass, not a
522/// validator. Evaluates to the number of hops applied (`u32`).
523///
524/// The expansion uses `?`, so the surrounding function must return
525/// `Result<_, ProgramError>` (or a compatible error).
526#[macro_export]
527macro_rules! migrate_chain {
528    ($account:expr, $program_id:expr, { $($old:ty => $new:ty : $f:expr),+ $(,)? }) => {{
529        let __hopper_chain_view = $account;
530        let __hopper_chain_pid = $program_id;
531        let mut __hopper_chain_hops: u32 = 0;
532        $(
533            {
534                let __hopper_chain_is_old = {
535                    let __hopper_chain_data = __hopper_chain_view.try_borrow()?;
536                    <$old as $crate::LayoutContract>::validate_header(
537                        &__hopper_chain_data,
538                    )
539                    .is_ok()
540                };
541                if __hopper_chain_is_old {
542                    $crate::migrate_layout::<$old, $new, _>(
543                        __hopper_chain_view,
544                        __hopper_chain_pid,
545                        $f,
546                    )?;
547                    __hopper_chain_hops += 1;
548                }
549            }
550        )+
551        __hopper_chain_hops
552    }};
553    ($account:expr, $program_id:expr, payer = $payer:expr,
554     { $($old:ty => $new:ty : $f:expr),+ $(,)? }) => {{
555        let __hopper_chain_view = $account;
556        let __hopper_chain_pid = $program_id;
557        // ONE grow, sized to the LARGEST hop target (not merely the
558        // final one: a middle hop may be the widest shape the chain
559        // passes through).
560        let mut __hopper_chain_max: usize = 0;
561        $(
562            {
563                let __hopper_chain_len =
564                    <$new as $crate::LayoutContract>::required_len();
565                if __hopper_chain_len > __hopper_chain_max {
566                    __hopper_chain_max = __hopper_chain_len;
567                }
568            }
569        )+
570        $crate::ensure_fits_with_rent(
571            __hopper_chain_view,
572            $payer,
573            __hopper_chain_pid,
574            __hopper_chain_max,
575        )?;
576        let mut __hopper_chain_hops: u32 = 0;
577        $(
578            {
579                let __hopper_chain_is_old = {
580                    let __hopper_chain_data = __hopper_chain_view.try_borrow()?;
581                    <$old as $crate::LayoutContract>::validate_header(
582                        &__hopper_chain_data,
583                    )
584                    .is_ok()
585                };
586                if __hopper_chain_is_old {
587                    $crate::migrate_layout::<$old, $new, _>(
588                        __hopper_chain_view,
589                        __hopper_chain_pid,
590                        $f,
591                    )?;
592                    __hopper_chain_hops += 1;
593                }
594            }
595        )+
596        __hopper_chain_hops
597    }};
598}
599
600/// Locate the edge whose `from_epoch == epoch`. Returns an
601/// `InvalidAccountData` error if the chain is discontinuous.
602#[inline]
603fn find_edge(edges: &[MigrationEdge], epoch: u32) -> Result<&MigrationEdge, ProgramError> {
604    for edge in edges {
605        if edge.from_epoch == epoch {
606            if !edge.is_forward() {
607                // A declared migration that doesn't advance the
608                // epoch is malformed by construction.
609                return Err(ProgramError::InvalidAccountData);
610            }
611            return Ok(edge);
612        }
613    }
614    Err(ProgramError::InvalidAccountData)
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620
621    fn identity(_body: &mut [u8]) -> Result<(), ProgramError> {
622        Ok(())
623    }
624
625    #[test]
626    fn migration_edge_is_forward_detects_non_monotonic() {
627        let forward = MigrationEdge {
628            from_epoch: 1,
629            to_epoch: 2,
630            migrator: identity,
631        };
632        let backward = MigrationEdge {
633            from_epoch: 3,
634            to_epoch: 2,
635            migrator: identity,
636        };
637        let same = MigrationEdge {
638            from_epoch: 2,
639            to_epoch: 2,
640            migrator: identity,
641        };
642        assert!(forward.is_forward());
643        assert!(!backward.is_forward());
644        assert!(!same.is_forward());
645    }
646
647    #[test]
648    fn find_edge_returns_matching_edge() {
649        let edges = [
650            MigrationEdge {
651                from_epoch: 1,
652                to_epoch: 2,
653                migrator: identity,
654            },
655            MigrationEdge {
656                from_epoch: 2,
657                to_epoch: 3,
658                migrator: identity,
659            },
660        ];
661        let e1 = find_edge(&edges, 1).expect("edge exists");
662        assert_eq!(e1.to_epoch, 2);
663        let e2 = find_edge(&edges, 2).expect("edge exists");
664        assert_eq!(e2.to_epoch, 3);
665    }
666
667    #[test]
668    fn find_edge_errs_on_missing_epoch() {
669        let edges = [MigrationEdge {
670            from_epoch: 1,
671            to_epoch: 2,
672            migrator: identity,
673        }];
674        // No edge starts at epoch 5.
675        assert!(find_edge(&edges, 5).is_err());
676    }
677
678    #[test]
679    fn find_edge_rejects_non_forward_edge() {
680        let edges = [MigrationEdge {
681            from_epoch: 3,
682            to_epoch: 2,
683            migrator: identity,
684        }];
685        assert!(find_edge(&edges, 3).is_err());
686    }
687
688    mod overshoot {
689        use super::*;
690        use crate::layout::{HopperHeader, LayoutContract};
691        use crate::zerocopy::AccountLayout;
692        use hopper_native::{
693            AccountView as NativeAccountView, Address as NativeAddress, RuntimeAccount,
694            NOT_BORROWED,
695        };
696
697        #[repr(C)]
698        #[derive(Clone, Copy)]
699        struct EpochTwo {
700            v: [u8; 8],
701        }
702        // SAFETY: repr(C), byte-array field, every bit pattern valid,
703        // align 1, no padding.
704        unsafe impl crate::Zeroable for EpochTwo {}
705        // SAFETY: as above.
706        unsafe impl crate::Pod for EpochTwo {}
707        // SAFETY: test-local layout upholding the sealed overlay contract.
708        unsafe impl crate::zerocopy::__sealed::HopperZeroCopySealed for EpochTwo {}
709        impl crate::field_map::FieldMap for EpochTwo {
710            const FIELDS: &'static [crate::field_map::FieldInfo] =
711                &[crate::field_map::FieldInfo::new("v", HopperHeader::SIZE, 8)];
712        }
713        impl LayoutContract for EpochTwo {
714            const DISC: u8 = 91;
715            const VERSION: u8 = 1;
716            const LAYOUT_ID: [u8; 8] = [0x91; 8];
717            const SIZE: usize = HopperHeader::SIZE + core::mem::size_of::<Self>();
718            const SCHEMA_EPOCH: u32 = 2;
719        }
720        impl LayoutMigration for EpochTwo {
721            // Misdeclared chain: jumps 1 → 3 while SCHEMA_EPOCH is 2.
722            const MIGRATIONS: &'static [MigrationEdge] = &[MigrationEdge {
723                from_epoch: 1,
724                to_epoch: 3,
725                migrator: identity,
726            }];
727        }
728
729        /// The epoch chain is gated identically to the typed migration:
730        /// a foreign-owned account is refused before any edge runs.
731        #[test]
732        fn foreign_owned_account_is_refused_before_any_edge_runs() {
733            let mut backing =
734                std::vec![0u64; (RuntimeAccount::SIZE + HopperHeader::SIZE + 8).div_ceil(8)];
735            let raw = backing.as_mut_ptr() as *mut RuntimeAccount;
736            // SAFETY: backing is sized for the header plus data and
737            // outlives the view.
738            unsafe {
739                raw.write(RuntimeAccount {
740                    borrow_state: NOT_BORROWED,
741                    is_signer: 0,
742                    is_writable: 1,
743                    executable: 0,
744                    resize_delta: 0,
745                    address: NativeAddress::new_from_array([5; 32]),
746                    owner: NativeAddress::new_from_array([9; 32]),
747                    lamports: 1,
748                    data_len: (HopperHeader::SIZE + 8) as u64,
749                });
750            }
751            // SAFETY: raw points at a fully initialized RuntimeAccount.
752            let backend = unsafe { NativeAccountView::new_unchecked(raw) };
753            let account = crate::AccountView::from_backend(backend);
754            assert_eq!(
755                apply_pending_migrations::<EpochTwo>(
756                    &account,
757                    &Address::new_from_array([6; 32]),
758                    1
759                ),
760                Err(ProgramError::IncorrectProgramId)
761            );
762        }
763
764        #[test]
765        fn overshooting_edge_is_refused_before_writing() {
766            let mut backing =
767                std::vec![0u64; (RuntimeAccount::SIZE + HopperHeader::SIZE + 8).div_ceil(8)];
768            let raw = backing.as_mut_ptr() as *mut RuntimeAccount;
769            // SAFETY: backing is sized for the header plus data and
770            // outlives the view.
771            unsafe {
772                raw.write(RuntimeAccount {
773                    borrow_state: NOT_BORROWED,
774                    is_signer: 0,
775                    is_writable: 1,
776                    executable: 0,
777                    resize_delta: 0,
778                    address: NativeAddress::new_from_array([5; 32]),
779                    owner: NativeAddress::new_from_array([6; 32]),
780                    lamports: 1,
781                    data_len: (HopperHeader::SIZE + 8) as u64,
782                });
783            }
784            // SAFETY: raw points at a fully initialized RuntimeAccount.
785            let backend = unsafe { NativeAccountView::new_unchecked(raw) };
786            let account = crate::AccountView::from_backend(backend);
787
788            // The 1→3 edge would stamp the header past SCHEMA_EPOCH=2:
789            // must refuse instead of silently marking the account as
790            // from the future.
791            assert_eq!(
792                apply_pending_migrations::<EpochTwo>(
793                    &account,
794                    &Address::new_from_array([6; 32]),
795                    1
796                ),
797                Err(ProgramError::InvalidAccountData)
798            );
799            let _ = <EpochTwo as AccountLayout>::SCHEMA_EPOCH;
800        }
801    }
802
803    mod typed_layout_migration {
804        use super::*;
805        use crate::layout::{
806            read_disc, read_flags, read_layout_id, read_schema_epoch, read_version, write_header,
807            HopperHeader,
808        };
809        use hopper_native::{
810            AccountView as NativeAccountView, Address as NativeAddress, RuntimeAccount,
811            NOT_BORROWED,
812        };
813
814        const KIND: u8 = 77;
815
816        /// Version 1: a narrow counter plus a legacy blob.
817        #[repr(C)]
818        #[derive(Clone, Copy)]
819        struct VaultV1 {
820            count: [u8; 4],
821            legacy: [u8; 4],
822        }
823        // SAFETY: repr(C), byte-array fields, every bit pattern valid,
824        // align 1, no padding.
825        unsafe impl crate::Zeroable for VaultV1 {}
826        // SAFETY: as above.
827        unsafe impl crate::Pod for VaultV1 {}
828        // SAFETY: test-local layout upholding the sealed overlay contract.
829        unsafe impl crate::zerocopy::__sealed::HopperZeroCopySealed for VaultV1 {}
830        impl crate::field_map::FieldMap for VaultV1 {
831            const FIELDS: &'static [crate::field_map::FieldInfo] = &[
832                crate::field_map::FieldInfo::new("count", HopperHeader::SIZE, 4),
833                crate::field_map::FieldInfo::new("legacy", HopperHeader::SIZE + 4, 4),
834            ];
835        }
836        impl LayoutContract for VaultV1 {
837            const DISC: u8 = KIND;
838            const VERSION: u8 = 1;
839            const LAYOUT_ID: [u8; 8] = [0x11; 8];
840            const SIZE: usize = HopperHeader::SIZE + core::mem::size_of::<Self>();
841        }
842
843        /// Version 2: the counter widens to u64, a flag appears, the
844        /// legacy blob is gone. 12-byte body > V1's 8.
845        #[repr(C)]
846        #[derive(Clone, Copy)]
847        struct VaultV2 {
848            count: [u8; 8],
849            flag: u8,
850            pad: [u8; 3],
851        }
852        // SAFETY: repr(C), byte/byte-array fields, every bit pattern
853        // valid, align 1, no padding.
854        unsafe impl crate::Zeroable for VaultV2 {}
855        // SAFETY: as above.
856        unsafe impl crate::Pod for VaultV2 {}
857        // SAFETY: test-local layout upholding the sealed overlay contract.
858        unsafe impl crate::zerocopy::__sealed::HopperZeroCopySealed for VaultV2 {}
859        impl crate::field_map::FieldMap for VaultV2 {
860            const FIELDS: &'static [crate::field_map::FieldInfo] = &[
861                crate::field_map::FieldInfo::new("count", HopperHeader::SIZE, 8),
862                crate::field_map::FieldInfo::new("flag", HopperHeader::SIZE + 8, 1),
863                crate::field_map::FieldInfo::new("pad", HopperHeader::SIZE + 9, 3),
864            ];
865        }
866        impl LayoutContract for VaultV2 {
867            const DISC: u8 = KIND;
868            const VERSION: u8 = 2;
869            const LAYOUT_ID: [u8; 8] = [0x22; 8];
870            const SIZE: usize = HopperHeader::SIZE + core::mem::size_of::<Self>();
871            // A non-default target epoch, to prove the stamp writes the
872            // NEW layout's epoch rather than inheriting the old one.
873            const SCHEMA_EPOCH: u32 = 5;
874        }
875
876        /// A different account kind entirely (wrong disc).
877        #[repr(C)]
878        #[derive(Clone, Copy)]
879        struct OtherKind {
880            v: [u8; 8],
881        }
882        // SAFETY: repr(C), byte-array field, every bit pattern valid,
883        // align 1, no padding.
884        unsafe impl crate::Zeroable for OtherKind {}
885        // SAFETY: as above.
886        unsafe impl crate::Pod for OtherKind {}
887        // SAFETY: test-local layout upholding the sealed overlay contract.
888        unsafe impl crate::zerocopy::__sealed::HopperZeroCopySealed for OtherKind {}
889        impl crate::field_map::FieldMap for OtherKind {
890            const FIELDS: &'static [crate::field_map::FieldInfo] =
891                &[crate::field_map::FieldInfo::new("v", HopperHeader::SIZE, 8)];
892        }
893        impl LayoutContract for OtherKind {
894            const DISC: u8 = KIND + 1;
895            const VERSION: u8 = 3;
896            const LAYOUT_ID: [u8; 8] = [0x33; 8];
897            const SIZE: usize = HopperHeader::SIZE + core::mem::size_of::<Self>();
898        }
899
900        /// Same kind, same version as V1 (non-forward target).
901        #[repr(C)]
902        #[derive(Clone, Copy)]
903        struct VaultV1b {
904            count: [u8; 8],
905        }
906        // SAFETY: repr(C), byte-array field, every bit pattern valid,
907        // align 1, no padding.
908        unsafe impl crate::Zeroable for VaultV1b {}
909        // SAFETY: as above.
910        unsafe impl crate::Pod for VaultV1b {}
911        // SAFETY: test-local layout upholding the sealed overlay contract.
912        unsafe impl crate::zerocopy::__sealed::HopperZeroCopySealed for VaultV1b {}
913        impl crate::field_map::FieldMap for VaultV1b {
914            const FIELDS: &'static [crate::field_map::FieldInfo] =
915                &[crate::field_map::FieldInfo::new(
916                    "count",
917                    HopperHeader::SIZE,
918                    8,
919                )];
920        }
921        impl LayoutContract for VaultV1b {
922            const DISC: u8 = KIND;
923            const VERSION: u8 = 1;
924            const LAYOUT_ID: [u8; 8] = [0x44; 8];
925            const SIZE: usize = HopperHeader::SIZE + core::mem::size_of::<Self>();
926        }
927
928        /// The executing program: the owner the fixtures stamp.
929        fn pid() -> crate::address::Address {
930            crate::address::Address::new_from_array([6; 32])
931        }
932
933        /// Raw account builder: `data_len` bytes plus the loader's
934        /// `MAX_PERMITTED_DATA_INCREASE` growth headroom (so `resize`
935        /// behaves exactly as on-chain), with the given balance, flags,
936        /// and owner.
937        fn raw_account(
938            data_len: usize,
939            lamports: u64,
940            is_writable: bool,
941            is_signer: bool,
942            owner: [u8; 32],
943        ) -> (std::vec::Vec<u64>, crate::AccountView<'static>) {
944            use hopper_native::MAX_PERMITTED_DATA_INCREASE;
945            let mut backing = std::vec![0u64; (RuntimeAccount::SIZE + data_len + MAX_PERMITTED_DATA_INCREASE).div_ceil(8)];
946            let raw = backing.as_mut_ptr() as *mut RuntimeAccount;
947            // SAFETY: backing is sized for the runtime header plus
948            // `data_len` bytes plus the loader growth reserve, and
949            // outlives the returned view (the caller holds the Vec).
950            unsafe {
951                raw.write(RuntimeAccount {
952                    borrow_state: NOT_BORROWED,
953                    is_signer: is_signer as u8,
954                    is_writable: is_writable as u8,
955                    executable: 0,
956                    resize_delta: 0,
957                    address: NativeAddress::new_from_array([5; 32]),
958                    owner: NativeAddress::new_from_array(owner),
959                    lamports,
960                    data_len: data_len as u64,
961                });
962            }
963            // SAFETY: raw points at a fully initialized RuntimeAccount
964            // with its data region in the same allocation.
965            let backend = unsafe { NativeAccountView::new_unchecked(raw) };
966            (backing, crate::AccountView::from_backend(backend))
967        }
968
969        /// Stamp a valid VaultV1 (count = 7, legacy = [1,2,3,4], flags =
970        /// 0x0102 to prove flag preservation) into an account.
971        fn stamp_v1(account: &crate::AccountView<'_>) {
972            let mut data = account.try_borrow_mut().expect("fixture borrow");
973            write_header(
974                &mut data,
975                <VaultV1 as LayoutContract>::DISC,
976                <VaultV1 as LayoutContract>::VERSION,
977                &<VaultV1 as LayoutContract>::LAYOUT_ID,
978            )
979            .expect("fixture header");
980            // Account-state flags a migration must carry across.
981            data[2..4].copy_from_slice(&0x0102u16.to_le_bytes());
982            data[16..20].copy_from_slice(&7u32.to_le_bytes());
983            data[20..24].copy_from_slice(&[1, 2, 3, 4]);
984        }
985
986        /// Build a writable account of `data_len` bytes seeded as a
987        /// valid VaultV1, owned by [`pid`].
988        fn seeded_v1(data_len: usize) -> (std::vec::Vec<u64>, crate::AccountView<'static>) {
989            let (backing, account) = raw_account(data_len, 1, true, false, [6; 32]);
990            stamp_v1(&account);
991            (backing, account)
992        }
993
994        fn widen(old: &VaultV1, new: &mut VaultV2) -> Result<(), ProgramError> {
995            let count = u32::from_le_bytes(old.count) as u64;
996            new.count = count.to_le_bytes();
997            new.flag = 1;
998            Ok(())
999        }
1000
1001        #[test]
1002        fn typed_migration_restamps_header_and_transforms_body() {
1003            // Allocation already big enough for V2 (the realloc-first
1004            // rule for larger shapes is exercised separately below).
1005            let (_b, account) = seeded_v1(HopperHeader::SIZE + 16);
1006
1007            migrate_layout::<VaultV1, VaultV2, _>(&account, &pid(), widen).expect("migrates");
1008
1009            let data = account.try_borrow().expect("read back");
1010            // Header: New identity, OLD flags.
1011            assert_eq!(read_disc(&data), Some(KIND));
1012            assert_eq!(read_version(&data), Some(2));
1013            assert_eq!(read_layout_id(&data), Some(&[0x22; 8]));
1014            assert_eq!(read_schema_epoch(&data), Some(5));
1015            assert_eq!(
1016                read_flags(&data),
1017                Some(0x0102),
1018                "flags are account state and must survive the re-stamp"
1019            );
1020            // Body: widened counter, set flag, and NOTHING left of the
1021            // legacy bytes (the zeroed span is the deterministic
1022            // default for unset fields).
1023            assert_eq!(&data[16..24], &7u64.to_le_bytes());
1024            assert_eq!(data[24], 1);
1025            assert_eq!(&data[25..28], &[0, 0, 0]);
1026        }
1027
1028        #[test]
1029        fn migrated_account_refuses_a_second_migration() {
1030            let (_b, account) = seeded_v1(HopperHeader::SIZE + 16);
1031            migrate_layout::<VaultV1, VaultV2, _>(&account, &pid(), widen).expect("first migrates");
1032            // The header now reads V2: it is no longer a valid VaultV1,
1033            // so the identity check refuses, migrate exactly once.
1034            assert_eq!(
1035                migrate_layout::<VaultV1, VaultV2, _>(&account, &pid(), widen),
1036                Err(ProgramError::InvalidAccountData)
1037            );
1038        }
1039
1040        #[test]
1041        fn transform_error_leaves_the_header_on_the_old_layout() {
1042            let (_b, account) = seeded_v1(HopperHeader::SIZE + 16);
1043            let result = migrate_layout::<VaultV1, VaultV2, _>(&account, &pid(), |_, _| {
1044                Err(ProgramError::Custom(9))
1045            });
1046            assert_eq!(result, Err(ProgramError::Custom(9)));
1047            let data = account.try_borrow().expect("read back");
1048            // The stamp is LAST: the header still says V1, so under
1049            // transaction-abort semantics the account is never observed
1050            // half-migrated (the body writes roll back with the tx).
1051            assert_eq!(read_version(&data), Some(1));
1052            assert_eq!(read_layout_id(&data), Some(&[0x11; 8]));
1053        }
1054
1055        #[test]
1056        fn larger_new_shape_requires_realloc_first() {
1057            // Allocation fits V1 exactly (24 bytes); V2 needs 28.
1058            let (_b, account) = seeded_v1(HopperHeader::SIZE + 8);
1059            assert_eq!(
1060                migrate_layout::<VaultV1, VaultV2, _>(&account, &pid(), widen),
1061                Err(ProgramError::AccountDataTooSmall)
1062            );
1063            let data = account.try_borrow().expect("read back");
1064            assert_eq!(read_version(&data), Some(1), "refused before any write");
1065            assert_eq!(&data[16..20], &7u32.to_le_bytes());
1066        }
1067
1068        #[test]
1069        fn cross_kind_migration_is_refused() {
1070            let (_b, account) = seeded_v1(HopperHeader::SIZE + 16);
1071            // OtherKind::DISC != VaultV1::DISC: repurposing the account
1072            // kind is not a migration.
1073            assert_eq!(
1074                migrate_layout::<VaultV1, OtherKind, _>(&account, &pid(), |_, _| Ok(())),
1075                Err(ProgramError::InvalidAccountData)
1076            );
1077        }
1078
1079        #[test]
1080        fn non_forward_version_is_refused() {
1081            let (_b, account) = seeded_v1(HopperHeader::SIZE + 16);
1082            // Same version (1 → 1): not forward, refused before any
1083            // borrow or write.
1084            assert_eq!(
1085                migrate_layout::<VaultV1, VaultV1b, _>(&account, &pid(), |_, _| Ok(())),
1086                Err(ProgramError::InvalidAccountData)
1087            );
1088            // And backward (2 → 1) likewise.
1089            assert_eq!(
1090                migrate_layout::<VaultV2, VaultV1b, _>(&account, &pid(), |_, _| Ok(())),
1091                Err(ProgramError::InvalidAccountData)
1092            );
1093        }
1094
1095        /// The crank-before-validators fix: a foreign-owned account whose
1096        /// bytes parse as a perfect Old header must be refused BEFORE the
1097        /// user transform reads a byte, the macro crank runs at bind
1098        /// ahead of the per-field validators, so this gate is the first
1099        /// authority to look at the account.
1100        #[test]
1101        fn foreign_owned_account_is_refused_before_the_transform_runs() {
1102            let (_b, account) = raw_account(HopperHeader::SIZE + 16, 1, true, false, [9; 32]);
1103            stamp_v1(&account);
1104            let mut transform_ran = false;
1105            let result = migrate_layout::<VaultV1, VaultV2, _>(&account, &pid(), |old, new| {
1106                transform_ran = true;
1107                widen(old, new)
1108            });
1109            assert_eq!(result, Err(ProgramError::IncorrectProgramId));
1110            assert!(
1111                !transform_ran,
1112                "the user transform must never run over another program's bytes"
1113            );
1114            let data = account.try_borrow().expect("read back");
1115            assert_eq!(read_version(&data), Some(1), "nothing was written");
1116        }
1117
1118        /// Same gate, writability dimension.
1119        #[test]
1120        fn non_writable_account_is_refused_before_the_transform_runs() {
1121            let (_b, account) = raw_account(HopperHeader::SIZE + 16, 1, false, false, [6; 32]);
1122            stamp_v1(&account);
1123            assert_eq!(
1124                migrate_layout::<VaultV1, VaultV2, _>(&account, &pid(), widen),
1125                Err(ProgramError::InvalidAccountData)
1126            );
1127        }
1128
1129        /// The resizing variant grows the allocation to fit New and tops
1130        /// up the rent-exempt minimum from the payer, exactly the
1131        /// deficit, nothing more.
1132        #[test]
1133        fn resizing_migration_grows_and_tops_up_exactly_the_deficit() {
1134            use crate::rent::minimum_balance_live;
1135            // Allocation fits V1 exactly (24 B); V2 needs 28. The account
1136            // holds 1 lamport, far below the grown minimum.
1137            let (_b, account) = raw_account(HopperHeader::SIZE + 8, 1, true, false, [6; 32]);
1138            stamp_v1(&account);
1139            let payer_start = 1_000_000_000u64;
1140            let (_pb, payer) = raw_account(0, payer_start, true, true, [0; 32]);
1141
1142            migrate_layout_resizing::<VaultV1, VaultV2, _>(&account, &payer, &pid(), false, widen)
1143                .expect("grow + migrate");
1144
1145            let min_new = minimum_balance_live(HopperHeader::SIZE + 12).expect("host rent");
1146            assert_eq!(account.data_len(), HopperHeader::SIZE + 12);
1147            assert_eq!(account.lamports(), min_new, "topped up to the minimum");
1148            assert_eq!(
1149                payer.lamports(),
1150                payer_start - (min_new - 1),
1151                "payer debited exactly the deficit"
1152            );
1153            let data = account.try_borrow().expect("read back");
1154            assert_eq!(read_version(&data), Some(2));
1155            assert_eq!(&data[16..24], &7u64.to_le_bytes());
1156        }
1157
1158        /// A well-funded account grows without touching (or requiring a
1159        /// signature from) the payer at all.
1160        #[test]
1161        fn resizing_migration_needs_no_payer_when_already_funded() {
1162            use crate::rent::minimum_balance_live;
1163            let funded = minimum_balance_live(HopperHeader::SIZE + 12).expect("host rent") + 777;
1164            let (_b, account) = raw_account(HopperHeader::SIZE + 8, funded, true, false, [6; 32]);
1165            stamp_v1(&account);
1166            // The payer is NOT a signer and NOT writable: must not matter.
1167            let (_pb, payer) = raw_account(0, 5, false, false, [0; 32]);
1168
1169            migrate_layout_resizing::<VaultV1, VaultV2, _>(&account, &payer, &pid(), false, widen)
1170                .expect("grow without payer");
1171            assert_eq!(account.lamports(), funded, "balance untouched");
1172            assert_eq!(payer.lamports(), 5, "payer untouched");
1173        }
1174
1175        /// Version 3: narrows back to a 4-byte body, SMALLER than V2,
1176        /// to exercise the shrink path.
1177        #[repr(C)]
1178        #[derive(Clone, Copy)]
1179        struct VaultV3 {
1180            count: [u8; 4],
1181        }
1182        // SAFETY: repr(C), byte-array field, every bit pattern valid,
1183        // align 1, no padding.
1184        unsafe impl crate::Zeroable for VaultV3 {}
1185        // SAFETY: as above.
1186        unsafe impl crate::Pod for VaultV3 {}
1187        // SAFETY: test-local layout upholding the sealed overlay contract.
1188        unsafe impl crate::zerocopy::__sealed::HopperZeroCopySealed for VaultV3 {}
1189        impl crate::field_map::FieldMap for VaultV3 {
1190            const FIELDS: &'static [crate::field_map::FieldInfo] =
1191                &[crate::field_map::FieldInfo::new(
1192                    "count",
1193                    HopperHeader::SIZE,
1194                    4,
1195                )];
1196        }
1197        impl LayoutContract for VaultV3 {
1198            const DISC: u8 = KIND;
1199            const VERSION: u8 = 3;
1200            const LAYOUT_ID: [u8; 8] = [0x55; 8];
1201            const SIZE: usize = HopperHeader::SIZE + core::mem::size_of::<Self>();
1202        }
1203
1204        fn narrow(old: &VaultV2, new: &mut VaultV3) -> Result<(), ProgramError> {
1205            let count = u64::from_le_bytes(old.count) as u32;
1206            new.count = count.to_le_bytes();
1207            Ok(())
1208        }
1209
1210        /// Migrate a V1 fixture up to V2 so shrink tests have a V2 start.
1211        fn seeded_v2(lamports: u64) -> (std::vec::Vec<u64>, crate::AccountView<'static>) {
1212            let (backing, account) = raw_account(HopperHeader::SIZE + 12, 1, true, false, [6; 32]);
1213            stamp_v1(&account);
1214            migrate_layout::<VaultV1, VaultV2, _>(&account, &pid(), widen).expect("to V2");
1215            account.try_set_lamports(lamports).expect("fund fixture");
1216            (backing, account)
1217        }
1218
1219        /// THE anti-drain proof (Quasar `account.rs:117-125` normalizes
1220        /// the balance to rent-min and pays the whole difference to the
1221        /// payer, deposits leave with it). Hopper's shrink refunds
1222        /// EXACTLY the freed rent delta; a surplus deposit riding on the
1223        /// account stays on the account.
1224        #[test]
1225        fn shrink_refunds_only_the_rent_delta_and_never_touches_deposits() {
1226            use crate::rent::minimum_balance_live;
1227            let min_old = minimum_balance_live(HopperHeader::SIZE + 12).expect("host rent");
1228            let min_new = minimum_balance_live(HopperHeader::SIZE + 4).expect("host rent");
1229            let deposit = 500_000u64;
1230            let (_b, account) = seeded_v2(min_old + deposit);
1231            let (_pb, payer) = raw_account(0, 10, true, false, [0; 32]);
1232
1233            migrate_layout_resizing::<VaultV2, VaultV3, _>(&account, &payer, &pid(), true, narrow)
1234                .expect("migrate + shrink");
1235
1236            assert_eq!(account.data_len(), HopperHeader::SIZE + 4);
1237            assert_eq!(
1238                account.lamports(),
1239                min_new + deposit,
1240                "the deposit MUST stay on the account, only the freed \
1241                 rent requirement is refunded"
1242            );
1243            assert_eq!(
1244                payer.lamports(),
1245                10 + (min_old - min_new),
1246                "payer receives exactly the rent delta"
1247            );
1248        }
1249
1250        /// One `migrate_chain!` call heals an account from ANY declared
1251        /// starting version: V1 walks both hops, V2 only the second,
1252        /// V3 none, and a foreign layout is untouched with 0 hops.
1253        #[test]
1254        fn migrate_chain_heals_from_any_starting_version() {
1255            fn run_chain(account: &crate::AccountView<'_>) -> Result<u32, ProgramError> {
1256                Ok(crate::migrate_chain!(account, &pid(), {
1257                    VaultV1 => VaultV2: widen,
1258                    VaultV2 => VaultV3: narrow,
1259                }))
1260            }
1261
1262            // V1 start: both hops fire; the value threads through both
1263            // transforms (7 widened, then narrowed back to u32).
1264            let (_b, v1) = seeded_v1(HopperHeader::SIZE + 16);
1265            assert_eq!(run_chain(&v1), Ok(2));
1266            {
1267                let data = v1.try_borrow().unwrap();
1268                assert_eq!(read_version(&data), Some(3));
1269                assert_eq!(&data[16..20], &7u32.to_le_bytes());
1270            }
1271
1272            // V2 start: only the second hop fires.
1273            let (_b2, v2) = seeded_v2(1);
1274            assert_eq!(run_chain(&v2), Ok(1));
1275            assert_eq!(read_version(&v2.try_borrow().unwrap()), Some(3));
1276
1277            // Already-V3: zero hops, byte-identical.
1278            assert_eq!(run_chain(&v2), Ok(0));
1279
1280            // Foreign layout: untouched, zero hops (the chain is a
1281            // healing pass; the caller's typed load still rejects it).
1282            let (_b3, other) = raw_account(HopperHeader::SIZE + 16, 1, true, false, [6; 32]);
1283            {
1284                let mut data = other.try_borrow_mut().unwrap();
1285                write_header(
1286                    &mut data,
1287                    <OtherKind as LayoutContract>::DISC,
1288                    <OtherKind as LayoutContract>::VERSION,
1289                    &<OtherKind as LayoutContract>::LAYOUT_ID,
1290                )
1291                .unwrap();
1292            }
1293            assert_eq!(run_chain(&other), Ok(0));
1294            assert_eq!(
1295                read_disc(&other.try_borrow().unwrap()),
1296                Some(<OtherKind as LayoutContract>::DISC)
1297            );
1298        }
1299
1300        /// The resizing chain grows ONCE, to the LARGEST hop target (the
1301        /// middle V2 shape here, 28 B; not the smaller final V3), with
1302        /// the rent deficit debited from the payer.
1303        #[test]
1304        fn migrate_chain_with_payer_grows_once_to_the_largest_hop() {
1305            fn run_chain<'a>(
1306                account: &crate::AccountView<'a>,
1307                payer: &crate::AccountView<'a>,
1308            ) -> Result<u32, ProgramError> {
1309                Ok(crate::migrate_chain!(account, &pid(), payer = payer, {
1310                    VaultV1 => VaultV2: widen,
1311                    VaultV2 => VaultV3: narrow,
1312                }))
1313            }
1314
1315            // Sized for V1 only (24 B) with 1 lamport: the V2 hop (28 B)
1316            // cannot run without the up-front grow + top-up.
1317            let (_b, account) = raw_account(HopperHeader::SIZE + 8, 1, true, false, [6; 32]);
1318            stamp_v1(&account);
1319            let payer_start = 1_000_000_000u64;
1320            let (_pb, payer) = raw_account(0, payer_start, true, true, [0; 32]);
1321
1322            assert_eq!(run_chain(&account, &payer), Ok(2));
1323            // Grown to the LARGEST hop (V2's 28), never shrunk (the
1324            // chain has no shrink phase, that is `resize = fit`'s job).
1325            assert_eq!(account.data_len(), HopperHeader::SIZE + 12);
1326            let data = account.try_borrow().unwrap();
1327            assert_eq!(read_version(&data), Some(3));
1328            assert_eq!(&data[16..20], &7u32.to_le_bytes());
1329            assert!(payer.lamports() < payer_start, "payer funded the grow");
1330        }
1331
1332        /// The shared acceptance predicate behind `epoch_migrate`:
1333        /// identity must match exactly, the epoch may lag but never
1334        /// lead, and pre-epoch zero headers read as epoch 1.
1335        #[test]
1336        fn epoch_migration_header_predicate_accepts_lag_refuses_lead() {
1337            use crate::layout::write_header_with_epoch;
1338
1339            #[repr(C)]
1340            #[derive(Clone, Copy)]
1341            struct EpochThree {
1342                v: [u8; 8],
1343            }
1344            // SAFETY: repr(C), byte-array field, every bit pattern
1345            // valid, align 1, no padding.
1346            unsafe impl crate::Zeroable for EpochThree {}
1347            // SAFETY: as above.
1348            unsafe impl crate::Pod for EpochThree {}
1349            // SAFETY: test-local layout upholding the sealed overlay
1350            // contract.
1351            unsafe impl crate::zerocopy::__sealed::HopperZeroCopySealed for EpochThree {}
1352            impl crate::field_map::FieldMap for EpochThree {
1353                const FIELDS: &'static [crate::field_map::FieldInfo] =
1354                    &[crate::field_map::FieldInfo::new("v", HopperHeader::SIZE, 8)];
1355            }
1356            impl LayoutContract for EpochThree {
1357                const DISC: u8 = 93;
1358                const VERSION: u8 = 1;
1359                const LAYOUT_ID: [u8; 8] = [0x93; 8];
1360                const SIZE: usize = HopperHeader::SIZE + 8;
1361                const SCHEMA_EPOCH: u32 = 3;
1362            }
1363
1364            let mut data = std::vec![0u8; HopperHeader::SIZE + 8];
1365            // Stale epoch (1): accepted, effective epoch returned.
1366            write_header_with_epoch(&mut data, 93, 1, &[0x93; 8], 1).unwrap();
1367            assert_eq!(
1368                validate_header_for_epoch_migration::<EpochThree>(&data),
1369                Ok(1)
1370            );
1371            // Current epoch (3): accepted (the crank then no-ops).
1372            write_header_with_epoch(&mut data, 93, 1, &[0x93; 8], 3).unwrap();
1373            assert_eq!(
1374                validate_header_for_epoch_migration::<EpochThree>(&data),
1375                Ok(3)
1376            );
1377            // Future epoch (4): refused, never "migrated" down.
1378            write_header_with_epoch(&mut data, 93, 1, &[0x93; 8], 4).unwrap();
1379            assert!(validate_header_for_epoch_migration::<EpochThree>(&data).is_err());
1380            // Pre-epoch zero header reads as effective epoch 1.
1381            write_header_with_epoch(&mut data, 93, 1, &[0x93; 8], 0).unwrap();
1382            assert_eq!(
1383                validate_header_for_epoch_migration::<EpochThree>(&data),
1384                Ok(1)
1385            );
1386            // Identity mismatches refuse regardless of epoch.
1387            write_header_with_epoch(&mut data, 94, 1, &[0x93; 8], 1).unwrap();
1388            assert!(validate_header_for_epoch_migration::<EpochThree>(&data).is_err());
1389            write_header_with_epoch(&mut data, 93, 2, &[0x93; 8], 1).unwrap();
1390            assert!(validate_header_for_epoch_migration::<EpochThree>(&data).is_err());
1391            write_header_with_epoch(&mut data, 93, 1, &[0x44; 8], 1).unwrap();
1392            assert!(validate_header_for_epoch_migration::<EpochThree>(&data).is_err());
1393            // Undersized allocation refused.
1394            let tiny = std::vec![0u8; HopperHeader::SIZE + 4];
1395            assert!(validate_header_for_epoch_migration::<EpochThree>(&tiny).is_err());
1396        }
1397
1398        /// Shrink is opt-in: with `shrink_to_fit = false` the allocation
1399        /// keeps its size and no lamport moves (dynamic-tail layouts
1400        /// depend on this default, shrinking to `required_len` would
1401        /// truncate their tail).
1402        #[test]
1403        fn shrink_is_opt_in_and_off_by_default_in_the_macro() {
1404            use crate::rent::minimum_balance_live;
1405            let min_old = minimum_balance_live(HopperHeader::SIZE + 12).expect("host rent");
1406            let (_b, account) = seeded_v2(min_old);
1407            let (_pb, payer) = raw_account(0, 10, true, false, [0; 32]);
1408
1409            migrate_layout_resizing::<VaultV2, VaultV3, _>(&account, &payer, &pid(), false, narrow)
1410                .expect("migrate without shrink");
1411            assert_eq!(account.data_len(), HopperHeader::SIZE + 12, "size kept");
1412            assert_eq!(account.lamports(), min_old, "no refund");
1413            assert_eq!(payer.lamports(), 10);
1414        }
1415    }
1416}