Skip to main content

hopper_runtime/
return_data.rs

1//! CPI return-data helpers.
2//!
3//! Solana return data is a 1 KiB byte channel from the most recent CPI. Hopper
4//! keeps it stack-backed and exposes typed reads by value for `Pod` types.
5
6use crate::{Address, Pod, ProgramError, ProgramResult};
7use core::mem::MaybeUninit;
8
9/// Maximum Solana return-data payload length.
10pub const MAX_RETURN_DATA: usize = 1024;
11
12/// Stack-backed snapshot of CPI return data.
13///
14/// The 1 KiB buffer is deliberately left uninitialized until the
15/// `sol_get_return_data` syscall fills it; only the syscall-initialized prefix
16/// (`data_len` bytes) is ever exposed to callers.
17#[derive(Clone)]
18pub struct ReturnData {
19    program_id: Address,
20    data: [MaybeUninit<u8>; MAX_RETURN_DATA],
21    data_len: usize,
22    actual_len: usize,
23}
24
25impl ReturnData {
26    /// Program id that set this return data.
27    #[inline(always)]
28    pub const fn program_id(&self) -> &Address {
29        &self.program_id
30    }
31
32    /// Bytes copied into this snapshot.
33    #[inline(always)]
34    pub fn data(&self) -> &[u8] {
35        // Fail-closed backstop for the invariant the SAFETY comment relies
36        // on: `data_len` can never exceed the buffer capacity.
37        debug_assert!(self.data_len <= MAX_RETURN_DATA);
38        // SAFETY: `sol_get_return_data` initializes exactly
39        // `min(actual_len, MAX_RETURN_DATA)` bytes of the buffer it was handed,
40        // and `get_return_data` sets `data_len` to that same value (the test
41        // constructor likewise writes `data_len` bytes before setting it), so
42        // the first `data_len` bytes are always initialized `u8`s.
43        unsafe { core::slice::from_raw_parts(self.data.as_ptr() as *const u8, self.data_len) }
44    }
45
46    /// Copied byte length.
47    #[inline(always)]
48    pub const fn len(&self) -> usize {
49        self.data_len
50    }
51
52    /// Runtime-reported byte length before truncation to Hopper's stack buffer.
53    #[inline(always)]
54    pub const fn actual_len(&self) -> usize {
55        self.actual_len
56    }
57
58    /// Whether no bytes were copied.
59    #[inline(always)]
60    pub const fn is_empty(&self) -> bool {
61        self.data_len == 0
62    }
63
64    /// Whether the runtime reported more bytes than Hopper copied.
65    #[inline(always)]
66    pub const fn is_truncated(&self) -> bool {
67        self.actual_len > self.data_len
68    }
69
70    /// Copy return bytes into `dst`.
71    #[inline]
72    pub fn copy_to(&self, dst: &mut [u8]) -> ProgramResult {
73        crate::memory::copy_bytes(dst, self.data())
74    }
75
76    /// Read a `Pod` value from the first bytes of the return-data payload.
77    #[inline]
78    pub fn read_pod<T: Pod>(&self) -> Result<T, ProgramError> {
79        let size = core::mem::size_of::<T>();
80        if self.data_len < size {
81            return Err(ProgramError::AccountDataTooSmall);
82        }
83        // SAFETY: `T: Pod` is copyable from raw bytes, and the length check
84        // above keeps the read inside the initialized `data_len`-byte prefix
85        // exposed by `data()`. Use an unaligned read so the stack byte buffer
86        // never imposes alignment requirements on callers.
87        Ok(unsafe { core::ptr::read_unaligned(self.data().as_ptr() as *const T) })
88    }
89}
90
91impl core::fmt::Debug for ReturnData {
92    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
93        // Manual impl: deriving Debug would format the raw `MaybeUninit`
94        // buffer; only the initialized prefix may be read.
95        f.debug_struct("ReturnData")
96            .field("program_id", &self.program_id)
97            .field("data", &self.data())
98            .field("data_len", &self.data_len)
99            .field("actual_len", &self.actual_len)
100            .finish()
101    }
102}
103
104/// Set return data for this instruction.
105#[inline(always)]
106pub fn set_return_data(data: &[u8]) {
107    // SAFETY: `data` is a valid byte slice for its full length.
108    unsafe {
109        crate::syscalls::sol_set_return_data(data.as_ptr(), data.len() as u64);
110    }
111}
112
113/// Set return data, rejecting payloads larger than Solana's 1 KiB limit.
114#[inline]
115pub fn try_set_return_data(data: &[u8]) -> ProgramResult {
116    if data.len() > MAX_RETURN_DATA {
117        return Err(ProgramError::InvalidArgument);
118    }
119    set_return_data(data);
120    Ok(())
121}
122
123/// Read return data from the most recent CPI.
124///
125/// The 1 KiB snapshot buffer is *not* zero-filled before the syscall, the
126/// syscall initializes exactly the reported prefix, and `None` is returned
127/// before any read when the runtime reports zero bytes. This is the bug class
128/// behind Quasar #238/#234 (an `assume_init` over a buffer the syscall never
129/// wrote, exposing uninitialized stack bytes as return data); Hopper's shape
130/// is immune because uninitialized bytes can never escape: empty return data
131/// short-circuits to `None`, and every accessor reads only the
132/// syscall-initialized `data_len` prefix.
133#[inline]
134pub fn get_return_data() -> Option<ReturnData> {
135    let mut snapshot = ReturnData {
136        program_id: Address::default(),
137        data: [const { MaybeUninit::uninit() }; MAX_RETURN_DATA],
138        data_len: 0,
139        actual_len: 0,
140    };
141
142    // SAFETY: Snapshot buffers are stack-allocated with the exact capacities
143    // advertised to the runtime syscall; the data buffer may be uninitialized
144    // because the syscall only writes (never reads) it.
145    let actual_len = unsafe {
146        crate::syscalls::sol_get_return_data(
147            snapshot.data.as_mut_ptr() as *mut u8,
148            MAX_RETURN_DATA as u64,
149            snapshot.program_id.as_mut().as_mut_ptr(),
150        )
151    } as usize;
152
153    if actual_len == 0 {
154        // Nothing was written into the buffer: return before any field of the
155        // snapshot's data can be observed. Off-chain the syscall stub reports
156        // 0, so the uninitialized buffer never escapes there either.
157        return None;
158    }
159
160    snapshot.actual_len = actual_len;
161    snapshot.data_len = core::cmp::min(actual_len, MAX_RETURN_DATA);
162    Some(snapshot)
163}
164
165#[cfg(test)]
166impl ReturnData {
167    /// Test-only constructor: builds a snapshot whose buffer prefix is fully
168    /// initialized from `bytes`, mirroring what the syscall produces on-chain.
169    fn test_snapshot(bytes: &[u8], program_id: Address) -> Self {
170        assert!(bytes.len() <= MAX_RETURN_DATA);
171        let mut data = [const { MaybeUninit::uninit() }; MAX_RETURN_DATA];
172        for (dst, src) in data.iter_mut().zip(bytes) {
173            dst.write(*src);
174        }
175        ReturnData {
176            program_id,
177            data,
178            data_len: bytes.len(),
179            actual_len: bytes.len(),
180        }
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn offchain_get_return_data_is_none() {
190        assert!(get_return_data().is_none());
191    }
192
193    #[test]
194    fn try_set_return_data_rejects_oversized_payload() {
195        let oversized = [0u8; MAX_RETURN_DATA + 1];
196        assert!(try_set_return_data(&oversized).is_err());
197    }
198
199    #[test]
200    fn return_data_reads_pod_by_value() {
201        let snapshot = ReturnData::test_snapshot(&7u64.to_le_bytes(), Address::default());
202        let raw = snapshot.read_pod::<[u8; 8]>().unwrap();
203        assert_eq!(u64::from_le_bytes(raw), 7);
204    }
205
206    #[test]
207    fn data_exposes_exactly_the_written_prefix() {
208        let payload = [0xAB, 0xCD, 0xEF];
209        let snapshot = ReturnData::test_snapshot(&payload, Address::default());
210        assert_eq!(snapshot.data(), &payload);
211        assert_eq!(snapshot.len(), payload.len());
212        assert!(!snapshot.is_empty());
213    }
214
215    #[test]
216    fn read_pod_never_reads_past_the_prefix() {
217        let snapshot = ReturnData::test_snapshot(&[1, 2, 3], Address::default());
218        assert!(matches!(
219            snapshot.read_pod::<[u8; 4]>(),
220            Err(ProgramError::AccountDataTooSmall)
221        ));
222        assert_eq!(snapshot.read_pod::<[u8; 3]>().unwrap(), [1, 2, 3]);
223    }
224
225    #[test]
226    fn copy_to_copies_only_the_prefix() {
227        let snapshot = ReturnData::test_snapshot(&[9, 8], Address::default());
228        let mut dst = [0xFFu8; 4];
229        snapshot.copy_to(&mut dst).unwrap();
230        assert_eq!(dst, [9, 8, 0xFF, 0xFF]);
231
232        let mut too_small = [0u8; 1];
233        assert!(snapshot.copy_to(&mut too_small).is_err());
234    }
235
236    #[test]
237    fn is_truncated_reflects_runtime_reported_length() {
238        let mut snapshot = ReturnData::test_snapshot(&[0u8; 16], Address::default());
239        assert!(!snapshot.is_truncated());
240        snapshot.actual_len = MAX_RETURN_DATA + 512;
241        assert!(snapshot.is_truncated());
242        assert_eq!(snapshot.actual_len(), MAX_RETURN_DATA + 512);
243        assert_eq!(snapshot.len(), 16);
244    }
245
246    #[test]
247    fn debug_prints_only_the_initialized_prefix() {
248        let snapshot = ReturnData::test_snapshot(&[1, 2], Address::default());
249        let rendered = std::format!("{snapshot:?}");
250        assert!(rendered.contains("data: [1, 2]"));
251        assert!(rendered.contains("data_len: 2"));
252    }
253}