Skip to main content

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}