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 // This public API accepts any info order. Resolve the System transfer's
1040 // positional metas by address just as the SVM does; an extra info must
1041 // never be mistaken for the debited account.
1042 if instruction.accounts.len() < 2 {
1043 return Err(ProgramError::NotEnoughAccountKeys);
1044 }
1045 let source = find_info(infos, instruction.accounts[0].address)
1046 .ok_or(ProgramError::NotEnoughAccountKeys)?;
1047 let destination = find_info(infos, instruction.accounts[1].address)
1048 .ok_or(ProgramError::NotEnoughAccountKeys)?;
1049 return emulate_host_system_transfer(instruction, &[infos[source], infos[destination]]);
1050 }
1051
1052 validate_cpi_accounts_deduped(instruction, infos, signers_seeds)?;
1053
1054 let mut cpi_accounts: [MaybeUninit<CpiAccount<'_>>; MAX_INFOS] =
1055 // SAFETY: an array of `MaybeUninit<T>` is valid in any initialization
1056 // state, so materializing it uninitialized is sound; the first
1057 // `count` elements are written below before they are read.
1058 unsafe { MaybeUninit::uninit().assume_init() };
1059
1060 let count = infos.len();
1061 let mut i = 0;
1062 while i < count {
1063 cpi_accounts[i] = MaybeUninit::new(CpiAccount::from(infos[i]));
1064 i += 1;
1065 }
1066
1067 // SAFETY: the loop initialized the first `count` elements, and
1068 // `MaybeUninit<T>` shares `T`'s layout, so reading exactly that prefix
1069 // reads only initialized memory.
1070 let accounts = unsafe {
1071 core::slice::from_raw_parts(cpi_accounts.as_ptr() as *const CpiAccount<'_>, count)
1072 };
1073
1074 // SAFETY: `validate_cpi_accounts_deduped` above discharged the borrow /
1075 // aliasing contract (writable infos exclusively borrowable, read-only
1076 // infos shared-borrowable) required by the unchecked syscall wrappers.
1077 unsafe {
1078 if signers_seeds.is_empty() {
1079 invoke_unchecked(instruction, accounts)
1080 } else {
1081 invoke_signed_unchecked(instruction, accounts, signers_seeds)
1082 }
1083 }
1084}
1085
1086/// Explicit alias for Hopper's validated CPI path.
1087#[inline]
1088pub fn invoke_checked<const ACCOUNTS: usize>(
1089 instruction: &InstructionView<'_, '_, '_, '_>,
1090 account_views: &[&AccountView<'_>; ACCOUNTS],
1091) -> ProgramResult {
1092 invoke::<ACCOUNTS>(instruction, account_views)
1093}
1094
1095/// Explicit alias for Hopper's validated signed CPI path.
1096#[inline]
1097pub fn invoke_signed_checked<const ACCOUNTS: usize>(
1098 instruction: &InstructionView<'_, '_, '_, '_>,
1099 account_views: &[&AccountView<'_>; ACCOUNTS],
1100 signers_seeds: &[Signer<'_, '_>],
1101) -> ProgramResult {
1102 invoke_signed::<ACCOUNTS>(instruction, account_views, signers_seeds)
1103}
1104
1105// -- Borrow-checked (Pinocchio-equivalent) tier -------------------------
1106
1107/// Invoke after checking account-address correspondence and live data borrows.
1108///
1109/// Writable metas require an exclusive borrow; readonly metas require a shared
1110/// borrow. When a lamport write policy is active, writable CPI delegation also
1111/// requires whole-account data and lamport permission.
1112///
1113/// Unlike the default [`invoke`] path, this tier omits local signer and writable
1114/// privilege checks and allows duplicate writable metas. The SVM still enforces
1115/// privileges and PDA signer derivation. Choose this tier only when the application
1116/// intends that account aliasing and has validated its account relationships.
1117///
1118/// [`invoke_checked`] is an explicit alias for the default tier. Unsafe
1119/// [`invoke_unchecked`] skips Hopper's checks and requires the caller to uphold
1120/// its documented borrow and descriptor contracts.
1121///
1122/// On host targets, supported System transfers are emulated. Other CPIs are
1123/// validation-only no-ops; exercise real callee behavior in an SVM or on devnet.
1124#[inline]
1125pub fn invoke_borrow_checked<const ACCOUNTS: usize>(
1126 instruction: &InstructionView<'_, '_, '_, '_>,
1127 account_views: &[&AccountView<'_>; ACCOUNTS],
1128) -> ProgramResult {
1129 invoke_signed_borrow_checked::<ACCOUNTS>(instruction, account_views, &[])
1130}
1131
1132/// Signed variant of [`invoke_borrow_checked`]. Signer seeds are forwarded to
1133/// the SVM, which derives and validates the caller's PDA authorities.
1134#[inline]
1135pub fn invoke_signed_borrow_checked<const ACCOUNTS: usize>(
1136 instruction: &InstructionView<'_, '_, '_, '_>,
1137 account_views: &[&AccountView<'_>; ACCOUNTS],
1138 signers_seeds: &[Signer<'_, '_>],
1139) -> ProgramResult {
1140 #[cfg(not(target_os = "solana"))]
1141 if is_host_system_transfer(instruction) {
1142 // The emulation reads views[0] and views[1] directly; guard the
1143 // fixed-array length before indexing (ACCOUNTS may be < 2).
1144 if account_views.len() < 2 {
1145 return Err(ProgramError::NotEnoughAccountKeys);
1146 }
1147 validate_cpi_borrows(instruction, &account_views[..])?;
1148 return emulate_host_system_transfer(instruction, &account_views[..]);
1149 }
1150
1151 let metas_len = instruction.accounts.len();
1152
1153 // Fused validate+build (borrow_checked tier). `check_meta` runs the
1154 // per-account checks `validate_cpi_borrows` did, meta↔view address
1155 // correspondence and borrow state, while the scratch slot is
1156 // materialized in the same pass. The mutation-completeness lamport-delegation scan
1157 // runs ONCE per CPI in `post_check`, NOT per meta: the 2026-07-09
1158 // router bisect measured that any *reachable* gate-machinery call
1159 // inside this per-meta closure forces it into an outlined,
1160 // spill-heavy shape costing ~+52 CU per hop for programs that never
1161 // installed a gate (branch-inside variants only recovered to ~+21;
1162 // machinery-unreachable-from-the-closure recovered fully:
1163 // 1,564/3,044/4,525 → 1,559/3,035/4,512 measured). Gated programs
1164 // keep full enforcement, the sweep still refuses before the syscall
1165 // hand-off in `dispatch_cpi_fixed`, with one documented precedence
1166 // shift: in a multi-fault instruction, borrow errors now surface
1167 // before delegation errors (both are pre-syscall refusals).
1168 dispatch_cpi_fixed::<ACCOUNTS>(
1169 instruction,
1170 account_views,
1171 signers_seeds,
1172 metas_len,
1173 |i| {
1174 // The borrow state must be validated against the account the meta
1175 // actually names, not whatever view happens to sit at index `i`
1176 // (see `validate_cpi_borrows` for why: a mismatched order would
1177 // borrow-check the wrong (account, mutability) pair and reach
1178 // `invoke_unchecked` with its aliasing contract undischarged).
1179 if !address_eq(account_views[i].address(), instruction.accounts[i].address) {
1180 return Err(ProgramError::InvalidArgument);
1181 }
1182 if instruction.accounts[i].is_writable {
1183 account_views[i].check_borrow_mut()?;
1184 } else {
1185 account_views[i].check_borrow()?;
1186 }
1187 Ok(())
1188 },
1189 || {
1190 if crate::write_policy::lamport_gate_active() {
1191 let mut i = 0;
1192 while i < metas_len {
1193 if instruction.accounts[i].is_writable {
1194 crate::write_policy::check_lamport_delegation(account_views[i].address())?;
1195 }
1196 i += 1;
1197 }
1198 }
1199 Ok(())
1200 },
1201 )
1202}
1203
1204// ---------------------------------------------------------------------
1205
1206/// Set return data for the current instruction.
1207#[inline(always)]
1208pub fn set_return_data(data: &[u8]) {
1209 crate::return_data::set_return_data(data)
1210}
1211
1212#[cfg(test)]
1213mod tests {
1214 use super::*;
1215
1216 use crate::InstructionAccount;
1217 use hopper_native::{
1218 AccountView as NativeAccountView, Address as NativeAddress, RuntimeAccount, NOT_BORROWED,
1219 };
1220
1221 fn make_account(address: [u8; 32]) -> (std::vec::Vec<u64>, AccountView<'static>) {
1222 let mut backing = std::vec![0u64; (RuntimeAccount::SIZE + 16).div_ceil(8)];
1223 let raw = backing.as_mut_ptr() as *mut RuntimeAccount;
1224 // 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.
1225 unsafe {
1226 raw.write(RuntimeAccount {
1227 borrow_state: NOT_BORROWED,
1228 is_signer: 0,
1229 is_writable: 1,
1230 executable: 0,
1231 resize_delta: 0,
1232 address: NativeAddress::new_from_array(address),
1233 owner: NativeAddress::new_from_array([9; 32]),
1234 lamports: 1,
1235 data_len: 16,
1236 });
1237 }
1238 // 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.
1239 let backend = unsafe { NativeAccountView::new_unchecked(raw) };
1240 (backing, AccountView::from_backend(backend))
1241 }
1242
1243 #[test]
1244 fn duplicate_writable_accounts_are_rejected_before_cpi() {
1245 let (_first_backing, first) = make_account([3; 32]);
1246 let (_second_backing, second) = make_account([3; 32]);
1247
1248 let instruction_accounts = [
1249 InstructionAccount::writable(first.address()),
1250 InstructionAccount::writable(second.address()),
1251 ];
1252 let program_id = Address::new_from_array([7; 32]);
1253 let instruction = InstructionView {
1254 program_id: &program_id,
1255 data: &[0u8],
1256 accounts: &instruction_accounts,
1257 };
1258
1259 let err = validate_no_duplicate_writable(&instruction, &[&first, &second]).unwrap_err();
1260 assert_eq!(err, ProgramError::AccountBorrowFailed);
1261 }
1262
1263 // -- borrow_checked tier ------------------------------------------
1264
1265 #[test]
1266 fn borrow_checked_rejects_live_mutable_data_borrow() {
1267 let (_backing, account) = make_account([21; 32]);
1268 let metas = [InstructionAccount::writable(account.address())];
1269 let program_id = Address::new_from_array([7; 32]);
1270 let instruction = InstructionView {
1271 program_id: &program_id,
1272 data: &[0u8],
1273 accounts: &metas,
1274 };
1275
1276 let guard = account.try_borrow_mut().unwrap();
1277 let err = invoke_borrow_checked::<1>(&instruction, &[&account]).unwrap_err();
1278 assert_eq!(err, ProgramError::AccountBorrowFailed);
1279 drop(guard);
1280 }
1281
1282 #[test]
1283 fn borrow_checked_succeeds_after_borrow_release() {
1284 let (_backing, account) = make_account([22; 32]);
1285 let metas = [InstructionAccount::writable(account.address())];
1286 let program_id = Address::new_from_array([7; 32]);
1287 let instruction = InstructionView {
1288 program_id: &program_id,
1289 data: &[0u8],
1290 accounts: &metas,
1291 };
1292
1293 let guard = account.try_borrow_mut().unwrap();
1294 assert!(invoke_borrow_checked::<1>(&instruction, &[&account]).is_err());
1295 drop(guard);
1296
1297 // Off-chain the syscall is a no-op, so Ok(()) here proves the
1298 // borrow validation passed once the guard was released.
1299 invoke_borrow_checked::<1>(&instruction, &[&account]).unwrap();
1300 }
1301
1302 #[test]
1303 fn borrow_checked_permits_duplicate_writable_metas_unlike_default_tier() {
1304 let (_first_backing, first) = make_account([23; 32]);
1305 let (_second_backing, second) = make_account([23; 32]);
1306
1307 let metas = [
1308 InstructionAccount::writable(first.address()),
1309 InstructionAccount::writable(second.address()),
1310 ];
1311 let program_id = Address::new_from_array([7; 32]);
1312 let instruction = InstructionView {
1313 program_id: &program_id,
1314 data: &[0u8],
1315 accounts: &metas,
1316 };
1317
1318 // Default tier: duplicate writable metas are rejected, the
1319 // Sealevel double-mutation footgun `validate_no_duplicate_writable`
1320 // exists to guard.
1321 let err = invoke::<2>(&instruction, &[&first, &second]).unwrap_err();
1322 assert_eq!(err, ProgramError::AccountBorrowFailed);
1323
1324 // borrow_checked tier: per-account borrow state ONLY, matching
1325 // what Pinocchio's `invoke` checks. Not rejecting duplicates is
1326 // the documented contract of this tier, callers opt down only
1327 // after `require_unique_writable_accounts` (or a statically
1328 // duplicate-free account shape) has ruled the footgun out.
1329 invoke_borrow_checked::<2>(&instruction, &[&first, &second]).unwrap();
1330 }
1331
1332 #[test]
1333 fn borrow_checked_offchain_noop_path_returns_ok() {
1334 let (_backing, account) = make_account([24; 32]);
1335 let metas = [InstructionAccount::readonly(account.address())];
1336 let program_id = Address::new_from_array([7; 32]);
1337 let instruction = InstructionView {
1338 program_id: &program_id,
1339 data: &[0u8],
1340 accounts: &metas,
1341 };
1342
1343 assert_eq!(
1344 invoke_borrow_checked::<1>(&instruction, &[&account]),
1345 Ok(())
1346 );
1347 assert_eq!(
1348 invoke_signed_borrow_checked::<1>(&instruction, &[&account], &[]),
1349 Ok(())
1350 );
1351 }
1352
1353 // Lamport gate on writable metas.
1354
1355 // Guarded-tier semantics: installs a data-declaring policy, which the
1356 // `unguarded-raw-surfaces` fence refuses at install (covered by its
1357 // own explicit test in that shape).
1358 #[test]
1359 #[cfg(not(feature = "unguarded-raw-surfaces"))]
1360 fn writable_meta_is_refused_unless_both_dimensions_are_declared() {
1361 use crate::write_policy::{
1362 install_lamport_gate, write_policy_violation, WritePolicy, WriteRange,
1363 };
1364
1365 let (_b0, delegable) = make_account([31; 32]);
1366 let (_b1, lamports_only) = make_account([32; 32]);
1367 let (_b2, undeclared) = make_account([33; 32]);
1368 let accounts = [delegable, lamports_only, undeclared];
1369
1370 // Account 0 carries whole-account data + lamports (delegable);
1371 // account 1 lamports only; account 2 nothing.
1372 static P: WritePolicy =
1373 WritePolicy::with_lamports(&[WriteRange::whole_account(0)], &[0, 1]);
1374 let _gate = install_lamport_gate(&accounts, &P);
1375
1376 let program_id = Address::new_from_array([7; 32]);
1377
1378 // Writable meta on the fully declared account: allowed on the
1379 // default AND borrow_checked tiers (off-chain no-op syscall).
1380 let metas0 = [InstructionAccount::writable(accounts[0].address())];
1381 let ix0 = InstructionView {
1382 program_id: &program_id,
1383 data: &[0u8],
1384 accounts: &metas0,
1385 };
1386 invoke::<1>(&ix0, &[&accounts[0]]).unwrap();
1387 invoke_borrow_checked::<1>(&ix0, &[&accounts[0]]).unwrap();
1388
1389 // Lamports-only account: a writable hand-off is unbounded DATA
1390 // delegation too, so it is refused with the indexed policy error.
1391 let metas1 = [InstructionAccount::writable(accounts[1].address())];
1392 let ix1 = InstructionView {
1393 program_id: &program_id,
1394 data: &[0u8],
1395 accounts: &metas1,
1396 };
1397 assert_eq!(
1398 invoke::<1>(&ix1, &[&accounts[1]]).unwrap_err(),
1399 write_policy_violation(1)
1400 );
1401 assert_eq!(
1402 invoke_borrow_checked::<1>(&ix1, &[&accounts[1]]).unwrap_err(),
1403 write_policy_violation(1)
1404 );
1405
1406 // Entirely undeclared account: refused on every safe tier,
1407 // including the deduped path.
1408 let metas2 = [InstructionAccount::writable(accounts[2].address())];
1409 let ix2 = InstructionView {
1410 program_id: &program_id,
1411 data: &[0u8],
1412 accounts: &metas2,
1413 };
1414 assert_eq!(
1415 invoke_signed_deduped::<1>(&ix2, &[&accounts[2]], &[]).unwrap_err(),
1416 write_policy_violation(2)
1417 );
1418
1419 // Read-only metas are never lamport-gated.
1420 let metas_ro = [InstructionAccount::readonly(accounts[2].address())];
1421 let ix_ro = InstructionView {
1422 program_id: &program_id,
1423 data: &[0u8],
1424 accounts: &metas_ro,
1425 };
1426 invoke::<1>(&ix_ro, &[&accounts[2]]).unwrap();
1427 }
1428
1429 // Guarded-tier semantics: installs a data-declaring policy, which the
1430 // `unguarded-raw-surfaces` fence refuses at install (covered by its
1431 // own explicit test in that shape).
1432 #[test]
1433 #[cfg(not(feature = "unguarded-raw-surfaces"))]
1434 fn host_system_transfer_is_gated_through_the_lamport_funnel() {
1435 use crate::write_policy::{
1436 install_lamport_gate, write_policy_violation, WritePolicy, WriteRange,
1437 };
1438
1439 let (_b0, from) = make_account([41; 32]);
1440 let (_b1, to) = make_account([42; 32]);
1441 let accounts = [from, to];
1442
1443 // Both sides declared: the emulated transfer succeeds and the
1444 // balances actually move.
1445 static OPEN: WritePolicy = WritePolicy::with_lamports(
1446 &[WriteRange::whole_account(0), WriteRange::whole_account(1)],
1447 &[0, 1],
1448 );
1449 // Only `from` declared: the transfer must be refused before any
1450 // balance changes.
1451 static HALF: WritePolicy =
1452 WritePolicy::with_lamports(&[WriteRange::whole_account(0)], &[0]);
1453
1454 let system_id = Address::new_from_array([0; 32]);
1455 let mut data = [0u8; 12];
1456 data[0] = 2; // System Transfer tag
1457 data[4..12].copy_from_slice(&1u64.to_le_bytes());
1458 let metas = [
1459 InstructionAccount::writable(accounts[0].address()),
1460 InstructionAccount::writable(accounts[1].address()),
1461 ];
1462 let ix = InstructionView {
1463 program_id: &system_id,
1464 data: &data,
1465 accounts: &metas,
1466 };
1467
1468 {
1469 let _gate = install_lamport_gate(&accounts, &OPEN);
1470 invoke::<2>(&ix, &[&accounts[0], &accounts[1]]).unwrap();
1471 assert_eq!(accounts[0].lamports(), 0);
1472 assert_eq!(accounts[1].lamports(), 2);
1473 }
1474 {
1475 let _gate = install_lamport_gate(&accounts, &HALF);
1476 assert_eq!(
1477 invoke::<2>(&ix, &[&accounts[0], &accounts[1]]).unwrap_err(),
1478 write_policy_violation(1)
1479 );
1480 // Refused before mutation: balances unchanged.
1481 assert_eq!(accounts[0].lamports(), 0);
1482 assert_eq!(accounts[1].lamports(), 2);
1483 }
1484 }
1485
1486 // Guarded-tier semantics: installs a data-declaring policy, which the
1487 // `unguarded-raw-surfaces` fence refuses at install (covered by its
1488 // own explicit test in that shape).
1489 #[test]
1490 #[cfg(not(feature = "unguarded-raw-surfaces"))]
1491 fn host_system_transfer_refusal_leaves_both_balances_untouched() {
1492 use crate::write_policy::{
1493 install_lamport_gate, write_policy_violation, WritePolicy, WriteRange,
1494 };
1495
1496 let (_b0, from) = make_account([43; 32]);
1497 let (_b1, to) = make_account([44; 32]);
1498 let accounts = [from, to];
1499
1500 // Only `from` is declared for lamport mutation.
1501 static HALF: WritePolicy =
1502 WritePolicy::with_lamports(&[WriteRange::whole_account(0)], &[0]);
1503 let _gate = install_lamport_gate(&accounts, &HALF);
1504
1505 let system_id = Address::new_from_array([0; 32]);
1506 let mut data = [0u8; 12];
1507 data[0] = 2; // System Transfer tag
1508 data[4..12].copy_from_slice(&1u64.to_le_bytes());
1509 // `to` is deliberately a READ-ONLY meta: the writable-meta
1510 // delegation gate then never fires for it, so without the
1511 // emulation's own both-sides pre-validation the refusal would
1512 // come from the `set_lamports` funnel *after* `from` was
1513 // already debited, destroying a lamport in host state.
1514 let metas = [
1515 InstructionAccount::writable(accounts[0].address()),
1516 InstructionAccount::readonly(accounts[1].address()),
1517 ];
1518 let ix = InstructionView {
1519 program_id: &system_id,
1520 data: &data,
1521 accounts: &metas,
1522 };
1523
1524 assert_eq!(
1525 invoke_borrow_checked::<2>(&ix, &[&accounts[0], &accounts[1]]).unwrap_err(),
1526 write_policy_violation(1)
1527 );
1528 // Refused BEFORE any mutation: neither side moved (make_account
1529 // seeds each balance with 1 lamport).
1530 assert_eq!(accounts[0].lamports(), 1);
1531 assert_eq!(accounts[1].lamports(), 1);
1532 }
1533
1534 #[test]
1535 fn borrow_checked_requires_enough_account_views() {
1536 let (_first_backing, first) = make_account([25; 32]);
1537 let (_second_backing, second) = make_account([26; 32]);
1538
1539 let metas = [
1540 InstructionAccount::writable(first.address()),
1541 InstructionAccount::writable(second.address()),
1542 ];
1543 let program_id = Address::new_from_array([7; 32]);
1544 let instruction = InstructionView {
1545 program_id: &program_id,
1546 data: &[0u8],
1547 accounts: &metas,
1548 };
1549
1550 let err = invoke_borrow_checked::<1>(&instruction, &[&first]).unwrap_err();
1551 assert_eq!(err, ProgramError::NotEnoughAccountKeys);
1552 }
1553
1554 // -- FUSED-CPI: fused validate+build == prior validate-then-build ------
1555
1556 /// Serialize the built `CpiAccount` scratch to a stable string. The
1557 /// production fused path writes `CpiAccount::from(view)` into each slot;
1558 /// its `Debug` (pointers + flags + lengths) is a faithful fingerprint of
1559 /// the scratch handed to the syscall.
1560 fn scratch_fingerprint(account_views: &[&AccountView<'_>]) -> std::string::String {
1561 let mut s = std::string::String::new();
1562 let mut i = 0;
1563 while i < account_views.len() {
1564 s.push_str(&std::format!(
1565 "[{}]={:?};",
1566 i,
1567 CpiAccount::from(account_views[i])
1568 ));
1569 i += 1;
1570 }
1571 s
1572 }
1573
1574 /// PRE-fusion default tier: validate the *whole* meta list, THEN build
1575 /// the scratch in a second walk. Kept in the test as the byte-for-byte
1576 /// oracle the production fused path must match.
1577 fn reference_split_default(
1578 instruction: &InstructionView<'_, '_, '_, '_>,
1579 account_views: &[&AccountView<'_>],
1580 signers_seeds: &[Signer<'_, '_>],
1581 ) -> Result<std::string::String, ProgramError> {
1582 if account_views.len() < instruction.accounts.len() {
1583 return Err(ProgramError::NotEnoughAccountKeys);
1584 }
1585 let mut i = 0;
1586 while i < instruction.accounts.len() {
1587 let expected = &instruction.accounts[i];
1588 let actual = account_views[i];
1589 if !address_eq(actual.address(), expected.address) {
1590 return Err(ProgramError::InvalidAccountData);
1591 }
1592 if expected.is_signer
1593 && !actual.is_signer()
1594 && !signer_authority_supplied(signers_seeds)
1595 {
1596 return Err(ProgramError::MissingRequiredSignature);
1597 }
1598 if expected.is_writable && !actual.is_writable() {
1599 return Err(ProgramError::Immutable);
1600 }
1601 if expected.is_writable {
1602 actual.check_borrow_mut()?;
1603 } else {
1604 actual.check_borrow()?;
1605 }
1606 i += 1;
1607 }
1608 // Mirrors production: the delegation sweep runs once per CPI
1609 // after the per-meta pass (borrow-before-delegation precedence).
1610 if crate::write_policy::lamport_gate_active() {
1611 let mut m = 0;
1612 while m < instruction.accounts.len() {
1613 if instruction.accounts[m].is_writable {
1614 crate::write_policy::check_lamport_delegation(account_views[m].address())?;
1615 }
1616 m += 1;
1617 }
1618 }
1619 validate_no_duplicate_writable(instruction, account_views)?;
1620 // Second (build) walk over the FULL view list.
1621 Ok(scratch_fingerprint(account_views))
1622 }
1623
1624 /// The fused default tier reproduced exactly as production `invoke_signed`
1625 /// runs it: interleave per-meta validation with the scratch build, then
1626 /// run the duplicate-writable scan.
1627 fn reference_fused_default(
1628 instruction: &InstructionView<'_, '_, '_, '_>,
1629 account_views: &[&AccountView<'_>],
1630 signers_seeds: &[Signer<'_, '_>],
1631 ) -> Result<std::string::String, ProgramError> {
1632 let metas_len = instruction.accounts.len();
1633 if account_views.len() < metas_len {
1634 return Err(ProgramError::NotEnoughAccountKeys);
1635 }
1636 let mut s = std::string::String::new();
1637 let mut i = 0;
1638 while i < account_views.len() {
1639 let actual = account_views[i];
1640 if i < metas_len {
1641 let expected = &instruction.accounts[i];
1642 if !address_eq(actual.address(), expected.address) {
1643 return Err(ProgramError::InvalidAccountData);
1644 }
1645 if expected.is_signer
1646 && !actual.is_signer()
1647 && !signer_authority_supplied(signers_seeds)
1648 {
1649 return Err(ProgramError::MissingRequiredSignature);
1650 }
1651 if expected.is_writable && !actual.is_writable() {
1652 return Err(ProgramError::Immutable);
1653 }
1654 if expected.is_writable {
1655 actual.check_borrow_mut()?;
1656 } else {
1657 actual.check_borrow()?;
1658 }
1659 }
1660 s.push_str(&std::format!("[{}]={:?};", i, CpiAccount::from(actual)));
1661 i += 1;
1662 }
1663 // Mirrors production's once-per-CPI delegation sweep placement.
1664 if crate::write_policy::lamport_gate_active() {
1665 let mut m = 0;
1666 while m < metas_len {
1667 if instruction.accounts[m].is_writable {
1668 crate::write_policy::check_lamport_delegation(account_views[m].address())?;
1669 }
1670 m += 1;
1671 }
1672 }
1673 validate_no_duplicate_writable(instruction, account_views)?;
1674 Ok(s)
1675 }
1676
1677 #[test]
1678 fn signed_preflight_defers_pda_derivation_to_the_svm() {
1679 let (_backing, account) = make_account([50; 32]);
1680 let callee = Address::new_from_array([7; 32]);
1681 let metas = [InstructionAccount::readonly_signer(account.address())];
1682 let instruction = InstructionView {
1683 program_id: &callee,
1684 data: &[0u8],
1685 accounts: &metas,
1686 };
1687 let views = [&account];
1688 let seed_bytes = [9u8];
1689 let seeds = [Seed::from(&seed_bytes)];
1690 let signers = [Signer::from(&seeds)];
1691
1692 // The callee id is not the caller id and therefore cannot be used to
1693 // derive the PDA here. Host invocation is a no-op after preflight;
1694 // on SVM the invoke_signed syscall validates the same seed group
1695 // against the actual caller before granting signer privilege.
1696 assert_eq!(invoke_signed(&instruction, &views, &signers), Ok(()));
1697 assert_eq!(
1698 invoke_signed(&instruction, &views, &[]),
1699 Err(ProgramError::MissingRequiredSignature)
1700 );
1701 }
1702
1703 #[test]
1704 fn fused_build_matches_split_build_and_per_tier_errors() {
1705 use crate::write_policy::{install_lamport_gate, write_policy_violation, WritePolicy};
1706
1707 let program_id = Address::new_from_array([7; 32]);
1708
1709 // (1) Valid multi-account CPI (two distinct writable accounts, no
1710 // gate installed). Fused and split builds must produce the SAME
1711 // scratch, and production `invoke` must accept it.
1712 {
1713 let (_a, first) = make_account([51; 32]);
1714 let (_b, second) = make_account([52; 32]);
1715 let metas = [
1716 InstructionAccount::writable(first.address()),
1717 InstructionAccount::writable(second.address()),
1718 ];
1719 let ix = InstructionView {
1720 program_id: &program_id,
1721 data: &[0u8],
1722 accounts: &metas,
1723 };
1724 let views: [&AccountView<'_>; 2] = [&first, &second];
1725
1726 let split = reference_split_default(&ix, &views[..], &[]);
1727 let fused = reference_fused_default(&ix, &views[..], &[]);
1728 assert!(split.is_ok());
1729 // Same scratch bytes, and same Result overall.
1730 assert_eq!(split, fused);
1731 // Production fused path accepts the valid CPI (off-chain no-op).
1732 assert_eq!(invoke::<2>(&ix, &views), Ok(()));
1733 }
1734
1735 // (2) Signer-missing meta: a required-signer meta over a non-signer
1736 // account. Both builds refuse identically, and production too.
1737 {
1738 let (_a, acct) = make_account([53; 32]);
1739 let metas = [InstructionAccount::readonly_signer(acct.address())];
1740 let ix = InstructionView {
1741 program_id: &program_id,
1742 data: &[0u8],
1743 accounts: &metas,
1744 };
1745 let views: [&AccountView<'_>; 1] = [&acct];
1746
1747 let split = reference_split_default(&ix, &views[..], &[]);
1748 let fused = reference_fused_default(&ix, &views[..], &[]);
1749 assert_eq!(split, Err(ProgramError::MissingRequiredSignature));
1750 assert_eq!(split, fused);
1751 assert_eq!(
1752 invoke::<1>(&ix, &views).unwrap_err(),
1753 ProgramError::MissingRequiredSignature
1754 );
1755 }
1756
1757 // (3) Writable-meta lamport-delegation refusal: an installed gate
1758 // that declares nothing for the account. The refusal must fire on
1759 // the fused build exactly as on the split build (indexed policy
1760 // error), and production must surface the same error.
1761 {
1762 let (_a, acct) = make_account([54; 32]);
1763 let accounts = [acct];
1764 static P: WritePolicy = WritePolicy::with_lamports(&[], &[]);
1765 let _gate = install_lamport_gate(&accounts, &P);
1766
1767 let metas = [InstructionAccount::writable(accounts[0].address())];
1768 let ix = InstructionView {
1769 program_id: &program_id,
1770 data: &[0u8],
1771 accounts: &metas,
1772 };
1773 let views: [&AccountView<'_>; 1] = [&accounts[0]];
1774
1775 let split = reference_split_default(&ix, &views[..], &[]);
1776 let fused = reference_fused_default(&ix, &views[..], &[]);
1777 assert_eq!(split, Err(write_policy_violation(0)));
1778 assert_eq!(split, fused);
1779 assert_eq!(
1780 invoke::<1>(&ix, &views).unwrap_err(),
1781 write_policy_violation(0)
1782 );
1783 }
1784
1785 // (4) Deduped (duplicate account) case: two writable metas naming the
1786 // SAME account. The deduped tier (unchanged by fusion) must still
1787 // reject the double-mutation footgun.
1788 {
1789 let (_a, acct) = make_account([55; 32]);
1790 let metas = [
1791 InstructionAccount::writable(acct.address()),
1792 InstructionAccount::writable(acct.address()),
1793 ];
1794 let ix = InstructionView {
1795 program_id: &program_id,
1796 data: &[0u8],
1797 accounts: &metas,
1798 };
1799 // A single deduped info backs both metas.
1800 assert_eq!(
1801 invoke_signed_deduped::<1>(&ix, &[&acct], &[]).unwrap_err(),
1802 ProgramError::AccountBorrowFailed
1803 );
1804 }
1805 }
1806}
1807
1808#[cfg(test)]
1809#[path = "cpi_dedup_tests.rs"]
1810mod dedup_tests;