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::{
37    account::AccountView,
38    address::Address,
39    error::ProgramError,
40    result::ProgramResult,
41};
42
43/// Variable-length CPI builder with compile-time stack capacity.
44///
45/// `MAX_ACCTS` is the upper bound on the number of `AccountMeta`
46/// entries. `MAX_DATA` is the upper bound on the instruction data
47/// byte count. Exceeding either returns an error; nothing panics.
48///
49/// Use when the CPI shape is not known at compile time. For
50/// statically-shaped CPIs, prefer `cpi::invoke_signed::<N>` which
51/// avoids the two bounds entirely.
52pub struct DynCpi<'a, const MAX_ACCTS: usize, const MAX_DATA: usize> {
53    program_id: &'a Address,
54    accounts: [MaybeUninit<&'a AccountView>; MAX_ACCTS],
55    writable: [bool; MAX_ACCTS],
56    signer: [bool; MAX_ACCTS],
57    account_count: usize,
58    data: [MaybeUninit<u8>; MAX_DATA],
59    data_len: usize,
60}
61
62impl<'a, const MAX_ACCTS: usize, const MAX_DATA: usize> DynCpi<'a, MAX_ACCTS, MAX_DATA> {
63    /// Start a new dynamic CPI against the given program.
64    #[inline]
65    pub fn new(program_id: &'a Address) -> Self {
66        Self {
67            program_id,
68            accounts: [const { MaybeUninit::uninit() }; MAX_ACCTS],
69            writable: [false; MAX_ACCTS],
70            signer: [false; MAX_ACCTS],
71            account_count: 0,
72            data: [const { MaybeUninit::uninit() }; MAX_DATA],
73            data_len: 0,
74        }
75    }
76
77    /// Append one account meta. The `writable` and `signer` flags
78    /// are carried through to the emitted CPI instruction.
79    ///
80    /// Returns `Err(ProgramError::InvalidArgument)` when the builder
81    /// is already at `MAX_ACCTS` capacity. Users pick the capacity
82    /// at the type parameter; bumping it is a type-system edit, not
83    /// a runtime error.
84    #[inline]
85    pub fn push_account(
86        &mut self,
87        account: &'a AccountView,
88        writable: bool,
89        signer: bool,
90    ) -> ProgramResult {
91        if self.account_count >= MAX_ACCTS {
92            return Err(ProgramError::InvalidArgument);
93        }
94        self.accounts[self.account_count] = MaybeUninit::new(account);
95        self.writable[self.account_count] = writable;
96        self.signer[self.account_count] = signer;
97        self.account_count = self.account_count.wrapping_add(1);
98        Ok(())
99    }
100
101    /// Append the given bytes to the instruction data buffer.
102    ///
103    /// Returns `Err(ProgramError::InvalidArgument)` when the buffer
104    /// does not have room for the full slice. The append is
105    /// all-or-nothing; a partial write does not happen.
106    #[inline]
107    pub fn push_data(&mut self, bytes: &[u8]) -> ProgramResult {
108        if self.data_len.saturating_add(bytes.len()) > MAX_DATA {
109            return Err(ProgramError::InvalidArgument);
110        }
111        let dst = &mut self.data[self.data_len..self.data_len + bytes.len()];
112        for (i, b) in bytes.iter().enumerate() {
113            dst[i] = MaybeUninit::new(*b);
114        }
115        self.data_len = self.data_len.wrapping_add(bytes.len());
116        Ok(())
117    }
118
119    /// Append one byte. Sugar for programs that build instruction
120    /// data one discriminator + one argument at a time.
121    #[inline]
122    pub fn push_byte(&mut self, byte: u8) -> ProgramResult {
123        self.push_data(core::slice::from_ref(&byte))
124    }
125
126    /// Append the little-endian encoding of a `u64`. Covers the
127    /// most common arg shape (lamports, timestamps, flags).
128    #[inline]
129    pub fn push_u64_le(&mut self, value: u64) -> ProgramResult {
130        self.push_data(&value.to_le_bytes())
131    }
132
133    /// Append a 32-byte pubkey.
134    #[inline]
135    pub fn push_pubkey(&mut self, address: &Address) -> ProgramResult {
136        self.push_data(address.as_array())
137    }
138
139    /// Current account count.
140    #[inline(always)]
141    pub const fn account_count(&self) -> usize {
142        self.account_count
143    }
144
145    /// Program id this dynamic CPI targets.
146    #[inline(always)]
147    pub const fn program_id(&self) -> &Address {
148        self.program_id
149    }
150
151    /// Current data length.
152    #[inline(always)]
153    pub const fn data_len(&self) -> usize {
154        self.data_len
155    }
156
157    /// Borrow the finalized data buffer. Useful for tests that
158    /// want to inspect the wire bytes without actually submitting
159    /// the CPI.
160    #[inline]
161    pub fn data(&self) -> &[u8] {
162        unsafe {
163            core::slice::from_raw_parts(
164                self.data.as_ptr() as *const u8,
165                self.data_len,
166            )
167        }
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn byte_push_walks_the_buffer() {
177        let program = Address::from([0u8; 32]);
178        let mut cpi: DynCpi<4, 32> = DynCpi::new(&program);
179        cpi.push_byte(0xA1).unwrap();
180        cpi.push_u64_le(0xCAFEBABE_u64).unwrap();
181        assert_eq!(cpi.data_len(), 1 + 8);
182        assert_eq!(cpi.data()[0], 0xA1);
183        assert_eq!(
184            &cpi.data()[1..9],
185            &0xCAFEBABE_u64.to_le_bytes()
186        );
187    }
188
189    #[test]
190    fn data_overflow_rejects() {
191        let program = Address::from([0u8; 32]);
192        let mut cpi: DynCpi<0, 4> = DynCpi::new(&program);
193        cpi.push_u64_le(1).expect_err("u64 is 8 bytes, buffer is 4");
194    }
195
196    #[test]
197    fn push_pubkey_fills_32_bytes() {
198        let program = Address::from([0u8; 32]);
199        let mut cpi: DynCpi<0, 64> = DynCpi::new(&program);
200        let pk = Address::from([0x7Au8; 32]);
201        cpi.push_pubkey(&pk).unwrap();
202        assert_eq!(cpi.data_len(), 32);
203        assert!(cpi.data().iter().all(|b| *b == 0x7A));
204    }
205}