Skip to main content

hopper_runtime/
dyn_cpi.rs

1//! Stack-allocated variable-length CPI builder.
2//!
3//! The existing `hopper_runtime::cpi::invoke_signed::<N>` family is
4//! const-generic over the account count, which is perfect for CPI
5//! shapes known at compile time and about ninety percent of real
6//! cases. The exceptions are:
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//! [`DynCpi`] covers those cases. It is parameterised on two
18//! compile-time capacities, `MAX_ACCTS` and `MAX_DATA`, so the whole
19//! buffer lives on the stack in a single `MaybeUninit` array. No
20//! heap, no `Vec`, no panic on overflow: [`DynCpi::push_account`]
21//! and [`DynCpi::push_data`] return errors when the declared
22//! capacity would be exceeded.
23//!
24//! ## Innovation vs. Quasar
25//!
26//! Quasar's `DynCpiCall` is conceptually the same shape but expects
27//! the caller to hand-roll seed threading. Hopper's builder carries
28//! a typed `Signer` slice through the invoke call so a PDA-authored
29//! CPI reads like a single method chain. The overflow discipline
30//! also differs: Hopper propagates `Err(ProgramError::InvalidArgument)`
31//! rather than panicking, which keeps the handler's error surface
32//! uniform.
33
34use core::mem::MaybeUninit;
35
36use crate::{account::AccountView, address::Address, error::ProgramError, result::ProgramResult};
37
38/// Variable-length CPI builder with compile-time stack capacity.
39///
40/// `MAX_ACCTS` is the upper bound on the number of `AccountMeta`
41/// entries. `MAX_DATA` is the upper bound on the instruction data
42/// byte count. Exceeding either returns an error; nothing panics.
43///
44/// Use when the CPI shape is not known at compile time. For
45/// statically-shaped CPIs, prefer `cpi::invoke_signed::<N>` which
46/// avoids the two bounds entirely.
47pub struct DynCpi<'a, const MAX_ACCTS: usize, const MAX_DATA: usize> {
48    program_id: &'a Address,
49    accounts: [MaybeUninit<&'a AccountView>; MAX_ACCTS],
50    writable: [bool; MAX_ACCTS],
51    signer: [bool; MAX_ACCTS],
52    account_count: usize,
53    data: [MaybeUninit<u8>; MAX_DATA],
54    data_len: usize,
55}
56
57impl<'a, const MAX_ACCTS: usize, const MAX_DATA: usize> DynCpi<'a, MAX_ACCTS, MAX_DATA> {
58    /// Start a new dynamic CPI against the given program.
59    #[inline]
60    pub fn new(program_id: &'a Address) -> Self {
61        Self {
62            program_id,
63            accounts: [const { MaybeUninit::uninit() }; MAX_ACCTS],
64            writable: [false; MAX_ACCTS],
65            signer: [false; MAX_ACCTS],
66            account_count: 0,
67            data: [const { MaybeUninit::uninit() }; MAX_DATA],
68            data_len: 0,
69        }
70    }
71
72    /// Append one account meta. The `writable` and `signer` flags
73    /// are carried through to the emitted CPI instruction.
74    ///
75    /// Returns `Err(ProgramError::InvalidArgument)` when the builder
76    /// is already at `MAX_ACCTS` capacity. Users pick the capacity
77    /// at the type parameter; bumping it is a type-system edit, not
78    /// a runtime error.
79    #[inline]
80    pub fn push_account(
81        &mut self,
82        account: &'a AccountView,
83        writable: bool,
84        signer: bool,
85    ) -> ProgramResult {
86        if self.account_count >= MAX_ACCTS {
87            return Err(ProgramError::InvalidArgument);
88        }
89        self.accounts[self.account_count] = MaybeUninit::new(account);
90        self.writable[self.account_count] = writable;
91        self.signer[self.account_count] = signer;
92        self.account_count = self.account_count.wrapping_add(1);
93        Ok(())
94    }
95
96    /// Append the given bytes to the instruction data buffer.
97    ///
98    /// Returns `Err(ProgramError::InvalidArgument)` when the buffer
99    /// does not have room for the full slice. The append is
100    /// all-or-nothing; a partial write does not happen.
101    #[inline]
102    pub fn push_data(&mut self, bytes: &[u8]) -> ProgramResult {
103        if self.data_len.saturating_add(bytes.len()) > MAX_DATA {
104            return Err(ProgramError::InvalidArgument);
105        }
106        let dst = &mut self.data[self.data_len..self.data_len + bytes.len()];
107        for (i, b) in bytes.iter().enumerate() {
108            dst[i] = MaybeUninit::new(*b);
109        }
110        self.data_len = self.data_len.wrapping_add(bytes.len());
111        Ok(())
112    }
113
114    /// Append one byte. Sugar for programs that build instruction
115    /// data one discriminator + one argument at a time.
116    #[inline]
117    pub fn push_byte(&mut self, byte: u8) -> ProgramResult {
118        self.push_data(core::slice::from_ref(&byte))
119    }
120
121    /// Append the little-endian encoding of a `u64`. Covers the
122    /// most common arg shape (lamports, timestamps, flags).
123    #[inline]
124    pub fn push_u64_le(&mut self, value: u64) -> ProgramResult {
125        self.push_data(&value.to_le_bytes())
126    }
127
128    /// Append a 32-byte pubkey.
129    #[inline]
130    pub fn push_pubkey(&mut self, address: &Address) -> ProgramResult {
131        self.push_data(address.as_array())
132    }
133
134    /// Current account count.
135    #[inline(always)]
136    pub const fn account_count(&self) -> usize {
137        self.account_count
138    }
139
140    /// Program id this dynamic CPI targets.
141    #[inline(always)]
142    pub const fn program_id(&self) -> &Address {
143        self.program_id
144    }
145
146    /// Current data length.
147    #[inline(always)]
148    pub const fn data_len(&self) -> usize {
149        self.data_len
150    }
151
152    /// Borrow the finalized data buffer. Useful for tests that
153    /// want to inspect the wire bytes without actually submitting
154    /// the CPI.
155    #[inline]
156    pub fn data(&self) -> &[u8] {
157        // 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.
158        unsafe { core::slice::from_raw_parts(self.data.as_ptr() as *const u8, self.data_len) }
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn byte_push_walks_the_buffer() {
168        let program = Address::from([0u8; 32]);
169        let mut cpi: DynCpi<4, 32> = DynCpi::new(&program);
170        cpi.push_byte(0xA1).unwrap();
171        cpi.push_u64_le(0xCAFEBABE_u64).unwrap();
172        assert_eq!(cpi.data_len(), 1 + 8);
173        assert_eq!(cpi.data()[0], 0xA1);
174        assert_eq!(&cpi.data()[1..9], &0xCAFEBABE_u64.to_le_bytes());
175    }
176
177    #[test]
178    fn data_overflow_rejects() {
179        let program = Address::from([0u8; 32]);
180        let mut cpi: DynCpi<0, 4> = DynCpi::new(&program);
181        cpi.push_u64_le(1).expect_err("u64 is 8 bytes, buffer is 4");
182    }
183
184    #[test]
185    fn push_pubkey_fills_32_bytes() {
186        let program = Address::from([0u8; 32]);
187        let mut cpi: DynCpi<0, 64> = DynCpi::new(&program);
188        let pk = Address::from([0x7Au8; 32]);
189        cpi.push_pubkey(&pk).unwrap();
190        assert_eq!(cpi.data_len(), 32);
191        assert!(cpi.data().iter().all(|b| *b == 0x7A));
192    }
193}