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/// Whether this crate was built with the `cu-trace` feature.
64///
65/// `cu_trace!` and `cu_measure!` branch on this constant instead of a
66/// `#[cfg(feature = "cu-trace")]` inside their bodies: a `cfg` in an
67/// exported macro is evaluated against the *calling* crate's features, so
68/// the old bodies silently did nothing in a program that enabled
69/// `hopper/cu-trace` without declaring a `cu-trace` feature of its own
70/// (and warned under check-cfg). The branch folds away when the feature is
71/// off.
72pub const CU_TRACE_ENABLED: bool = cfg!(feature = "cu-trace");
73
74/// Compute-unit budget tracker.
75///
76/// On BPF this is backed by the `sol_remaining_compute_units` syscall
77/// (SIMD-0049), so snapshots and guards read the *real* remaining budget.
78/// That SIMD is Withdrawn and its feature gate has never been activated on
79/// mainnet-beta, devnet, or testnet: a deployed program referencing the
80/// symbol is rejected by the loader. On-chain builds therefore include this
81/// type only with the crate's `remaining-compute-units-syscall` feature,
82/// for private clusters that activate the gate. Off-chain there is no CU
83/// metering: [`remaining`](Self::remaining) reports `u64::MAX`, so every
84/// guard passes trivially and [`used`](Self::used) reports 0.
85#[derive(Clone, Copy)]
86pub struct CuBudget {
87    /// Remaining CU at the moment [`snapshot`](Self::snapshot) was taken
88    /// (`u64::MAX` off-chain, where nothing is metered).
89    snapshot: u64,
90}
91
92impl CuBudget {
93    /// Remaining compute units for the current invocation.
94    ///
95    /// On BPF, reads the `sol_remaining_compute_units` syscall
96    /// (SIMD-0049; not activated on any public cluster, see the type
97    /// docs). Off-chain, returns `u64::MAX`, host builds have no CU
98    /// meter, so guards built on this pass trivially, matching the
99    /// crate's other host fallbacks.
100    #[inline(always)]
101    pub fn remaining() -> u64 {
102        #[cfg(target_os = "solana")]
103        {
104            // SAFETY: nullary syscall with no memory arguments; the
105            // runtime returns the invocation's remaining CU by value, so
106            // there are no pointer, layout, or aliasing obligations.
107            unsafe { crate::syscalls::sol_remaining_compute_units() }
108        }
109        #[cfg(not(target_os = "solana"))]
110        {
111            u64::MAX
112        }
113    }
114
115    /// Take a snapshot of the current compute budget.
116    ///
117    /// Stores the remaining CU at call time (via
118    /// [`remaining`](Self::remaining)), enabling the snapshot/check
119    /// pattern: `let b = CuBudget::snapshot(); ...; b.used()`.
120    ///
121    /// Note: this no longer emits a `sol_log_compute_units` log line the
122    /// way pre-SIMD-0049 versions did; it *reads* instead. Use
123    /// [`checkpoint`](Self::checkpoint) for the logging behavior.
124    #[inline(always)]
125    pub fn snapshot() -> Self {
126        Self {
127            snapshot: Self::remaining(),
128        }
129    }
130
131    /// Compute units consumed since this snapshot was taken.
132    ///
133    /// Saturates at 0 (the remaining budget can only decrease within an
134    /// invocation, but saturating keeps hostile or host-side inputs from
135    /// wrapping). Off-chain this is always 0.
136    #[inline(always)]
137    pub fn used(&self) -> u64 {
138        self.snapshot.saturating_sub(Self::remaining())
139    }
140
141    /// Log the current compute unit consumption for profiling.
142    ///
143    /// Emits via `sol_log_compute_units` on BPF. Use this to instrument
144    /// hot paths and identify CU bottlenecks.
145    #[inline(always)]
146    pub fn checkpoint() {
147        #[cfg(target_os = "solana")]
148        // SAFETY: The syscall takes no pointer and has no memory
149        // precondition.
150        unsafe {
151            crate::syscalls::sol_log_compute_units_();
152        }
153    }
154
155    /// Assert that at least `min_remaining` CU are available.
156    ///
157    /// On BPF this is a **real guard** (SIMD-0049): it reads the remaining
158    /// budget and returns `Err(ProgramError::Custom(`[`ERR_INSUFFICIENT_CU`]`))`
159    /// when it is below the floor, letting the program fail cleanly (state
160    /// untouched, clear error) instead of hard-aborting mid-CPI when the
161    /// runtime exhausts the meter.
162    ///
163    /// Off-chain, [`remaining`](Self::remaining) is `u64::MAX`, so this
164    /// always returns Ok.
165    #[inline(always)]
166    pub fn require_remaining(&self, min_remaining: u64) -> ProgramResult {
167        Self::floor_check(Self::remaining(), min_remaining)
168    }
169
170    /// Pure comparison core of [`require_remaining`](Self::require_remaining),
171    /// factored out so the reject branch is host-testable (host builds can
172    /// never observe a low `remaining()`).
173    #[inline(always)]
174    fn floor_check(remaining: u64, min_remaining: u64) -> ProgramResult {
175        if remaining < min_remaining {
176            return Err(ProgramError::Custom(ERR_INSUFFICIENT_CU));
177        }
178        Ok(())
179    }
180
181    /// Log CU consumed since the snapshot.
182    ///
183    /// Emits a structured log that off-chain tools can parse.
184    /// Format: `"cu-delta: <label>"`
185    #[inline(always)]
186    pub fn log_delta(&self, label: &str) {
187        Self::checkpoint();
188        crate::log::log(label);
189    }
190}
191
192/// Structured CU tracing macro for profiling.
193///
194/// When the `cu-trace` feature is enabled, emits both a compute-unit
195/// log and a label log, allowing off-chain tooling to reconstruct
196/// a CU flame graph from program logs.
197///
198/// When `cu-trace` is NOT enabled, this is a complete no-op with zero
199/// CU cost.
200///
201/// # Usage
202///
203/// ```ignore
204/// cu_trace!("validate_accounts");
205/// // ... validation code ...
206/// cu_trace!("begin_cpi");
207/// ```
208#[macro_export]
209macro_rules! cu_trace {
210    ( $label:expr ) => {{
211        if $crate::budget::CU_TRACE_ENABLED {
212            $crate::budget::CuBudget::checkpoint();
213            $crate::log::log(concat!("[cu-trace] ", $label));
214        }
215    }};
216}
217
218/// Run a closure and log the CU consumed by it (feature-gated).
219///
220/// Returns the closure's result. When `cu-trace` is not enabled,
221/// just runs the closure with zero overhead.
222///
223/// # Usage
224///
225/// ```ignore
226/// let result = cu_measure!("deserialize", || {
227///     parse_instruction_data(data)
228/// });
229/// ```
230#[macro_export]
231macro_rules! cu_measure {
232    ( $label:expr, $body:expr ) => {{
233        if $crate::budget::CU_TRACE_ENABLED {
234            $crate::budget::CuBudget::checkpoint();
235            $crate::log::log(concat!("[cu-start] ", $label));
236        }
237        let __result = $body;
238        if $crate::budget::CU_TRACE_ENABLED {
239            $crate::budget::CuBudget::checkpoint();
240            $crate::log::log(concat!("[cu-end] ", $label));
241        }
242        __result
243    }};
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    // Host builds have no CU meter, so the on-chain read path cannot be
251    // exercised here; these pin the host contract (guards pass trivially)
252    // and the pure reject/accept core the on-chain path routes through.
253
254    #[test]
255    fn host_remaining_is_unmetered_max() {
256        assert_eq!(CuBudget::remaining(), u64::MAX);
257    }
258
259    #[test]
260    fn host_snapshot_reports_zero_used_and_passes_guards() {
261        let budget = CuBudget::snapshot();
262        assert_eq!(budget.used(), 0);
263        assert!(budget.require_remaining(u64::MAX).is_ok());
264    }
265
266    #[test]
267    fn floor_check_rejects_below_floor_with_coded_error() {
268        assert_eq!(
269            CuBudget::floor_check(49_999, 50_000),
270            Err(ProgramError::Custom(ERR_INSUFFICIENT_CU))
271        );
272        assert_eq!(ERR_INSUFFICIENT_CU, 0xE000);
273    }
274
275    #[test]
276    fn floor_check_accepts_at_and_above_floor() {
277        assert!(CuBudget::floor_check(50_000, 50_000).is_ok());
278        assert!(CuBudget::floor_check(u64::MAX, 0).is_ok());
279        assert!(CuBudget::floor_check(0, 0).is_ok());
280    }
281}