hopper_runtime/cpi.rs
1//! Cross-program invocation for Hopper programs.
2//!
3//! Provides both checked (borrow-validating) and unchecked invoke paths.
4//! Hopper uses direct runtime syscalls after Hopper-level validation.
5
6use crate::account::AccountView;
7use crate::address::{address_eq, Address};
8use crate::error::ProgramError;
9use crate::instruction::{CpiAccount, InstructionView};
10use crate::ProgramResult;
11use core::mem::MaybeUninit;
12
13#[cfg(target_os = "solana")]
14use crate::instruction::InstructionAccount;
15
16// Re-export Signer and Seed so callers can use `cpi::Signer` / `cpi::Seed`.
17pub use crate::instruction::{Seed, Signer};
18
19/// Default stack-sized ceiling for a *static* CPI call.
20///
21/// This is deliberately the low pre-SIMD-0339 value. It is used to size
22/// fixed `MaybeUninit` scratch arrays (e.g. `token.rs`) that live on the
23/// SBF stack, whose per-frame budget is only 4 KiB. Raising this constant
24/// would grow those arrays for every program regardless of need. Wide-CPI
25/// callers instead pick a larger per-call const-generic `MAX_ACCOUNTS`
26/// (bounded by [`MAX_CPI_ACCOUNTS`]), which is zero-cost when unused.
27pub const MAX_STATIC_CPI_ACCOUNTS: usize = 64;
28
29/// Hard ceiling on the number of account-infos in any single CPI.
30///
31/// Raised from 128 to 255 for **SIMD-0339** (`increase_cpi_account_info_limit`,
32/// agave gate `H6iVbVaDZgDphcPbcZwc5LoznMPWQfnJ1AM7L1xzqvt5`, live on testnet
33/// epoch 883), which lifts the runtime CPI account-info limit from 64 to 255.
34/// This is a *ceiling* constant only; it does not size any stack array, so
35/// widening it costs nothing for programs that stay small. The actual scratch
36/// allocation is governed by a per-call const-generic `MAX_ACCOUNTS`.
37///
38/// Under 0339 every distinct account-info also carries a per-info CU cost, so
39/// passing the *fewest* infos per CPI becomes a cost axis. [`DynCpi`] exploits
40/// this by deduplicating account-infos by pubkey; see
41/// [`invoke_signed_deduped`].
42///
43/// [`DynCpi`]: crate::dyn_cpi::DynCpi
44pub const MAX_CPI_ACCOUNTS: usize = 255;
45
46/// Maximum return data size (1 KiB).
47pub const MAX_RETURN_DATA: usize = 1024;
48
49// -- Hopper CPI -------------------------------------------------------
50
51#[cfg(target_os = "solana")]
52#[repr(C)]
53struct CInstruction<'a> {
54 program_id: *const Address,
55 accounts: *const InstructionAccount<'a>,
56 accounts_len: u64,
57 data: *const u8,
58 data_len: u64,
59}
60
61// -- Unchecked invoke -------------------------------------------------
62
63/// Invoke a CPI without borrow validation (lowest CU cost).
64///
65/// # Safety
66///
67/// The caller must ensure no account data borrows conflict with the CPI.
68#[inline]
69pub unsafe fn invoke_unchecked(
70 instruction: &InstructionView<'_, '_, '_, '_>,
71 accounts: &[CpiAccount<'_>],
72) -> ProgramResult {
73 // The signed form with no seeds is the unsigned invoke: the syscall
74 // reads the seed pointer only when the count is nonzero. One wrapper
75 // body serves both, so a program that invokes signed and unsigned links
76 // one syscall site instead of two.
77 // SAFETY: the caller upholds the unchecked CPI contract; forwarded as is.
78 unsafe { invoke_signed_unchecked(instruction, accounts, &[]) }
79}
80
81/// Invoke a signed CPI without borrow validation.
82///
83/// # Safety
84///
85/// The caller must ensure no account data borrows conflict with the CPI.
86#[inline(always)]
87pub unsafe fn invoke_signed_unchecked(
88 instruction: &InstructionView<'_, '_, '_, '_>,
89 accounts: &[CpiAccount<'_>],
90 signers_seeds: &[Signer<'_, '_>],
91) -> ProgramResult {
92 #[cfg(target_os = "solana")]
93 {
94 let c_instruction = CInstruction {
95 program_id: instruction.program_id as *const Address,
96 accounts: instruction.accounts.as_ptr(),
97 accounts_len: instruction.accounts.len() as u64,
98 data: instruction.data.as_ptr(),
99 data_len: instruction.data.len() as u64,
100 };
101
102 // SAFETY: `c_instruction` and the three slices are live for the
103 // synchronous call and have the C layouts the syscall reads; aliasing
104 // and privilege are this function's contract.
105 let result = unsafe {
106 hopper_native::syscalls::sol_invoke_signed_c(
107 &c_instruction as *const _ as *const u8,
108 accounts.as_ptr() as *const u8,
109 accounts.len() as u64,
110 signers_seeds.as_ptr() as *const u8,
111 signers_seeds.len() as u64,
112 )
113 };
114 if result == 0 {
115 Ok(())
116 } else {
117 Err(cpi_error(result))
118 }
119 }
120 #[cfg(not(target_os = "solana"))]
121 {
122 let _ = (instruction, accounts, signers_seeds);
123 Ok(())
124 }
125}
126
127/// Map a failed invoke's return code. Out of line and cold, so the success
128/// path after the syscall is one compare.
129#[cfg(target_os = "solana")]
130#[cold]
131#[inline(never)]
132fn cpi_error(code: u64) -> ProgramError {
133 ProgramError::from(code)
134}
135
136// ---------------------------------------------------------------------
137
138/// Reject duplicate writable accounts before invoking CPI.
139#[inline]
140pub(crate) fn validate_no_duplicate_writable(
141 instruction: &InstructionView<'_, '_, '_, '_>,
142 account_views: &[&AccountView<'_>],
143) -> ProgramResult {
144 let mut i = 0;
145 while i < instruction.accounts.len() {
146 if instruction.accounts[i].is_writable {
147 let mut j = i + 1;
148 while j < instruction.accounts.len() {
149 if instruction.accounts[j].is_writable
150 && address_eq(account_views[i].address(), account_views[j].address())
151 {
152 return Err(ProgramError::AccountBorrowFailed);
153 }
154 j += 1;
155 }
156 }
157 i += 1;
158 }
159 Ok(())
160}
161
162#[inline]
163fn signer_authority_supplied(signers_seeds: &[Signer<'_, '_>]) -> bool {
164 // PDA signer addresses are derived with the *calling* program id. A CPI
165 // instruction only carries the callee id, so this layer cannot reproduce
166 // that derivation without accidentally checking against the wrong
167 // program. The SVM's `sol_invoke_signed` syscall performs the
168 // authoritative seed validation and required-signer match. Preflight can
169 // safely reject the unambiguous no-authority case and otherwise defer the
170 // cryptographic check to the runtime.
171 //
172 // Host System-program emulation follows the same rule. It cannot know the
173 // caller id either, so signed host tests should validate their PDA inputs
174 // separately when caller-id correctness is the subject of the test.
175 !signers_seeds.is_empty()
176}
177
178/// Per-account meta↔view correspondence + borrow-state validation, the
179/// borrow-checked tier.
180///
181/// For each account: the view at index `i` must name the same address as
182/// meta `i` (so the borrow check applies to the correct account), then
183/// writable metas must be exclusively borrowable
184/// ([`AccountView::check_borrow_mut`]) and read-only metas must be
185/// shared-borrowable ([`AccountView::check_borrow`]). This is exactly the
186/// per-account check Pinocchio's safe `invoke` performs before a CPI. No
187/// signer, writability, or duplicate-writable validation happens here,
188/// those belong to the default [`invoke_signed`] tier.
189#[inline]
190#[cfg_attr(target_os = "solana", allow(dead_code))]
191fn validate_cpi_borrows(
192 instruction: &InstructionView<'_, '_, '_, '_>,
193 account_views: &[&AccountView<'_>],
194) -> ProgramResult {
195 if account_views.len() < instruction.accounts.len() {
196 return Err(ProgramError::NotEnoughAccountKeys);
197 }
198
199 let mut i = 0;
200 while i < instruction.accounts.len() {
201 // The borrow state must be validated against the account the meta
202 // actually names, not whatever view happens to sit at index `i`.
203 // Without this, a caller passing views in a different order than
204 // the metas would borrow-check the wrong (account, mutability)
205 // pair and then reach `invoke_unchecked` with its aliasing
206 // contract undischarged, UB from safe code. Pinocchio's safe
207 // `invoke` keeps exactly this check for exactly this reason
208 // (solana-instruction-view `cpi.rs`).
209 if !address_eq(account_views[i].address(), instruction.accounts[i].address) {
210 return Err(ProgramError::InvalidArgument);
211 }
212 if instruction.accounts[i].is_writable {
213 account_views[i].check_borrow_mut()?;
214 } else {
215 account_views[i].check_borrow()?;
216 }
217 i += 1;
218 }
219
220 // Sweep the mutation-completeness hand-off gate once per CPI behind the
221 // liveness branch, never reachable from the per-meta loop (the
222 // 2026-07-09 bisect measured closure-reachable gate machinery at
223 // ~+52 CU per router hop for ungated programs; see invoke_signed).
224 if crate::write_policy::lamport_gate_active() {
225 let mut m = 0;
226 while m < instruction.accounts.len() {
227 if instruction.accounts[m].is_writable {
228 crate::write_policy::check_lamport_delegation(account_views[m].address())?;
229 }
230 m += 1;
231 }
232 }
233
234 Ok(())
235}
236
237#[cfg(not(target_os = "solana"))]
238fn is_host_system_transfer(instruction: &InstructionView<'_, '_, '_, '_>) -> bool {
239 // `SYSTEM_PROGRAM_ID` is the all-zero address, so an OR-fold
240 // is-zero check is equivalent to (and cheaper than) comparing
241 // against the constant.
242 crate::address::address_is_zero(instruction.program_id)
243 && instruction.data.len() == 12
244 && instruction.data[0..4] == [2, 0, 0, 0]
245}
246
247// This validator only walks `instruction.accounts` (address/signer/
248// writable/borrow checks); it never inspects `instruction.data`; so it
249// is not actually Transfer-specific. `emulate_host_system_create_account`,
250// `emulate_host_system_allocate`, and `emulate_host_system_assign` below
251// reuse it verbatim for their host emulations instead of duplicating the
252// same four checks under a second name. `min_views` is each instruction's
253// account arity (2 for Transfer/CreateAccount, 1 for Allocate/Assign): the
254// emulations index `account_views[..min_views]` directly, so the guard
255// must refuse a shorter hand-built view list before they do.
256#[cfg(not(target_os = "solana"))]
257fn validate_host_system_transfer(
258 instruction: &InstructionView<'_, '_, '_, '_>,
259 account_views: &[&AccountView<'_>],
260 signers_seeds: &[Signer<'_, '_>],
261 min_views: usize,
262) -> ProgramResult {
263 if account_views.len() < instruction.accounts.len() || account_views.len() < min_views {
264 return Err(ProgramError::NotEnoughAccountKeys);
265 }
266
267 let mut i = 0;
268 while i < instruction.accounts.len() {
269 let expected = &instruction.accounts[i];
270 let actual = account_views[i];
271
272 if !address_eq(actual.address(), expected.address) {
273 return Err(ProgramError::InvalidAccountData);
274 }
275 if expected.is_signer && !actual.is_signer() && !signer_authority_supplied(signers_seeds) {
276 return Err(ProgramError::MissingRequiredSignature);
277 }
278 if expected.is_writable && !actual.is_writable() {
279 return Err(ProgramError::Immutable);
280 }
281 // Mirror the on-chain default tier's borrow-state checks so the
282 // host emulation is not *weaker* than the borrow-checked tier it
283 // sits above (tier ordering: checked ≥ default > borrow_checked).
284 if expected.is_writable {
285 actual.check_borrow_mut()?;
286 } else {
287 actual.check_borrow()?;
288 }
289
290 i += 1;
291 }
292
293 // Sweep the mutation-completeness hand-off gate after the loop, matching the
294 // on-chain tiers' once-per-CPI placement so the host emulation's
295 // error surface (including the borrow-before-delegation precedence)
296 // stays identical to on-chain.
297 if crate::write_policy::lamport_gate_active() {
298 let mut m = 0;
299 while m < instruction.accounts.len() {
300 if instruction.accounts[m].is_writable {
301 crate::write_policy::check_lamport_delegation(account_views[m].address())?;
302 }
303 m += 1;
304 }
305 }
306
307 validate_no_duplicate_writable(instruction, account_views)
308}
309
310#[cfg(not(target_os = "solana"))]
311fn emulate_host_system_transfer(
312 instruction: &InstructionView<'_, '_, '_, '_>,
313 account_views: &[&AccountView<'_>],
314) -> ProgramResult {
315 let amount = u64::from_le_bytes([
316 instruction.data[4],
317 instruction.data[5],
318 instruction.data[6],
319 instruction.data[7],
320 instruction.data[8],
321 instruction.data[9],
322 instruction.data[10],
323 instruction.data[11],
324 ]);
325 let from = account_views[0];
326 let to = account_views[1];
327
328 // Pre-validate both sides against the lamport gate before
329 // any balance mutation. Relying on the per-account `set_lamports`
330 // funnel alone would debit `from` and then have `to` refused at the
331 // funnel, destroying lamports in host state on the error path, a
332 // transfer must be all-or-nothing.
333 crate::write_policy::check_lamport_mutation(from.address())?;
334 crate::write_policy::check_lamport_mutation(to.address())?;
335
336 // Self-transfer (same address = same underlying account): net zero.
337 // Handled explicitly because the compute-both-then-apply sequence
338 // below would otherwise credit from the pre-debit balance and mint
339 // `amount` out of thin air.
340 if address_eq(from.address(), to.address()) {
341 if from.lamports() < amount {
342 return Err(ProgramError::InsufficientFunds);
343 }
344 return Ok(());
345 }
346
347 // Compute both post-balances before applying either, so an
348 // arithmetic refusal (insufficient funds, overflow) also cannot
349 // half-apply the transfer.
350 let debited = from
351 .lamports()
352 .checked_sub(amount)
353 .ok_or(ProgramError::InsufficientFunds)?;
354 let credited = to
355 .lamports()
356 .checked_add(amount)
357 .ok_or(ProgramError::ArithmeticOverflow)?;
358 from.set_lamports(debited)?;
359 to.set_lamports(credited)?;
360 Ok(())
361}
362
363#[cfg(not(target_os = "solana"))]
364fn is_host_system_create_account(instruction: &InstructionView<'_, '_, '_, '_>) -> bool {
365 // `CreateAccount { lamports, space, owner }`,
366 // `[0u32 LE][lamports: u64 LE][space: u64 LE][owner: 32 bytes]`
367 // (52 bytes). See `hopper_system::encoders::encode_create_account`.
368 crate::address::address_is_zero(instruction.program_id)
369 && instruction.data.len() == 52
370 && instruction.data[0..4] == [0, 0, 0, 0]
371}
372
373/// Host-only emulation of the System Program's `CreateAccount`.
374///
375/// Programs that build this CPI directly, via
376/// [`crate::system::CreateAccount`], fund + allocate + assign a brand-new
377/// account with it. (`hopper_init!` now issues `CreateAccountAllowPrefund`
378/// instead; see [`emulate_host_system_create_account_allow_prefund`].)
379/// Off-chain, the raw syscall wrappers ([`invoke_unchecked`] /
380/// [`invoke_signed_unchecked`]) are no-ops by design (there is no runtime
381/// to service the syscall), without this emulation the account is left
382/// at its pre-CPI zero-length state and the header write that immediately
383/// follows fails with `AccountDataTooSmall`, making every `init` /
384/// `init_if_needed` context untestable end-to-end through a host harness.
385/// This reproduces the System Program's own observable effect: debit
386/// `from`, credit `to`, resize `to` to `space` (zero-filling the new
387/// region, mirroring [`AccountView::resize`]'s on-chain growth
388/// semantics), and assign `to`'s owner.
389#[cfg(not(target_os = "solana"))]
390fn emulate_host_system_create_account(
391 instruction: &InstructionView<'_, '_, '_, '_>,
392 account_views: &[&AccountView<'_>],
393) -> ProgramResult {
394 let lamports = u64::from_le_bytes(instruction.data[4..12].try_into().unwrap());
395 let space = u64::from_le_bytes(instruction.data[12..20].try_into().unwrap()) as usize;
396 let mut owner_bytes = [0u8; 32];
397 owner_bytes.copy_from_slice(&instruction.data[20..52]);
398 let owner = Address::new_from_array(owner_bytes);
399
400 let from = account_views[0];
401 let to = account_views[1];
402
403 // The System Program refuses to create over an account that already
404 // carries lamports or data. `hopper_init!` only issues this CPI once
405 // it has already checked `to.data_len() == 0` itself, but the guard
406 // is repeated here so a `CreateAccount` CPI built directly (bypassing
407 // `hopper_init!`) gets the same off-chain refusal it would get
408 // on-chain.
409 if to.lamports() != 0 || to.data_len() != 0 {
410 return Err(ProgramError::AccountAlreadyInitialized);
411 }
412
413 // Pre-validate both sides against the lamport gate before any
414 // balance mutation; see the identical note on
415 // `emulate_host_system_transfer`.
416 crate::write_policy::check_lamport_mutation(from.address())?;
417 crate::write_policy::check_lamport_mutation(to.address())?;
418
419 let debited = from
420 .lamports()
421 .checked_sub(lamports)
422 .ok_or(ProgramError::InsufficientFunds)?;
423 let credited = to
424 .lamports()
425 .checked_add(lamports)
426 .ok_or(ProgramError::ArithmeticOverflow)?;
427 from.set_lamports(debited)?;
428 to.set_lamports(credited)?;
429
430 to.resize(space)?;
431 // SAFETY: `to` was validated writable by `validate_host_system_transfer`
432 // (the generic meta-check reused above) before this point, and this
433 // function stands in for the System Program's own CreateAccount
434 // handler, the one caller the real runtime authorizes to assign a
435 // fresh (System-owned, empty) account's owner.
436 unsafe {
437 to.assign(&owner);
438 }
439
440 Ok(())
441}
442
443#[cfg(not(target_os = "solana"))]
444fn is_host_system_create_account_allow_prefund(
445 instruction: &InstructionView<'_, '_, '_, '_>,
446) -> bool {
447 // `CreateAccountAllowPrefund { lamports, space, owner }`,
448 // `[13u32 LE][lamports: u64 LE][space: u64 LE][owner: 32 bytes]`
449 // (52 bytes). See
450 // `hopper_system::encoders::encode_create_account_allow_prefund`.
451 crate::address::address_is_zero(instruction.program_id)
452 && instruction.data.len() == 52
453 && instruction.data[0..4] == [13, 0, 0, 0]
454}
455
456/// Host-only emulation of the System Program's `CreateAccountAllowPrefund`.
457///
458/// `init` / `init_if_needed` (`hopper_init!` in `hopper-macros`) reaches
459/// this CPI, via [`crate::system::CreateAccountAllowPrefund`], for every
460/// account it creates, pre-funded or not. Off-chain the raw syscall
461/// wrappers are no-ops, so without this emulation the account is left at
462/// zero length and the header write that follows fails with
463/// `AccountDataTooSmall`.
464///
465/// This reproduces the System Program handler's observable effect and
466/// order (agave `system_processor.rs`, `create_account_allow_prefund`):
467/// refuse an account that already carries data or a foreign owner, then
468/// allocate `space` (zero-filled), assign `owner`, and finally transfer
469/// the `lamports` delta from the funding account at index 1 when it is
470/// nonzero. An existing balance on `to` is allowed; that is the
471/// instruction's purpose. The lamport arithmetic is checked before any
472/// mutation so a refused transfer leaves the account untouched, matching
473/// the on-chain transaction rollback.
474#[cfg(not(target_os = "solana"))]
475fn emulate_host_system_create_account_allow_prefund(
476 instruction: &InstructionView<'_, '_, '_, '_>,
477 account_views: &[&AccountView<'_>],
478) -> ProgramResult {
479 let lamports = u64::from_le_bytes(instruction.data[4..12].try_into().unwrap());
480 let space = u64::from_le_bytes(instruction.data[12..20].try_into().unwrap()) as usize;
481 let mut owner_bytes = [0u8; 32];
482 owner_bytes.copy_from_slice(&instruction.data[20..52]);
483 let owner = Address::new_from_array(owner_bytes);
484
485 let to = account_views[0];
486 // SAFETY: the host emulator runs on one thread with no live CPI, so the
487 // owner field cannot change while this reference is held; it is read
488 // once and dropped before any mutation below.
489 let system_owned = crate::address::address_is_zero(unsafe { to.owner() });
490 if to.data_len() != 0 || !system_owned {
491 return Err(ProgramError::AccountAlreadyInitialized);
492 }
493
494 let funding = if lamports > 0 {
495 let from = *account_views
496 .get(1)
497 .ok_or(ProgramError::NotEnoughAccountKeys)?;
498 // Pre-validate both sides against the lamport gate before any
499 // mutation; see the identical note on `emulate_host_system_transfer`.
500 crate::write_policy::check_lamport_mutation(from.address())?;
501 crate::write_policy::check_lamport_mutation(to.address())?;
502 let debited = from
503 .lamports()
504 .checked_sub(lamports)
505 .ok_or(ProgramError::InsufficientFunds)?;
506 let credited = to
507 .lamports()
508 .checked_add(lamports)
509 .ok_or(ProgramError::ArithmeticOverflow)?;
510 Some((from, debited, credited))
511 } else {
512 None
513 };
514
515 to.resize(space)?;
516 // SAFETY: `to` was validated writable by `validate_host_system_transfer`
517 // (the generic meta-check reused at the dispatch site) before this
518 // point, and this function stands in for the System Program's own
519 // handler, the one caller the real runtime authorizes to assign a
520 // fresh (System-owned, empty) account's owner.
521 unsafe {
522 to.assign(&owner);
523 }
524 if let Some((from, debited, credited)) = funding {
525 from.set_lamports(debited)?;
526 to.set_lamports(credited)?;
527 }
528 Ok(())
529}
530
531#[cfg(not(target_os = "solana"))]
532fn is_host_system_allocate(instruction: &InstructionView<'_, '_, '_, '_>) -> bool {
533 // `Allocate { space }`, `[8u32 LE][space: u64 LE]` (12 bytes).
534 // See `hopper_system::encoders::encode_allocate`.
535 crate::address::address_is_zero(instruction.program_id)
536 && instruction.data.len() == 12
537 && instruction.data[0..4] == [8, 0, 0, 0]
538}
539
540/// Host-only emulation of the System Program's `Allocate`.
541///
542/// Programs that build this CPI directly, via [`crate::system::Allocate`],
543/// reach it when they allocate a pre-funded System account by hand.
544/// (`hopper_init!` used to issue Transfer, Allocate, and Assign for that
545/// case and now issues one `CreateAccountAllowPrefund`.) Off-chain the raw
546/// syscall wrappers are no-ops, so without this emulation the account is
547/// left at zero length and any header write that follows fails with
548/// `AccountDataTooSmall`. This reproduces the System Program's own
549/// observable effect: resize the account to `space`, zero-filling the
550/// new region (mirroring [`AccountView::resize`]'s on-chain growth
551/// semantics). No lamports move in an `Allocate`, so unlike the
552/// Transfer/CreateAccount emulations there is deliberately no mutation-completeness
553/// lamport-mutation precheck here; the shared validator's
554/// writable/borrow/delegation sweep is the whole gate, exactly as for
555/// the real instruction.
556#[cfg(not(target_os = "solana"))]
557fn emulate_host_system_allocate(
558 instruction: &InstructionView<'_, '_, '_, '_>,
559 account_views: &[&AccountView<'_>],
560) -> ProgramResult {
561 let space = u64::from_le_bytes(instruction.data[4..12].try_into().unwrap()) as usize;
562 let target = account_views[0];
563
564 // The System Program refuses to allocate an account that already
565 // carries data (the "account already in use" class of refusal).
566 // `hopper_init!` only issues this CPI once it has already checked
567 // `data_len() == 0` itself, but the guard is repeated here so an
568 // `Allocate` CPI built directly (bypassing `hopper_init!`) gets the
569 // same off-chain refusal it would get on-chain.
570 if target.data_len() != 0 {
571 return Err(ProgramError::AccountAlreadyInitialized);
572 }
573
574 target.resize(space)
575}
576
577#[cfg(not(target_os = "solana"))]
578fn is_host_system_assign(instruction: &InstructionView<'_, '_, '_, '_>) -> bool {
579 // `Assign { owner }`, `[1u32 LE][owner: 32 bytes]` (36 bytes).
580 // See `hopper_system::encoders::encode_assign`.
581 crate::address::address_is_zero(instruction.program_id)
582 && instruction.data.len() == 36
583 && instruction.data[0..4] == [1, 0, 0, 0]
584}
585
586/// Host-only emulation of the System Program's `Assign`.
587///
588/// The companion of [`emulate_host_system_allocate`] for programs that
589/// allocate and assign a pre-funded System account by hand, via
590/// [`crate::system::Assign`]. This reproduces the System Program's own observable
591/// effect: set the account's owner. Like the real `Assign`, it moves no
592/// lamports, so there is deliberately no mutation-completeness lamport-mutation
593/// precheck; the shared validator's writable/borrow/delegation sweep is
594/// the whole gate.
595#[cfg(not(target_os = "solana"))]
596fn emulate_host_system_assign(
597 instruction: &InstructionView<'_, '_, '_, '_>,
598 account_views: &[&AccountView<'_>],
599) -> ProgramResult {
600 let mut owner_bytes = [0u8; 32];
601 owner_bytes.copy_from_slice(&instruction.data[4..36]);
602 let owner = Address::new_from_array(owner_bytes);
603
604 let target = account_views[0];
605
606 // SAFETY: `target` was validated writable by
607 // `validate_host_system_transfer` (the generic meta-check reused at
608 // the dispatch site) before this point, and this function stands in
609 // for the System Program's own Assign handler, the one caller the
610 // real runtime authorizes to reassign a System-owned account's owner
611 // (with the assignee's signature, which the same validator checked
612 // against the builder's writable_signer meta).
613 unsafe {
614 target.assign(&owner);
615 }
616
617 Ok(())
618}
619
620// ---------------------------------------------------------------------
621
622/// Invoke a CPI with full validation.
623#[inline]
624pub fn invoke<const ACCOUNTS: usize>(
625 instruction: &InstructionView<'_, '_, '_, '_>,
626 account_views: &[&AccountView<'_>; ACCOUNTS],
627) -> ProgramResult {
628 invoke_signed::<ACCOUNTS>(instruction, account_views, &[])
629}
630
631/// Host-only System Program emulation shared by the checked invoke tiers:
632/// `Some` when the instruction is one of the emulated System instructions
633/// (and carries its result), `None` when the caller should proceed to its
634/// validation pass and the (no-op off-chain) syscall.
635#[cfg(not(target_os = "solana"))]
636#[inline]
637fn emulate_host_system(
638 instruction: &InstructionView<'_, '_, '_, '_>,
639 account_views: &[&AccountView<'_>],
640 signers_seeds: &[Signer<'_, '_>],
641) -> Option<ProgramResult> {
642 if is_host_system_transfer(instruction) {
643 return Some(
644 validate_host_system_transfer(instruction, account_views, signers_seeds, 2)
645 .and_then(|()| emulate_host_system_transfer(instruction, account_views)),
646 );
647 }
648 if is_host_system_create_account(instruction) {
649 return Some(
650 validate_host_system_transfer(instruction, account_views, signers_seeds, 2)
651 .and_then(|()| emulate_host_system_create_account(instruction, account_views)),
652 );
653 }
654 if is_host_system_create_account_allow_prefund(instruction) {
655 return Some(
656 validate_host_system_transfer(instruction, account_views, signers_seeds, 1).and_then(
657 |()| emulate_host_system_create_account_allow_prefund(instruction, account_views),
658 ),
659 );
660 }
661 if is_host_system_allocate(instruction) {
662 return Some(
663 validate_host_system_transfer(instruction, account_views, signers_seeds, 1)
664 .and_then(|()| emulate_host_system_allocate(instruction, account_views)),
665 );
666 }
667 if is_host_system_assign(instruction) {
668 return Some(
669 validate_host_system_transfer(instruction, account_views, signers_seeds, 1)
670 .and_then(|()| emulate_host_system_assign(instruction, account_views)),
671 );
672 }
673 None
674}
675
676/// Invoke a signed CPI with full validation.
677#[inline]
678pub fn invoke_signed<const ACCOUNTS: usize>(
679 instruction: &InstructionView<'_, '_, '_, '_>,
680 account_views: &[&AccountView<'_>; ACCOUNTS],
681 signers_seeds: &[Signer<'_, '_>],
682) -> ProgramResult {
683 #[cfg(not(target_os = "solana"))]
684 if let Some(result) = emulate_host_system(instruction, &account_views[..], signers_seeds) {
685 return result;
686 }
687
688 let metas_len = instruction.accounts.len();
689
690 // Fused validate+build (default tier). `check_meta` runs the default
691 // tier's per-account contract, address identity, required-signer
692 // presence (or supplied PDA authority), writability coverage,
693 // and borrow state, in the *same* pass that materializes each
694 // `CpiAccount` scratch slot. `post_check` then runs the mutation-completeness
695 // lamport-delegation sweep (once per CPI, gate-liveness-guarded; see
696 // the note at the sweep) and the duplicate-writable footgun scan,
697 // then the syscall.
698 dispatch_cpi_fixed::<ACCOUNTS>(
699 instruction,
700 account_views,
701 signers_seeds,
702 metas_len,
703 |i| {
704 let expected = &instruction.accounts[i];
705 let actual = account_views[i];
706
707 if !address_eq(actual.address(), expected.address) {
708 return Err(ProgramError::InvalidAccountData);
709 }
710
711 if expected.is_signer
712 && !actual.is_signer()
713 && !signer_authority_supplied(signers_seeds)
714 {
715 return Err(ProgramError::MissingRequiredSignature);
716 }
717
718 if expected.is_writable && !actual.is_writable() {
719 return Err(ProgramError::Immutable);
720 }
721
722 if expected.is_writable {
723 actual.check_borrow_mut()?;
724 } else {
725 actual.check_borrow()?;
726 }
727
728 Ok(())
729 },
730 || {
731 // A writable CPI meta delegates unbounded data and
732 // lamport mutation to the callee. The delegation sweep runs
733 // ONCE per CPI here (not per meta) behind a liveness branch:
734 // keeping gate machinery reachable from the per-meta closure
735 // was measured to force spill-heavy codegen costing ~+52 CU
736 // per router hop for ungated programs (2026-07-09 bisect).
737 // Gated programs are still refused before the syscall.
738 if crate::write_policy::lamport_gate_active() {
739 let mut i = 0;
740 while i < metas_len {
741 if instruction.accounts[i].is_writable {
742 crate::write_policy::check_lamport_delegation(account_views[i].address())?;
743 }
744 i += 1;
745 }
746 }
747 validate_no_duplicate_writable(instruction, &account_views[..])
748 },
749 )
750}
751
752/// Fused validate-and-build for the fixed-array CPI tiers, plus the syscall
753/// (a no-op off-chain). Shared tail of the fixed-array invoke tiers.
754///
755/// Performs ONE pass over the account array: for each meta index `i` in
756/// `0..metas_len` it runs the tier-specific per-account check (`check_meta`)
757/// AND writes the `CpiAccount` scratch slot in the same iteration, replacing
758/// the previous validate-walk-then-build-walk pair. Slots `metas_len..
759/// ACCOUNTS` (account infos with no corresponding meta) are build-only, as
760/// before. `post_check` runs once after the pass; e.g. the default tier's
761/// duplicate-writable scan, which needs the full meta list, and before the
762/// syscall.
763///
764/// Fusing preserves observable behavior exactly: `check_meta` is invoked in
765/// ascending meta order, so the first failing meta returns the same error at
766/// the same point as the prior split; building a `CpiAccount` has no side
767/// effects and `CpiAccount` is `Copy`, so a `?` early-return from
768/// `check_meta` or `post_check` discards the never-read `MaybeUninit` scratch
769/// with no drop and no observable difference.
770///
771/// Validation is the **caller's** responsibility via the two closures: every
772/// caller must run at least the borrow-state checks over `account_views` (see
773/// [`invoke_signed`] and [`invoke_signed_borrow_checked`]), which discharges
774/// the `invoke_unchecked` safety contract.
775#[inline]
776fn dispatch_cpi_fixed<const ACCOUNTS: usize>(
777 instruction: &InstructionView<'_, '_, '_, '_>,
778 account_views: &[&AccountView<'_>; ACCOUNTS],
779 signers_seeds: &[Signer<'_, '_>],
780 metas_len: usize,
781 check_meta: impl Fn(usize) -> ProgramResult,
782 post_check: impl FnOnce() -> ProgramResult,
783) -> ProgramResult {
784 if ACCOUNTS < metas_len {
785 return Err(ProgramError::NotEnoughAccountKeys);
786 }
787
788 let mut cpi_accounts: [MaybeUninit<CpiAccount<'_>>; ACCOUNTS] =
789 // SAFETY: an array of `MaybeUninit<T>` is valid in any initialization
790 // state, so materializing it uninitialized is sound; every element is
791 // written by the loop below before it is read, and on an early
792 // `?`-return the array is discarded unread (`CpiAccount` is `Copy`, so
793 // no drop runs on the partially-filled scratch).
794 unsafe { MaybeUninit::uninit().assume_init() };
795
796 let mut i = 0;
797 while i < ACCOUNTS {
798 if i < metas_len {
799 check_meta(i)?;
800 }
801 cpi_accounts[i] = MaybeUninit::new(CpiAccount::from(account_views[i]));
802 i += 1;
803 }
804
805 post_check()?;
806
807 // SAFETY: the loop above initialized all `ACCOUNTS` elements, and
808 // `MaybeUninit<T>` has the same layout as `T`, so reinterpreting the
809 // array as `[CpiAccount; ACCOUNTS]` reads only initialized memory.
810 let accounts: &[CpiAccount<'_>; ACCOUNTS] =
811 unsafe { &*(cpi_accounts.as_ptr() as *const [CpiAccount<'_>; ACCOUNTS]) };
812
813 // SAFETY: `check_meta`/`post_check` validated the borrow state of each
814 // account view (writable metas exclusively borrowable, read-only metas
815 // shared-borrowable), so no live borrow conflicts with the runtime's
816 // access during the CPI, exactly the invariant
817 // `invoke_unchecked`/`invoke_signed_unchecked` require.
818 unsafe { invoke_signed_unchecked(instruction, accounts.as_slice(), signers_seeds) }
819}
820
821/// The tier Hopper's own instruction builders invoke through.
822///
823/// A builder derives every meta from the view it passes at the same index,
824/// so meta and view name the same account by construction and the address
825/// check of [`invoke_signed`] has nothing to find. What stays is what
826/// soundness needs: writable metas must be exclusively borrowable and
827/// read-only metas shared-borrowable, so no live Rust borrow sees the
828/// callee's writes, and, when a lamport write policy is installed, every
829/// writable meta must be one the policy lets the instruction hand off.
830///
831/// Signer and writable privileges are left to the runtime, which refuses
832/// an escalation before the callee runs; the early refusals
833/// [`invoke_signed`] adds are diagnostics, not protection. So is its
834/// repeated-writable scan: the System, Token, and Token-2022 programs these
835/// builders target handle one account named twice (a self-transfer moves
836/// nothing; `CreateAccount` onto its own payer fails in the System
837/// Program). This is the shape of Pinocchio's builders, with the lamport
838/// gate on top.
839///
840/// Off chain the supported System instructions are emulated exactly as in
841/// [`invoke_signed`].
842#[inline(always)]
843pub(crate) fn invoke_signed_builder<const ACCOUNTS: usize>(
844 instruction: &InstructionView<'_, '_, '_, '_>,
845 account_views: &[&AccountView<'_>; ACCOUNTS],
846 signers_seeds: &[Signer<'_, '_>],
847) -> ProgramResult {
848 #[cfg(not(target_os = "solana"))]
849 if let Some(result) = emulate_host_system(instruction, &account_views[..], signers_seeds) {
850 return result;
851 }
852
853 // A plain loop and no closures: builders are inlined into the handler,
854 // and a closure-shaped body there gets outlined with its captures
855 // passed through the stack.
856 let metas = instruction.accounts;
857 let mut i = 0;
858 while i < ACCOUNTS {
859 if i < metas.len() {
860 if metas[i].is_writable {
861 account_views[i].check_borrow_mut()?;
862 } else {
863 account_views[i].check_borrow()?;
864 }
865 }
866 i += 1;
867 }
868 if crate::write_policy::lamport_gate_active() {
869 check_builder_delegation(metas, &account_views[..])?;
870 }
871
872 let mut cpi_accounts: [MaybeUninit<CpiAccount<'_>>; ACCOUNTS] =
873 // SAFETY: an array of `MaybeUninit<T>` is valid in any initialization
874 // state; every element is written below before it is read.
875 unsafe { MaybeUninit::uninit().assume_init() };
876 let mut j = 0;
877 while j < ACCOUNTS {
878 cpi_accounts[j] = MaybeUninit::new(CpiAccount::from(account_views[j]));
879 j += 1;
880 }
881 // SAFETY: the loop above initialized all `ACCOUNTS` elements, and
882 // `MaybeUninit<T>` has the layout of `T`.
883 let accounts: &[CpiAccount<'_>; ACCOUNTS] =
884 unsafe { &*(cpi_accounts.as_ptr() as *const [CpiAccount<'_>; ACCOUNTS]) };
885
886 // SAFETY: every meta's account passed its borrow check above (writable
887 // metas exclusively borrowable, read-only metas shared-borrowable), the
888 // invariant `invoke_signed_unchecked` requires.
889 unsafe { invoke_signed_unchecked(instruction, accounts.as_slice(), signers_seeds) }
890}
891
892/// [`invoke_signed_builder`] with the repeated-writable refusal of
893/// [`invoke_signed`] kept.
894///
895/// For callees that accept one account named twice where a caller almost
896/// never means it: an SPL Token transfer from an account to itself
897/// succeeds and moves nothing, so a program that credits a deposit after
898/// such a transfer would credit tokens it never received. The Token and
899/// Token-2022 builders invoke through this tier.
900#[inline(always)]
901pub(crate) fn invoke_signed_builder_distinct<const ACCOUNTS: usize>(
902 instruction: &InstructionView<'_, '_, '_, '_>,
903 account_views: &[&AccountView<'_>; ACCOUNTS],
904 signers_seeds: &[Signer<'_, '_>],
905) -> ProgramResult {
906 validate_no_duplicate_writable(instruction, &account_views[..])?;
907 invoke_signed_builder(instruction, account_views, signers_seeds)
908}
909
910/// The lamport-delegation sweep of [`invoke_signed_builder`], run only when
911/// a write policy is installed. Out of line so the builders' inlined body
912/// stays the ungated path.
913#[cold]
914#[inline(never)]
915fn check_builder_delegation(
916 metas: &[crate::instruction::InstructionAccount<'_>],
917 account_views: &[&AccountView<'_>],
918) -> ProgramResult {
919 let mut i = 0;
920 while i < metas.len() && i < account_views.len() {
921 if metas[i].is_writable {
922 crate::write_policy::check_lamport_delegation(account_views[i].address())?;
923 }
924 i += 1;
925 }
926 Ok(())
927}
928
929/// Invoke with a dynamic number of accounts (bounded by const generic).
930#[inline]
931pub fn invoke_with_bounds<const MAX_ACCOUNTS: usize>(
932 instruction: &InstructionView<'_, '_, '_, '_>,
933 account_views: &[&AccountView<'_>],
934) -> ProgramResult {
935 invoke_signed_with_bounds::<MAX_ACCOUNTS>(instruction, account_views, &[])
936}
937
938/// Signed invoke with a dynamic number of accounts (bounded by const generic).
939#[inline]
940pub fn invoke_signed_with_bounds<const MAX_ACCOUNTS: usize>(
941 instruction: &InstructionView<'_, '_, '_, '_>,
942 account_views: &[&AccountView<'_>],
943 signers_seeds: &[Signer<'_, '_>],
944) -> ProgramResult {
945 invoke_signed_bounded::<MAX_ACCOUNTS>(instruction, account_views, signers_seeds, false)
946}
947
948/// [`invoke_signed_with_bounds`] for an instruction that carries several
949/// inner instructions and therefore names one account through several
950/// writable metas on purpose: the SPL Token `Batch` (255), whose account
951/// list is the concatenation of every inner instruction's accounts.
952///
953/// Every other check of the default tier runs unchanged (address match per
954/// meta, signer and writability coverage, borrow state per meta, the
955/// lamport hand-off gate). Only the duplicate-writable refusal is skipped,
956/// because for a batch the repeat is the contract, not the footgun. The
957/// runtime serializes a repeated account once and marks the later metas as
958/// duplicates, so the callee sees one account through every one of them.
959/// This low-level function does not parse inner instructions. `TokenBatch`
960/// separately refuses repeated writable accounts within each appended
961/// instruction; custom callers must establish their own alias policy.
962#[inline]
963pub fn invoke_signed_batch_with_bounds<const MAX_ACCOUNTS: usize>(
964 instruction: &InstructionView<'_, '_, '_, '_>,
965 account_views: &[&AccountView<'_>],
966 signers_seeds: &[Signer<'_, '_>],
967) -> ProgramResult {
968 invoke_signed_bounded::<MAX_ACCOUNTS>(instruction, account_views, signers_seeds, true)
969}
970
971// Not inlined: the scratch array is MAX_ACCOUNTS CpiAccounts, and a
972// token builder that inlines this on top of its own meta and view arrays
973// overflows the 4 KiB frame.
974#[inline(never)]
975fn invoke_signed_bounded<const MAX_ACCOUNTS: usize>(
976 instruction: &InstructionView<'_, '_, '_, '_>,
977 account_views: &[&AccountView<'_>],
978 signers_seeds: &[Signer<'_, '_>],
979 repeated_writable_allowed: bool,
980) -> ProgramResult {
981 if account_views.len() > MAX_ACCOUNTS {
982 return Err(ProgramError::InvalidArgument);
983 }
984
985 #[cfg(not(target_os = "solana"))]
986 if let Some(result) = emulate_host_system(instruction, account_views, signers_seeds) {
987 return result;
988 }
989
990 let metas_len = instruction.accounts.len();
991 let count = account_views.len();
992 if count < metas_len {
993 return Err(ProgramError::NotEnoughAccountKeys);
994 }
995
996 let mut cpi_accounts: [MaybeUninit<CpiAccount<'_>>; MAX_ACCOUNTS] =
997 // SAFETY: an array of `MaybeUninit<T>` is valid in any initialization
998 // state; the first `count` slots are written before being read below,
999 // and on an early `?`-return the array is discarded unread
1000 // (`CpiAccount` is `Copy`, so no drop runs on the partial scratch).
1001 unsafe { MaybeUninit::uninit().assume_init() };
1002
1003 // Fused validate+build (default tier, dynamic): one pass runs the default
1004 // per-account contract for each meta AND writes its scratch slot; slots
1005 // `metas_len..count` are build-only. The duplicate-writable scan runs
1006 // afterward, exactly as `validate_cpi_accounts` ordered it.
1007 let mut i = 0;
1008 while i < count {
1009 let actual = account_views[i];
1010 if i < metas_len {
1011 let expected = &instruction.accounts[i];
1012
1013 if !address_eq(actual.address(), expected.address) {
1014 return Err(ProgramError::InvalidAccountData);
1015 }
1016
1017 if expected.is_signer
1018 && !actual.is_signer()
1019 && !signer_authority_supplied(signers_seeds)
1020 {
1021 return Err(ProgramError::MissingRequiredSignature);
1022 }
1023
1024 if expected.is_writable && !actual.is_writable() {
1025 return Err(ProgramError::Immutable);
1026 }
1027
1028 if expected.is_writable {
1029 actual.check_borrow_mut()?;
1030 } else {
1031 actual.check_borrow()?;
1032 }
1033 }
1034 cpi_accounts[i] = MaybeUninit::new(CpiAccount::from(actual));
1035 i += 1;
1036 }
1037
1038 // Sweep the mutation-completeness hand-off gate once per CPI behind the
1039 // liveness branch (never reachable from the hot per-meta loop; see
1040 // the 2026-07-09 bisect note in `invoke_signed`'s sweep).
1041 if crate::write_policy::lamport_gate_active() {
1042 let mut m = 0;
1043 while m < instruction.accounts.len() {
1044 if instruction.accounts[m].is_writable {
1045 crate::write_policy::check_lamport_delegation(account_views[m].address())?;
1046 }
1047 m += 1;
1048 }
1049 }
1050
1051 if !repeated_writable_allowed {
1052 validate_no_duplicate_writable(instruction, account_views)?;
1053 }
1054
1055 // SAFETY: the loop above initialized the first `count` slots, and
1056 // `MaybeUninit<T>` shares `T`'s layout, so reading exactly that prefix
1057 // reads only initialized memory.
1058 let accounts = unsafe {
1059 core::slice::from_raw_parts(cpi_accounts.as_ptr() as *const CpiAccount<'_>, count)
1060 };
1061
1062 // SAFETY: Every account was checked above against the instruction's metas
1063 // (address, privilege, borrow state), which is the contract of the
1064 // unchecked call.
1065 unsafe { invoke_signed_unchecked(instruction, accounts, signers_seeds) }
1066}
1067
1068// -- SIMD-0339 dedup-aware path ---------------------------------------
1069
1070/// Locate the deduplicated info that carries `address` (linear scan).
1071#[inline]
1072fn find_info(infos: &[&AccountView<'_>], address: &Address) -> Option<usize> {
1073 let mut i = 0;
1074 while i < infos.len() {
1075 if address_eq(infos[i].address(), address) {
1076 return Some(i);
1077 }
1078 i += 1;
1079 }
1080 None
1081}
1082
1083/// Validate metas against a **deduplicated** info set (matched by pubkey).
1084///
1085/// Unlike the default tier's positional validation, `infos` is *not*
1086/// positionally aligned
1087/// with `instruction.accounts`: it holds exactly one [`AccountView`] per
1088/// unique address. Each meta is resolved to its info by address. Signer
1089/// presence (or supplied PDA authority), writability coverage,
1090/// per-account borrow state, and the duplicate-writable footgun are all
1091/// enforced over the full (un-deduplicated) meta list, so collapsing the
1092/// info list never weakens what the default tier checks.
1093#[inline]
1094fn validate_cpi_accounts_deduped(
1095 instruction: &InstructionView<'_, '_, '_, '_>,
1096 infos: &[&AccountView<'_>],
1097 signers_seeds: &[Signer<'_, '_>],
1098) -> ProgramResult {
1099 // Duplicate-writable footgun: two writable metas naming one account.
1100 // The infos are deduped, so `validate_no_duplicate_writable`'s
1101 // view-pair scan cannot observe it, check meta addresses directly.
1102 let mut i = 0;
1103 while i < instruction.accounts.len() {
1104 if instruction.accounts[i].is_writable {
1105 let mut j = i + 1;
1106 while j < instruction.accounts.len() {
1107 if instruction.accounts[j].is_writable
1108 && address_eq(
1109 instruction.accounts[i].address,
1110 instruction.accounts[j].address,
1111 )
1112 {
1113 return Err(ProgramError::AccountBorrowFailed);
1114 }
1115 j += 1;
1116 }
1117 }
1118 i += 1;
1119 }
1120
1121 let mut i = 0;
1122 while i < instruction.accounts.len() {
1123 let expected = &instruction.accounts[i];
1124 // Resolve this meta to its unique account-info by pubkey. A meta
1125 // whose account was never supplied as an info is a malformed CPI.
1126 let info = match find_info(infos, expected.address) {
1127 Some(idx) => infos[idx],
1128 None => return Err(ProgramError::NotEnoughAccountKeys),
1129 };
1130
1131 if expected.is_signer && !info.is_signer() && !signer_authority_supplied(signers_seeds) {
1132 return Err(ProgramError::MissingRequiredSignature);
1133 }
1134 if expected.is_writable && !info.is_writable() {
1135 return Err(ProgramError::Immutable);
1136 }
1137 // Borrow state is checked per meta; `check_borrow`/`check_borrow_mut`
1138 // only *inspect* the borrow flag (they do not acquire), so resolving
1139 // several metas to the same info and checking each is sound. A
1140 // writable meta demands exclusive borrowability of that one info,
1141 // which is exactly the OR-merged requirement dedup must preserve.
1142 if expected.is_writable {
1143 info.check_borrow_mut()?;
1144 } else {
1145 info.check_borrow()?;
1146 }
1147 i += 1;
1148 }
1149
1150 // Sweep the mutation-completeness hand-off gate over the full, non-deduplicated meta
1151 // list (dedup collapses infos, never the delegation requirement),
1152 // swept once per CPI behind the liveness branch, never reachable
1153 // from the per-meta loop (2026-07-09 bisect; see invoke_signed).
1154 if crate::write_policy::lamport_gate_active() {
1155 let mut m = 0;
1156 while m < instruction.accounts.len() {
1157 let expected = &instruction.accounts[m];
1158 if expected.is_writable {
1159 if let Some(idx) = find_info(infos, expected.address) {
1160 crate::write_policy::check_lamport_delegation(infos[idx].address())?;
1161 }
1162 }
1163 m += 1;
1164 }
1165 }
1166
1167 Ok(())
1168}
1169
1170/// Invoke a CPI whose account-info list has been **deduplicated by pubkey**,
1171/// the SIMD-0339 fewest-infos-per-CPI optimization.
1172///
1173/// `instruction.accounts` (the metas) may reference the same account in
1174/// several positions and the callee still sees that full ordered list.
1175/// `infos`, by contrast, holds exactly one [`AccountView`] per unique
1176/// address. Because the SVM resolves account-infos to metas by pubkey, N
1177/// metas of one account need only ONE info; under SIMD-0339 every distinct
1178/// info also costs CU, so collapsing them is a measurable saving that a
1179/// naive one-info-per-meta builder cannot claim.
1180///
1181/// `infos.len()` must be `<= MAX_INFOS` (the deduped list is what is handed
1182/// to the syscall). Validation runs over the full, un-deduplicated meta
1183/// list via the private `validate_cpi_accounts_deduped` helper, so this path is
1184/// strict as the default [`invoke_signed`] tier.
1185#[inline]
1186pub fn invoke_signed_deduped<const MAX_INFOS: usize>(
1187 instruction: &InstructionView<'_, '_, '_, '_>,
1188 infos: &[&AccountView<'_>],
1189 signers_seeds: &[Signer<'_, '_>],
1190) -> ProgramResult {
1191 if infos.len() > MAX_INFOS {
1192 return Err(ProgramError::InvalidArgument);
1193 }
1194
1195 #[cfg(not(target_os = "solana"))]
1196 if is_host_system_transfer(instruction) {
1197 validate_cpi_accounts_deduped(instruction, infos, signers_seeds)?;
1198 // This public API accepts any info order. Resolve the System transfer's
1199 // positional metas by address just as the SVM does; an extra info must
1200 // never be mistaken for the debited account.
1201 if instruction.accounts.len() < 2 {
1202 return Err(ProgramError::NotEnoughAccountKeys);
1203 }
1204 let source = find_info(infos, instruction.accounts[0].address)
1205 .ok_or(ProgramError::NotEnoughAccountKeys)?;
1206 let destination = find_info(infos, instruction.accounts[1].address)
1207 .ok_or(ProgramError::NotEnoughAccountKeys)?;
1208 return emulate_host_system_transfer(instruction, &[infos[source], infos[destination]]);
1209 }
1210
1211 validate_cpi_accounts_deduped(instruction, infos, signers_seeds)?;
1212
1213 let mut cpi_accounts: [MaybeUninit<CpiAccount<'_>>; MAX_INFOS] =
1214 // SAFETY: an array of `MaybeUninit<T>` is valid in any initialization
1215 // state, so materializing it uninitialized is sound; the first
1216 // `count` elements are written below before they are read.
1217 unsafe { MaybeUninit::uninit().assume_init() };
1218
1219 let count = infos.len();
1220 let mut i = 0;
1221 while i < count {
1222 cpi_accounts[i] = MaybeUninit::new(CpiAccount::from(infos[i]));
1223 i += 1;
1224 }
1225
1226 // SAFETY: the loop initialized the first `count` elements, and
1227 // `MaybeUninit<T>` shares `T`'s layout, so reading exactly that prefix
1228 // reads only initialized memory.
1229 let accounts = unsafe {
1230 core::slice::from_raw_parts(cpi_accounts.as_ptr() as *const CpiAccount<'_>, count)
1231 };
1232
1233 // SAFETY: `validate_cpi_accounts_deduped` above discharged the borrow /
1234 // aliasing contract (writable infos exclusively borrowable, read-only
1235 // infos shared-borrowable) required by the unchecked syscall wrappers.
1236 unsafe {
1237 if signers_seeds.is_empty() {
1238 invoke_unchecked(instruction, accounts)
1239 } else {
1240 invoke_signed_unchecked(instruction, accounts, signers_seeds)
1241 }
1242 }
1243}
1244
1245/// Explicit alias for Hopper's validated CPI path.
1246#[inline]
1247pub fn invoke_checked<const ACCOUNTS: usize>(
1248 instruction: &InstructionView<'_, '_, '_, '_>,
1249 account_views: &[&AccountView<'_>; ACCOUNTS],
1250) -> ProgramResult {
1251 invoke::<ACCOUNTS>(instruction, account_views)
1252}
1253
1254/// Explicit alias for Hopper's validated signed CPI path.
1255#[inline]
1256pub fn invoke_signed_checked<const ACCOUNTS: usize>(
1257 instruction: &InstructionView<'_, '_, '_, '_>,
1258 account_views: &[&AccountView<'_>; ACCOUNTS],
1259 signers_seeds: &[Signer<'_, '_>],
1260) -> ProgramResult {
1261 invoke_signed::<ACCOUNTS>(instruction, account_views, signers_seeds)
1262}
1263
1264// -- Borrow-checked (Pinocchio-equivalent) tier -------------------------
1265
1266/// Invoke after checking account-address correspondence and live data borrows.
1267///
1268/// Writable metas require an exclusive borrow; readonly metas require a shared
1269/// borrow. When a lamport write policy is active, writable CPI delegation also
1270/// requires whole-account data and lamport permission.
1271///
1272/// Unlike the default [`invoke`] path, this tier omits local signer and writable
1273/// privilege checks and allows duplicate writable metas. The SVM still enforces
1274/// privileges and PDA signer derivation. Choose this tier only when the application
1275/// intends that account aliasing and has validated its account relationships.
1276///
1277/// [`invoke_checked`] is an explicit alias for the default tier. Unsafe
1278/// [`invoke_unchecked`] skips Hopper's checks and requires the caller to uphold
1279/// its documented borrow and descriptor contracts.
1280///
1281/// On host targets, supported System transfers are emulated. Other CPIs are
1282/// validation-only no-ops; exercise real callee behavior in an SVM or on devnet.
1283#[inline]
1284pub fn invoke_borrow_checked<const ACCOUNTS: usize>(
1285 instruction: &InstructionView<'_, '_, '_, '_>,
1286 account_views: &[&AccountView<'_>; ACCOUNTS],
1287) -> ProgramResult {
1288 invoke_signed_borrow_checked::<ACCOUNTS>(instruction, account_views, &[])
1289}
1290
1291/// Signed variant of [`invoke_borrow_checked`]. Signer seeds are forwarded to
1292/// the SVM, which derives and validates the caller's PDA authorities.
1293#[inline]
1294pub fn invoke_signed_borrow_checked<const ACCOUNTS: usize>(
1295 instruction: &InstructionView<'_, '_, '_, '_>,
1296 account_views: &[&AccountView<'_>; ACCOUNTS],
1297 signers_seeds: &[Signer<'_, '_>],
1298) -> ProgramResult {
1299 #[cfg(not(target_os = "solana"))]
1300 if is_host_system_transfer(instruction) {
1301 // The emulation reads views[0] and views[1] directly; guard the
1302 // fixed-array length before indexing (ACCOUNTS may be < 2).
1303 if account_views.len() < 2 {
1304 return Err(ProgramError::NotEnoughAccountKeys);
1305 }
1306 validate_cpi_borrows(instruction, &account_views[..])?;
1307 return emulate_host_system_transfer(instruction, &account_views[..]);
1308 }
1309
1310 let metas_len = instruction.accounts.len();
1311
1312 // Fused validate+build (borrow_checked tier). `check_meta` runs the
1313 // per-account checks `validate_cpi_borrows` did, meta↔view address
1314 // correspondence and borrow state, while the scratch slot is
1315 // materialized in the same pass. The mutation-completeness lamport-delegation scan
1316 // runs ONCE per CPI in `post_check`, NOT per meta: the 2026-07-09
1317 // router bisect measured that any *reachable* gate-machinery call
1318 // inside this per-meta closure forces it into an outlined,
1319 // spill-heavy shape costing ~+52 CU per hop for programs that never
1320 // installed a gate (branch-inside variants only recovered to ~+21;
1321 // machinery-unreachable-from-the-closure recovered fully:
1322 // 1,564/3,044/4,525 → 1,559/3,035/4,512 measured). Gated programs
1323 // keep full enforcement, the sweep still refuses before the syscall
1324 // hand-off in `dispatch_cpi_fixed`, with one documented precedence
1325 // shift: in a multi-fault instruction, borrow errors now surface
1326 // before delegation errors (both are pre-syscall refusals).
1327 dispatch_cpi_fixed::<ACCOUNTS>(
1328 instruction,
1329 account_views,
1330 signers_seeds,
1331 metas_len,
1332 |i| {
1333 // The borrow state must be validated against the account the meta
1334 // actually names, not whatever view happens to sit at index `i`
1335 // (see `validate_cpi_borrows` for why: a mismatched order would
1336 // borrow-check the wrong (account, mutability) pair and reach
1337 // `invoke_unchecked` with its aliasing contract undischarged).
1338 if !address_eq(account_views[i].address(), instruction.accounts[i].address) {
1339 return Err(ProgramError::InvalidArgument);
1340 }
1341 if instruction.accounts[i].is_writable {
1342 account_views[i].check_borrow_mut()?;
1343 } else {
1344 account_views[i].check_borrow()?;
1345 }
1346 Ok(())
1347 },
1348 || {
1349 if crate::write_policy::lamport_gate_active() {
1350 let mut i = 0;
1351 while i < metas_len {
1352 if instruction.accounts[i].is_writable {
1353 crate::write_policy::check_lamport_delegation(account_views[i].address())?;
1354 }
1355 i += 1;
1356 }
1357 }
1358 Ok(())
1359 },
1360 )
1361}
1362
1363// ---------------------------------------------------------------------
1364
1365/// Set return data for the current instruction.
1366#[inline(always)]
1367pub fn set_return_data(data: &[u8]) {
1368 crate::return_data::set_return_data(data)
1369}
1370
1371#[cfg(test)]
1372mod tests {
1373 use super::*;
1374
1375 use crate::InstructionAccount;
1376 use hopper_native::{
1377 AccountView as NativeAccountView, Address as NativeAddress, RuntimeAccount, NOT_BORROWED,
1378 };
1379
1380 fn make_account(address: [u8; 32]) -> (std::vec::Vec<u64>, AccountView<'static>) {
1381 let mut backing = std::vec![0u64; (RuntimeAccount::SIZE + 16).div_ceil(8)];
1382 let raw = backing.as_mut_ptr() as *mut RuntimeAccount;
1383 // SAFETY: This block is part of Hopper's reviewed zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
1384 unsafe {
1385 raw.write(RuntimeAccount {
1386 borrow_state: NOT_BORROWED,
1387 is_signer: 0,
1388 is_writable: 1,
1389 executable: 0,
1390 resize_delta: 0,
1391 address: NativeAddress::new_from_array(address),
1392 owner: NativeAddress::new_from_array([9; 32]),
1393 lamports: 1,
1394 data_len: 16,
1395 });
1396 }
1397 // SAFETY: This block is part of Hopper's reviewed zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
1398 let backend = unsafe { NativeAccountView::new_unchecked(raw) };
1399 (backing, AccountView::from_backend(backend))
1400 }
1401
1402 #[test]
1403 fn duplicate_writable_accounts_are_rejected_before_cpi() {
1404 let (_first_backing, first) = make_account([3; 32]);
1405 let (_second_backing, second) = make_account([3; 32]);
1406
1407 let instruction_accounts = [
1408 InstructionAccount::writable(first.address()),
1409 InstructionAccount::writable(second.address()),
1410 ];
1411 let program_id = Address::new_from_array([7; 32]);
1412 let instruction = InstructionView {
1413 program_id: &program_id,
1414 data: &[0u8],
1415 accounts: &instruction_accounts,
1416 };
1417
1418 let err = validate_no_duplicate_writable(&instruction, &[&first, &second]).unwrap_err();
1419 assert_eq!(err, ProgramError::AccountBorrowFailed);
1420 }
1421
1422 // -- borrow_checked tier ------------------------------------------
1423
1424 #[test]
1425 fn borrow_checked_rejects_live_mutable_data_borrow() {
1426 let (_backing, account) = make_account([21; 32]);
1427 let metas = [InstructionAccount::writable(account.address())];
1428 let program_id = Address::new_from_array([7; 32]);
1429 let instruction = InstructionView {
1430 program_id: &program_id,
1431 data: &[0u8],
1432 accounts: &metas,
1433 };
1434
1435 let guard = account.try_borrow_mut().unwrap();
1436 let err = invoke_borrow_checked::<1>(&instruction, &[&account]).unwrap_err();
1437 assert_eq!(err, ProgramError::AccountBorrowFailed);
1438 drop(guard);
1439 }
1440
1441 #[test]
1442 fn borrow_checked_succeeds_after_borrow_release() {
1443 let (_backing, account) = make_account([22; 32]);
1444 let metas = [InstructionAccount::writable(account.address())];
1445 let program_id = Address::new_from_array([7; 32]);
1446 let instruction = InstructionView {
1447 program_id: &program_id,
1448 data: &[0u8],
1449 accounts: &metas,
1450 };
1451
1452 let guard = account.try_borrow_mut().unwrap();
1453 assert!(invoke_borrow_checked::<1>(&instruction, &[&account]).is_err());
1454 drop(guard);
1455
1456 // Off-chain the syscall is a no-op, so Ok(()) here proves the
1457 // borrow validation passed once the guard was released.
1458 invoke_borrow_checked::<1>(&instruction, &[&account]).unwrap();
1459 }
1460
1461 #[test]
1462 fn borrow_checked_permits_duplicate_writable_metas_unlike_default_tier() {
1463 let (_first_backing, first) = make_account([23; 32]);
1464 let (_second_backing, second) = make_account([23; 32]);
1465
1466 let metas = [
1467 InstructionAccount::writable(first.address()),
1468 InstructionAccount::writable(second.address()),
1469 ];
1470 let program_id = Address::new_from_array([7; 32]);
1471 let instruction = InstructionView {
1472 program_id: &program_id,
1473 data: &[0u8],
1474 accounts: &metas,
1475 };
1476
1477 // Default tier: duplicate writable metas are rejected, the
1478 // Sealevel double-mutation footgun `validate_no_duplicate_writable`
1479 // exists to guard.
1480 let err = invoke::<2>(&instruction, &[&first, &second]).unwrap_err();
1481 assert_eq!(err, ProgramError::AccountBorrowFailed);
1482
1483 // borrow_checked tier: per-account borrow state ONLY, matching
1484 // what Pinocchio's `invoke` checks. Not rejecting duplicates is
1485 // the documented contract of this tier, callers opt down only
1486 // after `require_unique_writable_accounts` (or a statically
1487 // duplicate-free account shape) has ruled the footgun out.
1488 invoke_borrow_checked::<2>(&instruction, &[&first, &second]).unwrap();
1489 }
1490
1491 #[test]
1492 fn borrow_checked_offchain_noop_path_returns_ok() {
1493 let (_backing, account) = make_account([24; 32]);
1494 let metas = [InstructionAccount::readonly(account.address())];
1495 let program_id = Address::new_from_array([7; 32]);
1496 let instruction = InstructionView {
1497 program_id: &program_id,
1498 data: &[0u8],
1499 accounts: &metas,
1500 };
1501
1502 assert_eq!(
1503 invoke_borrow_checked::<1>(&instruction, &[&account]),
1504 Ok(())
1505 );
1506 assert_eq!(
1507 invoke_signed_borrow_checked::<1>(&instruction, &[&account], &[]),
1508 Ok(())
1509 );
1510 }
1511
1512 // Lamport gate on writable metas.
1513
1514 // Guarded-tier semantics: installs a data-declaring policy, which the
1515 // `unguarded-raw-surfaces` fence refuses at install (covered by its
1516 // own explicit test in that shape).
1517 #[test]
1518 #[cfg(not(feature = "unguarded-raw-surfaces"))]
1519 fn writable_meta_is_refused_unless_both_dimensions_are_declared() {
1520 use crate::write_policy::{
1521 install_lamport_gate, write_policy_violation, WritePolicy, WriteRange,
1522 };
1523
1524 let (_b0, delegable) = make_account([31; 32]);
1525 let (_b1, lamports_only) = make_account([32; 32]);
1526 let (_b2, undeclared) = make_account([33; 32]);
1527 let accounts = [delegable, lamports_only, undeclared];
1528
1529 // Account 0 carries whole-account data + lamports (delegable);
1530 // account 1 lamports only; account 2 nothing.
1531 static P: WritePolicy =
1532 WritePolicy::with_lamports(&[WriteRange::whole_account(0)], &[0, 1]);
1533 let _gate = install_lamport_gate(&accounts, &P);
1534
1535 let program_id = Address::new_from_array([7; 32]);
1536
1537 // Writable meta on the fully declared account: allowed on the
1538 // default AND borrow_checked tiers (off-chain no-op syscall).
1539 let metas0 = [InstructionAccount::writable(accounts[0].address())];
1540 let ix0 = InstructionView {
1541 program_id: &program_id,
1542 data: &[0u8],
1543 accounts: &metas0,
1544 };
1545 invoke::<1>(&ix0, &[&accounts[0]]).unwrap();
1546 invoke_borrow_checked::<1>(&ix0, &[&accounts[0]]).unwrap();
1547
1548 // Lamports-only account: a writable hand-off is unbounded DATA
1549 // delegation too, so it is refused with the indexed policy error.
1550 let metas1 = [InstructionAccount::writable(accounts[1].address())];
1551 let ix1 = InstructionView {
1552 program_id: &program_id,
1553 data: &[0u8],
1554 accounts: &metas1,
1555 };
1556 assert_eq!(
1557 invoke::<1>(&ix1, &[&accounts[1]]).unwrap_err(),
1558 write_policy_violation(1)
1559 );
1560 assert_eq!(
1561 invoke_borrow_checked::<1>(&ix1, &[&accounts[1]]).unwrap_err(),
1562 write_policy_violation(1)
1563 );
1564
1565 // Entirely undeclared account: refused on every safe tier,
1566 // including the deduped path.
1567 let metas2 = [InstructionAccount::writable(accounts[2].address())];
1568 let ix2 = InstructionView {
1569 program_id: &program_id,
1570 data: &[0u8],
1571 accounts: &metas2,
1572 };
1573 assert_eq!(
1574 invoke_signed_deduped::<1>(&ix2, &[&accounts[2]], &[]).unwrap_err(),
1575 write_policy_violation(2)
1576 );
1577
1578 // Read-only metas are never lamport-gated.
1579 let metas_ro = [InstructionAccount::readonly(accounts[2].address())];
1580 let ix_ro = InstructionView {
1581 program_id: &program_id,
1582 data: &[0u8],
1583 accounts: &metas_ro,
1584 };
1585 invoke::<1>(&ix_ro, &[&accounts[2]]).unwrap();
1586 }
1587
1588 // Guarded-tier semantics: installs a data-declaring policy, which the
1589 // `unguarded-raw-surfaces` fence refuses at install (covered by its
1590 // own explicit test in that shape).
1591 #[test]
1592 #[cfg(not(feature = "unguarded-raw-surfaces"))]
1593 fn host_system_transfer_is_gated_through_the_lamport_funnel() {
1594 use crate::write_policy::{
1595 install_lamport_gate, write_policy_violation, WritePolicy, WriteRange,
1596 };
1597
1598 let (_b0, from) = make_account([41; 32]);
1599 let (_b1, to) = make_account([42; 32]);
1600 let accounts = [from, to];
1601
1602 // Both sides declared: the emulated transfer succeeds and the
1603 // balances actually move.
1604 static OPEN: WritePolicy = WritePolicy::with_lamports(
1605 &[WriteRange::whole_account(0), WriteRange::whole_account(1)],
1606 &[0, 1],
1607 );
1608 // Only `from` declared: the transfer must be refused before any
1609 // balance changes.
1610 static HALF: WritePolicy =
1611 WritePolicy::with_lamports(&[WriteRange::whole_account(0)], &[0]);
1612
1613 let system_id = Address::new_from_array([0; 32]);
1614 let mut data = [0u8; 12];
1615 data[0] = 2; // System Transfer tag
1616 data[4..12].copy_from_slice(&1u64.to_le_bytes());
1617 let metas = [
1618 InstructionAccount::writable(accounts[0].address()),
1619 InstructionAccount::writable(accounts[1].address()),
1620 ];
1621 let ix = InstructionView {
1622 program_id: &system_id,
1623 data: &data,
1624 accounts: &metas,
1625 };
1626
1627 {
1628 let _gate = install_lamport_gate(&accounts, &OPEN);
1629 invoke::<2>(&ix, &[&accounts[0], &accounts[1]]).unwrap();
1630 assert_eq!(accounts[0].lamports(), 0);
1631 assert_eq!(accounts[1].lamports(), 2);
1632 }
1633 {
1634 let _gate = install_lamport_gate(&accounts, &HALF);
1635 assert_eq!(
1636 invoke::<2>(&ix, &[&accounts[0], &accounts[1]]).unwrap_err(),
1637 write_policy_violation(1)
1638 );
1639 // Refused before mutation: balances unchanged.
1640 assert_eq!(accounts[0].lamports(), 0);
1641 assert_eq!(accounts[1].lamports(), 2);
1642 }
1643 }
1644
1645 // Guarded-tier semantics: installs a data-declaring policy, which the
1646 // `unguarded-raw-surfaces` fence refuses at install (covered by its
1647 // own explicit test in that shape).
1648 #[test]
1649 #[cfg(not(feature = "unguarded-raw-surfaces"))]
1650 fn host_system_transfer_refusal_leaves_both_balances_untouched() {
1651 use crate::write_policy::{
1652 install_lamport_gate, write_policy_violation, WritePolicy, WriteRange,
1653 };
1654
1655 let (_b0, from) = make_account([43; 32]);
1656 let (_b1, to) = make_account([44; 32]);
1657 let accounts = [from, to];
1658
1659 // Only `from` is declared for lamport mutation.
1660 static HALF: WritePolicy =
1661 WritePolicy::with_lamports(&[WriteRange::whole_account(0)], &[0]);
1662 let _gate = install_lamport_gate(&accounts, &HALF);
1663
1664 let system_id = Address::new_from_array([0; 32]);
1665 let mut data = [0u8; 12];
1666 data[0] = 2; // System Transfer tag
1667 data[4..12].copy_from_slice(&1u64.to_le_bytes());
1668 // `to` is deliberately a READ-ONLY meta: the writable-meta
1669 // delegation gate then never fires for it, so without the
1670 // emulation's own both-sides pre-validation the refusal would
1671 // come from the `set_lamports` funnel *after* `from` was
1672 // already debited, destroying a lamport in host state.
1673 let metas = [
1674 InstructionAccount::writable(accounts[0].address()),
1675 InstructionAccount::readonly(accounts[1].address()),
1676 ];
1677 let ix = InstructionView {
1678 program_id: &system_id,
1679 data: &data,
1680 accounts: &metas,
1681 };
1682
1683 assert_eq!(
1684 invoke_borrow_checked::<2>(&ix, &[&accounts[0], &accounts[1]]).unwrap_err(),
1685 write_policy_violation(1)
1686 );
1687 // Refused BEFORE any mutation: neither side moved (make_account
1688 // seeds each balance with 1 lamport).
1689 assert_eq!(accounts[0].lamports(), 1);
1690 assert_eq!(accounts[1].lamports(), 1);
1691 }
1692
1693 #[test]
1694 fn borrow_checked_requires_enough_account_views() {
1695 let (_first_backing, first) = make_account([25; 32]);
1696 let (_second_backing, second) = make_account([26; 32]);
1697
1698 let metas = [
1699 InstructionAccount::writable(first.address()),
1700 InstructionAccount::writable(second.address()),
1701 ];
1702 let program_id = Address::new_from_array([7; 32]);
1703 let instruction = InstructionView {
1704 program_id: &program_id,
1705 data: &[0u8],
1706 accounts: &metas,
1707 };
1708
1709 let err = invoke_borrow_checked::<1>(&instruction, &[&first]).unwrap_err();
1710 assert_eq!(err, ProgramError::NotEnoughAccountKeys);
1711 }
1712
1713 // -- FUSED-CPI: fused validate+build == prior validate-then-build ------
1714
1715 /// Serialize the built `CpiAccount` scratch to a stable string. The
1716 /// production fused path writes `CpiAccount::from(view)` into each slot;
1717 /// its `Debug` (pointers + flags + lengths) is a faithful fingerprint of
1718 /// the scratch handed to the syscall.
1719 fn scratch_fingerprint(account_views: &[&AccountView<'_>]) -> std::string::String {
1720 let mut s = std::string::String::new();
1721 let mut i = 0;
1722 while i < account_views.len() {
1723 s.push_str(&std::format!(
1724 "[{}]={:?};",
1725 i,
1726 CpiAccount::from(account_views[i])
1727 ));
1728 i += 1;
1729 }
1730 s
1731 }
1732
1733 /// PRE-fusion default tier: validate the *whole* meta list, THEN build
1734 /// the scratch in a second walk. Kept in the test as the byte-for-byte
1735 /// oracle the production fused path must match.
1736 fn reference_split_default(
1737 instruction: &InstructionView<'_, '_, '_, '_>,
1738 account_views: &[&AccountView<'_>],
1739 signers_seeds: &[Signer<'_, '_>],
1740 ) -> Result<std::string::String, ProgramError> {
1741 if account_views.len() < instruction.accounts.len() {
1742 return Err(ProgramError::NotEnoughAccountKeys);
1743 }
1744 let mut i = 0;
1745 while i < instruction.accounts.len() {
1746 let expected = &instruction.accounts[i];
1747 let actual = account_views[i];
1748 if !address_eq(actual.address(), expected.address) {
1749 return Err(ProgramError::InvalidAccountData);
1750 }
1751 if expected.is_signer
1752 && !actual.is_signer()
1753 && !signer_authority_supplied(signers_seeds)
1754 {
1755 return Err(ProgramError::MissingRequiredSignature);
1756 }
1757 if expected.is_writable && !actual.is_writable() {
1758 return Err(ProgramError::Immutable);
1759 }
1760 if expected.is_writable {
1761 actual.check_borrow_mut()?;
1762 } else {
1763 actual.check_borrow()?;
1764 }
1765 i += 1;
1766 }
1767 // Mirrors production: the delegation sweep runs once per CPI
1768 // after the per-meta pass (borrow-before-delegation precedence).
1769 if crate::write_policy::lamport_gate_active() {
1770 let mut m = 0;
1771 while m < instruction.accounts.len() {
1772 if instruction.accounts[m].is_writable {
1773 crate::write_policy::check_lamport_delegation(account_views[m].address())?;
1774 }
1775 m += 1;
1776 }
1777 }
1778 validate_no_duplicate_writable(instruction, account_views)?;
1779 // Second (build) walk over the FULL view list.
1780 Ok(scratch_fingerprint(account_views))
1781 }
1782
1783 /// The fused default tier reproduced exactly as production `invoke_signed`
1784 /// runs it: interleave per-meta validation with the scratch build, then
1785 /// run the duplicate-writable scan.
1786 fn reference_fused_default(
1787 instruction: &InstructionView<'_, '_, '_, '_>,
1788 account_views: &[&AccountView<'_>],
1789 signers_seeds: &[Signer<'_, '_>],
1790 ) -> Result<std::string::String, ProgramError> {
1791 let metas_len = instruction.accounts.len();
1792 if account_views.len() < metas_len {
1793 return Err(ProgramError::NotEnoughAccountKeys);
1794 }
1795 let mut s = std::string::String::new();
1796 let mut i = 0;
1797 while i < account_views.len() {
1798 let actual = account_views[i];
1799 if i < metas_len {
1800 let expected = &instruction.accounts[i];
1801 if !address_eq(actual.address(), expected.address) {
1802 return Err(ProgramError::InvalidAccountData);
1803 }
1804 if expected.is_signer
1805 && !actual.is_signer()
1806 && !signer_authority_supplied(signers_seeds)
1807 {
1808 return Err(ProgramError::MissingRequiredSignature);
1809 }
1810 if expected.is_writable && !actual.is_writable() {
1811 return Err(ProgramError::Immutable);
1812 }
1813 if expected.is_writable {
1814 actual.check_borrow_mut()?;
1815 } else {
1816 actual.check_borrow()?;
1817 }
1818 }
1819 s.push_str(&std::format!("[{}]={:?};", i, CpiAccount::from(actual)));
1820 i += 1;
1821 }
1822 // Mirrors production's once-per-CPI delegation sweep placement.
1823 if crate::write_policy::lamport_gate_active() {
1824 let mut m = 0;
1825 while m < metas_len {
1826 if instruction.accounts[m].is_writable {
1827 crate::write_policy::check_lamport_delegation(account_views[m].address())?;
1828 }
1829 m += 1;
1830 }
1831 }
1832 validate_no_duplicate_writable(instruction, account_views)?;
1833 Ok(s)
1834 }
1835
1836 #[test]
1837 fn signed_preflight_defers_pda_derivation_to_the_svm() {
1838 let (_backing, account) = make_account([50; 32]);
1839 let callee = Address::new_from_array([7; 32]);
1840 let metas = [InstructionAccount::readonly_signer(account.address())];
1841 let instruction = InstructionView {
1842 program_id: &callee,
1843 data: &[0u8],
1844 accounts: &metas,
1845 };
1846 let views = [&account];
1847 let seed_bytes = [9u8];
1848 let seeds = [Seed::from(&seed_bytes)];
1849 let signers = [Signer::from(&seeds)];
1850
1851 // The callee id is not the caller id and therefore cannot be used to
1852 // derive the PDA here. Host invocation is a no-op after preflight;
1853 // on SVM the invoke_signed syscall validates the same seed group
1854 // against the actual caller before granting signer privilege.
1855 assert_eq!(invoke_signed(&instruction, &views, &signers), Ok(()));
1856 assert_eq!(
1857 invoke_signed(&instruction, &views, &[]),
1858 Err(ProgramError::MissingRequiredSignature)
1859 );
1860 }
1861
1862 #[test]
1863 fn fused_build_matches_split_build_and_per_tier_errors() {
1864 use crate::write_policy::{install_lamport_gate, write_policy_violation, WritePolicy};
1865
1866 let program_id = Address::new_from_array([7; 32]);
1867
1868 // (1) Valid multi-account CPI (two distinct writable accounts, no
1869 // gate installed). Fused and split builds must produce the SAME
1870 // scratch, and production `invoke` must accept it.
1871 {
1872 let (_a, first) = make_account([51; 32]);
1873 let (_b, second) = make_account([52; 32]);
1874 let metas = [
1875 InstructionAccount::writable(first.address()),
1876 InstructionAccount::writable(second.address()),
1877 ];
1878 let ix = InstructionView {
1879 program_id: &program_id,
1880 data: &[0u8],
1881 accounts: &metas,
1882 };
1883 let views: [&AccountView<'_>; 2] = [&first, &second];
1884
1885 let split = reference_split_default(&ix, &views[..], &[]);
1886 let fused = reference_fused_default(&ix, &views[..], &[]);
1887 assert!(split.is_ok());
1888 // Same scratch bytes, and same Result overall.
1889 assert_eq!(split, fused);
1890 // Production fused path accepts the valid CPI (off-chain no-op).
1891 assert_eq!(invoke::<2>(&ix, &views), Ok(()));
1892 }
1893
1894 // (2) Signer-missing meta: a required-signer meta over a non-signer
1895 // account. Both builds refuse identically, and production too.
1896 {
1897 let (_a, acct) = make_account([53; 32]);
1898 let metas = [InstructionAccount::readonly_signer(acct.address())];
1899 let ix = InstructionView {
1900 program_id: &program_id,
1901 data: &[0u8],
1902 accounts: &metas,
1903 };
1904 let views: [&AccountView<'_>; 1] = [&acct];
1905
1906 let split = reference_split_default(&ix, &views[..], &[]);
1907 let fused = reference_fused_default(&ix, &views[..], &[]);
1908 assert_eq!(split, Err(ProgramError::MissingRequiredSignature));
1909 assert_eq!(split, fused);
1910 assert_eq!(
1911 invoke::<1>(&ix, &views).unwrap_err(),
1912 ProgramError::MissingRequiredSignature
1913 );
1914 }
1915
1916 // (3) Writable-meta lamport-delegation refusal: an installed gate
1917 // that declares nothing for the account. The refusal must fire on
1918 // the fused build exactly as on the split build (indexed policy
1919 // error), and production must surface the same error.
1920 {
1921 let (_a, acct) = make_account([54; 32]);
1922 let accounts = [acct];
1923 static P: WritePolicy = WritePolicy::with_lamports(&[], &[]);
1924 let _gate = install_lamport_gate(&accounts, &P);
1925
1926 let metas = [InstructionAccount::writable(accounts[0].address())];
1927 let ix = InstructionView {
1928 program_id: &program_id,
1929 data: &[0u8],
1930 accounts: &metas,
1931 };
1932 let views: [&AccountView<'_>; 1] = [&accounts[0]];
1933
1934 let split = reference_split_default(&ix, &views[..], &[]);
1935 let fused = reference_fused_default(&ix, &views[..], &[]);
1936 assert_eq!(split, Err(write_policy_violation(0)));
1937 assert_eq!(split, fused);
1938 assert_eq!(
1939 invoke::<1>(&ix, &views).unwrap_err(),
1940 write_policy_violation(0)
1941 );
1942 }
1943
1944 // (4) Deduped (duplicate account) case: two writable metas naming the
1945 // SAME account. The deduped tier (unchanged by fusion) must still
1946 // reject the double-mutation footgun.
1947 {
1948 let (_a, acct) = make_account([55; 32]);
1949 let metas = [
1950 InstructionAccount::writable(acct.address()),
1951 InstructionAccount::writable(acct.address()),
1952 ];
1953 let ix = InstructionView {
1954 program_id: &program_id,
1955 data: &[0u8],
1956 accounts: &metas,
1957 };
1958 // A single deduped info backs both metas.
1959 assert_eq!(
1960 invoke_signed_deduped::<1>(&ix, &[&acct], &[]).unwrap_err(),
1961 ProgramError::AccountBorrowFailed
1962 );
1963 }
1964 }
1965}
1966
1967#[cfg(test)]
1968#[path = "cpi_dedup_tests.rs"]
1969mod dedup_tests;