hopper_native/cpi.rs
1//! Cross-program invocation via `sol_invoke_signed_c`.
2//!
3//! Provides both checked (borrow-validating) and unchecked invoke paths.
4
5use crate::account_view::AccountView;
6use crate::address::address_eq;
7use crate::error::ProgramError;
8use crate::instruction::{CpiAccount, InstructionView, Signer};
9use crate::ProgramResult;
10use core::mem::MaybeUninit;
11
12/// Maximum number of accounts in a static CPI call.
13pub const MAX_STATIC_CPI_ACCOUNTS: usize = 64;
14
15/// Maximum number of accounts in any CPI call.
16pub const MAX_CPI_ACCOUNTS: usize = 128;
17
18/// Maximum return data size (1 KiB).
19pub const MAX_RETURN_DATA: usize = 1024;
20
21// ── Unchecked invoke ─────────────────────────────────────────────────
22
23/// Invoke a CPI without borrow validation (lowest CU cost).
24///
25/// This is Tier C of the CPI surface. The checked variant
26/// ([`invoke`](crate::cpi::invoke)) enforces the full contract below
27/// before calling this function; prefer that unless you have measured
28/// a reason to bypass the validation pass.
29///
30/// # Safety
31///
32/// The caller must uphold every one of the following invariants. A
33/// violation of any of them is undefined behaviour, because the Solana
34/// runtime's `sol_invoke_signed_c` syscall assumes they already hold.
35///
36/// 1. **No aliasing borrows.** No `&` or `&mut` references into any
37/// account data region referenced by `accounts` may be live for
38/// the duration of the call. The CPI can (and will) mutate those
39/// regions via the callee, and Rust's aliasing rules do not permit
40/// the caller to hold outstanding references to memory that is
41/// about to change under it.
42/// 2. **Account list consistency.** Every `CpiAccount` in `accounts`
43/// must correspond to a real account previously passed to the
44/// program's entrypoint (same address, same `is_signer` /
45/// `is_writable` flags the runtime already knows about). The
46/// runtime will not re-derive account permissions; invalid flags
47/// propagate into the callee.
48/// 3. **Writability coverage.** Every account that the `instruction`
49/// marks writable must have `is_writable = true` in `accounts`,
50/// and every account the instruction marks as signer must have
51/// `is_signer = true`. Mismatches are rejected by the runtime but
52/// the rejection path is not cheap and the caller is expected to
53/// get this right.
54/// 4. **No shared mutable slices across CPIs.** If the same account
55/// appears more than once in `accounts` (duplicate accounts), the
56/// caller is responsible for ensuring that any subsequent borrow
57/// of that account's data respects the CPI's writes.
58/// 5. **Valid instruction encoding.** `instruction.program_id`,
59/// `instruction.accounts`, and `instruction.data` must all point
60/// to valid memory for the duration of the call. An
61/// `InstructionView` built from a local `InstructionAccount` slice
62/// is fine; one built from a dropped stack slot is not.
63///
64/// The runtime does not enforce any of these from the caller side —
65/// it assumes a well-formed CPI. That is the cost of the Tier C path.
66#[inline]
67pub unsafe fn invoke_unchecked(
68 instruction: &InstructionView,
69 accounts: &[CpiAccount],
70) -> ProgramResult {
71 #[cfg(target_os = "solana")]
72 {
73 // Build the C-ABI instruction struct on the stack.
74 // The Solana runtime expects:
75 // struct { program_id: *const u8, accounts: *const SolAccountMeta, acct_len: u64, data: *const u8, data_len: u64 }
76 // But sol_invoke_signed_c takes the instruction as raw bytes.
77 let result = unsafe {
78 crate::syscalls::sol_invoke_signed_c(
79 instruction as *const _ as *const u8,
80 accounts.as_ptr() as *const u8,
81 accounts.len() as u64,
82 core::ptr::null(),
83 0,
84 )
85 };
86 if result == 0 {
87 Ok(())
88 } else {
89 Err(ProgramError::from(result))
90 }
91 }
92 #[cfg(not(target_os = "solana"))]
93 {
94 let _ = (instruction, accounts);
95 Ok(())
96 }
97}
98
99/// Invoke a signed CPI without borrow validation.
100///
101/// Same as [`invoke_unchecked`] but also passes PDA signer seeds so
102/// the callee can accept writes that would otherwise require a
103/// signature.
104///
105/// # Safety
106///
107/// All of [`invoke_unchecked`]'s invariants apply, plus two more for
108/// the signer-seeds path:
109///
110/// 6. **Signer seeds must derive the claimed PDA.** For every
111/// `Signer` in `signers_seeds`, the derived address
112/// (sha256 of `seeds || program_id || PDA_MARKER`) must equal an
113/// address in `accounts` that is marked as signer. A mismatch will
114/// cause the runtime to reject the CPI, but the caller is expected
115/// to have verified this before reaching the Tier C path.
116/// 7. **Seed lifetime.** `signers_seeds` (and every `&[u8]` it points
117/// at) must outlive the call. Temporary seed slices built inside a
118/// function frame are fine; seeds referencing dropped storage are
119/// not.
120///
121/// For the happy path the caller should hold a `CpiValidator` or
122/// equivalent proof-object constructed by the checked path and let
123/// that drive both this function's inputs and the aliasing discipline
124/// required above.
125#[inline]
126pub unsafe fn invoke_signed_unchecked(
127 instruction: &InstructionView,
128 accounts: &[CpiAccount],
129 signers_seeds: &[Signer],
130) -> ProgramResult {
131 #[cfg(target_os = "solana")]
132 {
133 let result = unsafe {
134 crate::syscalls::sol_invoke_signed_c(
135 instruction as *const _ as *const u8,
136 accounts.as_ptr() as *const u8,
137 accounts.len() as u64,
138 signers_seeds.as_ptr() as *const u8,
139 signers_seeds.len() as u64,
140 )
141 };
142 if result == 0 {
143 Ok(())
144 } else {
145 Err(ProgramError::from(result))
146 }
147 }
148 #[cfg(not(target_os = "solana"))]
149 {
150 let _ = (instruction, accounts, signers_seeds);
151 Ok(())
152 }
153}
154
155// ── CPI validation ───────────────────────────────────────────────────
156
157/// Validate that CPI account views match the instruction's expectations.
158///
159/// Checks:
160/// - Sufficient number of accounts.
161/// - Address identity (order-dependent matching).
162/// - Signer requirements.
163/// - Writable requirements.
164/// - Borrow compatibility (writable accounts must not be already borrowed,
165/// read-only accounts must not be exclusively borrowed).
166#[inline]
167fn validate_cpi_accounts(
168 instruction: &InstructionView,
169 account_views: &[&AccountView],
170) -> ProgramResult {
171 if account_views.len() < instruction.accounts.len() {
172 return Err(ProgramError::NotEnoughAccountKeys);
173 }
174
175 let mut i = 0;
176 while i < instruction.accounts.len() {
177 let expected = &instruction.accounts[i];
178 let actual = account_views[i];
179
180 if !address_eq(actual.address(), expected.address) {
181 return Err(ProgramError::InvalidAccountData);
182 }
183
184 if expected.is_signer && !actual.is_signer() {
185 return Err(ProgramError::MissingRequiredSignature);
186 }
187
188 if expected.is_writable && !actual.is_writable() {
189 return Err(ProgramError::Immutable);
190 }
191
192 // Borrow compatibility: writable needs exclusive access,
193 // read-only needs at least shared access.
194 if expected.is_writable {
195 actual.check_borrow_mut()?;
196 } else {
197 actual.check_borrow()?;
198 }
199
200 i += 1;
201 }
202
203 Ok(())
204}
205
206// ── Checked invoke ───────────────────────────────────────────────────
207
208/// Invoke a CPI with full validation.
209///
210/// Validates account count, address identity, signer/writable requirements,
211/// and borrow compatibility before calling the runtime.
212#[inline]
213pub fn invoke<const ACCOUNTS: usize>(
214 instruction: &InstructionView,
215 account_views: &[&AccountView; ACCOUNTS],
216) -> ProgramResult {
217 invoke_signed::<ACCOUNTS>(instruction, account_views, &[])
218}
219
220/// Invoke a signed CPI with full validation.
221///
222/// Validates account count, address identity, signer/writable requirements,
223/// and borrow compatibility before calling the runtime.
224#[inline]
225pub fn invoke_signed<const ACCOUNTS: usize>(
226 instruction: &InstructionView,
227 account_views: &[&AccountView; ACCOUNTS],
228 signers_seeds: &[Signer],
229) -> ProgramResult {
230 validate_cpi_accounts(instruction, &account_views[..])?;
231
232 // Build CpiAccount array on the stack.
233 let mut cpi_accounts: [MaybeUninit<CpiAccount>; ACCOUNTS] =
234 unsafe { MaybeUninit::uninit().assume_init() };
235
236 let mut i = 0;
237 while i < ACCOUNTS {
238 cpi_accounts[i] = MaybeUninit::new(CpiAccount::from(account_views[i]));
239 i += 1;
240 }
241
242 // SAFETY: All ACCOUNTS slots are now initialized.
243 let accounts: &[CpiAccount; ACCOUNTS] =
244 unsafe { &*(cpi_accounts.as_ptr() as *const [CpiAccount; ACCOUNTS]) };
245
246 unsafe {
247 if signers_seeds.is_empty() {
248 invoke_unchecked(instruction, accounts.as_slice())
249 } else {
250 invoke_signed_unchecked(instruction, accounts.as_slice(), signers_seeds)
251 }
252 }
253}
254
255/// Invoke with a dynamic number of accounts (bounded by const generic).
256#[inline]
257pub fn invoke_with_bounds<const MAX_ACCOUNTS: usize>(
258 instruction: &InstructionView,
259 account_views: &[&AccountView],
260) -> ProgramResult {
261 invoke_signed_with_bounds::<MAX_ACCOUNTS>(instruction, account_views, &[])
262}
263
264/// Signed invoke with a dynamic number of accounts (bounded by const generic).
265///
266/// Returns `Err(InvalidArgument)` if `account_views.len() > MAX_ACCOUNTS`.
267/// Validates accounts before invoking.
268#[inline]
269pub fn invoke_signed_with_bounds<const MAX_ACCOUNTS: usize>(
270 instruction: &InstructionView,
271 account_views: &[&AccountView],
272 signers_seeds: &[Signer],
273) -> ProgramResult {
274 if account_views.len() > MAX_ACCOUNTS {
275 return Err(ProgramError::InvalidArgument);
276 }
277
278 validate_cpi_accounts(instruction, account_views)?;
279
280 let mut cpi_accounts: [MaybeUninit<CpiAccount>; MAX_ACCOUNTS] =
281 unsafe { MaybeUninit::uninit().assume_init() };
282
283 let count = account_views.len();
284 let mut i = 0;
285 while i < count {
286 cpi_accounts[i] = MaybeUninit::new(CpiAccount::from(account_views[i]));
287 i += 1;
288 }
289
290 // SAFETY: first `count` slots are initialized.
291 let accounts =
292 unsafe { core::slice::from_raw_parts(cpi_accounts.as_ptr() as *const CpiAccount, count) };
293
294 unsafe {
295 if signers_seeds.is_empty() {
296 invoke_unchecked(instruction, accounts)
297 } else {
298 invoke_signed_unchecked(instruction, accounts, signers_seeds)
299 }
300 }
301}
302
303// ── Return data ──────────────────────────────────────────────────────
304
305/// Set return data for the current instruction.
306#[inline(always)]
307pub fn set_return_data(data: &[u8]) {
308 #[cfg(target_os = "solana")]
309 unsafe {
310 crate::syscalls::sol_set_return_data(data.as_ptr(), data.len() as u64);
311 }
312 #[cfg(not(target_os = "solana"))]
313 {
314 let _ = data;
315 }
316}