Skip to main content

ethrex_levm/
memory.rs

1use std::{cell::RefCell, rc::Rc};
2
3use crate::{
4    constants::{MEMORY_EXPANSION_QUOTIENT, WORD_SIZE_IN_BYTES_U64, WORD_SIZE_IN_BYTES_USIZE},
5    errors::{ExceptionalHalt, InternalError, VMError},
6};
7use ExceptionalHalt::OutOfBounds;
8use bytes::Bytes;
9use ethrex_common::{
10    U256,
11    utils::{u256_from_big_endian_const, u256_to_big_endian},
12};
13
14/// A cheaply clonable callframe-shared memory buffer.
15///
16/// When a new callframe is created a RC clone of this memory is made, with the current base offset at the length of the buffer at that time.
17#[derive(Debug, Clone)]
18pub struct Memory {
19    pub buffer: Rc<RefCell<Vec<u8>>>,
20    pub len: usize,
21    current_base: usize,
22}
23
24impl Memory {
25    #[inline]
26    pub fn new() -> Self {
27        Self {
28            buffer: Rc::new(RefCell::new(Vec::new())),
29            len: 0,
30            current_base: 0,
31        }
32    }
33
34    /// Resets this memory so its buffer can be reused from a pool by the next transaction:
35    /// drops all contents (length → 0, capacity retained) and rebases to 0.
36    ///
37    /// Truncating the buffer to length 0 is REQUIRED for correctness, not just hygiene:
38    /// [`Memory::resize`] only zero-fills bytes grown *past* `buffer.len()`, so handing a
39    /// non-empty buffer to the next tx would expose stale data from the previous one (a
40    /// consensus bug). Capacity is kept so the grown allocation is reused.
41    #[inline]
42    pub fn reset_for_reuse(&mut self) {
43        self.buf_mut().clear();
44        self.len = 0;
45        self.current_base = 0;
46    }
47
48    /// Gets the Memory for the next children callframe.
49    #[inline]
50    pub fn next_memory(&self) -> Memory {
51        let mut mem = self.clone();
52        mem.current_base = mem.buffer.borrow().len();
53        mem.len = 0;
54        mem
55    }
56
57    /// Cleans the memory from base onwards, this must be used in callframes when handling returns.
58    ///
59    /// On the callframe that is about to be dropped.
60    #[inline]
61    pub fn clean_from_base(&self) {
62        #[expect(unsafe_code)]
63        unsafe {
64            self.buffer
65                .borrow_mut()
66                .get_unchecked_mut(self.current_base..(self.current_base.wrapping_add(self.len)))
67                .fill(0);
68        }
69    }
70
71    /// Truncates the memory back to base. This is crucial for constrained
72    /// memory in zkVMs. The memory is not freed, but rather shrunk in `len`,
73    /// so that the already allocated `capacity` is reused.
74    #[cfg(target_arch = "riscv64")]
75    #[inline]
76    pub fn truncate_to_base(&self) {
77        self.buf_mut().truncate(self.current_base);
78    }
79
80    /// Unchecked shared access to the backing buffer, skipping `RefCell`'s runtime
81    /// borrow-flag read/branch/inc/dec. Sound because the guest is single-threaded
82    /// and only the current call frame ever mutates its own memory region, so
83    /// these borrows never overlap or alias — the same invariant `clean_from_base`
84    /// and the `get_unchecked` reads already rely on. Used only on the hot
85    /// MLOAD/MSTORE paths where the borrow is taken, used locally, and dropped.
86    #[expect(unsafe_code, reason = "single active frame; no overlapping borrows")]
87    #[inline]
88    fn buf(&self) -> &Vec<u8> {
89        unsafe { &*self.buffer.as_ptr() }
90    }
91
92    /// Unchecked mutable counterpart of [`Memory::buf`]; same invariant.
93    #[expect(unsafe_code, reason = "single active frame; no overlapping borrows")]
94    #[expect(clippy::mut_from_ref, reason = "single active frame; see buf()")]
95    #[inline]
96    fn buf_mut(&self) -> &mut Vec<u8> {
97        unsafe { &mut *self.buffer.as_ptr() }
98    }
99
100    /// Returns the len of the current memory, from the current base.
101    #[inline]
102    pub fn len(&self) -> usize {
103        self.len
104    }
105
106    #[inline]
107    pub fn is_empty(&self) -> bool {
108        self.len() == 0
109    }
110
111    /// Returns a copy of the live byte slice for this frame (from `current_base` to
112    /// `current_base + len`).  Used by the struct-log tracer for memory capture.
113    pub fn live_bytes(&self) -> Vec<u8> {
114        if self.len == 0 {
115            return Vec::new();
116        }
117        let buf = self.buf();
118        let end = self.current_base.saturating_add(self.len);
119        buf.get(self.current_base..end)
120            .map(<[u8]>::to_vec)
121            .unwrap_or_default()
122    }
123
124    /// Resizes the from the current base to fit the memory specified at new_memory_size.
125    ///
126    /// Note: new_memory_size is increased to the next 32 byte multiple.
127    #[inline(always)]
128    pub fn resize(&mut self, new_memory_size: usize) -> Result<(), VMError> {
129        if new_memory_size == 0 {
130            return Ok(());
131        }
132
133        let new_memory_size = new_memory_size
134            .checked_next_multiple_of(WORD_SIZE_IN_BYTES_USIZE)
135            .ok_or(OutOfBounds)?;
136
137        let current_len = self.len();
138
139        if new_memory_size <= current_len {
140            return Ok(());
141        }
142
143        self.len = new_memory_size;
144
145        let buffer = self.buf_mut();
146
147        #[allow(clippy::arithmetic_side_effects)]
148        let real_new_memory_size = new_memory_size + self.current_base;
149
150        if real_new_memory_size > buffer.len() {
151            // when resizing, avoid really small resizes.
152            let new_size = real_new_memory_size.next_multiple_of(64);
153            buffer.resize(new_size, 0);
154        }
155
156        Ok(())
157    }
158
159    /// Load `size` bytes from the given offset. Returning a Bytes.
160    #[inline]
161    pub fn load_range(&mut self, offset: usize, size: usize) -> Result<Bytes, VMError> {
162        if size == 0 {
163            return Ok(Bytes::new());
164        }
165
166        let new_size = offset.checked_add(size).ok_or(OutOfBounds)?;
167        self.resize(new_size)?;
168
169        let true_offset = offset.wrapping_add(self.current_base);
170
171        let buf = self.buf();
172
173        // SAFETY: resize already makes sure bounds are correct.
174        #[allow(unsafe_code)]
175        unsafe {
176            Ok(Bytes::copy_from_slice(buf.get_unchecked(
177                true_offset..(true_offset.wrapping_add(size)),
178            )))
179        }
180    }
181
182    /// Borrow `size` bytes from the given offset and pass them to `f`, without
183    /// allocating a `Bytes` copy of the range.
184    ///
185    /// `load_range` reads through `self.buf()`, whose `Ref` guard cannot
186    /// outlive this call — so a `-> &[u8]` accessor can't be written. Callers that
187    /// only need to read the range (e.g. hashing) take the borrow via this closure
188    /// instead. Semantics match `load_range` exactly, including zero-padding reads
189    /// past the current length (handled by `resize`).
190    #[inline]
191    pub fn with_range<R>(
192        &mut self,
193        offset: usize,
194        size: usize,
195        f: impl FnOnce(&[u8]) -> R,
196    ) -> Result<R, VMError> {
197        if size == 0 {
198            return Ok(f(&[]));
199        }
200
201        let new_size = offset.checked_add(size).ok_or(OutOfBounds)?;
202        self.resize(new_size)?;
203
204        let true_offset = offset.wrapping_add(self.current_base);
205
206        let buf = self.buf();
207
208        // SAFETY: resize already makes sure bounds are correct.
209        #[allow(unsafe_code)]
210        let range = unsafe { buf.get_unchecked(true_offset..(true_offset.wrapping_add(size))) };
211        Ok(f(range))
212    }
213
214    /// Load N bytes from the given offset.
215    #[inline(always)]
216    pub fn load_range_const<const N: usize>(&mut self, offset: usize) -> Result<[u8; N], VMError> {
217        let new_size = offset.checked_add(N).ok_or(OutOfBounds)?;
218        self.resize(new_size)?;
219
220        let true_offset = offset.checked_add(self.current_base).ok_or(OutOfBounds)?;
221
222        let buf = self.buf();
223        // SAFETY: resize already makes sure bounds are correct.
224        #[allow(unsafe_code)]
225        unsafe {
226            Ok(*buf
227                .get_unchecked(true_offset..(true_offset.wrapping_add(N)))
228                .as_ptr()
229                .cast::<[u8; N]>())
230        }
231    }
232
233    /// Load a word from at the given offset.
234    #[inline(always)]
235    pub fn load_word(&mut self, offset: usize) -> Result<U256, VMError> {
236        let value: [u8; 32] = self.load_range_const(offset)?;
237        Ok(u256_from_big_endian_const(value))
238    }
239
240    /// Stores the given data and data size at the given offset.
241    ///
242    /// Internal use.
243    #[inline(always)]
244    fn store(&self, data: &[u8], at_offset: usize, data_size: usize) -> Result<(), VMError> {
245        if data_size == 0 {
246            return Ok(());
247        }
248
249        let real_offset = self.current_base.wrapping_add(at_offset);
250
251        let buffer = self.buf_mut();
252
253        let real_data_size = data_size.min(data.len());
254
255        // SAFETY: Used internally, resize always called before this function.
256        #[allow(clippy::indexing_slicing, clippy::arithmetic_side_effects)]
257        #[allow(unsafe_code)]
258        unsafe {
259            std::ptr::copy_nonoverlapping(
260                data.get_unchecked(..real_data_size).as_ptr(),
261                buffer
262                    .get_unchecked_mut(real_offset..(real_offset + real_data_size))
263                    .as_mut_ptr(),
264                real_data_size,
265            );
266        }
267
268        Ok(())
269    }
270
271    /// Stores the given data at the given offset.
272    #[inline(always)]
273    pub fn store_data(&mut self, offset: usize, data: &[u8]) -> Result<(), VMError> {
274        if data.is_empty() {
275            return Ok(());
276        }
277        let new_size = offset.checked_add(data.len()).ok_or(OutOfBounds)?;
278        self.resize(new_size)?;
279        self.store(data, offset, data.len())
280    }
281
282    /// Stores data and zero-pads up to total_size at the given offset.
283    #[inline(always)]
284    pub fn store_data_zero_padded(
285        &mut self,
286        offset: usize,
287        data: &[u8],
288        total_size: usize,
289    ) -> Result<(), VMError> {
290        if total_size == 0 {
291            return Ok(());
292        }
293
294        let new_size = offset.checked_add(total_size).ok_or(OutOfBounds)?;
295        self.resize(new_size)?;
296
297        let copy_size = data.len().min(total_size);
298        if copy_size > 0 {
299            self.store(data, offset, copy_size)?;
300        }
301
302        #[allow(clippy::arithmetic_side_effects)]
303        if copy_size < total_size {
304            // SAFETY: copy_size < total_size and offset + total_size didn't overflow (checked above),
305            // so offset + copy_size cannot overflow.
306            let zero_offset = offset.wrapping_add(copy_size);
307            let zero_size = total_size - copy_size;
308            let real_offset = self.current_base.wrapping_add(zero_offset);
309            let buffer = self.buf_mut();
310
311            // resize ensures bounds are correct
312            #[expect(unsafe_code)]
313            unsafe {
314                buffer
315                    .get_unchecked_mut(real_offset..real_offset.wrapping_add(zero_size))
316                    .fill(0);
317            }
318        }
319
320        Ok(())
321    }
322
323    /// Stores a word at the given offset, resizing memory if needed.
324    #[inline(always)]
325    pub fn store_word(&mut self, offset: usize, word: U256) -> Result<(), VMError> {
326        let new_size: usize = offset
327            .checked_add(WORD_SIZE_IN_BYTES_USIZE)
328            .ok_or(OutOfBounds)?;
329
330        self.resize(new_size)?;
331        self.store(&u256_to_big_endian(word), offset, WORD_SIZE_IN_BYTES_USIZE)?;
332        Ok(())
333    }
334
335    /// Like [`resize`], but `new_memory_size` MUST already be rounded up to a
336    /// multiple of `WORD_SIZE_IN_BYTES_USIZE` (e.g. the value returned by
337    /// `calculate_memory_size`). Skips the internal re-rounding the caller has
338    /// already performed for the gas charge.
339    #[inline]
340    pub fn resize_prerounded(&mut self, new_memory_size: usize) -> Result<(), VMError> {
341        debug_assert_eq!(new_memory_size % WORD_SIZE_IN_BYTES_USIZE, 0);
342        if new_memory_size == 0 {
343            return Ok(());
344        }
345        let current_len = self.len();
346        if new_memory_size <= current_len {
347            return Ok(());
348        }
349        self.len = new_memory_size;
350        let buffer = self.buf_mut();
351        #[allow(clippy::arithmetic_side_effects)]
352        let real_new_memory_size = new_memory_size + self.current_base;
353        if real_new_memory_size > buffer.len() {
354            let new_size = real_new_memory_size.next_multiple_of(64);
355            buffer.resize(new_size, 0);
356        }
357        Ok(())
358    }
359
360    /// [`store_word`] variant taking the pre-rounded memory size (see
361    /// [`resize_prerounded`]) so the word-rounding is computed once by the
362    /// caller instead of twice per MSTORE.
363    pub fn store_word_prerounded(
364        &mut self,
365        offset: usize,
366        word: U256,
367        new_memory_size: usize,
368    ) -> Result<(), VMError> {
369        self.resize_prerounded(new_memory_size)?;
370        self.store(&u256_to_big_endian(word), offset, WORD_SIZE_IN_BYTES_USIZE)?;
371        Ok(())
372    }
373
374    /// [`load_word`] variant taking the pre-rounded memory size (see
375    /// [`resize_prerounded`]) so MLOAD computes the word-rounding once (for its
376    /// gas charge) instead of twice.
377    pub fn load_word_prerounded(
378        &mut self,
379        offset: usize,
380        new_memory_size: usize,
381    ) -> Result<U256, VMError> {
382        self.resize_prerounded(new_memory_size)?;
383        let true_offset = offset.checked_add(self.current_base).ok_or(OutOfBounds)?;
384        let buf = self.buf();
385        // SAFETY: resize_prerounded above guarantees `true_offset + 32` is in bounds.
386        #[allow(unsafe_code)]
387        let value: [u8; WORD_SIZE_IN_BYTES_USIZE] = unsafe {
388            *buf.get_unchecked(true_offset..(true_offset.wrapping_add(WORD_SIZE_IN_BYTES_USIZE)))
389                .as_ptr()
390                .cast::<[u8; WORD_SIZE_IN_BYTES_USIZE]>()
391        };
392        Ok(u256_from_big_endian_const(value))
393    }
394
395    /// Copies memory within 2 offsets. Like a memmove.
396    ///
397    /// Resizes if needed, because one can copy from "expanded memory", which is initialized with zeroes.
398    pub fn copy_within(
399        &mut self,
400        from_offset: usize,
401        to_offset: usize,
402        size: usize,
403    ) -> Result<(), VMError> {
404        if size == 0 {
405            return Ok(());
406        }
407
408        self.resize(
409            to_offset
410                .max(from_offset)
411                .checked_add(size)
412                .ok_or(InternalError::Overflow)?,
413        )?;
414
415        let true_from_offset = from_offset
416            .checked_add(self.current_base)
417            .ok_or(OutOfBounds)?;
418
419        let true_to_offset = to_offset
420            .checked_add(self.current_base)
421            .ok_or(OutOfBounds)?;
422        let buffer = self.buf_mut();
423
424        buffer.copy_within(
425            true_from_offset
426                ..(true_from_offset
427                    .checked_add(size)
428                    .ok_or(InternalError::Overflow)?),
429            true_to_offset,
430        );
431
432        Ok(())
433    }
434
435    #[inline(always)]
436    pub fn store_zeros(&mut self, offset: usize, size: usize) -> Result<(), VMError> {
437        if size == 0 {
438            return Ok(());
439        }
440
441        let new_size = offset.checked_add(size).ok_or(OutOfBounds)?;
442        self.resize(new_size)?;
443
444        let real_offset = self.current_base.wrapping_add(offset);
445        let buffer = self.buf_mut();
446
447        // resize ensures bounds are correct
448        #[expect(unsafe_code)]
449        unsafe {
450            buffer
451                .get_unchecked_mut(real_offset..(real_offset.wrapping_add(size)))
452                .fill(0);
453        }
454
455        Ok(())
456    }
457}
458
459impl Default for Memory {
460    fn default() -> Self {
461        Self::new()
462    }
463}
464
465/// When a memory expansion is triggered, only the additional bytes of memory
466/// must be paid for.
467#[inline]
468pub fn expansion_cost(new_memory_size: usize, current_memory_size: usize) -> Result<u64, VMError> {
469    let cost = if new_memory_size <= current_memory_size {
470        0
471    } else {
472        // We already know new_memory_size > current_memory_size,
473        // and cost(x) > cost(y) where x > y, so cost should not underflow.
474        cost(new_memory_size)?.wrapping_sub(cost(current_memory_size)?)
475    };
476    Ok(cost)
477}
478
479/// The total cost for a given memory size.
480/// Gas cost should always be computed in u64
481#[inline]
482fn cost(memory_size: usize) -> Result<u64, VMError> {
483    let memory_size = u64::try_from(memory_size).map_err(|_| InternalError::TypeConversion)?;
484
485    // memory size measured in 32 byte words
486    let words = memory_size.div_ceil(WORD_SIZE_IN_BYTES_U64);
487
488    // Cost(words) ≈ floor(words^2 / q) + 3 * words
489    // For this to overflow memory size in words should be 2^32, which is impossible.
490    #[expect(clippy::arithmetic_side_effects)]
491    let gas_cost = words * words / MEMORY_EXPANSION_QUOTIENT + 3 * words;
492
493    Ok(gas_cost)
494}
495
496#[inline]
497pub fn calculate_memory_size(offset: usize, size: usize) -> Result<usize, VMError> {
498    if size == 0 {
499        return Ok(0);
500    }
501
502    offset
503        .checked_add(size)
504        .and_then(|sum| sum.checked_next_multiple_of(WORD_SIZE_IN_BYTES_USIZE))
505        .ok_or(OutOfBounds.into())
506}