hopper_native/lazy.rs
1//! Lazy account parser -- on-demand account deserialization.
2//!
3//! The standard entrypoint parses every account upfront, burning CU even
4//! for accounts the instruction never touches. The lazy parser gives you
5//! instruction data and program ID immediately, then hands back an
6//! iterator that parses accounts one at a time ON DEMAND.
7//!
8//! Hopper's lazy path is distinct not because Pinocchio lacks lazy parsing,
9//! but because Hopper preserves canonical duplicate-account handling *and*
10//! keeps `instruction_data()` / `program_id()` available at any time -- even
11//! before a single account is consumed. Pinocchio's lazy entrypoint, by
12//! contrast, errors (`InvalidInstructionData`) if you read instruction data
13//! before draining every account, because it locates the tail purely by
14//! where the account cursor lands. Hopper instead runs a memoized skip-walk
15//! to the instruction tail the *first* time `instruction_data()` /
16//! `program_id()` is requested, then caches it. If a program never asks for
17//! the tail, the walk never runs; if it consumes `k` accounts first, only the
18//! remaining records are walked. There is no upfront pre-scan and no
19//! zero-fill of the resolved-account array -- both were pure overhead the
20//! pre-fusion shape paid on every invocation.
21//!
22//! # CU Savings
23//!
24//! Programs that dispatch on `instruction_data[0]` and only need a subset
25//! of accounts save measurable CU. A vault program that routes 8 instruction
26//! variants through a single entrypoint might only parse 2-3 of 10 accounts
27//! for a given variant.
28//!
29//! # Usage
30//!
31//! ```ignore
32//! use hopper_native::lazy::LazyContext;
33//! use hopper_native::hopper_lazy_entrypoint;
34//!
35//! hopper_lazy_entrypoint!(process);
36//!
37//! fn process(ctx: LazyContext) -> ProgramResult {
38//! let disc = ctx.instruction_data().first().copied().unwrap_or(0);
39//! match disc {
40//! 0 => {
41//! let payer = ctx.next_account()?;
42//! let vault = ctx.next_account()?;
43//! // Remaining accounts are never parsed.
44//! do_deposit(payer, vault, &ctx.instruction_data()[1..])
45//! }
46//! _ => Err(ProgramError::InvalidInstructionData),
47//! }
48//! }
49//! ```
50
51use core::cell::Cell;
52use core::mem::MaybeUninit;
53
54use crate::account_view::AccountView;
55use crate::address::Address;
56use crate::error::ProgramError;
57use crate::raw_account::RuntimeAccount;
58use crate::MAX_PERMITTED_DATA_INCREASE;
59
60const BPF_ALIGN_OF_U128: usize = 8;
61
62/// Byte stride from one non-duplicate account record to the next.
63///
64/// This is the folded, straight-line form of the per-account advance: an
65/// 88-byte `RuntimeAccount` header, `data_len` bytes of account data, the
66/// `MAX_PERMITTED_DATA_INCREASE` realloc reserve, u128 alignment padding, and
67/// the 8-byte rent-epoch tail. It is the pointer-delta specialization of
68/// `raw_input::next_record_offset` (private there; this replica should be
69/// hoisted into a shared helper -- see followups):
70///
71/// `next_record_offset(off, dl) - off`
72/// `= SIZE + MAX_PERMITTED_DATA_INCREASE + 8 + round_up_8(dl)`
73///
74/// which is independent of `off` because `SIZE` (88), the reserve (10240),
75/// and the rent-epoch tail (8) are all multiples of 8. Correctness of
76/// rounding the *relative* `data_len` instead of the absolute address rests
77/// on every record start being 8-aligned: the loader serializes the input at
78/// `MM_INPUT_START` (8-aligned), and each stride here is a multiple of 8, so
79/// `(base + off) % 8 == off % 8` and the folded mask lands on the same byte
80/// the old `<*mut u8>::align_offset` did. This compiles to adds + one
81/// `and`-mask, dropping the ~6 instructions per account `align_offset` cost
82/// (the same class of fix that took `deserialize_accounts` from ~30 to ~8
83/// instructions/account).
84#[inline(always)]
85const fn non_dup_stride(data_len: usize) -> usize {
86 RuntimeAccount::SIZE
87 + MAX_PERMITTED_DATA_INCREASE
88 + 8
89 + ((data_len + (BPF_ALIGN_OF_U128 - 1)) & !(BPF_ALIGN_OF_U128 - 1))
90}
91
92/// Pre-parsed header from the BPF input buffer: a cursor positioned at the
93/// first account, plus enough state to locate instruction data + program ID
94/// on demand.
95///
96/// Accounts are parsed lazily as you call `next_account()`. Instruction data
97/// and program ID are found by a memoized skip-walk the first time they are
98/// requested (see [`LazyContext::instruction_data`]).
99pub struct LazyContext<'info> {
100 /// Raw pointer into the BPF input buffer, positioned at the record for
101 /// account `parsed_count` (or past the account count if none remain).
102 /// Advanced by `next_account()` / `skip()` / `drain_remaining()`.
103 cursor: *mut u8,
104 /// Number of accounts exposed to the lazy iterator, clamped to the
105 /// 254-slot addressable/encoding limit (matches the pre-fusion
106 /// `account_count` cap). Bounds `next_account()` and `remaining()`.
107 total_accounts: usize,
108 /// Real loader account count (may exceed 254). Used only by the tail
109 /// skip-walk so the instruction tail is located even when the transaction
110 /// declares more accounts than the lazy iterator exposes.
111 declared_accounts: usize,
112 /// Number of accounts already consumed. Also the initialization
113 /// high-water mark for `resolved`: every consume path
114 /// (`next_account` / `skip` / `drain_remaining`) writes
115 /// `resolved[parsed_count]` before incrementing, so slots
116 /// `0..parsed_count` are always initialized and slots
117 /// `parsed_count..254` are always uninitialized.
118 parsed_count: usize,
119 /// Memoized pointer to the instruction-data length prefix (the byte just
120 /// past the last account record). Null until the first
121 /// `instruction_data()` / `program_id()` call triggers the skip-walk.
122 tail: Cell<*const u8>,
123 /// Stack of already-parsed AccountViews so we can resolve duplicates
124 /// that reference earlier accounts. Fixed size = MAX_TX_ACCOUNTS.
125 ///
126 /// `MaybeUninit` (not zeroed): the pre-fusion shape `mem::zeroed`'d all
127 /// 254 slots (a ~2KB memset) on every invocation. Uninitialized slots are
128 /// unreachable: `resolved[i]` is read only for `i < parsed_count`, either
129 /// by `get()` / `drain_remaining()` (bounded by `parsed_count`) or by the
130 /// duplicate branch of `parse_one_account`, which traps unless
131 /// `original_idx < parsed_count`.
132 resolved: [MaybeUninit<AccountView<'info>>; 254],
133}
134
135// SAFETY: On Solana execution is single-threaded, so the raw account/input
136// pointers (and the interior-mutable `tail` cache) in `LazyContext` are never
137// shared across threads. Gated to the SVM target (matching `AccountView`) so
138// host tools and fuzzers do not rely on cross-thread sharing of these raw
139// pointers.
140#[cfg(target_os = "solana")]
141unsafe impl<'info> Send for LazyContext<'info> {}
142#[cfg(target_os = "solana")]
143unsafe impl<'info> Sync for LazyContext<'info> {}
144
145impl<'info> LazyContext<'info> {
146 /// Walk the remaining account records to the instruction tail, memoizing
147 /// the result. Returns a pointer to the 8-byte instruction-data length
148 /// prefix.
149 ///
150 /// Runs at most once: the first `instruction_data()` / `program_id()`
151 /// call pays for it, every later call reads the cached pointer. The walk
152 /// starts at the current `cursor` (record `parsed_count`) and advances
153 /// through `declared_accounts - parsed_count` records, so already-consumed
154 /// accounts are never re-walked -- pay-as-you-go. Duplicate markers are
155 /// validated exactly as the account-parse path validates them.
156 #[inline]
157 fn tail_ptr(&self) -> *const u8 {
158 let cached = self.tail.get();
159 if !cached.is_null() {
160 return cached;
161 }
162 let mut scan = self.cursor as *const u8;
163 let mut slot = self.parsed_count;
164 // SAFETY: `cursor` sits on the record boundary for account
165 // `parsed_count` in the loader input buffer (the consume paths keep
166 // that invariant). Each iteration reads the marker byte in bounds and
167 // advances by the loader-defined record stride, so `scan` stays on
168 // record boundaries until it reaches the instruction tail after
169 // `declared_accounts` records. Record starts stay 8-aligned, so
170 // `non_dup_stride` is exact (see its docs).
171 unsafe {
172 while slot < self.declared_accounts {
173 let marker = *scan;
174 if marker == u8::MAX {
175 let raw = scan as *const RuntimeAccount;
176 let data_len = (*raw).data_len as usize;
177 scan = scan.add(non_dup_stride(data_len));
178 } else {
179 let duplicate_of = marker as usize;
180 // Same forward-reference well-formedness rule the parse
181 // path enforces: a marker must point strictly earlier.
182 if duplicate_of >= slot {
183 crate::raw_input::malformed_duplicate_marker(marker, slot);
184 }
185 scan = scan.add(8);
186 }
187 slot += 1;
188 }
189 }
190 self.tail.set(scan);
191 scan
192 }
193
194 /// Instruction data for this invocation.
195 ///
196 /// Available at any time, including before any account is consumed. The
197 /// first call (or the first `program_id()` call) runs the memoized
198 /// skip-walk to the instruction tail; later calls are cache reads.
199 #[inline(always)]
200 pub fn instruction_data(&self) -> &[u8] {
201 let tail = self.tail_ptr();
202 // SAFETY: `tail` points at the 8-byte instruction-data length prefix
203 // in the BPF input buffer, immediately followed by that many data
204 // bytes. `read_unaligned` avoids assuming pointer alignment (the tail
205 // is in fact 8-aligned). The buffer outlives the whole instruction.
206 unsafe {
207 let len = core::ptr::read_unaligned(tail as *const u64) as usize;
208 core::slice::from_raw_parts(tail.add(8), len)
209 }
210 }
211
212 /// The program ID of this invocation.
213 ///
214 /// Available at any time (see [`instruction_data`](Self::instruction_data)).
215 #[inline(always)]
216 pub fn program_id(&self) -> &Address {
217 let tail = self.tail_ptr();
218 // SAFETY: `tail` points at the instruction-data length prefix; the
219 // 32-byte program id trails `len` data bytes after it. `Address` is
220 // `#[repr(transparent)]` over `[u8; 32]` (alignment 1), so the cast is
221 // valid at any offset, and the buffer outlives the returned borrow.
222 unsafe {
223 let len = core::ptr::read_unaligned(tail as *const u64) as usize;
224 &*(tail.add(8 + len) as *const Address)
225 }
226 }
227
228 /// Number of accounts declared in the transaction.
229 #[inline(always)]
230 pub fn total_accounts(&self) -> usize {
231 self.total_accounts
232 }
233
234 /// Number of accounts parsed so far.
235 #[inline(always)]
236 pub fn parsed_count(&self) -> usize {
237 self.parsed_count
238 }
239
240 /// Number of accounts remaining to be parsed.
241 #[inline(always)]
242 pub fn remaining(&self) -> usize {
243 self.total_accounts - self.parsed_count
244 }
245
246 /// Parse and return the next account from the input buffer.
247 ///
248 /// Each call advances the internal cursor by one account. Returns
249 /// `Err(NotEnoughAccountKeys)` if all accounts have been consumed.
250 #[inline]
251 pub fn next_account(&mut self) -> Result<AccountView<'info>, ProgramError> {
252 if self.parsed_count >= self.total_accounts {
253 return Err(ProgramError::NotEnoughAccountKeys);
254 }
255
256 // SAFETY: `parsed_count < total_accounts <= declared_accounts`, so the
257 // cursor sits on a valid loader-produced account record.
258 let view = unsafe { self.parse_one_account() };
259 // Initialize slot `parsed_count` before bumping the counter, upholding
260 // the `resolved[0..parsed_count]` initialization invariant.
261 self.resolved[self.parsed_count] = MaybeUninit::new(view.clone());
262 self.parsed_count += 1;
263 Ok(view)
264 }
265
266 /// Parse the next account and validate it is a signer.
267 #[inline]
268 pub fn next_signer(&mut self) -> Result<AccountView<'info>, ProgramError> {
269 let acct = self.next_account()?;
270 acct.require_signer()?;
271 Ok(acct)
272 }
273
274 /// Parse the next account and validate it is writable.
275 #[inline]
276 pub fn next_writable(&mut self) -> Result<AccountView<'info>, ProgramError> {
277 let acct = self.next_account()?;
278 acct.require_writable()?;
279 Ok(acct)
280 }
281
282 /// Parse the next account and validate it is a writable signer (payer).
283 #[inline]
284 pub fn next_payer(&mut self) -> Result<AccountView<'info>, ProgramError> {
285 let acct = self.next_account()?;
286 acct.require_payer()?;
287 Ok(acct)
288 }
289
290 /// Parse the next account and validate it is owned by `program`.
291 #[inline]
292 pub fn next_owned_by(&mut self, program: &Address) -> Result<AccountView<'info>, ProgramError> {
293 let acct = self.next_account()?;
294 acct.require_owned_by(program)?;
295 Ok(acct)
296 }
297
298 /// Skip `n` accounts without returning them.
299 ///
300 /// Advances the cursor through the raw buffer, resolving each skipped slot
301 /// so `get()` and `drain_remaining()` stay consistent with `parsed_count`.
302 /// Constructing an `AccountView` is a single pointer wrap, so this is
303 /// materially the same cost as advancing past the record; the duplicate
304 /// well-formedness trap fires here too.
305 #[inline]
306 pub fn skip(&mut self, n: usize) -> Result<(), ProgramError> {
307 for _ in 0..n {
308 if self.parsed_count >= self.total_accounts {
309 return Err(ProgramError::NotEnoughAccountKeys);
310 }
311 // SAFETY: `parsed_count < total_accounts <= declared_accounts`, so
312 // the cursor sits on a valid loader-produced account record.
313 let view = unsafe { self.parse_one_account() };
314 self.resolved[self.parsed_count] = MaybeUninit::new(view);
315 self.parsed_count += 1;
316 }
317 Ok(())
318 }
319
320 /// Collect all remaining accounts into a slice of the internal buffer.
321 ///
322 /// Parses all remaining accounts eagerly and returns them as a slice.
323 /// After this call, `remaining()` returns 0.
324 #[inline]
325 pub fn drain_remaining(&mut self) -> Result<&[AccountView<'info>], ProgramError> {
326 let start = self.parsed_count;
327 while self.parsed_count < self.total_accounts {
328 // SAFETY: `parsed_count < total_accounts <= declared_accounts`, so
329 // the cursor sits on a valid loader-produced account record.
330 let view = unsafe { self.parse_one_account() };
331 self.resolved[self.parsed_count] = MaybeUninit::new(view);
332 self.parsed_count += 1;
333 }
334 // SAFETY: every slot in `start..parsed_count` was just initialized
335 // above (and `0..start` earlier), so this range of `resolved` is fully
336 // initialized. `MaybeUninit<AccountView>` has the same layout as
337 // `AccountView`, so the reinterpretation as `&[AccountView]` is sound.
338 unsafe {
339 Ok(core::slice::from_raw_parts(
340 self.resolved.as_ptr().add(start) as *const AccountView<'info>,
341 self.parsed_count - start,
342 ))
343 }
344 }
345
346 /// Get an already-parsed account by index.
347 ///
348 /// Returns `None` if `index >= parsed_count`.
349 #[inline(always)]
350 pub fn get(&self, index: usize) -> Option<&AccountView<'info>> {
351 if index < self.parsed_count {
352 // SAFETY: `index < parsed_count`, and every slot below
353 // `parsed_count` was initialized by a consume path before the
354 // counter advanced past it.
355 Some(unsafe { self.resolved[index].assume_init_ref() })
356 } else {
357 None
358 }
359 }
360
361 /// Parse one account at the current cursor position and advance cursor.
362 ///
363 /// # Safety
364 ///
365 /// Caller must ensure `parsed_count < total_accounts` and that `cursor`
366 /// points to valid BPF input buffer data.
367 #[inline(always)]
368 unsafe fn parse_one_account(&mut self) -> AccountView<'info> {
369 // SAFETY: caller guarantees `cursor` is on a valid loader-produced
370 // account record; the marker byte selects canonical vs duplicate
371 // framing and each branch advances by the loader-defined stride.
372 unsafe {
373 let dup_marker = *self.cursor;
374
375 if dup_marker == u8::MAX {
376 // Non-duplicate: RuntimeAccount header starts here.
377 let raw = self.cursor as *mut RuntimeAccount;
378 let view = AccountView::new_unchecked(raw);
379 // Capture the invocation-wide resize baseline before this
380 // newly materialized view can escape or be passed to CPI.
381 view.initialize_original_data_len();
382 let data_len = (*raw).data_len as usize;
383 // Folded straight-line stride (see `non_dup_stride`), replacing
384 // the pre-fusion `align_offset` walk.
385 self.cursor = self.cursor.add(non_dup_stride(data_len));
386 view
387 } else {
388 // Duplicate: references an earlier account.
389 let original_idx = dup_marker as usize;
390 self.cursor = self.cursor.add(8); // skip 8-byte padding
391 // The loader guarantees duplicate markers refer to
392 // **previously parsed** slots. A marker that points at
393 // ourselves or forward is malformed loader input -
394 // Previously this returned `self.resolved[0]`, which is a
395 // zeroed `AccountView` until a real account has been
396 // parsed, silently handing out a null-pointer view. The
397 // Parser input is malformed, so we trap.
398 if original_idx >= self.parsed_count {
399 crate::raw_input::malformed_duplicate_marker(dup_marker, self.parsed_count);
400 }
401 // SAFETY: `original_idx < parsed_count`, so `resolved[original_idx]`
402 // was initialized by an earlier consume path.
403 self.resolved[original_idx].assume_init_ref().clone()
404 }
405 }
406 }
407}
408
409/// Deserialize a BPF input buffer into a `LazyContext`.
410///
411/// Reads the account count and positions a cursor at the first account.
412/// Instruction data and program ID are NOT located here; they are found by a
413/// memoized skip-walk the first time [`LazyContext::instruction_data`] /
414/// [`LazyContext::program_id`] is called. Individual accounts are parsed on
415/// demand by [`LazyContext::next_account`]. There is no upfront account
416/// pre-scan and no zero-fill of the resolved-account array.
417///
418/// # Safety
419///
420/// `input` must point to a valid Solana BPF input buffer.
421#[inline(always)]
422pub unsafe fn lazy_deserialize<'info>(input: *mut u8) -> LazyContext<'info> {
423 // SAFETY: the first 8 bytes of the BPF input buffer are the account count;
424 // `read_unaligned` avoids assuming 8-byte pointer alignment.
425 let num_accounts = unsafe { core::ptr::read_unaligned(input as *const u64) as usize };
426 // SAFETY: the account records begin immediately after the 8-byte count.
427 let accounts_start = unsafe { input.add(8) };
428 // Preserve the pre-fusion 254-slot clamp for the iterator-visible count
429 // (marker encoding addresses indices 0..=254; slot 254 is skip-only).
430 let total_accounts = if num_accounts > 254 {
431 254
432 } else {
433 num_accounts
434 };
435 // SAFETY: an array of `MaybeUninit` is valid in the uninitialized state by
436 // definition; individual slots are initialized before they are read (see
437 // the `resolved` field invariant). This replaces the pre-fusion
438 // `core::mem::zeroed` memset of all 254 slots.
439 let resolved: [MaybeUninit<AccountView<'info>>; 254] =
440 unsafe { MaybeUninit::uninit().assume_init() };
441
442 LazyContext {
443 cursor: accounts_start,
444 total_accounts,
445 declared_accounts: num_accounts,
446 parsed_count: 0,
447 tail: Cell::new(core::ptr::null()),
448 resolved,
449 }
450}
451
452#[cfg(test)]
453mod tests {
454 extern crate std;
455
456 use std::vec;
457 use std::vec::Vec;
458
459 use super::*;
460 use crate::raw_input::parse_instruction_frame_checked;
461
462 /// One account slot description for the frame builder.
463 enum Slot {
464 /// Canonical account: 0xFF marker, header, `data` bytes, realloc
465 /// reserve, alignment padding, rent epoch.
466 Fresh {
467 data_len: usize,
468 lamports: u64,
469 signer: bool,
470 },
471 /// Duplicate reference: 1 marker byte + 7 padding bytes.
472 Dup(u8),
473 }
474
475 fn fresh(data_len: usize, lamports: u64) -> Slot {
476 Slot::Fresh {
477 data_len,
478 lamports,
479 signer: true,
480 }
481 }
482
483 /// 8-aligned loader-input fixture (u64 backing => 8-aligned base, matching
484 /// the loader's `MM_INPUT_START` guarantee the folded stride relies on).
485 struct Frame {
486 words: Vec<u64>,
487 byte_len: usize,
488 }
489
490 impl Frame {
491 fn as_mut_ptr(&mut self) -> *mut u8 {
492 self.words.as_mut_ptr() as *mut u8
493 }
494 fn as_bytes(&self) -> &[u8] {
495 // SAFETY: `words` owns at least `byte_len` initialized bytes.
496 unsafe { core::slice::from_raw_parts(self.words.as_ptr() as *const u8, self.byte_len) }
497 }
498 }
499
500 /// Serialize a loader input frame per the Solana BPF loader layout.
501 fn build_frame(slots: &[Slot], ix_data: &[u8], program_id: [u8; 32]) -> Frame {
502 let mut buf: Vec<u8> = Vec::new();
503 buf.extend_from_slice(&(slots.len() as u64).to_le_bytes());
504
505 for (i, slot) in slots.iter().enumerate() {
506 match slot {
507 Slot::Fresh {
508 data_len,
509 lamports,
510 signer,
511 } => {
512 let mut header = [0u8; RuntimeAccount::SIZE];
513 header[0] = 0xFF; // canonical marker / borrow_state
514 header[1] = if *signer { 1 } else { 0 }; // is_signer
515 header[2] = 1; // is_writable
516 // address: recognizable per-slot pattern
517 header[8..40].copy_from_slice(&[i as u8 + 1; 32]);
518 // owner
519 header[40..72].copy_from_slice(&[0x55; 32]);
520 // lamports at offset 72
521 header[72..80].copy_from_slice(&lamports.to_le_bytes());
522 // data_len at offset 80
523 header[80..88].copy_from_slice(&(*data_len as u64).to_le_bytes());
524 buf.extend_from_slice(&header);
525 buf.extend_from_slice(&vec![0xABu8; *data_len]);
526 buf.extend_from_slice(&vec![0u8; MAX_PERMITTED_DATA_INCREASE]);
527 while !buf.len().is_multiple_of(BPF_ALIGN_OF_U128) {
528 buf.push(0);
529 }
530 buf.extend_from_slice(&u64::MAX.to_le_bytes()); // rent epoch
531 }
532 Slot::Dup(of) => {
533 buf.push(*of);
534 buf.extend_from_slice(&[0u8; 7]);
535 }
536 }
537 }
538
539 buf.extend_from_slice(&(ix_data.len() as u64).to_le_bytes());
540 buf.extend_from_slice(ix_data);
541 buf.extend_from_slice(&program_id);
542
543 let byte_len = buf.len();
544 let mut words = vec![0u64; buf.len().div_ceil(8)];
545 // SAFETY: `words` has at least `buf.len()` bytes of capacity and the
546 // regions do not overlap.
547 unsafe {
548 core::ptr::copy_nonoverlapping(buf.as_ptr(), words.as_mut_ptr() as *mut u8, buf.len());
549 }
550 Frame { words, byte_len }
551 }
552
553 const PID: [u8; 32] = [0xC4; 32];
554
555 fn assert_base_aligned(frame: &mut Frame) {
556 assert_eq!(
557 frame.as_mut_ptr() as usize % 8,
558 0,
559 "fixture base must be 8-aligned"
560 );
561 }
562
563 // ── Basic tail resolution (ix-data-anytime) ──────────────────────────
564
565 #[test]
566 fn zero_accounts_serves_ix_and_pid_before_any_consume() {
567 let mut frame = build_frame(&[], &[9, 8, 7], PID);
568 assert_base_aligned(&mut frame);
569 // SAFETY: well-formed 8-aligned loader-layout fixture.
570 let ctx = unsafe { lazy_deserialize(frame.as_mut_ptr()) };
571 assert_eq!(ctx.total_accounts(), 0);
572 assert_eq!(ctx.remaining(), 0);
573 assert_eq!(ctx.instruction_data(), &[9, 8, 7]);
574 assert_eq!(ctx.program_id().as_array(), &PID);
575 }
576
577 #[test]
578 fn one_account_ix_before_consume_then_account() {
579 let mut frame = build_frame(&[fresh(11, 42)], &[1, 2, 3, 4], PID);
580 assert_base_aligned(&mut frame);
581 // SAFETY: well-formed 8-aligned loader-layout fixture.
582 let mut ctx = unsafe { lazy_deserialize(frame.as_mut_ptr()) };
583 // ix-data BEFORE consuming any account (the DX edge over Pinocchio).
584 assert_eq!(ctx.instruction_data(), &[1, 2, 3, 4]);
585 assert_eq!(ctx.program_id().as_array(), &PID);
586 // The tail scan must not have disturbed the account cursor.
587 let a = ctx.next_account().expect("one account");
588 assert_eq!(a.data_len(), 11);
589 assert_eq!(a.lamports(), 42);
590 assert!(a.is_signer());
591 assert_eq!(ctx.remaining(), 0);
592 assert!(ctx.next_account().is_err());
593 }
594
595 #[test]
596 fn ix_after_consuming_all_accounts() {
597 let slots = [fresh(3, 1), fresh(0, 2), fresh(9, 3)];
598 let mut frame = build_frame(&slots, &[0xEE, 0xEF], PID);
599 assert_base_aligned(&mut frame);
600 // SAFETY: well-formed 8-aligned loader-layout fixture.
601 let mut ctx = unsafe { lazy_deserialize(frame.as_mut_ptr()) };
602 for _ in 0..3 {
603 ctx.next_account().unwrap();
604 }
605 // Tail scan starts from a fully-advanced cursor (remaining == 0).
606 assert_eq!(ctx.instruction_data(), &[0xEE, 0xEF]);
607 assert_eq!(ctx.program_id().as_array(), &PID);
608 }
609
610 #[test]
611 fn ix_after_consuming_some_accounts() {
612 let slots = [fresh(5, 1), fresh(6, 2), fresh(7, 3), fresh(8, 4)];
613 let mut frame = build_frame(&slots, &[0xD1, 0xD2, 0xD3], PID);
614 assert_base_aligned(&mut frame);
615 // SAFETY: well-formed 8-aligned loader-layout fixture.
616 let mut ctx = unsafe { lazy_deserialize(frame.as_mut_ptr()) };
617 ctx.next_account().unwrap();
618 ctx.next_account().unwrap();
619 // Tail scan walks only the remaining two records.
620 assert_eq!(ctx.instruction_data(), &[0xD1, 0xD2, 0xD3]);
621 // Cursor undisturbed: the next two accounts still parse.
622 assert_eq!(ctx.next_account().unwrap().data_len(), 7);
623 assert_eq!(ctx.next_account().unwrap().data_len(), 8);
624 assert!(ctx.next_account().is_err());
625 }
626
627 #[test]
628 fn memoized_tail_is_stable_across_calls_and_consumes() {
629 let slots = [fresh(4, 1), fresh(5, 2)];
630 let mut frame = build_frame(&slots, &[7, 7, 7], PID);
631 assert_base_aligned(&mut frame);
632 // SAFETY: well-formed 8-aligned loader-layout fixture.
633 let mut ctx = unsafe { lazy_deserialize(frame.as_mut_ptr()) };
634 let d1 = ctx.instruction_data().as_ptr();
635 ctx.next_account().unwrap();
636 let d2 = ctx.instruction_data().as_ptr();
637 ctx.next_account().unwrap();
638 let d3 = ctx.instruction_data().as_ptr();
639 assert_eq!(d1, d2);
640 assert_eq!(d2, d3);
641 assert_eq!(ctx.instruction_data(), &[7, 7, 7]);
642 }
643
644 // ── Differential against the checked parser ──────────────────────────
645
646 /// Lazy on-demand resolution must agree with the bounds-checked parser on
647 /// every canonical record location, duplicate aliasing, the instruction
648 /// data span, and the program id offset -- the lazy analog of raw_input's
649 /// `fused_walk_agrees_with_checked_parser`.
650 fn assert_lazy_agrees(slots: &[Slot], ix_data: &[u8]) {
651 let mut frame = build_frame(slots, ix_data, PID);
652 assert_base_aligned(&mut frame);
653 let bytes = frame.as_bytes().to_vec();
654 let checked = parse_instruction_frame_checked(&bytes).expect("well-formed");
655 let base = frame.as_mut_ptr() as usize;
656
657 // SAFETY: well-formed 8-aligned loader-layout fixture.
658 let mut ctx = unsafe { lazy_deserialize(frame.as_mut_ptr()) };
659
660 let visible = checked.account_count.min(254);
661 assert_eq!(ctx.total_accounts(), visible);
662
663 for i in 0..visible {
664 let view = ctx.next_account().expect("account in range");
665 let off = checked.slot_offsets[i];
666 let ptr_off = view.account_ptr() as usize - base;
667 if bytes[off] == 0xFF {
668 assert_eq!(ptr_off, off, "canonical slot {i} pointer mismatch");
669 } else {
670 let dup_of = bytes[off] as usize;
671 assert_eq!(
672 ptr_off, checked.slot_offsets[dup_of],
673 "dup slot {i} must alias canonical {dup_of}"
674 );
675 }
676 // `get()` returns the same materialized view.
677 assert_eq!(ctx.get(i).unwrap().account_ptr(), view.account_ptr());
678 }
679
680 assert_eq!(
681 ctx.instruction_data(),
682 &bytes[checked.instruction_data_range.clone()]
683 );
684 assert_eq!(
685 ctx.program_id().as_array().as_slice(),
686 &bytes[checked.program_id_offset..checked.program_id_offset + 32]
687 );
688 }
689
690 #[test]
691 fn agrees_zero_accounts() {
692 assert_lazy_agrees(&[], &[]);
693 assert_lazy_agrees(&[], &[1, 2, 3]);
694 }
695
696 #[test]
697 fn agrees_one_account() {
698 assert_lazy_agrees(&[fresh(0, 1)], &[]); // short frame: empty data + empty ix
699 assert_lazy_agrees(&[fresh(1, 1)], &[9]);
700 }
701
702 #[test]
703 fn agrees_with_duplicates() {
704 let slots = [
705 fresh(9, 7),
706 Slot::Dup(0),
707 fresh(3, 8),
708 Slot::Dup(2),
709 Slot::Dup(0),
710 ];
711 assert_lazy_agrees(&slots, &[0x11, 0x22]);
712 }
713
714 #[test]
715 fn agrees_every_data_len_residue() {
716 // data_len 0..=7 covers every alignment residue; 8..=15 repeats them.
717 for base in [0usize, 8] {
718 let slots: Vec<Slot> = (0..8).map(|r| fresh(base + r, r as u64)).collect();
719 assert_lazy_agrees(&slots, &[0x42; 5]);
720 }
721 }
722
723 #[test]
724 fn agrees_huge_data_len() {
725 let big = 100_003usize; // residue 3 forces nonzero padding
726 assert_lazy_agrees(&[fresh(big, 5), fresh(2, 6)], &[0x77, 0x66]);
727 }
728
729 #[test]
730 fn agrees_exactly_max_254_accounts() {
731 // 1 canonical + 253 duplicates = 254 declared, all iterator-visible.
732 let mut slots: Vec<Slot> = vec![fresh(4, 9)];
733 slots.extend((0..253).map(|_| Slot::Dup(0)));
734 assert_eq!(slots.len(), 254);
735 assert_lazy_agrees(&slots, &[0x0F; 3]);
736 }
737
738 #[test]
739 fn max_plus_accounts_clamp_and_tail_still_found() {
740 // 1 canonical + 259 duplicates = 260 declared. Iterator exposes 254;
741 // the remaining records are skip-only but the tail scan must still
742 // walk past them to reach ix data + program id.
743 let mut slots: Vec<Slot> = vec![fresh(4, 9)];
744 slots.extend((0..259).map(|_| Slot::Dup(0)));
745 let mut frame = build_frame(&slots, &[0x0F; 3], PID);
746 assert_base_aligned(&mut frame);
747 // SAFETY: well-formed 8-aligned loader-layout fixture.
748 let mut ctx = unsafe { lazy_deserialize(frame.as_mut_ptr()) };
749 assert_eq!(ctx.total_accounts(), 254);
750 // ix-data available before consuming, even though 260 > 254 records
751 // must be skip-walked past to find the tail.
752 assert_eq!(ctx.instruction_data(), &[0x0F; 3]);
753 assert_eq!(ctx.program_id().as_array(), &PID);
754 // Consume up to the clamp.
755 for _ in 0..254 {
756 ctx.next_account().unwrap();
757 }
758 assert_eq!(ctx.remaining(), 0);
759 assert!(ctx.next_account().is_err());
760 }
761
762 // ── skip / drain / get ───────────────────────────────────────────────
763
764 #[test]
765 fn skip_advances_and_populates_get() {
766 let slots = [fresh(1, 1), fresh(2, 2), fresh(3, 3)];
767 let mut frame = build_frame(&slots, &[0xAB], PID);
768 assert_base_aligned(&mut frame);
769 // SAFETY: well-formed 8-aligned loader-layout fixture.
770 let mut ctx = unsafe { lazy_deserialize(frame.as_mut_ptr()) };
771 ctx.skip(2).unwrap();
772 assert_eq!(ctx.parsed_count(), 2);
773 // Skipped slots are materialized (sound `get`, not a zeroed view).
774 assert_eq!(ctx.get(0).unwrap().data_len(), 1);
775 assert_eq!(ctx.get(1).unwrap().data_len(), 2);
776 assert!(ctx.get(2).is_none());
777 assert_eq!(ctx.next_account().unwrap().data_len(), 3);
778 assert_eq!(ctx.instruction_data(), &[0xAB]);
779 }
780
781 #[test]
782 fn skip_past_end_errors() {
783 let slots = [fresh(1, 1)];
784 let mut frame = build_frame(&slots, &[], PID);
785 // SAFETY: well-formed 8-aligned loader-layout fixture.
786 let mut ctx = unsafe { lazy_deserialize(frame.as_mut_ptr()) };
787 assert!(ctx.skip(2).is_err());
788 }
789
790 #[test]
791 fn drain_remaining_returns_all() {
792 let slots = [fresh(4, 1), fresh(5, 2), fresh(6, 3)];
793 let mut frame = build_frame(&slots, &[0x01], PID);
794 assert_base_aligned(&mut frame);
795 // SAFETY: well-formed 8-aligned loader-layout fixture.
796 let mut ctx = unsafe { lazy_deserialize(frame.as_mut_ptr()) };
797 ctx.next_account().unwrap();
798 let rest = ctx.drain_remaining().unwrap();
799 assert_eq!(rest.len(), 2);
800 assert_eq!(rest[0].data_len(), 5);
801 assert_eq!(rest[1].data_len(), 6);
802 assert_eq!(ctx.remaining(), 0);
803 assert_eq!(ctx.instruction_data(), &[0x01]);
804 }
805
806 #[test]
807 fn drain_with_duplicates_aliases_canonical() {
808 let slots = [fresh(9, 1), Slot::Dup(0), fresh(3, 2)];
809 let mut frame = build_frame(&slots, &[], PID);
810 assert_base_aligned(&mut frame);
811 // SAFETY: well-formed 8-aligned loader-layout fixture.
812 let mut ctx = unsafe { lazy_deserialize(frame.as_mut_ptr()) };
813 let all = ctx.drain_remaining().unwrap();
814 assert_eq!(all.len(), 3);
815 assert_eq!(
816 all[0].account_ptr(),
817 all[1].account_ptr(),
818 "dup aliases canonical"
819 );
820 assert_ne!(all[0].account_ptr(), all[2].account_ptr());
821 assert_eq!(all[1].data_len(), 9);
822 }
823
824 // ── malformed input traps ────────────────────────────────────────────
825
826 #[test]
827 #[should_panic(expected = "malformed duplicate marker")]
828 fn forward_duplicate_traps_on_consume() {
829 let slots = [fresh(1, 1), Slot::Dup(1)]; // self-reference at slot 1
830 let mut frame = build_frame(&slots, &[], PID);
831 // SAFETY: buffer layout is loader-shaped; the malformed marker is the
832 // condition under test and traps before any OOB access.
833 let mut ctx = unsafe { lazy_deserialize(frame.as_mut_ptr()) };
834 ctx.next_account().unwrap();
835 let _ = ctx.next_account();
836 }
837
838 #[test]
839 #[should_panic(expected = "malformed duplicate marker")]
840 fn forward_duplicate_traps_in_tail_scan() {
841 // ix-data requested before consuming: the tail skip-walk must catch a
842 // forward duplicate marker just as the parse path would.
843 let slots = [fresh(1, 1), Slot::Dup(5)];
844 let mut frame = build_frame(&slots, &[], PID);
845 // SAFETY: buffer layout is loader-shaped; the malformed marker is the
846 // condition under test and traps before any OOB access.
847 let ctx = unsafe { lazy_deserialize(frame.as_mut_ptr()) };
848 let _ = ctx.instruction_data();
849 }
850}