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-native-backend uses direct runtime syscalls; compatibility
5//! backends delegate through `compat` after Hopper-level validation.
6
7use crate::account::AccountView;
8use crate::address::{address_eq, Address};
9use crate::error::ProgramError;
10use crate::instruction::InstructionView;
11use crate::ProgramResult;
12
13#[cfg(all(feature = "hopper-native-backend", 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/// Maximum number of accounts in a static CPI call.
20pub const MAX_STATIC_CPI_ACCOUNTS: usize = 64;
21
22/// Maximum number of accounts in any CPI call.
23pub const MAX_CPI_ACCOUNTS: usize = 128;
24
25/// Maximum return data size (1 KiB).
26pub const MAX_RETURN_DATA: usize = 1024;
27
28// ══════════════════════════════════════════════════════════════════════
29//  hopper-native-backend CPI
30// ══════════════════════════════════════════════════════════════════════
31
32#[cfg(feature = "hopper-native-backend")]
33use crate::instruction::CpiAccount;
34#[cfg(feature = "hopper-native-backend")]
35use core::mem::MaybeUninit;
36
37#[cfg(all(feature = "hopper-native-backend", target_os = "solana"))]
38#[repr(C)]
39struct CInstruction<'a> {
40    program_id: *const Address,
41    accounts: *const InstructionAccount<'a>,
42    accounts_len: u64,
43    data: *const u8,
44    data_len: u64,
45}
46
47// ── Unchecked invoke ─────────────────────────────────────────────────
48
49/// Invoke a CPI without borrow validation (lowest CU cost).
50///
51/// # Safety
52///
53/// The caller must ensure no account data borrows conflict with the CPI.
54#[cfg(feature = "hopper-native-backend")]
55#[inline]
56pub unsafe fn invoke_unchecked(
57    instruction: &InstructionView,
58    accounts: &[CpiAccount],
59) -> ProgramResult {
60    #[cfg(target_os = "solana")]
61    {
62        let c_instruction = CInstruction {
63            program_id: instruction.program_id as *const Address,
64            accounts: instruction.accounts.as_ptr(),
65            accounts_len: instruction.accounts.len() as u64,
66            data: instruction.data.as_ptr(),
67            data_len: instruction.data.len() as u64,
68        };
69
70        // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
71        let result = unsafe {
72            hopper_native::syscalls::sol_invoke_signed_c(
73                &c_instruction as *const _ as *const u8,
74                accounts.as_ptr() as *const u8,
75                accounts.len() as u64,
76                core::ptr::null(),
77                0,
78            )
79        };
80        if result == 0 {
81            Ok(())
82        } else {
83            Err(ProgramError::from(result))
84        }
85    }
86    #[cfg(not(target_os = "solana"))]
87    {
88        let _ = (instruction, accounts);
89        Ok(())
90    }
91}
92
93/// Invoke a signed CPI without borrow validation.
94///
95/// # Safety
96///
97/// The caller must ensure no account data borrows conflict with the CPI.
98#[cfg(feature = "hopper-native-backend")]
99#[inline]
100pub unsafe fn invoke_signed_unchecked(
101    instruction: &InstructionView,
102    accounts: &[CpiAccount],
103    signers_seeds: &[Signer],
104) -> ProgramResult {
105    #[cfg(target_os = "solana")]
106    {
107        let c_instruction = CInstruction {
108            program_id: instruction.program_id as *const Address,
109            accounts: instruction.accounts.as_ptr(),
110            accounts_len: instruction.accounts.len() as u64,
111            data: instruction.data.as_ptr(),
112            data_len: instruction.data.len() as u64,
113        };
114
115        // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
116        let result = unsafe {
117            hopper_native::syscalls::sol_invoke_signed_c(
118                &c_instruction as *const _ as *const u8,
119                accounts.as_ptr() as *const u8,
120                accounts.len() as u64,
121                signers_seeds.as_ptr() as *const u8,
122                signers_seeds.len() as u64,
123            )
124        };
125        if result == 0 {
126            Ok(())
127        } else {
128            Err(ProgramError::from(result))
129        }
130    }
131    #[cfg(not(target_os = "solana"))]
132    {
133        let _ = (instruction, accounts, signers_seeds);
134        Ok(())
135    }
136}
137
138// ── CPI validation ───────────────────────────────────────────────────
139
140/// Reject duplicate writable accounts before invoking CPI.
141#[inline]
142fn validate_no_duplicate_writable(
143    instruction: &InstructionView,
144    account_views: &[&AccountView],
145) -> ProgramResult {
146    let mut i = 0;
147    while i < instruction.accounts.len() {
148        if instruction.accounts[i].is_writable {
149            let mut j = i + 1;
150            while j < instruction.accounts.len() {
151                if instruction.accounts[j].is_writable
152                    && address_eq(account_views[i].address(), account_views[j].address())
153                {
154                    return Err(ProgramError::AccountBorrowFailed);
155                }
156                j += 1;
157            }
158        }
159        i += 1;
160    }
161    Ok(())
162}
163
164#[inline]
165fn signer_matches_pda(program_id: &Address, account: &Address, signers_seeds: &[Signer]) -> bool {
166    let mut i = 0;
167    while i < signers_seeds.len() {
168        let signer = &signers_seeds[i];
169        // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
170        let seeds = unsafe { core::slice::from_raw_parts(signer.seeds, signer.len as usize) };
171
172        if seeds.len() <= crate::address::MAX_SEEDS {
173            let mut seed_refs: [&[u8]; crate::address::MAX_SEEDS] =
174                [&[]; crate::address::MAX_SEEDS];
175            let mut j = 0;
176            while j < seeds.len() {
177                // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
178                seed_refs[j] =
179                    unsafe { core::slice::from_raw_parts(seeds[j].seed, seeds[j].len as usize) };
180                j += 1;
181            }
182
183            if let Ok(derived) =
184                crate::compat::create_program_address(&seed_refs[..seeds.len()], program_id)
185            {
186                if address_eq(&derived, account) {
187                    return true;
188                }
189            }
190        }
191
192        i += 1;
193    }
194
195    false
196}
197
198/// Validate CPI account views match the instruction's expectations.
199#[inline]
200fn validate_cpi_accounts(
201    instruction: &InstructionView,
202    account_views: &[&AccountView],
203    signers_seeds: &[Signer],
204) -> ProgramResult {
205    if account_views.len() < instruction.accounts.len() {
206        return Err(ProgramError::NotEnoughAccountKeys);
207    }
208
209    let mut i = 0;
210    while i < instruction.accounts.len() {
211        let expected = &instruction.accounts[i];
212        let actual = account_views[i];
213
214        if !address_eq(actual.address(), expected.address) {
215            return Err(ProgramError::InvalidAccountData);
216        }
217
218        if expected.is_signer
219            && !actual.is_signer()
220            && !signer_matches_pda(instruction.program_id, actual.address(), signers_seeds)
221        {
222            return Err(ProgramError::MissingRequiredSignature);
223        }
224
225        if expected.is_writable && !actual.is_writable() {
226            return Err(ProgramError::Immutable);
227        }
228
229        if expected.is_writable {
230            actual.check_borrow_mut()?;
231        } else {
232            actual.check_borrow()?;
233        }
234
235        i += 1;
236    }
237
238    validate_no_duplicate_writable(instruction, account_views)?;
239
240    Ok(())
241}
242
243// ── Checked invoke ───────────────────────────────────────────────────
244
245/// Invoke a CPI with full validation.
246#[cfg(feature = "hopper-native-backend")]
247#[inline]
248pub fn invoke<const ACCOUNTS: usize>(
249    instruction: &InstructionView,
250    account_views: &[&AccountView; ACCOUNTS],
251) -> ProgramResult {
252    invoke_signed::<ACCOUNTS>(instruction, account_views, &[])
253}
254
255/// Invoke a signed CPI with full validation.
256#[cfg(feature = "hopper-native-backend")]
257#[inline]
258pub fn invoke_signed<const ACCOUNTS: usize>(
259    instruction: &InstructionView,
260    account_views: &[&AccountView; ACCOUNTS],
261    signers_seeds: &[Signer],
262) -> ProgramResult {
263    validate_cpi_accounts(instruction, &account_views[..], signers_seeds)?;
264
265    let mut cpi_accounts: [MaybeUninit<CpiAccount>; ACCOUNTS] =
266        // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
267        unsafe { MaybeUninit::uninit().assume_init() };
268
269    let mut i = 0;
270    while i < ACCOUNTS {
271        cpi_accounts[i] = MaybeUninit::new(CpiAccount::from(account_views[i]));
272        i += 1;
273    }
274
275    // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
276    let accounts: &[CpiAccount; ACCOUNTS] =
277        unsafe { &*(cpi_accounts.as_ptr() as *const [CpiAccount; ACCOUNTS]) };
278
279    // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
280    unsafe {
281        if signers_seeds.is_empty() {
282            invoke_unchecked(instruction, accounts.as_slice())
283        } else {
284            invoke_signed_unchecked(instruction, accounts.as_slice(), signers_seeds)
285        }
286    }
287}
288
289/// Invoke with a dynamic number of accounts (bounded by const generic).
290#[cfg(feature = "hopper-native-backend")]
291#[inline]
292pub fn invoke_with_bounds<const MAX_ACCOUNTS: usize>(
293    instruction: &InstructionView,
294    account_views: &[&AccountView],
295) -> ProgramResult {
296    invoke_signed_with_bounds::<MAX_ACCOUNTS>(instruction, account_views, &[])
297}
298
299/// Signed invoke with a dynamic number of accounts (bounded by const generic).
300#[cfg(feature = "hopper-native-backend")]
301#[inline]
302pub fn invoke_signed_with_bounds<const MAX_ACCOUNTS: usize>(
303    instruction: &InstructionView,
304    account_views: &[&AccountView],
305    signers_seeds: &[Signer],
306) -> ProgramResult {
307    if account_views.len() > MAX_ACCOUNTS {
308        return Err(ProgramError::InvalidArgument);
309    }
310
311    validate_cpi_accounts(instruction, account_views, signers_seeds)?;
312
313    let mut cpi_accounts: [MaybeUninit<CpiAccount>; MAX_ACCOUNTS] =
314        // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
315        unsafe { MaybeUninit::uninit().assume_init() };
316
317    let count = account_views.len();
318    let mut i = 0;
319    while i < count {
320        cpi_accounts[i] = MaybeUninit::new(CpiAccount::from(account_views[i]));
321        i += 1;
322    }
323
324    // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
325    let accounts =
326        unsafe { core::slice::from_raw_parts(cpi_accounts.as_ptr() as *const CpiAccount, count) };
327
328    // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
329    unsafe {
330        if signers_seeds.is_empty() {
331            invoke_unchecked(instruction, accounts)
332        } else {
333            invoke_signed_unchecked(instruction, accounts, signers_seeds)
334        }
335    }
336}
337
338// ══════════════════════════════════════════════════════════════════════
339//  Compatibility backends CPI
340// ══════════════════════════════════════════════════════════════════════
341
342/// Invoke a CPI through the active compatibility backend.
343#[cfg(any(
344    feature = "legacy-pinocchio-compat",
345    feature = "solana-program-backend"
346))]
347#[inline]
348pub fn invoke<const ACCOUNTS: usize>(
349    instruction: &InstructionView,
350    account_views: &[&AccountView; ACCOUNTS],
351) -> ProgramResult {
352    invoke_signed::<ACCOUNTS>(instruction, account_views, &[])
353}
354
355/// Invoke a signed CPI through the active compatibility backend.
356#[cfg(any(
357    feature = "legacy-pinocchio-compat",
358    feature = "solana-program-backend"
359))]
360#[inline]
361pub fn invoke_signed<const ACCOUNTS: usize>(
362    instruction: &InstructionView,
363    account_views: &[&AccountView; ACCOUNTS],
364    signers_seeds: &[Signer],
365) -> ProgramResult {
366    validate_cpi_accounts(instruction, &account_views[..], signers_seeds)?;
367    crate::compat::invoke_signed(instruction, account_views, signers_seeds)
368}
369
370// ── Return data ──────────────────────────────────────────────────────
371
372/// Set return data for the current instruction.
373#[inline(always)]
374pub fn set_return_data(data: &[u8]) {
375    crate::compat::set_return_data(data)
376}
377
378#[cfg(all(test, feature = "hopper-native-backend"))]
379mod tests {
380    use super::*;
381
382    use crate::InstructionAccount;
383    use hopper_native::{
384        AccountView as NativeAccountView, Address as NativeAddress, RuntimeAccount, NOT_BORROWED,
385    };
386
387    fn make_account(address: [u8; 32]) -> (std::vec::Vec<u8>, AccountView) {
388        let mut backing = std::vec![0u8; RuntimeAccount::SIZE + 16];
389        let raw = backing.as_mut_ptr() as *mut RuntimeAccount;
390        // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
391        unsafe {
392            raw.write(RuntimeAccount {
393                borrow_state: NOT_BORROWED,
394                is_signer: 0,
395                is_writable: 1,
396                executable: 0,
397                resize_delta: 0,
398                address: NativeAddress::new_from_array(address),
399                owner: NativeAddress::new_from_array([9; 32]),
400                lamports: 1,
401                data_len: 16,
402            });
403        }
404        // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
405        let backend = unsafe { NativeAccountView::new_unchecked(raw) };
406        (backing, AccountView::from_backend(backend))
407    }
408
409    #[test]
410    fn duplicate_writable_accounts_are_rejected_before_cpi() {
411        let (_first_backing, first) = make_account([3; 32]);
412        let (_second_backing, second) = make_account([3; 32]);
413
414        let instruction_accounts = [
415            InstructionAccount::writable(first.address()),
416            InstructionAccount::writable(second.address()),
417        ];
418        let program_id = Address::new_from_array([7; 32]);
419        let instruction = InstructionView {
420            program_id: &program_id,
421            data: &[0u8],
422            accounts: &instruction_accounts,
423        };
424
425        let err = validate_no_duplicate_writable(&instruction, &[&first, &second]).unwrap_err();
426        assert_eq!(err, ProgramError::AccountBorrowFailed);
427    }
428}