Skip to main content

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}