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}