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}