Skip to main content

hopper_native/
log.rs

1//! Zero-allocation logging via Solana syscalls.
2
3/// Log a UTF-8 message to the runtime log.
4#[inline(always)]
5pub fn log(message: &str) {
6    #[cfg(target_os = "solana")]
7    // SAFETY: The pointer and the length come from one live slice, which
8    // outlives the synchronous syscall.
9    unsafe {
10        crate::syscalls::sol_log_(message.as_ptr(), message.len() as u64);
11    }
12    #[cfg(not(target_os = "solana"))]
13    {
14        // No-op off-chain; test harnesses capture logs separately.
15        let _ = message;
16    }
17}
18
19/// Log five u64 values for quick debugging.
20#[inline(always)]
21pub fn log_64(a: u64, b: u64, c: u64, d: u64, e: u64) {
22    #[cfg(target_os = "solana")]
23    // SAFETY: The syscall takes no pointer and has no memory precondition.
24    unsafe {
25        crate::syscalls::sol_log_64_(a, b, c, d, e);
26    }
27    #[cfg(not(target_os = "solana"))]
28    {
29        let _ = (a, b, c, d, e);
30    }
31}
32
33/// Log the current compute unit consumption.
34#[inline(always)]
35pub fn log_compute_units() {
36    #[cfg(target_os = "solana")]
37    // SAFETY: The syscall takes no pointer and has no memory precondition.
38    unsafe {
39        crate::syscalls::sol_log_compute_units_();
40    }
41}
42
43/// Emit structured data segments via `sol_log_data` (for events).
44#[inline(always)]
45pub fn log_data(segments: &[&[u8]]) {
46    #[cfg(target_os = "solana")]
47    // SAFETY: `segments` is a live `&[&[u8]]`; on SBF each element is the
48    // 16-byte (pointer, length) pair the syscall reads, and every inner slice
49    // outlives the synchronous call.
50    unsafe {
51        crate::syscalls::sol_log_data(segments.as_ptr() as *const u8, segments.len() as u64);
52    }
53    #[cfg(not(target_os = "solana"))]
54    {
55        let _ = segments;
56    }
57}
58
59/// Convenience macro for logging. Equivalent to `hopper_native::log::log(msg)`.
60///
61/// Usage: `msg!("Hello, {}", name);`
62///
63/// On BPF, this formats into a 256-byte stack buffer and calls
64/// `sol_log_`. A longer message is cut at the last whole character
65/// that fits.
66/// For simple string literals, prefer `hopper_native::log::log("...")` directly.
67#[macro_export]
68macro_rules! msg {
69    ( $literal:expr ) => {
70        $crate::log::log($literal)
71    };
72    ( $fmt:expr, $($arg:tt)* ) => {{
73        // On BPF we have limited stack, so use a fixed 256-byte buffer.
74        // For string literals, the branch above avoids this entirely.
75        #[cfg(target_os = "solana")]
76        {
77            use core::fmt::Write;
78            let mut buf = [0u8; 256];
79            let mut wrapper = $crate::log::StackWriter::new(&mut buf);
80            let _ = write!(wrapper, $fmt, $($arg)*);
81            $crate::log::log(wrapper.as_str());
82        }
83        #[cfg(not(target_os = "solana"))]
84        {
85            let _ = ($fmt, $($arg)*);
86        }
87    }};
88}
89
90/// Stack-allocated write buffer for formatted log messages on BPF.
91pub struct StackWriter<'a> {
92    buf: &'a mut [u8],
93    pos: usize,
94    truncated: bool,
95}
96
97impl<'a> StackWriter<'a> {
98    /// Create a new writer over the given buffer.
99    #[inline(always)]
100    pub fn new(buf: &'a mut [u8]) -> Self {
101        Self {
102            buf,
103            pos: 0,
104            truncated: false,
105        }
106    }
107
108    /// Number of bytes written.
109    #[inline(always)]
110    pub fn pos(&self) -> usize {
111        self.pos
112    }
113
114    /// Whether the message did not fit and was cut.
115    #[inline(always)]
116    pub fn truncated(&self) -> bool {
117        self.truncated
118    }
119
120    /// The text written so far. A message that did not fit ends at the
121    /// last whole character that did.
122    #[inline(always)]
123    pub fn as_str(&self) -> &str {
124        // SAFETY: every byte in `buf[..pos]` was copied from a `&str` by
125        // `write_str`, which copies whole strings or cuts on a character
126        // boundary and then accepts nothing more, so the prefix is a
127        // concatenation of valid UTF-8 strings.
128        unsafe { core::str::from_utf8_unchecked(&self.buf[..self.pos]) }
129    }
130}
131
132impl core::fmt::Write for StackWriter<'_> {
133    fn write_str(&mut self, s: &str) -> core::fmt::Result {
134        // After a cut nothing more is accepted: text appended behind a
135        // dropped tail would read as if nothing were missing.
136        if self.truncated {
137            return Ok(());
138        }
139        let remaining = self.buf.len().saturating_sub(self.pos);
140        let mut to_write = s.len();
141        if to_write > remaining {
142            // Cut on a character boundary: the log syscall refuses bytes
143            // that are not UTF-8 and fails the transaction.
144            to_write = remaining;
145            while !s.is_char_boundary(to_write) {
146                to_write -= 1;
147            }
148            self.truncated = true;
149        }
150        self.buf[self.pos..self.pos + to_write].copy_from_slice(&s.as_bytes()[..to_write]);
151        self.pos += to_write;
152        Ok(())
153    }
154}
155
156#[cfg(test)]
157mod stack_writer_tests {
158    use super::StackWriter;
159    use core::fmt::Write;
160
161    #[test]
162    fn a_message_that_fits_is_written_whole() {
163        let mut buf = [0u8; 16];
164        let mut writer = StackWriter::new(&mut buf);
165        write!(writer, "slot {}", 42).unwrap();
166        assert_eq!(writer.as_str(), "slot 42");
167        assert!(!writer.truncated());
168    }
169
170    #[test]
171    fn a_cut_never_splits_a_character() {
172        // Every buffer length against text with 1, 2, 3 and 4 byte
173        // characters: the result is always a prefix made of whole
174        // characters, and the longest one that fits.
175        let text = "a\u{e9}\u{20ac}\u{1f980}z\u{e9}\u{1f980}";
176        for len in 0..=text.len() + 2 {
177            let mut buf = [0xffu8; 32];
178            let mut writer = StackWriter::new(&mut buf[..len]);
179            write!(writer, "{text}").unwrap();
180            let written = writer.as_str();
181            assert!(text.starts_with(written), "len {len}");
182            assert!(core::str::from_utf8(written.as_bytes()).is_ok());
183            let next = text[written.len()..].chars().next();
184            match next {
185                Some(c) => {
186                    assert!(writer.truncated());
187                    assert!(
188                        written.len() + c.len_utf8() > len,
189                        "len {len}: room was left"
190                    );
191                }
192                None => assert!(!writer.truncated()),
193            }
194        }
195    }
196
197    #[test]
198    fn nothing_is_appended_after_a_cut() {
199        let mut buf = [0u8; 4];
200        let mut writer = StackWriter::new(&mut buf);
201        // The second argument would fit in the byte the first one left.
202        let (head, tail) = ("ab\u{20ac}", "c");
203        write!(writer, "{head}{tail}").unwrap();
204        assert_eq!(writer.as_str(), "ab");
205        assert!(writer.truncated());
206    }
207}