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/// The input of a call frame: a transaction's data, or a range of the calling frame's memory.
15///
16/// A call's arguments are read where they are instead of being copied for each call, so a
17/// CALL-family opcode costs the same whatever the size of its argument region. This is safe
18/// because the caller's memory cannot change while the callee runs: the caller grows its
19/// memory over the argument and return regions before the call, and only the running frame
20/// writes memory, past its own base. The callee's growth can still move the whole buffer to a
21/// new allocation, so a range is located through the buffer on every read rather than held
22/// as a pointer.
23#[derive(Debug, Clone)]
24pub enum CallData {
25    Owned(Bytes),
26    CallerMemory(CallerRange),
27}
28
29/// A range of a calling frame's memory, built only by [`Memory::calldata_view`] so that it
30/// always lies in a region that stays unchanged while the callee runs.
31#[derive(Debug, Clone)]
32pub struct CallerRange {
33    buffer: Rc<RefCell<Vec<u8>>>,
34    /// Offset of the range in the shared buffer, not relative to the caller's base.
35    start: usize,
36    len: usize,
37}
38
39impl Default for CallData {
40    fn default() -> Self {
41        Self::Owned(Bytes::new())
42    }
43}
44
45impl From<Bytes> for CallData {
46    fn from(bytes: Bytes) -> Self {
47        Self::Owned(bytes)
48    }
49}
50
51impl CallData {
52    #[inline]
53    pub fn len(&self) -> usize {
54        match self {
55            Self::Owned(bytes) => bytes.len(),
56            Self::CallerMemory(range) => range.len,
57        }
58    }
59
60    #[inline]
61    pub fn is_empty(&self) -> bool {
62        self.len() == 0
63    }
64
65    /// Passes the input to `f` as a slice. For a range of caller memory the buffer stays
66    /// borrowed while `f` runs, so `f` must not write memory.
67    #[inline]
68    pub fn with_slice<R>(&self, f: impl FnOnce(&[u8]) -> R) -> Result<R, VMError> {
69        match self {
70            Self::Owned(bytes) => Ok(f(bytes)),
71            Self::CallerMemory(range) => {
72                let buffer = range.buffer.borrow();
73                let end = range.start.checked_add(range.len).ok_or(OutOfBounds)?;
74                // The buffer never shrinks below a live frame's memory, so this only fails
75                // if that invariant is broken.
76                let data = buffer.get(range.start..end).ok_or_else(|| {
77                    InternalError::Custom("call data outside the memory buffer".to_owned())
78                })?;
79                Ok(f(data))
80            }
81        }
82    }
83
84    /// The input as `Bytes`: shared when owned, copied when it is a range of caller memory.
85    /// For uses that outlive the call, such as traces.
86    pub fn to_bytes(&self) -> Result<Bytes, VMError> {
87        match self {
88            Self::Owned(bytes) => Ok(bytes.clone()),
89            Self::CallerMemory(_) => self.with_slice(Bytes::copy_from_slice),
90        }
91    }
92
93    /// The 32-byte word at `offset`, zero-padded past the end of the input (CALLDATALOAD).
94    #[inline]
95    pub fn load_word(&self, offset: usize) -> Result<U256, VMError> {
96        self.with_slice(|data| {
97            let mut word = [0u8; WORD_SIZE_IN_BYTES_USIZE];
98            if let Some(tail) = data.get(offset..) {
99                let available = tail.len().min(WORD_SIZE_IN_BYTES_USIZE);
100                #[expect(clippy::indexing_slicing, reason = "available is within both slices")]
101                word[..available].copy_from_slice(&tail[..available]);
102            }
103            u256_from_big_endian_const(word)
104        })
105    }
106}
107
108/// A cheaply clonable callframe-shared memory buffer.
109///
110/// 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.
111#[derive(Debug, Clone)]
112pub struct Memory {
113    pub buffer: Rc<RefCell<Vec<u8>>>,
114    pub len: usize,
115    current_base: usize,
116}
117
118impl Memory {
119    #[inline]
120    pub fn new() -> Self {
121        Self {
122            buffer: Rc::new(RefCell::new(Vec::new())),
123            len: 0,
124            current_base: 0,
125        }
126    }
127
128    /// Resets this memory so its buffer can be reused from a pool by the next transaction:
129    /// drops all contents (length → 0, capacity retained) and rebases to 0.
130    ///
131    /// Truncating the buffer to length 0 is REQUIRED for correctness, not just hygiene:
132    /// [`Memory::resize`] only zero-fills bytes grown *past* `buffer.len()`, so handing a
133    /// non-empty buffer to the next tx would expose stale data from the previous one (a
134    /// consensus bug). Capacity is kept so the grown allocation is reused.
135    #[inline]
136    pub fn reset_for_reuse(&mut self) {
137        self.buf_mut().clear();
138        self.len = 0;
139        self.current_base = 0;
140    }
141
142    /// Gets the Memory for the next children callframe.
143    #[inline]
144    pub fn next_memory(&self) -> Memory {
145        let mut mem = self.clone();
146        mem.current_base = mem.buffer.borrow().len();
147        mem.len = 0;
148        mem
149    }
150
151    /// Cleans the memory from base onwards, this must be used in callframes when handling returns.
152    ///
153    /// On the callframe that is about to be dropped.
154    #[inline]
155    pub fn clean_from_base(&self) {
156        #[expect(unsafe_code)]
157        unsafe {
158            self.buffer
159                .borrow_mut()
160                .get_unchecked_mut(self.current_base..(self.current_base.wrapping_add(self.len)))
161                .fill(0);
162        }
163    }
164
165    /// Truncates the memory back to base. This is crucial for constrained
166    /// memory in zkVMs. The memory is not freed, but rather shrunk in `len`,
167    /// so that the already allocated `capacity` is reused.
168    #[cfg(target_arch = "riscv64")]
169    #[inline]
170    pub fn truncate_to_base(&self) {
171        self.buf_mut().truncate(self.current_base);
172    }
173
174    /// Unchecked shared access to the backing buffer, skipping `RefCell`'s runtime
175    /// borrow-flag read/branch/inc/dec. Sound because the guest is single-threaded
176    /// and only the current call frame ever mutates its own memory region, so
177    /// these borrows never overlap or alias — the same invariant `clean_from_base`
178    /// and the `get_unchecked` reads already rely on. Used only on the hot
179    /// MLOAD/MSTORE paths where the borrow is taken, used locally, and dropped.
180    #[expect(unsafe_code, reason = "single active frame; no overlapping borrows")]
181    #[inline]
182    fn buf(&self) -> &Vec<u8> {
183        unsafe { &*self.buffer.as_ptr() }
184    }
185
186    /// Unchecked mutable counterpart of [`Memory::buf`]; same invariant.
187    #[expect(unsafe_code, reason = "single active frame; no overlapping borrows")]
188    #[expect(clippy::mut_from_ref, reason = "single active frame; see buf()")]
189    #[inline]
190    fn buf_mut(&self) -> &mut Vec<u8> {
191        unsafe { &mut *self.buffer.as_ptr() }
192    }
193
194    /// Returns the len of the current memory, from the current base.
195    #[inline]
196    pub fn len(&self) -> usize {
197        self.len
198    }
199
200    #[inline]
201    pub fn is_empty(&self) -> bool {
202        self.len() == 0
203    }
204
205    /// Returns a copy of the live byte slice for this frame (from `current_base` to
206    /// `current_base + len`).  Used by the struct-log tracer for memory capture.
207    pub fn live_bytes(&self) -> Vec<u8> {
208        if self.len == 0 {
209            return Vec::new();
210        }
211        let buf = self.buf();
212        let end = self.current_base.saturating_add(self.len);
213        buf.get(self.current_base..end)
214            .map(<[u8]>::to_vec)
215            .unwrap_or_default()
216    }
217
218    /// Resizes the from the current base to fit the memory specified at new_memory_size.
219    ///
220    /// Note: new_memory_size is increased to the next 32 byte multiple.
221    #[inline(always)]
222    pub fn resize(&mut self, new_memory_size: usize) -> Result<(), VMError> {
223        if new_memory_size == 0 {
224            return Ok(());
225        }
226
227        let new_memory_size = new_memory_size
228            .checked_next_multiple_of(WORD_SIZE_IN_BYTES_USIZE)
229            .ok_or(OutOfBounds)?;
230
231        let current_len = self.len();
232
233        if new_memory_size <= current_len {
234            return Ok(());
235        }
236
237        self.len = new_memory_size;
238
239        let buffer = self.buf_mut();
240
241        #[allow(clippy::arithmetic_side_effects)]
242        let real_new_memory_size = new_memory_size + self.current_base;
243
244        if real_new_memory_size > buffer.len() {
245            // when resizing, avoid really small resizes.
246            let new_size = real_new_memory_size.next_multiple_of(64);
247            buffer.resize(new_size, 0);
248        }
249
250        Ok(())
251    }
252
253    /// Load `size` bytes from the given offset. Returning a Bytes.
254    #[inline]
255    pub fn load_range(&mut self, offset: usize, size: usize) -> Result<Bytes, VMError> {
256        if size == 0 {
257            return Ok(Bytes::new());
258        }
259
260        let new_size = offset.checked_add(size).ok_or(OutOfBounds)?;
261        self.resize(new_size)?;
262
263        let true_offset = offset.wrapping_add(self.current_base);
264
265        let buf = self.buf();
266
267        // SAFETY: resize already makes sure bounds are correct.
268        #[allow(unsafe_code)]
269        unsafe {
270            Ok(Bytes::copy_from_slice(buf.get_unchecked(
271                true_offset..(true_offset.wrapping_add(size)),
272            )))
273        }
274    }
275
276    /// The `size` bytes at `offset` as a call's input, read in place rather than copied; see
277    /// [`CallData`]. Grows memory over the range first, like `load_range`, so reads past the
278    /// current length see zeros.
279    #[inline]
280    pub fn calldata_view(&mut self, offset: usize, size: usize) -> Result<CallData, VMError> {
281        if size == 0 {
282            return Ok(CallData::default());
283        }
284
285        let new_size = offset.checked_add(size).ok_or(OutOfBounds)?;
286        self.resize(new_size)?;
287
288        Ok(CallData::CallerMemory(CallerRange {
289            buffer: Rc::clone(&self.buffer),
290            start: self.current_base.checked_add(offset).ok_or(OutOfBounds)?,
291            len: size,
292        }))
293    }
294
295    /// Copies `size` bytes of `calldata` from `src_offset` to `dst_offset`, zero-filling past
296    /// the end of the input (CALLDATACOPY).
297    pub fn store_calldata(
298        &mut self,
299        dst_offset: usize,
300        calldata: &CallData,
301        src_offset: usize,
302        size: usize,
303    ) -> Result<(), VMError> {
304        if size == 0 {
305            return Ok(());
306        }
307
308        // Grow first: growing can move the buffer, and with it a caller range being read.
309        let new_size = dst_offset.checked_add(size).ok_or(OutOfBounds)?;
310        self.resize(new_size)?;
311
312        let copied = calldata.len().saturating_sub(src_offset).min(size);
313        if copied > 0 {
314            match calldata {
315                CallData::CallerMemory(range) if Rc::ptr_eq(&range.buffer, &self.buffer) => {
316                    let from = range.start.checked_add(src_offset).ok_or(OutOfBounds)?;
317                    let to = self
318                        .current_base
319                        .checked_add(dst_offset)
320                        .ok_or(OutOfBounds)?;
321                    let from_end = from.checked_add(copied).ok_or(OutOfBounds)?;
322                    let buffer = self.buf_mut();
323                    if from_end > buffer.len() {
324                        return Err(InternalError::Custom(
325                            "call data outside the memory buffer".to_owned(),
326                        )
327                        .into());
328                    }
329                    buffer.copy_within(from..from_end, to);
330                }
331                _ => calldata.with_slice(|data| {
332                    let data = data.get(src_offset..).unwrap_or_default();
333                    self.store(data, dst_offset, copied)
334                })??,
335            }
336        }
337
338        if copied < size {
339            #[expect(clippy::arithmetic_side_effects, reason = "copied < size")]
340            self.store_zeros(dst_offset.wrapping_add(copied), size - copied)?;
341        }
342
343        Ok(())
344    }
345
346    /// Borrow `size` bytes from the given offset and pass them to `f`, without
347    /// allocating a `Bytes` copy of the range.
348    ///
349    /// `load_range` reads through `self.buf()`, whose `Ref` guard cannot
350    /// outlive this call — so a `-> &[u8]` accessor can't be written. Callers that
351    /// only need to read the range (e.g. hashing) take the borrow via this closure
352    /// instead. Semantics match `load_range` exactly, including zero-padding reads
353    /// past the current length (handled by `resize`).
354    #[inline]
355    pub fn with_range<R>(
356        &mut self,
357        offset: usize,
358        size: usize,
359        f: impl FnOnce(&[u8]) -> R,
360    ) -> Result<R, VMError> {
361        if size == 0 {
362            return Ok(f(&[]));
363        }
364
365        let new_size = offset.checked_add(size).ok_or(OutOfBounds)?;
366        self.resize(new_size)?;
367
368        let true_offset = offset.wrapping_add(self.current_base);
369
370        let buf = self.buf();
371
372        // SAFETY: resize already makes sure bounds are correct.
373        #[allow(unsafe_code)]
374        let range = unsafe { buf.get_unchecked(true_offset..(true_offset.wrapping_add(size))) };
375        Ok(f(range))
376    }
377
378    /// Load N bytes from the given offset.
379    #[inline(always)]
380    pub fn load_range_const<const N: usize>(&mut self, offset: usize) -> Result<[u8; N], VMError> {
381        let new_size = offset.checked_add(N).ok_or(OutOfBounds)?;
382        self.resize(new_size)?;
383
384        let true_offset = offset.checked_add(self.current_base).ok_or(OutOfBounds)?;
385
386        let buf = self.buf();
387        // SAFETY: resize already makes sure bounds are correct.
388        #[allow(unsafe_code)]
389        unsafe {
390            Ok(*buf
391                .get_unchecked(true_offset..(true_offset.wrapping_add(N)))
392                .as_ptr()
393                .cast::<[u8; N]>())
394        }
395    }
396
397    /// Load a word from at the given offset.
398    #[inline(always)]
399    pub fn load_word(&mut self, offset: usize) -> Result<U256, VMError> {
400        let value: [u8; 32] = self.load_range_const(offset)?;
401        Ok(u256_from_big_endian_const(value))
402    }
403
404    /// Stores the given data and data size at the given offset.
405    ///
406    /// Internal use.
407    #[inline(always)]
408    fn store(&self, data: &[u8], at_offset: usize, data_size: usize) -> Result<(), VMError> {
409        if data_size == 0 {
410            return Ok(());
411        }
412
413        let real_offset = self.current_base.wrapping_add(at_offset);
414
415        let buffer = self.buf_mut();
416
417        let real_data_size = data_size.min(data.len());
418
419        // SAFETY: Used internally, resize always called before this function.
420        #[allow(clippy::indexing_slicing, clippy::arithmetic_side_effects)]
421        #[allow(unsafe_code)]
422        unsafe {
423            std::ptr::copy_nonoverlapping(
424                data.get_unchecked(..real_data_size).as_ptr(),
425                buffer
426                    .get_unchecked_mut(real_offset..(real_offset + real_data_size))
427                    .as_mut_ptr(),
428                real_data_size,
429            );
430        }
431
432        Ok(())
433    }
434
435    /// Stores the given data at the given offset.
436    #[inline(always)]
437    pub fn store_data(&mut self, offset: usize, data: &[u8]) -> Result<(), VMError> {
438        if data.is_empty() {
439            return Ok(());
440        }
441        let new_size = offset.checked_add(data.len()).ok_or(OutOfBounds)?;
442        self.resize(new_size)?;
443        self.store(data, offset, data.len())
444    }
445
446    /// Stores data and zero-pads up to total_size at the given offset.
447    #[inline(always)]
448    pub fn store_data_zero_padded(
449        &mut self,
450        offset: usize,
451        data: &[u8],
452        total_size: usize,
453    ) -> Result<(), VMError> {
454        if total_size == 0 {
455            return Ok(());
456        }
457
458        let new_size = offset.checked_add(total_size).ok_or(OutOfBounds)?;
459        self.resize(new_size)?;
460
461        let copy_size = data.len().min(total_size);
462        if copy_size > 0 {
463            self.store(data, offset, copy_size)?;
464        }
465
466        #[allow(clippy::arithmetic_side_effects)]
467        if copy_size < total_size {
468            // SAFETY: copy_size < total_size and offset + total_size didn't overflow (checked above),
469            // so offset + copy_size cannot overflow.
470            let zero_offset = offset.wrapping_add(copy_size);
471            let zero_size = total_size - copy_size;
472            let real_offset = self.current_base.wrapping_add(zero_offset);
473            let buffer = self.buf_mut();
474
475            // resize ensures bounds are correct
476            #[expect(unsafe_code)]
477            unsafe {
478                buffer
479                    .get_unchecked_mut(real_offset..real_offset.wrapping_add(zero_size))
480                    .fill(0);
481            }
482        }
483
484        Ok(())
485    }
486
487    /// Stores a word at the given offset, resizing memory if needed.
488    #[inline(always)]
489    pub fn store_word(&mut self, offset: usize, word: U256) -> Result<(), VMError> {
490        let new_size: usize = offset
491            .checked_add(WORD_SIZE_IN_BYTES_USIZE)
492            .ok_or(OutOfBounds)?;
493
494        self.resize(new_size)?;
495        self.store(&u256_to_big_endian(word), offset, WORD_SIZE_IN_BYTES_USIZE)?;
496        Ok(())
497    }
498
499    /// Like [`resize`], but `new_memory_size` MUST already be rounded up to a
500    /// multiple of `WORD_SIZE_IN_BYTES_USIZE` (e.g. the value returned by
501    /// `calculate_memory_size`). Skips the internal re-rounding the caller has
502    /// already performed for the gas charge.
503    #[inline]
504    pub fn resize_prerounded(&mut self, new_memory_size: usize) -> Result<(), VMError> {
505        debug_assert_eq!(new_memory_size % WORD_SIZE_IN_BYTES_USIZE, 0);
506        if new_memory_size == 0 {
507            return Ok(());
508        }
509        let current_len = self.len();
510        if new_memory_size <= current_len {
511            return Ok(());
512        }
513        self.len = new_memory_size;
514        let buffer = self.buf_mut();
515        #[allow(clippy::arithmetic_side_effects)]
516        let real_new_memory_size = new_memory_size + self.current_base;
517        if real_new_memory_size > buffer.len() {
518            let new_size = real_new_memory_size.next_multiple_of(64);
519            buffer.resize(new_size, 0);
520        }
521        Ok(())
522    }
523
524    /// [`store_word`] variant taking the pre-rounded memory size (see
525    /// [`resize_prerounded`]) so the word-rounding is computed once by the
526    /// caller instead of twice per MSTORE.
527    pub fn store_word_prerounded(
528        &mut self,
529        offset: usize,
530        word: U256,
531        new_memory_size: usize,
532    ) -> Result<(), VMError> {
533        self.resize_prerounded(new_memory_size)?;
534        self.store(&u256_to_big_endian(word), offset, WORD_SIZE_IN_BYTES_USIZE)?;
535        Ok(())
536    }
537
538    /// [`load_word`] variant taking the pre-rounded memory size (see
539    /// [`resize_prerounded`]) so MLOAD computes the word-rounding once (for its
540    /// gas charge) instead of twice.
541    pub fn load_word_prerounded(
542        &mut self,
543        offset: usize,
544        new_memory_size: usize,
545    ) -> Result<U256, VMError> {
546        self.resize_prerounded(new_memory_size)?;
547        let true_offset = offset.checked_add(self.current_base).ok_or(OutOfBounds)?;
548        let buf = self.buf();
549        // SAFETY: resize_prerounded above guarantees `true_offset + 32` is in bounds.
550        #[allow(unsafe_code)]
551        let value: [u8; WORD_SIZE_IN_BYTES_USIZE] = unsafe {
552            *buf.get_unchecked(true_offset..(true_offset.wrapping_add(WORD_SIZE_IN_BYTES_USIZE)))
553                .as_ptr()
554                .cast::<[u8; WORD_SIZE_IN_BYTES_USIZE]>()
555        };
556        Ok(u256_from_big_endian_const(value))
557    }
558
559    /// Copies memory within 2 offsets. Like a memmove.
560    ///
561    /// Resizes if needed, because one can copy from "expanded memory", which is initialized with zeroes.
562    pub fn copy_within(
563        &mut self,
564        from_offset: usize,
565        to_offset: usize,
566        size: usize,
567    ) -> Result<(), VMError> {
568        if size == 0 {
569            return Ok(());
570        }
571
572        self.resize(
573            to_offset
574                .max(from_offset)
575                .checked_add(size)
576                .ok_or(InternalError::Overflow)?,
577        )?;
578
579        let true_from_offset = from_offset
580            .checked_add(self.current_base)
581            .ok_or(OutOfBounds)?;
582
583        let true_to_offset = to_offset
584            .checked_add(self.current_base)
585            .ok_or(OutOfBounds)?;
586        let buffer = self.buf_mut();
587
588        buffer.copy_within(
589            true_from_offset
590                ..(true_from_offset
591                    .checked_add(size)
592                    .ok_or(InternalError::Overflow)?),
593            true_to_offset,
594        );
595
596        Ok(())
597    }
598
599    #[inline(always)]
600    pub fn store_zeros(&mut self, offset: usize, size: usize) -> Result<(), VMError> {
601        if size == 0 {
602            return Ok(());
603        }
604
605        let new_size = offset.checked_add(size).ok_or(OutOfBounds)?;
606        self.resize(new_size)?;
607
608        let real_offset = self.current_base.wrapping_add(offset);
609        let buffer = self.buf_mut();
610
611        // resize ensures bounds are correct
612        #[expect(unsafe_code)]
613        unsafe {
614            buffer
615                .get_unchecked_mut(real_offset..(real_offset.wrapping_add(size)))
616                .fill(0);
617        }
618
619        Ok(())
620    }
621}
622
623impl Default for Memory {
624    fn default() -> Self {
625        Self::new()
626    }
627}
628
629/// When a memory expansion is triggered, only the additional bytes of memory
630/// must be paid for.
631#[inline]
632pub fn expansion_cost(new_memory_size: usize, current_memory_size: usize) -> Result<u64, VMError> {
633    let cost = if new_memory_size <= current_memory_size {
634        0
635    } else {
636        // We already know new_memory_size > current_memory_size,
637        // and cost(x) > cost(y) where x > y, so cost should not underflow.
638        cost(new_memory_size)?.wrapping_sub(cost(current_memory_size)?)
639    };
640    Ok(cost)
641}
642
643/// The total cost for a given memory size.
644/// Gas cost should always be computed in u64
645#[inline]
646fn cost(memory_size: usize) -> Result<u64, VMError> {
647    let memory_size = u64::try_from(memory_size).map_err(|_| InternalError::TypeConversion)?;
648
649    // memory size measured in 32 byte words
650    let words = memory_size.div_ceil(WORD_SIZE_IN_BYTES_U64);
651
652    // Cost(words) ≈ floor(words^2 / q) + 3 * words
653    // For this to overflow memory size in words should be 2^32, which is impossible.
654    #[expect(clippy::arithmetic_side_effects)]
655    let gas_cost = words * words / MEMORY_EXPANSION_QUOTIENT + 3 * words;
656
657    Ok(gas_cost)
658}
659
660#[inline]
661pub fn calculate_memory_size(offset: usize, size: usize) -> Result<usize, VMError> {
662    if size == 0 {
663        return Ok(0);
664    }
665
666    offset
667        .checked_add(size)
668        .and_then(|sum| sum.checked_next_multiple_of(WORD_SIZE_IN_BYTES_USIZE))
669        .ok_or(OutOfBounds.into())
670}