Skip to main content

hopper_core/account/
realloc_guard.rs

1//! Realloc session guard -- cumulative growth-limit enforcement.
2//!
3//! Tracks original account sizes at instruction entry and enforces
4//! a per-instruction growth budget. Prevents runaway realloc chains
5//! that could exhaust transaction compute or memory budgets.
6//!
7//! ## Wire model
8//!
9//! Entirely stack-based. No account data overhead -- the guard lives
10//! in the instruction handler's stack frame and is discarded at return.
11//!
12//! ## Usage
13//!
14//! ```ignore
15//! let mut guard = ReallocGuard::<8>::new(10240); // 10KB budget
16//! guard.register(0, vault_account.data_len());
17//! guard.register(1, config_account.data_len());
18//!
19//! // Later, when reallocating:
20//! guard.check_growth(0, new_size)?;
21//! safe_realloc(vault_account, new_size, payer)?;
22//! guard.commit_growth(0, new_size);
23//! ```
24
25use hopper_runtime::error::ProgramError;
26
27/// Per-instruction realloc budget guard.
28///
29/// `N` is the maximum number of accounts to track (const generic, stack-allocated).
30/// Typical values: 4, 8, or 16.
31pub struct ReallocGuard<const N: usize> {
32    /// Original sizes of tracked accounts.
33    original: [u32; N],
34    /// Current sizes of tracked accounts (updated after each realloc).
35    current: [u32; N],
36    /// Number of registered accounts.
37    count: usize,
38    /// Maximum cumulative growth allowed (bytes).
39    budget: u32,
40    /// Cumulative growth consumed so far.
41    consumed: u32,
42}
43
44impl<const N: usize> ReallocGuard<N> {
45    /// Create a new guard with the given growth budget (bytes).
46    #[inline(always)]
47    pub const fn new(budget: u32) -> Self {
48        Self {
49            original: [0u32; N],
50            current: [0u32; N],
51            count: 0,
52            budget,
53            consumed: 0,
54        }
55    }
56
57    /// Register an account's original size for tracking.
58    ///
59    /// `slot` is the local tracking index (0..N-1), not the account index.
60    /// `size` is the current data length.
61    /// Returns `Err` if `slot >= N`.
62    #[inline(always)]
63    pub fn register(&mut self, slot: usize, size: usize) -> Result<(), ProgramError> {
64        if slot >= N {
65            return Err(ProgramError::InvalidArgument);
66        }
67        let size32 = size as u32;
68        self.original[slot] = size32;
69        self.current[slot] = size32;
70        if slot >= self.count {
71            self.count = slot + 1;
72        }
73        Ok(())
74    }
75
76    /// Check if growing account `slot` to `new_size` is within budget.
77    ///
78    /// Does NOT commit the growth -- call `commit_growth` after the
79    /// actual realloc succeeds.
80    #[inline]
81    pub fn check_growth(&self, slot: usize, new_size: usize) -> Result<(), ProgramError> {
82        if slot >= self.count {
83            return Err(ProgramError::InvalidArgument);
84        }
85        let new_size32 = new_size as u32;
86        let current = self.current[slot];
87
88        if new_size32 <= current {
89            // Shrinking or same -- always allowed.
90            return Ok(());
91        }
92
93        let delta = new_size32 - current;
94        let new_consumed = self
95            .consumed
96            .checked_add(delta)
97            .ok_or(ProgramError::ArithmeticOverflow)?;
98
99        if new_consumed > self.budget {
100            return Err(ProgramError::InvalidRealloc);
101        }
102
103        Ok(())
104    }
105
106    /// Commit a growth after successful realloc.
107    ///
108    /// Must be called after the actual `safe_realloc` succeeds.
109    /// Returns `Err` if `slot` is not registered.
110    #[inline(always)]
111    pub fn commit_growth(&mut self, slot: usize, new_size: usize) -> Result<(), ProgramError> {
112        if slot >= self.count {
113            return Err(ProgramError::InvalidArgument);
114        }
115        let new_size32 = new_size as u32;
116        let current = self.current[slot];
117
118        if new_size32 > current {
119            let delta = new_size32 - current;
120            self.consumed += delta;
121        } else if new_size32 < current {
122            // Shrinking: return budget credit.
123            let credit = current - new_size32;
124            self.consumed = self.consumed.saturating_sub(credit);
125        }
126
127        self.current[slot] = new_size32;
128        Ok(())
129    }
130
131    /// Remaining budget (bytes).
132    #[inline(always)]
133    pub const fn remaining(&self) -> u32 {
134        self.budget.saturating_sub(self.consumed)
135    }
136
137    /// Total consumed growth (bytes).
138    #[inline(always)]
139    pub const fn consumed(&self) -> u32 {
140        self.consumed
141    }
142
143    /// Budget cap (bytes).
144    #[inline(always)]
145    pub const fn budget(&self) -> u32 {
146        self.budget
147    }
148
149    /// Growth of a specific slot from its original size (bytes).
150    #[inline(always)]
151    pub fn slot_growth(&self, slot: usize) -> i32 {
152        if slot >= self.count {
153            return 0;
154        }
155        self.current[slot] as i32 - self.original[slot] as i32
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn basic_growth_tracking() {
165        let mut guard = ReallocGuard::<4>::new(1024);
166        guard.register(0, 100).unwrap();
167        guard.register(1, 200).unwrap();
168
169        // Growth within budget
170        assert!(guard.check_growth(0, 200).is_ok());
171        guard.commit_growth(0, 200).unwrap();
172        assert_eq!(guard.consumed(), 100);
173        assert_eq!(guard.remaining(), 924);
174
175        // Check slot growth
176        assert_eq!(guard.slot_growth(0), 100);
177        assert_eq!(guard.slot_growth(1), 0);
178    }
179
180    #[test]
181    fn budget_exceeded() {
182        let mut guard = ReallocGuard::<4>::new(100);
183        guard.register(0, 50).unwrap();
184
185        // Try to grow beyond budget
186        assert!(guard.check_growth(0, 200).is_err()); // 150 > 100 budget
187    }
188
189    #[test]
190    fn shrink_returns_credit() {
191        let mut guard = ReallocGuard::<4>::new(200);
192        guard.register(0, 100).unwrap();
193
194        // Grow
195        guard.commit_growth(0, 200).unwrap();
196        assert_eq!(guard.consumed(), 100);
197
198        // Shrink -- returns credit
199        guard.commit_growth(0, 150).unwrap();
200        assert_eq!(guard.consumed(), 50);
201        assert_eq!(guard.remaining(), 150);
202    }
203
204    #[test]
205    fn same_size_is_noop() {
206        let mut guard = ReallocGuard::<4>::new(100);
207        guard.register(0, 100).unwrap();
208
209        assert!(guard.check_growth(0, 100).is_ok());
210        guard.commit_growth(0, 100).unwrap();
211        assert_eq!(guard.consumed(), 0);
212    }
213
214    #[test]
215    fn register_out_of_bounds() {
216        let mut guard = ReallocGuard::<2>::new(1024);
217        assert!(guard.register(0, 100).is_ok());
218        assert!(guard.register(1, 200).is_ok());
219        assert!(guard.register(2, 300).is_err()); // N=2, slot 2 is OOB
220    }
221
222    #[test]
223    fn commit_unregistered_slot() {
224        let mut guard = ReallocGuard::<4>::new(1024);
225        guard.register(0, 100).unwrap();
226        // slot 3 was never registered
227        assert!(guard.commit_growth(3, 200).is_err());
228    }
229}