Skip to main content

hopper_runtime/
dyn_cpi.rs

1//! Stack-allocated variable-length CPI builder.
2//!
3//! The `hopper_runtime::cpi::invoke_signed::<N>` family is const-generic over
4//! the account count and serves CPI shapes known at compile time. [`DynCpi`]
5//! covers shapes whose account count or data length is determined while the
6//! instruction is being built, including:
7//!
8//! - Aggregators that invoke the same program with a runtime-
9//!   decided account count (fanout fee routers, batch settlement
10//!   cranks).
11//! - Forwarders that pass through the caller's remaining accounts
12//!   after splicing in a known prefix.
13//! - Generic instruction builders that construct the data buffer
14//!   byte-by-byte from user input (priority-fee overrides, optional
15//!   bump seeds) and do not know the final length until build time.
16//!
17//! The builder has compile-time capacities, `MAX_ACCTS` and `MAX_DATA`, and
18//! stores both buffers inline. [`DynCpi::push_account`] and
19//! [`DynCpi::push_data`] return `ProgramError::InvalidArgument` before a write
20//! that would exceed those capacities. Account insertion preserves the ordered
21//! meta list while maintaining a pubkey-deduplicated account-info projection.
22//! [`DynCpi::invoke_signed`] accepts Hopper's typed [`Signer`] values for PDA
23//! signer seeds.
24
25use core::mem::MaybeUninit;
26
27use crate::instruction::{InstructionAccount, InstructionView, Signer};
28use crate::{
29    account::AccountView,
30    address::{address_eq, Address},
31    error::ProgramError,
32    result::ProgramResult,
33};
34
35/// Variable-length CPI builder with compile-time stack capacity.
36///
37/// `MAX_ACCTS` is the upper bound on the number of `AccountMeta`
38/// entries. `MAX_DATA` is the upper bound on the instruction data
39/// byte count. Exceeding either returns an error; nothing panics.
40///
41/// Use when the CPI shape is not known at compile time. For
42/// statically-shaped CPIs, prefer `cpi::invoke_signed::<N>` which
43/// avoids the two bounds entirely.
44pub struct DynCpi<'a, const MAX_ACCTS: usize, const MAX_DATA: usize> {
45    program_id: &'a Address,
46    // Per-push (meta) storage: one slot per `push_account`, order and
47    // duplicates preserved; this is the ordered meta list the callee sees.
48    accounts: [MaybeUninit<&'a AccountView<'a>>; MAX_ACCTS],
49    writable: [bool; MAX_ACCTS],
50    signer: [bool; MAX_ACCTS],
51    account_count: usize,
52    // SIMD-0339 dedup projection (by pubkey): one entry per *unique*
53    // account. `info_first[k]` is the push index of the first occurrence of
54    // unique account `k` (used to recover its `AccountView`); the writable /
55    // signer flags are the OR across every occurrence, because an account
56    // that is writable (or a signer) in *any* meta must be passed to the
57    // syscall as writable (or signer). `u16` suffices: MAX_ACCTS never
58    // exceeds the 255 SIMD-0339 account ceiling in practice.
59    info_first: [u16; MAX_ACCTS],
60    info_writable: [bool; MAX_ACCTS],
61    info_signer: [bool; MAX_ACCTS],
62    info_count: usize,
63    data: [MaybeUninit<u8>; MAX_DATA],
64    data_len: usize,
65}
66
67impl<'a, const MAX_ACCTS: usize, const MAX_DATA: usize> DynCpi<'a, MAX_ACCTS, MAX_DATA> {
68    /// Start a new dynamic CPI against the given program.
69    #[inline]
70    pub fn new(program_id: &'a Address) -> Self {
71        Self {
72            program_id,
73            accounts: [const { MaybeUninit::uninit() }; MAX_ACCTS],
74            writable: [false; MAX_ACCTS],
75            signer: [false; MAX_ACCTS],
76            account_count: 0,
77            info_first: [0u16; MAX_ACCTS],
78            info_writable: [false; MAX_ACCTS],
79            info_signer: [false; MAX_ACCTS],
80            info_count: 0,
81            data: [const { MaybeUninit::uninit() }; MAX_DATA],
82            data_len: 0,
83        }
84    }
85
86    /// Append one account meta. The `writable` and `signer` flags
87    /// are carried through to the emitted CPI instruction.
88    ///
89    /// Each call appends one *meta*. Order and duplicates are preserved because
90    /// the callee reads accounts positionally. In parallel, the builder folds
91    /// the account into a deduplicated *info* set keyed by pubkey: pushing
92    /// an address already present does **not** allocate a second info slot,
93    /// it reuses the existing one and OR-merges the writable/signer flags.
94    /// Repeated metas for the same address therefore share one account-info at
95    /// submit time. See [`Self::info_count`].
96    ///
97    /// Returns `Err(ProgramError::InvalidArgument)` when the builder
98    /// is already at `MAX_ACCTS` capacity. Users pick the capacity
99    /// at the type parameter; bumping it is a type-system edit, not
100    /// a runtime error.
101    #[inline]
102    pub fn push_account(
103        &mut self,
104        account: &'a AccountView<'a>,
105        writable: bool,
106        signer: bool,
107    ) -> ProgramResult {
108        if self.account_count >= MAX_ACCTS {
109            return Err(ProgramError::InvalidArgument);
110        }
111        let idx = self.account_count;
112        self.accounts[idx] = MaybeUninit::new(account);
113        self.writable[idx] = writable;
114        self.signer[idx] = signer;
115
116        // Fold into the deduped info projection (match by pubkey).
117        let mut k = 0;
118        let mut merged = false;
119        while k < self.info_count {
120            // SAFETY: `info_first[k] < account_count`, so that `accounts`
121            // slot was initialized by an earlier `push_account`.
122            let existing = unsafe { self.accounts[self.info_first[k] as usize].assume_init() };
123            if address_eq(existing.address(), account.address()) {
124                self.info_writable[k] |= writable;
125                self.info_signer[k] |= signer;
126                merged = true;
127                break;
128            }
129            k += 1;
130        }
131        if !merged {
132            let slot = self.info_count;
133            self.info_first[slot] = idx as u16;
134            self.info_writable[slot] = writable;
135            self.info_signer[slot] = signer;
136            self.info_count = self.info_count.wrapping_add(1);
137        }
138
139        self.account_count = self.account_count.wrapping_add(1);
140        Ok(())
141    }
142
143    /// Append the given bytes to the instruction data buffer.
144    ///
145    /// Returns `Err(ProgramError::InvalidArgument)` when the buffer
146    /// does not have room for the full slice. The append is
147    /// all-or-nothing; a partial write does not happen.
148    #[inline]
149    pub fn push_data(&mut self, bytes: &[u8]) -> ProgramResult {
150        if self.data_len.saturating_add(bytes.len()) > MAX_DATA {
151            return Err(ProgramError::InvalidArgument);
152        }
153        let dst = &mut self.data[self.data_len..self.data_len + bytes.len()];
154        for (i, b) in bytes.iter().enumerate() {
155            dst[i] = MaybeUninit::new(*b);
156        }
157        self.data_len = self.data_len.wrapping_add(bytes.len());
158        Ok(())
159    }
160
161    /// Append one byte. Sugar for programs that build instruction
162    /// data one discriminator + one argument at a time.
163    #[inline]
164    pub fn push_byte(&mut self, byte: u8) -> ProgramResult {
165        self.push_data(core::slice::from_ref(&byte))
166    }
167
168    /// Append the little-endian encoding of a `u64`. Covers the
169    /// most common arg shape (lamports, timestamps, flags).
170    #[inline]
171    pub fn push_u64_le(&mut self, value: u64) -> ProgramResult {
172        self.push_data(&value.to_le_bytes())
173    }
174
175    /// Append a 32-byte pubkey.
176    #[inline]
177    pub fn push_pubkey(&mut self, address: &Address) -> ProgramResult {
178        self.push_data(address.as_array())
179    }
180
181    /// Current account (meta) count, one per `push_account`, including
182    /// duplicates. This is the length of the ordered meta list the callee
183    /// sees, *not* the deduped info count (see [`Self::info_count`]).
184    #[inline(always)]
185    pub const fn account_count(&self) -> usize {
186        self.account_count
187    }
188
189    /// Number of *unique* account-infos after SIMD-0339 pubkey dedup.
190    ///
191    /// This is `<= account_count()`, and is exactly the count of
192    /// account-infos handed to the syscall at submit time. Pushing the same
193    /// address twice leaves this unchanged.
194    #[inline(always)]
195    pub const fn info_count(&self) -> usize {
196        self.info_count
197    }
198
199    /// The `k`-th deduplicated account-info: its view plus the OR-merged
200    /// `(writable, signer)` privilege across every occurrence. Returns
201    /// `None` for `k >= info_count()`.
202    ///
203    /// Infos are in first-occurrence (push) order, so `dedup_info(0)` is the
204    /// account whose first push came first.
205    #[inline]
206    pub fn dedup_info(&self, k: usize) -> Option<(&'a AccountView<'a>, bool, bool)> {
207        if k >= self.info_count {
208            return None;
209        }
210        // SAFETY: `k < info_count`, so `info_first[k] < account_count` names
211        // an initialized `accounts` slot.
212        let view = unsafe { self.accounts[self.info_first[k] as usize].assume_init() };
213        Some((view, self.info_writable[k], self.info_signer[k]))
214    }
215
216    /// Program id this dynamic CPI targets.
217    #[inline(always)]
218    pub const fn program_id(&self) -> &Address {
219        self.program_id
220    }
221
222    /// Current data length.
223    #[inline(always)]
224    pub const fn data_len(&self) -> usize {
225        self.data_len
226    }
227
228    /// Borrow the finalized data buffer. Useful for tests that
229    /// want to inspect the wire bytes without actually submitting
230    /// the CPI.
231    #[inline]
232    pub fn data(&self) -> &[u8] {
233        // 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.
234        unsafe { core::slice::from_raw_parts(self.data.as_ptr() as *const u8, self.data_len) }
235    }
236
237    /// The pushed account views, in push order.
238    #[inline]
239    pub fn account_views(&self) -> &[&'a AccountView<'a>] {
240        // SAFETY: slots `0..account_count` were initialized by
241        // `push_account`, and we expose exactly that prefix.
242        unsafe {
243            core::slice::from_raw_parts(
244                self.accounts.as_ptr() as *const &'a AccountView<'a>,
245                self.account_count,
246            )
247        }
248    }
249
250    /// Submit the built CPI (no PDA signers).
251    ///
252    /// Equivalent to [`invoke_signed`](Self::invoke_signed) with an
253    /// empty signer set.
254    #[inline]
255    pub fn invoke(&self) -> ProgramResult {
256        self.invoke_signed(&[])
257    }
258
259    /// Submit the built CPI with the given PDA signer seeds.
260    ///
261    /// Assembles the pushed `(account, writable, signer)` metas, preserving the
262    /// full ordered list and duplicates, and the data buffer into an
263    /// [`InstructionView`], then routes it through the **validated,
264    /// dedup-aware** path
265    /// ([`cpi::invoke_signed_deduped`](crate::cpi::invoke_signed_deduped)).
266    /// The metas define what the callee sees positionally; the account-info
267    /// list handed to the syscall is the pubkey-deduplicated set (one info
268    /// per unique account, flags OR-merged). Address/flag agreement, PDA-signer
269    /// resolution, live-borrow checks, and duplicate-writable rejection all
270    /// run over the full meta list before the syscall, so deduplication does not
271    /// skip those validation steps.
272    #[inline]
273    pub fn invoke_signed(&self, signers: &[Signer<'_, '_>]) -> ProgramResult {
274        let count = self.account_count;
275        let views = self.account_views();
276
277        // Full ordered meta list, one meta per push, duplicates kept.
278        let mut metas: [MaybeUninit<InstructionAccount<'a>>; MAX_ACCTS] =
279            [const { MaybeUninit::uninit() }; MAX_ACCTS];
280        let mut i = 0;
281        while i < count {
282            metas[i] = MaybeUninit::new(InstructionAccount::new(
283                views[i].address(),
284                self.writable[i],
285                self.signer[i],
286            ));
287            i += 1;
288        }
289        // SAFETY: slots `0..count` were initialized by the loop above.
290        let metas_slice = unsafe {
291            core::slice::from_raw_parts(metas.as_ptr() as *const InstructionAccount<'a>, count)
292        };
293        let instruction = InstructionView {
294            program_id: self.program_id,
295            data: self.data(),
296            accounts: metas_slice,
297        };
298
299        // Deduplicated account-info list, one AccountView per unique
300        // address, in first-occurrence order.
301        let mut infos: [MaybeUninit<&'a AccountView<'a>>; MAX_ACCTS] =
302            [const { MaybeUninit::uninit() }; MAX_ACCTS];
303        let mut k = 0;
304        while k < self.info_count {
305            // SAFETY: `info_first[k] < account_count` names an initialized
306            // `accounts` slot.
307            let view = unsafe { self.accounts[self.info_first[k] as usize].assume_init() };
308            infos[k] = MaybeUninit::new(view);
309            k += 1;
310        }
311        // SAFETY: slots `0..info_count` were initialized by the loop above.
312        let infos_slice = unsafe {
313            core::slice::from_raw_parts(
314                infos.as_ptr() as *const &'a AccountView<'a>,
315                self.info_count,
316            )
317        };
318
319        crate::cpi::invoke_signed_deduped::<MAX_ACCTS>(&instruction, infos_slice, signers)
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn byte_push_walks_the_buffer() {
329        let program = Address::from([0u8; 32]);
330        let mut cpi: DynCpi<4, 32> = DynCpi::new(&program);
331        cpi.push_byte(0xA1).unwrap();
332        cpi.push_u64_le(0xCAFEBABE_u64).unwrap();
333        assert_eq!(cpi.data_len(), 1 + 8);
334        assert_eq!(cpi.data()[0], 0xA1);
335        assert_eq!(&cpi.data()[1..9], &0xCAFEBABE_u64.to_le_bytes());
336    }
337
338    #[test]
339    fn data_overflow_rejects() {
340        let program = Address::from([0u8; 32]);
341        let mut cpi: DynCpi<0, 4> = DynCpi::new(&program);
342        cpi.push_u64_le(1).expect_err("u64 is 8 bytes, buffer is 4");
343    }
344
345    #[test]
346    fn push_pubkey_fills_32_bytes() {
347        let program = Address::from([0u8; 32]);
348        let mut cpi: DynCpi<0, 64> = DynCpi::new(&program);
349        let pk = Address::from([0x7Au8; 32]);
350        cpi.push_pubkey(&pk).unwrap();
351        assert_eq!(cpi.data_len(), 32);
352        assert!(cpi.data().iter().all(|b| *b == 0x7A));
353    }
354
355    mod invoke {
356        use super::*;
357        use hopper_native::{
358            AccountView as NativeAccountView, Address as NativeAddress, RuntimeAccount,
359            NOT_BORROWED,
360        };
361
362        fn make_account(
363            address_byte: u8,
364            is_signer: bool,
365            is_writable: bool,
366        ) -> (std::vec::Vec<u64>, AccountView<'static>) {
367            let mut backing = std::vec![0u64; (RuntimeAccount::SIZE + 8).div_ceil(8)];
368            let raw = backing.as_mut_ptr() as *mut RuntimeAccount;
369            // SAFETY: backing is sized for the header plus data and
370            // outlives the returned view.
371            unsafe {
372                raw.write(RuntimeAccount {
373                    borrow_state: NOT_BORROWED,
374                    is_signer: is_signer as u8,
375                    is_writable: is_writable as u8,
376                    executable: 0,
377                    resize_delta: 0,
378                    address: NativeAddress::new_from_array([address_byte; 32]),
379                    owner: NativeAddress::new_from_array([2; 32]),
380                    lamports: 1,
381                    data_len: 8,
382                });
383            }
384            // SAFETY: raw points at a fully initialized RuntimeAccount.
385            let backend = unsafe { NativeAccountView::new_unchecked(raw) };
386            (backing, AccountView::from_backend(backend))
387        }
388
389        #[test]
390        fn invoke_validates_and_submits_built_metas() {
391            let program = Address::from([9u8; 32]);
392            let (_b1, signer_acct) = make_account(1, true, false);
393            let (_b2, writable_acct) = make_account(2, false, true);
394
395            let mut cpi: DynCpi<4, 16> = DynCpi::new(&program);
396            cpi.push_account(&signer_acct, false, true).unwrap();
397            cpi.push_account(&writable_acct, true, false).unwrap();
398            cpi.push_byte(3).unwrap();
399            cpi.push_u64_le(42).unwrap();
400
401            // Off-chain the syscall is a no-op, but the full validation
402            // pipeline (address/flag agreement, borrow checks,
403            // duplicate-writable) runs against the metas this builder
404            // assembled, proving the build→submit chain is wired.
405            assert_eq!(cpi.invoke(), Ok(()));
406        }
407
408        #[test]
409        fn invoke_surfaces_flag_mismatch_from_built_metas() {
410            let program = Address::from([9u8; 32]);
411            // The view is NOT a transaction signer, but the builder
412            // declares it must sign (and no PDA seeds are provided):
413            // the validated path must refuse.
414            let (_b1, not_signer) = make_account(3, false, false);
415
416            let mut cpi: DynCpi<2, 4> = DynCpi::new(&program);
417            cpi.push_account(&not_signer, false, true).unwrap();
418            assert_eq!(cpi.invoke(), Err(ProgramError::MissingRequiredSignature));
419        }
420
421        #[test]
422        fn invoke_rejects_duplicate_writable_accounts() {
423            let program = Address::from([9u8; 32]);
424            let (_b1, first) = make_account(4, false, true);
425            let (_b2, second) = make_account(4, false, true);
426
427            let mut cpi: DynCpi<2, 4> = DynCpi::new(&program);
428            cpi.push_account(&first, true, false).unwrap();
429            cpi.push_account(&second, true, false).unwrap();
430            assert_eq!(cpi.invoke(), Err(ProgramError::AccountBorrowFailed));
431        }
432
433        #[test]
434        fn account_views_exposes_push_order_prefix() {
435            let program = Address::from([9u8; 32]);
436            let (_b1, a) = make_account(5, false, false);
437            let (_b2, b) = make_account(6, false, false);
438
439            let mut cpi: DynCpi<4, 4> = DynCpi::new(&program);
440            cpi.push_account(&a, false, false).unwrap();
441            cpi.push_account(&b, false, false).unwrap();
442            let views = cpi.account_views();
443            assert_eq!(views.len(), 2);
444            assert_eq!(views[0].address(), &Address::from([5u8; 32]));
445            assert_eq!(views[1].address(), &Address::from([6u8; 32]));
446        }
447
448        // -- SIMD-0339 account-info dedup --------------------------------
449
450        #[test]
451        fn repeated_address_collapses_to_one_info_with_or_merged_flags() {
452            let program = Address::from([9u8; 32]);
453            // One underlying account, pushed twice with complementary flags.
454            let (_b, acct) = make_account(7, true, true);
455
456            let mut cpi: DynCpi<4, 4> = DynCpi::new(&program);
457            cpi.push_account(&acct, true, false).unwrap(); // writable, not signer
458            cpi.push_account(&acct, false, true).unwrap(); // not writable, signer
459
460            // Both pushes are kept as metas...
461            assert_eq!(cpi.account_count(), 2);
462            // ...but collapse to a single deduplicated account-info.
463            assert_eq!(cpi.info_count(), 1);
464
465            let (view, writable, signer) = cpi.dedup_info(0).unwrap();
466            assert_eq!(view.address(), &Address::from([7u8; 32]));
467            // Flags are the OR across occurrences: writable in push #1,
468            // signer in push #2 => the single info is both.
469            assert!(writable, "writable in any meta => info writable");
470            assert!(signer, "signer in any meta => info signer");
471            assert!(cpi.dedup_info(1).is_none());
472        }
473
474        #[test]
475        fn distinct_addresses_stay_distinct_infos() {
476            let program = Address::from([9u8; 32]);
477            let (_b1, a) = make_account(5, false, false);
478            let (_b2, b) = make_account(6, false, false);
479
480            let mut cpi: DynCpi<4, 4> = DynCpi::new(&program);
481            cpi.push_account(&a, false, false).unwrap();
482            cpi.push_account(&b, false, false).unwrap();
483
484            assert_eq!(cpi.account_count(), 2);
485            assert_eq!(cpi.info_count(), 2);
486            assert_eq!(
487                cpi.dedup_info(0).unwrap().0.address(),
488                &Address::from([5u8; 32])
489            );
490            assert_eq!(
491                cpi.dedup_info(1).unwrap().0.address(),
492                &Address::from([6u8; 32])
493            );
494        }
495
496        #[test]
497        fn metas_preserve_order_and_duplicates_while_infos_dedup() {
498            let program = Address::from([9u8; 32]);
499            let (_b1, a) = make_account(5, false, false);
500            let (_b2, b) = make_account(6, false, false);
501
502            // Push order a, b, a: the middle account is distinct, the third
503            // repeats the first.
504            let mut cpi: DynCpi<4, 4> = DynCpi::new(&program);
505            cpi.push_account(&a, false, false).unwrap();
506            cpi.push_account(&b, false, false).unwrap();
507            cpi.push_account(&a, false, false).unwrap();
508
509            // Metas: all three, in push order, duplicate preserved.
510            let metas = cpi.account_views();
511            assert_eq!(metas.len(), 3);
512            assert_eq!(metas[0].address(), &Address::from([5u8; 32]));
513            assert_eq!(metas[1].address(), &Address::from([6u8; 32]));
514            assert_eq!(metas[2].address(), &Address::from([5u8; 32]));
515
516            // Infos: two unique, in first-occurrence order.
517            assert_eq!(cpi.info_count(), 2);
518            assert_eq!(
519                cpi.dedup_info(0).unwrap().0.address(),
520                &Address::from([5u8; 32])
521            );
522            assert_eq!(
523                cpi.dedup_info(1).unwrap().0.address(),
524                &Address::from([6u8; 32])
525            );
526        }
527
528        #[test]
529        fn invoke_submits_deduped_repeated_readonly_account() {
530            let program = Address::from([9u8; 32]);
531            let (_b, acct) = make_account(8, false, false);
532
533            let mut cpi: DynCpi<4, 4> = DynCpi::new(&program);
534            cpi.push_account(&acct, false, false).unwrap();
535            cpi.push_account(&acct, false, false).unwrap();
536            cpi.push_account(&acct, false, false).unwrap();
537
538            // Three read-only metas of one account collapse to one info.
539            assert_eq!(cpi.account_count(), 3);
540            assert_eq!(cpi.info_count(), 1);
541            // Off-chain the syscall is a no-op; Ok proves the dedup-aware
542            // validation pipeline accepted the built instruction.
543            assert_eq!(cpi.invoke(), Ok(()));
544        }
545
546        #[test]
547        fn wide_dyn_cpi_exceeds_legacy_64_account_ceiling() {
548            let program = Address::from([9u8; 32]);
549            // 65 distinct accounts, one past the pre-SIMD-0339 static
550            // ceiling of 64. Keep backings and views alive for the builder.
551            let mut backings: std::vec::Vec<std::vec::Vec<u64>> = std::vec::Vec::new();
552            let mut views: std::vec::Vec<AccountView<'static>> = std::vec::Vec::new();
553            for i in 1..=65u8 {
554                let (b, v) = make_account(i, false, false);
555                backings.push(b);
556                views.push(v);
557            }
558
559            let mut cpi: DynCpi<70, 4> = DynCpi::new(&program);
560            for v in &views {
561                cpi.push_account(v, false, false).unwrap();
562            }
563
564            assert_eq!(cpi.account_count(), 65);
565            // All distinct, so no dedup shrinkage here; but the shape is
566            // accepted, proving >64 account CPIs build and submit.
567            assert_eq!(cpi.info_count(), 65);
568            assert_eq!(cpi.invoke(), Ok(()));
569        }
570    }
571}