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}