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 let view = unsafe { self.parse_one_account() };
121 self.resolved[self.parsed_count] = view.clone();
122 self.parsed_count += 1;
123 Ok(view)
124 }
125
126 /// Parse the next account and validate it is a signer.
127 #[inline]
128 pub fn next_signer(&mut self) -> Result<AccountView, ProgramError> {
129 let acct = self.next_account()?;
130 acct.require_signer()?;
131 Ok(acct)
132 }
133
134 /// Parse the next account and validate it is writable.
135 #[inline]
136 pub fn next_writable(&mut self) -> Result<AccountView, ProgramError> {
137 let acct = self.next_account()?;
138 acct.require_writable()?;
139 Ok(acct)
140 }
141
142 /// Parse the next account and validate it is a writable signer (payer).
143 #[inline]
144 pub fn next_payer(&mut self) -> Result<AccountView, ProgramError> {
145 let acct = self.next_account()?;
146 acct.require_payer()?;
147 Ok(acct)
148 }
149
150 /// Parse the next account and validate it is owned by `program`.
151 #[inline]
152 pub fn next_owned_by(&mut self, program: &Address) -> Result<AccountView, ProgramError> {
153 let acct = self.next_account()?;
154 acct.require_owned_by(program)?;
155 Ok(acct)
156 }
157
158 /// Skip `n` accounts without returning them.
159 ///
160 /// Advances the cursor through the raw buffer without constructing
161 /// full AccountView values, only doing enough work to find account
162 /// boundaries.
163 #[inline]
164 pub fn skip(&mut self, n: usize) -> Result<(), ProgramError> {
165 for _ in 0..n {
166 if self.parsed_count >= self.total_accounts {
167 return Err(ProgramError::NotEnoughAccountKeys);
168 }
169 // Advance cursor past this account without storing it.
170 unsafe { self.advance_cursor() };
171 self.parsed_count += 1;
172 }
173 Ok(())
174 }
175
176 /// Collect all remaining accounts into a slice of the internal buffer.
177 ///
178 /// Parses all remaining accounts eagerly and returns them as a slice.
179 /// After this call, `remaining()` returns 0.
180 #[inline]
181 pub fn drain_remaining(&mut self) -> Result<&[AccountView], ProgramError> {
182 let start = self.parsed_count;
183 while self.parsed_count < self.total_accounts {
184 let view = unsafe { self.parse_one_account() };
185 self.resolved[self.parsed_count] = view;
186 self.parsed_count += 1;
187 }
188 Ok(&self.resolved[start..self.parsed_count])
189 }
190
191 /// Get an already-parsed account by index.
192 ///
193 /// Returns `None` if `index >= parsed_count`.
194 #[inline(always)]
195 pub fn get(&self, index: usize) -> Option<&AccountView> {
196 if index < self.parsed_count {
197 Some(&self.resolved[index])
198 } else {
199 None
200 }
201 }
202
203 /// Parse one account at the current cursor position and advance cursor.
204 ///
205 /// # Safety
206 ///
207 /// Caller must ensure `parsed_count < total_accounts` and that `cursor`
208 /// points to valid BPF input buffer data.
209 #[inline(always)]
210 unsafe fn parse_one_account(&mut self) -> AccountView {
211 unsafe {
212 let dup_marker = *self.cursor;
213
214 if dup_marker == u8::MAX {
215 // Non-duplicate: RuntimeAccount header starts here.
216 let raw = self.cursor as *mut RuntimeAccount;
217 let view = AccountView::new_unchecked(raw);
218 self.advance_non_dup_cursor(raw);
219 view
220 } else {
221 // Duplicate: references an earlier account.
222 let original_idx = dup_marker as usize;
223 self.cursor = self.cursor.add(8); // skip 8-byte padding
224 // The loader guarantees duplicate markers refer to
225 // **previously parsed** slots. A marker that points at
226 // ourselves or forward is malformed loader input -
227 // pre-audit we returned `self.resolved[0]` which is a
228 // zeroed `AccountView` until a real account has been
229 // parsed, silently handing out a null-pointer view. The
230 // Hopper Safety Audit flagged this; we now trap.
231 if original_idx >= self.parsed_count {
232 crate::raw_input::malformed_duplicate_marker(dup_marker, self.parsed_count);
233 }
234 self.resolved[original_idx].clone()
235 }
236 }
237 }
238
239 /// Advance the cursor past one account slot without constructing a view.
240 ///
241 /// # Safety
242 ///
243 /// Caller must ensure `parsed_count < total_accounts` and cursor is valid.
244 #[inline(always)]
245 unsafe fn advance_cursor(&mut self) {
246 unsafe {
247 let dup_marker = *self.cursor;
248 if dup_marker == u8::MAX {
249 let raw = self.cursor as *mut RuntimeAccount;
250 self.advance_non_dup_cursor(raw);
251 } else {
252 self.cursor = self.cursor.add(8);
253 }
254 }
255 }
256
257 /// Advance cursor past a non-duplicate account (shared by parse + skip).
258 #[inline(always)]
259 unsafe fn advance_non_dup_cursor(&mut self, raw: *mut RuntimeAccount) {
260 unsafe {
261 let data_len = (*raw).data_len as usize;
262 let mut offset = RuntimeAccount::SIZE + data_len + MAX_PERMITTED_DATA_INCREASE;
263 offset += self.cursor.add(offset).align_offset(BPF_ALIGN_OF_U128);
264 offset += 8;
265 self.cursor = self.cursor.add(offset);
266 }
267 }
268}
269
270/// Deserialize a BPF input buffer into a `LazyContext`.
271///
272/// Reads the account count, then scans forward to find instruction data
273/// and program ID WITHOUT parsing any individual accounts. The actual
274/// account parsing is deferred to `LazyContext::next_account()`.
275///
276/// # Safety
277///
278/// `input` must point to a valid Solana BPF input buffer.
279#[inline(always)]
280pub unsafe fn lazy_deserialize(input: *mut u8) -> LazyContext {
281 let frame = unsafe { crate::raw_input::scan_instruction_frame(input) };
282 // SAFETY: AccountView is a single raw pointer, zeroed is a valid
283 // sentinel (null). These slots are only read after `next_account()`
284 // initializes them via `parse_one_account()`.
285 let resolved: [AccountView; 254] = unsafe { core::mem::zeroed() };
286
287 LazyContext {
288 cursor: frame.accounts_start,
289 total_accounts: frame.account_count,
290 parsed_count: 0,
291 instruction_data: frame.instruction_data.as_ptr(),
292 instruction_data_len: frame.instruction_data.len(),
293 program_id: frame.program_id,
294 resolved,
295 }
296}