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        // 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;