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 Native pre-scans the instruction tail, preserves
10//! canonical duplicate-account handling in `raw_input`, and then exposes a
11//! `LazyContext` that already knows `instruction_data` and `program_id`
12//! before the first account is materialized.
13//!
14//! # CU Savings
15//!
16//! Programs that dispatch on `instruction_data[0]` and only need a subset
17//! of accounts save measurable CU. A vault program that routes 8 instruction
18//! variants through a single entrypoint might only parse 2-3 of 10 accounts
19//! for a given variant.
20//!
21//! # Usage
22//!
23//! ```ignore
24//! use hopper_native::lazy::LazyContext;
25//! use hopper_native::hopper_lazy_entrypoint;
26//!
27//! hopper_lazy_entrypoint!(process);
28//!
29//! fn process(ctx: LazyContext) -> ProgramResult {
30//!     let disc = ctx.instruction_data().first().copied().unwrap_or(0);
31//!     match disc {
32//!         0 => {
33//!             let payer = ctx.next_account()?;
34//!             let vault = ctx.next_account()?;
35//!             // Remaining accounts are never parsed.
36//!             do_deposit(payer, vault, &ctx.instruction_data()[1..])
37//!         }
38//!         _ => Err(ProgramError::InvalidInstructionData),
39//!     }
40//! }
41//! ```
42
43use crate::account_view::AccountView;
44use crate::address::Address;
45use crate::error::ProgramError;
46use crate::raw_account::RuntimeAccount;
47use crate::MAX_PERMITTED_DATA_INCREASE;
48
49const BPF_ALIGN_OF_U128: usize = 8;
50
51/// Pre-parsed header from the BPF input buffer: instruction data +
52/// program ID, plus a cursor positioned at the first account.
53///
54/// Accounts are parsed lazily as you call `next_account()`.
55pub struct LazyContext {
56    /// Raw pointer into the BPF input buffer, positioned at the first
57    /// account (or past the account count if num_accounts == 0).
58    cursor: *mut u8,
59    /// Total number of accounts declared in the input.
60    total_accounts: usize,
61    /// Number of accounts already parsed.
62    parsed_count: usize,
63    /// Instruction data slice (lifetime tied to the BPF input buffer).
64    instruction_data: *const u8,
65    instruction_data_len: usize,
66    /// Program ID (32 bytes, copied from the input buffer tail).
67    program_id: Address,
68    /// Stack of already-parsed AccountViews so we can resolve duplicates
69    /// that reference earlier accounts. Fixed size = MAX_TX_ACCOUNTS.
70    resolved: [AccountView; 254],
71}
72
73// SAFETY: Single-threaded BPF runtime.
74unsafe impl Send for LazyContext {}
75unsafe impl Sync for LazyContext {}
76
77impl LazyContext {
78    /// Instruction data for this invocation.
79    #[inline(always)]
80    pub fn instruction_data(&self) -> &[u8] {
81        // SAFETY: instruction_data points into the BPF input buffer which
82        // outlives the entire instruction execution.
83        unsafe { core::slice::from_raw_parts(self.instruction_data, self.instruction_data_len) }
84    }
85
86    /// The program ID of this invocation.
87    #[inline(always)]
88    pub fn program_id(&self) -> &Address {
89        &self.program_id
90    }
91
92    /// Number of accounts declared in the transaction.
93    #[inline(always)]
94    pub fn total_accounts(&self) -> usize {
95        self.total_accounts
96    }
97
98    /// Number of accounts parsed so far.
99    #[inline(always)]
100    pub fn parsed_count(&self) -> usize {
101        self.parsed_count
102    }
103
104    /// Number of accounts remaining to be parsed.
105    #[inline(always)]
106    pub fn remaining(&self) -> usize {
107        self.total_accounts - self.parsed_count
108    }
109
110    /// Parse and return the next account from the input buffer.
111    ///
112    /// Each call advances the internal cursor by one account. Returns
113    /// `Err(NotEnoughAccountKeys)` if all accounts have been consumed.
114    #[inline]
115    pub fn next_account(&mut self) -> Result<AccountView, ProgramError> {
116        if self.parsed_count >= self.total_accounts {
117            return Err(ProgramError::NotEnoughAccountKeys);
118        }
119
120        // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
121        let view = unsafe { self.parse_one_account() };
122        self.resolved[self.parsed_count] = view.clone();
123        self.parsed_count += 1;
124        Ok(view)
125    }
126
127    /// Parse the next account and validate it is a signer.
128    #[inline]
129    pub fn next_signer(&mut self) -> Result<AccountView, ProgramError> {
130        let acct = self.next_account()?;
131        acct.require_signer()?;
132        Ok(acct)
133    }
134
135    /// Parse the next account and validate it is writable.
136    #[inline]
137    pub fn next_writable(&mut self) -> Result<AccountView, ProgramError> {
138        let acct = self.next_account()?;
139        acct.require_writable()?;
140        Ok(acct)
141    }
142
143    /// Parse the next account and validate it is a writable signer (payer).
144    #[inline]
145    pub fn next_payer(&mut self) -> Result<AccountView, ProgramError> {
146        let acct = self.next_account()?;
147        acct.require_payer()?;
148        Ok(acct)
149    }
150
151    /// Parse the next account and validate it is owned by `program`.
152    #[inline]
153    pub fn next_owned_by(&mut self, program: &Address) -> Result<AccountView, ProgramError> {
154        let acct = self.next_account()?;
155        acct.require_owned_by(program)?;
156        Ok(acct)
157    }
158
159    /// Skip `n` accounts without returning them.
160    ///
161    /// Advances the cursor through the raw buffer without constructing
162    /// full AccountView values, only doing enough work to find account
163    /// boundaries.
164    #[inline]
165    pub fn skip(&mut self, n: usize) -> Result<(), ProgramError> {
166        for _ in 0..n {
167            if self.parsed_count >= self.total_accounts {
168                return Err(ProgramError::NotEnoughAccountKeys);
169            }
170            // Advance cursor past this account without storing it.
171            // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
172            unsafe { self.advance_cursor() };
173            self.parsed_count += 1;
174        }
175        Ok(())
176    }
177
178    /// Collect all remaining accounts into a slice of the internal buffer.
179    ///
180    /// Parses all remaining accounts eagerly and returns them as a slice.
181    /// After this call, `remaining()` returns 0.
182    #[inline]
183    pub fn drain_remaining(&mut self) -> Result<&[AccountView], ProgramError> {
184        let start = self.parsed_count;
185        while self.parsed_count < self.total_accounts {
186            // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
187            let view = unsafe { self.parse_one_account() };
188            self.resolved[self.parsed_count] = view;
189            self.parsed_count += 1;
190        }
191        Ok(&self.resolved[start..self.parsed_count])
192    }
193
194    /// Get an already-parsed account by index.
195    ///
196    /// Returns `None` if `index >= parsed_count`.
197    #[inline(always)]
198    pub fn get(&self, index: usize) -> Option<&AccountView> {
199        if index < self.parsed_count {
200            Some(&self.resolved[index])
201        } else {
202            None
203        }
204    }
205
206    /// Parse one account at the current cursor position and advance cursor.
207    ///
208    /// # Safety
209    ///
210    /// Caller must ensure `parsed_count < total_accounts` and that `cursor`
211    /// points to valid BPF input buffer data.
212    #[inline(always)]
213    unsafe fn parse_one_account(&mut self) -> AccountView {
214        // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
215        unsafe {
216            let dup_marker = *self.cursor;
217
218            if dup_marker == u8::MAX {
219                // Non-duplicate: RuntimeAccount header starts here.
220                let raw = self.cursor as *mut RuntimeAccount;
221                let view = AccountView::new_unchecked(raw);
222                self.advance_non_dup_cursor(raw);
223                view
224            } else {
225                // Duplicate: references an earlier account.
226                let original_idx = dup_marker as usize;
227                self.cursor = self.cursor.add(8); // skip 8-byte padding
228                                                  // The loader guarantees duplicate markers refer to
229                                                  // **previously parsed** slots. A marker that points at
230                                                  // ourselves or forward is malformed loader input -
231                                                  // pre-audit we returned `self.resolved[0]` which is a
232                                                  // zeroed `AccountView` until a real account has been
233                                                  // parsed, silently handing out a null-pointer view. The
234                                                  // Hopper Safety Audit flagged this; we now trap.
235                if original_idx >= self.parsed_count {
236                    crate::raw_input::malformed_duplicate_marker(dup_marker, self.parsed_count);
237                }
238                self.resolved[original_idx].clone()
239            }
240        }
241    }
242
243    /// Advance the cursor past one account slot without constructing a view.
244    ///
245    /// # Safety
246    ///
247    /// Caller must ensure `parsed_count < total_accounts` and cursor is valid.
248    #[inline(always)]
249    unsafe fn advance_cursor(&mut self) {
250        // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
251        unsafe {
252            let dup_marker = *self.cursor;
253            if dup_marker == u8::MAX {
254                let raw = self.cursor as *mut RuntimeAccount;
255                self.advance_non_dup_cursor(raw);
256            } else {
257                self.cursor = self.cursor.add(8);
258            }
259        }
260    }
261
262    /// Advance cursor past a non-duplicate account (shared by parse + skip).
263    #[inline(always)]
264    unsafe fn advance_non_dup_cursor(&mut self, raw: *mut RuntimeAccount) {
265        // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
266        unsafe {
267            let data_len = (*raw).data_len as usize;
268            let mut offset = RuntimeAccount::SIZE + data_len + MAX_PERMITTED_DATA_INCREASE;
269            offset += self.cursor.add(offset).align_offset(BPF_ALIGN_OF_U128);
270            offset += 8;
271            self.cursor = self.cursor.add(offset);
272        }
273    }
274}
275
276/// Deserialize a BPF input buffer into a `LazyContext`.
277///
278/// Reads the account count, then scans forward to find instruction data
279/// and program ID WITHOUT parsing any individual accounts. The actual
280/// account parsing is deferred to `LazyContext::next_account()`.
281///
282/// # Safety
283///
284/// `input` must point to a valid Solana BPF input buffer.
285#[inline(always)]
286pub unsafe fn lazy_deserialize(input: *mut u8) -> LazyContext {
287    // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
288    let frame = unsafe { crate::raw_input::scan_instruction_frame(input) };
289    // SAFETY: AccountView is a single raw pointer, zeroed is a valid
290    // sentinel (null). These slots are only read after `next_account()`
291    // initializes them via `parse_one_account()`.
292    let resolved: [AccountView; 254] = unsafe { core::mem::zeroed() };
293
294    LazyContext {
295        cursor: frame.accounts_start,
296        total_accounts: frame.account_count,
297        parsed_count: 0,
298        instruction_data: frame.instruction_data.as_ptr(),
299        instruction_data_len: frame.instruction_data.len(),
300        program_id: frame.program_id,
301        resolved,
302    }
303}