Skip to main content

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}