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