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}