Skip to main content

hopper_core/cpi/
mod.rs

1//! Const-generic CPI builder -- stack-only, zero-allocation CPI calls.
2//!
3//! Both account count and data size are const generics, ensuring everything
4//! lives on the SBF stack (4096 bytes). No heap allocation ever.
5//!
6//! ## Design
7//!
8//! - `HopperCpi<A, D>` -- fully const-generic: accounts + data
9//! - `HopperCpiBuf<A, MAX>` -- const accounts, runtime data length
10//! - Uses `MaybeUninit` for zero-cost initialization
11//! - Direct `sol_invoke_signed_c` syscall on SBF
12//!
13//! ```ignore
14//! let cpi = HopperCpi::<3, 9>::new(token_program_id)
15//!     .account(source, true, false)   // writable, not signer
16//!     .account(dest, true, false)
17//!     .account(authority, false, true) // not writable, signer
18//!     .data(&[3, /* transfer discriminator + amount */]);
19//! cpi.invoke()?;
20//! ```
21
22use core::mem::MaybeUninit;
23use hopper_runtime::error::ProgramError;
24use hopper_runtime::ProgramResult;
25
26/// Stack-allocated CPI call with compile-time-known account count and data size.
27///
28/// Both `ACCTS` and `DATA` are const generics -- the compiler knows the
29/// exact buffer sizes at compile time, enabling optimal stack allocation
30/// and no runtime branching on sizes.
31pub struct HopperCpi<'a, const ACCTS: usize, const DATA: usize> {
32    /// The program to invoke.
33    #[allow(dead_code)]
34    program_id: &'a hopper_runtime::Address,
35    /// Account metadata: (pubkey, is_writable, is_signer).
36    account_keys: [&'a hopper_runtime::Address; ACCTS],
37    account_flags: [(bool, bool); ACCTS], // (is_writable, is_signer)
38    /// Source AccountViews for the CPI (needed by the runtime).
39    /// Uses MaybeUninit to avoid UB from null/zeroed references.
40    /// Slots 0..acct_cursor are initialized; the rest are uninit.
41    account_views: [MaybeUninit<&'a hopper_runtime::AccountView>; ACCTS],
42    /// Instruction data (fixed size, fully on stack).
43    data: [u8; DATA],
44    /// Number of accounts added so far.
45    acct_cursor: usize,
46}
47
48impl<'a, const ACCTS: usize, const DATA: usize> HopperCpi<'a, ACCTS, DATA> {
49    /// Begin building a CPI call to `program_id`.
50    #[inline(always)]
51    pub fn new(program_id: &'a hopper_runtime::Address) -> Self {
52        Self {
53            program_id,
54            account_keys: [program_id; ACCTS], // init value; overwritten by add_account()
55            account_flags: [(false, false); ACCTS],
56            // SAFETY: MaybeUninit<T> does not require initialization.
57            // Creating an array of MaybeUninit is always safe.
58            account_views: unsafe { MaybeUninit::uninit().assume_init() },
59            data: [0u8; DATA],
60            acct_cursor: 0,
61        }
62    }
63
64    /// Add an account to the CPI call.
65    ///
66    /// Must be called exactly `ACCTS` times before `invoke`.
67    #[inline(always)]
68    pub fn add_account(
69        mut self,
70        view: &'a hopper_runtime::AccountView,
71        is_writable: bool,
72        is_signer: bool,
73    ) -> Self {
74        let idx = self.acct_cursor;
75        debug_assert!(idx < ACCTS, "Too many accounts added to CPI");
76        self.account_keys[idx] = view.address();
77        self.account_flags[idx] = (is_writable, is_signer);
78        self.account_views[idx] = MaybeUninit::new(view);
79        self.acct_cursor += 1;
80        self
81    }
82
83    /// Set the instruction data. Must be exactly `DATA` bytes.
84    #[inline(always)]
85    pub fn set_data(mut self, src: &[u8; DATA]) -> Self {
86        self.data = *src;
87        self
88    }
89
90    /// Write instruction data from a slice (must be exactly DATA bytes).
91    #[inline(always)]
92    pub fn set_data_from_slice(mut self, src: &[u8]) -> Result<Self, ProgramError> {
93        if src.len() != DATA {
94            return Err(ProgramError::InvalidInstructionData);
95        }
96        self.data.copy_from_slice(src);
97        Ok(self)
98    }
99
100    /// Invoke the CPI without signer seeds.
101    #[inline]
102    pub fn invoke(&self) -> ProgramResult {
103        debug_assert_eq!(self.acct_cursor, ACCTS, "Not all accounts added to CPI");
104        self.invoke_signed(&[])
105    }
106
107    /// Invoke the CPI with PDA signer seeds.
108    #[inline]
109    pub fn invoke_signed(&self, seeds: &[&[&[u8]]]) -> ProgramResult {
110        #[cfg(target_os = "solana")]
111        {
112            use hopper_runtime::instruction::{InstructionAccount, InstructionView, Seed, Signer};
113
114            debug_assert_eq!(self.acct_cursor, ACCTS, "Not all accounts added to CPI");
115
116            // SAFETY: All ACCTS slots have been initialized via add_account
117            // (enforced by the debug_assert above). We transmute the
118            // MaybeUninit array to the initialized reference array.
119            let views: &[&hopper_runtime::AccountView; ACCTS] = unsafe {
120                &*(&self.account_views as *const [MaybeUninit<&hopper_runtime::AccountView>; ACCTS]
121                    as *const [&hopper_runtime::AccountView; ACCTS])
122            };
123
124            // Build InstructionAccount array on the stack
125            // 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.
126            let mut ix_accounts: [InstructionAccount; ACCTS] = unsafe { core::mem::zeroed() };
127            let mut i = 0;
128            while i < ACCTS {
129                ix_accounts[i] = InstructionAccount {
130                    address: self.account_keys[i],
131                    is_writable: self.account_flags[i].0,
132                    is_signer: self.account_flags[i].1,
133                };
134                i += 1;
135            }
136
137            let ix = InstructionView {
138                program_id: self.program_id,
139                accounts: &ix_accounts,
140                data: &self.data,
141            };
142
143            if seeds.is_empty() {
144                hopper_runtime::cpi::invoke(&ix, views)
145            } else {
146                // 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.
147                let mut signers_buf: [Signer; 4] = unsafe { core::mem::zeroed() };
148                let signer_count = seeds.len().min(4);
149                let mut seed_bufs: [[Seed; 16]; 4] = unsafe { core::mem::zeroed() };
150                let mut seed_lens = [0usize; 4];
151
152                let mut s = 0;
153                while s < signer_count {
154                    let signer_seeds = seeds[s];
155                    let num_seeds = signer_seeds.len().min(16);
156                    let mut sd = 0;
157                    while sd < num_seeds {
158                        seed_bufs[s][sd] = Seed::from(signer_seeds[sd]);
159                        sd += 1;
160                    }
161                    seed_lens[s] = num_seeds;
162                    s += 1;
163                }
164
165                let mut s = 0;
166                while s < signer_count {
167                    signers_buf[s] = Signer::from(&seed_bufs[s][..seed_lens[s]]);
168                    s += 1;
169                }
170
171                hopper_runtime::cpi::invoke_signed(&ix, views, &signers_buf[..signer_count])
172            }
173        }
174        #[cfg(not(target_os = "solana"))]
175        {
176            let _ = seeds;
177            Ok(())
178        }
179    }
180}
181
182/// Variable-data CPI builder -- const accounts, runtime data length.
183///
184/// For instructions where data size isn't known at compile time
185/// (e.g., Borsh-serialized arguments), but bounded by `MAX`.
186pub struct HopperCpiBuf<'a, const ACCTS: usize, const MAX: usize> {
187    #[allow(dead_code)]
188    program_id: &'a hopper_runtime::Address,
189    account_keys: [&'a hopper_runtime::Address; ACCTS],
190    account_flags: [(bool, bool); ACCTS],
191    account_views: [MaybeUninit<&'a hopper_runtime::AccountView>; ACCTS],
192    data: [u8; MAX],
193    data_len: usize,
194    acct_cursor: usize,
195}
196
197impl<'a, const ACCTS: usize, const MAX: usize> HopperCpiBuf<'a, ACCTS, MAX> {
198    /// Begin building a variable-data CPI call.
199    #[inline(always)]
200    pub fn new(program_id: &'a hopper_runtime::Address) -> Self {
201        Self {
202            program_id,
203            account_keys: [program_id; ACCTS],
204            account_flags: [(false, false); ACCTS],
205            // SAFETY: MaybeUninit<T> does not require initialization.
206            account_views: unsafe { MaybeUninit::uninit().assume_init() },
207            data: [0u8; MAX],
208            data_len: 0,
209            acct_cursor: 0,
210        }
211    }
212
213    /// Add an account.
214    #[inline(always)]
215    pub fn add_account(
216        mut self,
217        view: &'a hopper_runtime::AccountView,
218        is_writable: bool,
219        is_signer: bool,
220    ) -> Self {
221        let idx = self.acct_cursor;
222        debug_assert!(idx < ACCTS);
223        self.account_keys[idx] = view.address();
224        self.account_flags[idx] = (is_writable, is_signer);
225        self.account_views[idx] = MaybeUninit::new(view);
226        self.acct_cursor += 1;
227        self
228    }
229
230    /// Write data into the buffer. Returns error if exceeds MAX.
231    #[inline]
232    pub fn write_data(mut self, src: &[u8]) -> Result<Self, ProgramError> {
233        if src.len() > MAX {
234            return Err(ProgramError::InvalidInstructionData);
235        }
236        self.data[..src.len()].copy_from_slice(src);
237        self.data_len = src.len();
238        Ok(self)
239    }
240
241    /// Invoke without signer seeds.
242    #[inline]
243    pub fn invoke(&self) -> ProgramResult {
244        self.invoke_signed(&[])
245    }
246
247    /// Invoke with PDA signer seeds.
248    #[inline]
249    pub fn invoke_signed(&self, seeds: &[&[&[u8]]]) -> ProgramResult {
250        #[cfg(target_os = "solana")]
251        {
252            use hopper_runtime::instruction::{InstructionAccount, InstructionView, Seed, Signer};
253
254            debug_assert_eq!(self.acct_cursor, ACCTS, "Not all accounts added to CPI");
255
256            // SAFETY: All ACCTS slots initialized via add_account.
257            let views: &[&hopper_runtime::AccountView; ACCTS] = unsafe {
258                &*(&self.account_views as *const [MaybeUninit<&hopper_runtime::AccountView>; ACCTS]
259                    as *const [&hopper_runtime::AccountView; ACCTS])
260            };
261
262            // 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.
263            let mut ix_accounts: [InstructionAccount; ACCTS] = unsafe { core::mem::zeroed() };
264            let mut i = 0;
265            while i < ACCTS {
266                ix_accounts[i] = InstructionAccount {
267                    address: self.account_keys[i],
268                    is_writable: self.account_flags[i].0,
269                    is_signer: self.account_flags[i].1,
270                };
271                i += 1;
272            }
273
274            let ix = InstructionView {
275                program_id: self.program_id,
276                accounts: &ix_accounts,
277                data: &self.data[..self.data_len],
278            };
279
280            if seeds.is_empty() {
281                hopper_runtime::cpi::invoke(&ix, views)
282            } else {
283                // 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.
284                let mut signers_buf: [Signer; 4] = unsafe { core::mem::zeroed() };
285                let signer_count = seeds.len().min(4);
286                let mut seed_bufs: [[Seed; 16]; 4] = unsafe { core::mem::zeroed() };
287                let mut seed_lens = [0usize; 4];
288
289                let mut s = 0;
290                while s < signer_count {
291                    let signer_seeds = seeds[s];
292                    let num_seeds = signer_seeds.len().min(16);
293                    let mut sd = 0;
294                    while sd < num_seeds {
295                        seed_bufs[s][sd] = Seed::from(signer_seeds[sd]);
296                        sd += 1;
297                    }
298                    seed_lens[s] = num_seeds;
299                    s += 1;
300                }
301
302                let mut s = 0;
303                while s < signer_count {
304                    signers_buf[s] = Signer::from(&seed_bufs[s][..seed_lens[s]]);
305                    s += 1;
306                }
307
308                hopper_runtime::cpi::invoke_signed(&ix, views, &signers_buf[..signer_count])
309            }
310        }
311        #[cfg(not(target_os = "solana"))]
312        {
313            let _ = seeds;
314            Ok(())
315        }
316    }
317}