hopper_native/budget.rs
1//! Compute-unit budget tracking and instrumentation.
2//!
3//! Solana programs have a finite CU budget per instruction. Exceeding it
4//! is a hard abort. Hopper provides runtime CU tracking at the substrate
5//! level so programs can avoid scattering ad hoc `sol_log_compute_units()`
6//! calls through business logic.
7//!
8//! Hopper's `CuBudget` provides:
9//!
10//! 1. **Snapshot/check pattern**: Take a CU snapshot, do work, check how
11//! much was consumed. Useful for profiling individual code paths.
12//!
13//! 2. **Guard pattern**: Set a CU floor and periodically check that you
14//! have enough budget remaining before expensive operations (like CPI).
15//!
16//! 3. **Feature-gated tracing**: With `#[cfg(feature = "cu-trace")]`,
17//! emit structured CU consumption logs at function boundaries that
18//! off-chain tools can parse into flame graphs.
19//!
20//! # Usage
21//!
22//! ```ignore
23//! use hopper_native::budget::CuBudget;
24//!
25//! fn process(accounts: &[AccountView], data: &[u8]) -> ProgramResult {
26//! let budget = CuBudget::snapshot();
27//!
28//! // ... do work ...
29//!
30//! // Before an expensive CPI, check we have at least 50k CU left.
31//! budget.require_remaining(50_000)?;
32//!
33//! // CPI call...
34//! Ok(())
35//! }
36//! ```
37//!
38//! With `cu-trace` enabled:
39//!
40//! ```ignore
41//! use hopper_native::budget::cu_trace;
42//!
43//! fn process_deposit(/* ... */) -> ProgramResult {
44//! cu_trace!("deposit::start");
45//! // ... work ...
46//! cu_trace!("deposit::after_validation");
47//! // ... CPI ...
48//! cu_trace!("deposit::end");
49//! Ok(())
50//! }
51//! ```
52
53use crate::{ProgramError, ProgramResult};
54
55/// Custom error code returned by [`CuBudget::require_remaining`] when the
56/// remaining compute budget is below the requested floor.
57///
58/// Substrate-coded like the other Hopper refusal ranges (`0xC000 | i`
59/// context acquisition, `0xD000 | i` write policy); programs should keep
60/// their own custom codes out of `0xE000..0xF000`.
61pub const ERR_INSUFFICIENT_CU: u32 = 0xE000;
62
63/// Compute-unit budget tracker.
64///
65/// On BPF this is backed by the `sol_remaining_compute_units` syscall
66/// (SIMD-0049), so snapshots and guards read the *real* remaining budget.
67/// That SIMD is Withdrawn and its feature gate has never been activated on
68/// mainnet-beta, devnet, or testnet: a deployed program referencing the
69/// symbol is rejected by the loader. On-chain builds therefore include this
70/// type only with the crate's `remaining-compute-units-syscall` feature,
71/// for private clusters that activate the gate. Off-chain there is no CU
72/// metering: [`remaining`](Self::remaining) reports `u64::MAX`, so every
73/// guard passes trivially and [`used`](Self::used) reports 0.
74#[derive(Clone, Copy)]
75pub struct CuBudget {
76 /// Remaining CU at the moment [`snapshot`](Self::snapshot) was taken
77 /// (`u64::MAX` off-chain, where nothing is metered).
78 snapshot: u64,
79}
80
81impl CuBudget {
82 /// Remaining compute units for the current invocation.
83 ///
84 /// On BPF, reads the `sol_remaining_compute_units` syscall
85 /// (SIMD-0049; not activated on any public cluster, see the type
86 /// docs). Off-chain, returns `u64::MAX`, host builds have no CU
87 /// meter, so guards built on this pass trivially, matching the
88 /// crate's other host fallbacks.
89 #[inline(always)]
90 pub fn remaining() -> u64 {
91 #[cfg(target_os = "solana")]
92 {
93 // SAFETY: nullary syscall with no memory arguments; the
94 // runtime returns the invocation's remaining CU by value, so
95 // there are no pointer, layout, or aliasing obligations.
96 unsafe { crate::syscalls::sol_remaining_compute_units() }
97 }
98 #[cfg(not(target_os = "solana"))]
99 {
100 u64::MAX
101 }
102 }
103
104 /// Take a snapshot of the current compute budget.
105 ///
106 /// Stores the remaining CU at call time (via
107 /// [`remaining`](Self::remaining)), enabling the snapshot/check
108 /// pattern: `let b = CuBudget::snapshot(); ...; b.used()`.
109 ///
110 /// Note: this no longer emits a `sol_log_compute_units` log line the
111 /// way pre-SIMD-0049 versions did; it *reads* instead. Use
112 /// [`checkpoint`](Self::checkpoint) for the logging behavior.
113 #[inline(always)]
114 pub fn snapshot() -> Self {
115 Self {
116 snapshot: Self::remaining(),
117 }
118 }
119
120 /// Compute units consumed since this snapshot was taken.
121 ///
122 /// Saturates at 0 (the remaining budget can only decrease within an
123 /// invocation, but saturating keeps hostile or host-side inputs from
124 /// wrapping). Off-chain this is always 0.
125 #[inline(always)]
126 pub fn used(&self) -> u64 {
127 self.snapshot.saturating_sub(Self::remaining())
128 }
129
130 /// Log the current compute unit consumption for profiling.
131 ///
132 /// Emits via `sol_log_compute_units` on BPF. Use this to instrument
133 /// hot paths and identify CU bottlenecks.
134 #[inline(always)]
135 pub fn checkpoint() {
136 #[cfg(target_os = "solana")]
137 // SAFETY: This block is part of Hopper's reviewed zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
138 unsafe {
139 crate::syscalls::sol_log_compute_units_();
140 }
141 }
142
143 /// Assert that at least `min_remaining` CU are available.
144 ///
145 /// On BPF this is a **real guard** (SIMD-0049): it reads the remaining
146 /// budget and returns `Err(ProgramError::Custom(`[`ERR_INSUFFICIENT_CU`]`))`
147 /// when it is below the floor, letting the program fail cleanly (state
148 /// untouched, clear error) instead of hard-aborting mid-CPI when the
149 /// runtime exhausts the meter.
150 ///
151 /// Off-chain, [`remaining`](Self::remaining) is `u64::MAX`, so this
152 /// always returns Ok.
153 #[inline(always)]
154 pub fn require_remaining(&self, min_remaining: u64) -> ProgramResult {
155 Self::floor_check(Self::remaining(), min_remaining)
156 }
157
158 /// Pure comparison core of [`require_remaining`](Self::require_remaining),
159 /// factored out so the reject branch is host-testable (host builds can
160 /// never observe a low `remaining()`).
161 #[inline(always)]
162 fn floor_check(remaining: u64, min_remaining: u64) -> ProgramResult {
163 if remaining < min_remaining {
164 return Err(ProgramError::Custom(ERR_INSUFFICIENT_CU));
165 }
166 Ok(())
167 }
168
169 /// Log CU consumed since the snapshot.
170 ///
171 /// Emits a structured log that off-chain tools can parse.
172 /// Format: `"cu-delta: <label>"`
173 #[inline(always)]
174 pub fn log_delta(&self, label: &str) {
175 Self::checkpoint();
176 crate::log::log(label);
177 }
178}
179
180/// Structured CU tracing macro for profiling.
181///
182/// When the `cu-trace` feature is enabled, emits both a compute-unit
183/// log and a label log, allowing off-chain tooling to reconstruct
184/// a CU flame graph from program logs.
185///
186/// When `cu-trace` is NOT enabled, this is a complete no-op with zero
187/// CU cost.
188///
189/// # Usage
190///
191/// ```ignore
192/// cu_trace!("validate_accounts");
193/// // ... validation code ...
194/// cu_trace!("begin_cpi");
195/// ```
196#[macro_export]
197macro_rules! cu_trace {
198 ( $label:expr ) => {{
199 #[cfg(feature = "cu-trace")]
200 {
201 $crate::budget::CuBudget::checkpoint();
202 $crate::log::log(concat!("[cu-trace] ", $label));
203 }
204 }};
205}
206
207/// Run a closure and log the CU consumed by it (feature-gated).
208///
209/// Returns the closure's result. When `cu-trace` is not enabled,
210/// just runs the closure with zero overhead.
211///
212/// # Usage
213///
214/// ```ignore
215/// let result = cu_measure!("deserialize", || {
216/// parse_instruction_data(data)
217/// });
218/// ```
219#[macro_export]
220macro_rules! cu_measure {
221 ( $label:expr, $body:expr ) => {{
222 #[cfg(feature = "cu-trace")]
223 {
224 $crate::budget::CuBudget::checkpoint();
225 $crate::log::log(concat!("[cu-start] ", $label));
226 }
227 let __result = $body;
228 #[cfg(feature = "cu-trace")]
229 {
230 $crate::budget::CuBudget::checkpoint();
231 $crate::log::log(concat!("[cu-end] ", $label));
232 }
233 __result
234 }};
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 // Host builds have no CU meter, so the on-chain read path cannot be
242 // exercised here; these pin the host contract (guards pass trivially)
243 // and the pure reject/accept core the on-chain path routes through.
244
245 #[test]
246 fn host_remaining_is_unmetered_max() {
247 assert_eq!(CuBudget::remaining(), u64::MAX);
248 }
249
250 #[test]
251 fn host_snapshot_reports_zero_used_and_passes_guards() {
252 let budget = CuBudget::snapshot();
253 assert_eq!(budget.used(), 0);
254 assert!(budget.require_remaining(u64::MAX).is_ok());
255 }
256
257 #[test]
258 fn floor_check_rejects_below_floor_with_coded_error() {
259 assert_eq!(
260 CuBudget::floor_check(49_999, 50_000),
261 Err(ProgramError::Custom(ERR_INSUFFICIENT_CU))
262 );
263 assert_eq!(ERR_INSUFFICIENT_CU, 0xE000);
264 }
265
266 #[test]
267 fn floor_check_accepts_at_and_above_floor() {
268 assert!(CuBudget::floor_check(50_000, 50_000).is_ok());
269 assert!(CuBudget::floor_check(u64::MAX, 0).is_ok());
270 assert!(CuBudget::floor_check(0, 0).is_ok());
271 }
272}