Skip to main content

zksync_vm2/
vm.rs

1use std::fmt;
2
3use primitive_types::{H160, U256};
4use zksync_vm2_interface::{opcodes::TypeLevelCallingMode, CallingMode, HeapId, Tracer};
5
6use crate::{
7    callframe::{Callframe, FrameRemnant},
8    decommit::{materialize_decommit_page, u256_into_address, DecommitOpcodeOutcome},
9    instruction::ExecutionStatus,
10    page_ids::{aux_heap_page_from_base, code_page_from_base, heap_page_from_base},
11    stack::StackPool,
12    state::{State, StateSnapshot},
13    world_diff::{ExternalSnapshot, Snapshot, WorldDiff},
14    ExecutionEnd, Program, World,
15};
16
17/// [`VirtualMachine`] settings.
18#[derive(Debug, Clone)]
19pub struct Settings {
20    /// Bytecode hash of the default account abstraction contract.
21    pub default_aa_code_hash: [u8; 32],
22    /// Bytecode hash of the EVM interpreter.
23    pub evm_interpreter_code_hash: [u8; 32],
24    /// Writing to this address in the bootloader's heap suspends execution
25    pub hook_address: u32,
26}
27
28/// High-performance out-of-circuit EraVM implementation.
29#[derive(Debug)]
30pub struct VirtualMachine<T, W> {
31    pub(crate) world_diff: WorldDiff,
32    pub(crate) state: State<T, W>,
33    pub(crate) settings: Settings,
34    pub(crate) stack_pool: StackPool,
35    pub(crate) snapshot: Option<VmSnapshot>,
36}
37
38impl<T: Tracer, W: World<T>> VirtualMachine<T, W> {
39    /// Creates a new VM instance.
40    pub fn new(
41        address: H160,
42        program: Program<T, W>,
43        caller: H160,
44        calldata: &[u8],
45        gas: u32,
46        settings: Settings,
47    ) -> Self {
48        let world_diff = WorldDiff::default();
49        let world_before_this_frame = world_diff.snapshot();
50        let mut stack_pool = StackPool::default();
51
52        Self {
53            world_diff,
54            state: State::new(
55                address,
56                caller,
57                calldata,
58                gas,
59                program,
60                world_before_this_frame,
61                stack_pool.get(),
62            ),
63            settings,
64            stack_pool,
65            snapshot: None,
66        }
67    }
68
69    /// Pre-reserve dynamic heap-group capacity to suppress mid-execution
70    /// Vec doublings inside `Heaps`. Hint with the worst-case far-call
71    /// count estimable from the witness.
72    pub fn reserve_dynamic_heap_capacity(&mut self, n: usize) {
73        self.state.heaps.reserve_dynamic_groups(n);
74    }
75
76    /// Provides a reference to the [`World`] diff accumulated by VM execution so far.
77    pub fn world_diff(&self) -> &WorldDiff {
78        &self.world_diff
79    }
80
81    /// Provides a mutable reference to the [`World`] diff accumulated by VM execution so far.
82    ///
83    /// It is unsound to mutate [`WorldDiff`] in the middle of VM execution in the general case; thus, this method should only be used in tests.
84    #[doc(hidden)]
85    pub fn world_diff_mut(&mut self) -> &mut WorldDiff {
86        &mut self.world_diff
87    }
88
89    /// Manually warms up a decommit of the code with the provided `code_hash`, materializing its
90    /// heap page exactly like the [`Decommit`](zksync_vm2_interface::opcodes::Decommit) opcode
91    /// handler does. Returns `true` if this was a fresh decommit (i.e., the code wasn't decommitted
92    /// previously in the same VM run).
93    ///
94    /// Unlike [`WorldDiff::decommit_opcode`], this records the decommit in the VM state (assigning a
95    /// reusable page), so a subsequent `decommit` opcode on the same hash is recognized as
96    /// already-decommitted and refunded — matching legacy `zk_evm` `execute_decommit` semantics.
97    ///
98    /// This is intended for execution-verification setup / tests; it can break VM operation if
99    /// called in the middle of execution.
100    #[doc(hidden)]
101    pub fn manually_decommit(&mut self, world: &mut W, tracer: &mut T, code_hash: U256) -> bool {
102        match self.world_diff.decommit_opcode(world, tracer, code_hash) {
103            DecommitOpcodeOutcome::Fresh(code) => {
104                // Materialize into a fresh code page rather than reusing the current frame's heap,
105                // so manual decommits performed between frames (e.g. against the bootloader frame)
106                // don't clobber live heap data.
107                let base_page = self.state.allocate_base_page();
108                materialize_decommit_page(self, code_hash, &code, code_page_from_base(base_page));
109                true
110            }
111            DecommitOpcodeOutcome::Cached(_) => false,
112        }
113    }
114
115    /// Runs this VM with the specified [`World`] and [`Tracer`] until an end of execution due to a hook, or an error.
116    pub fn run(&mut self, world: &mut W, tracer: &mut T) -> ExecutionEnd {
117        unsafe {
118            loop {
119                if let ExecutionStatus::Stopped(end) =
120                    ((*self.state.current_frame.pc).handler)(self, world, tracer)
121                {
122                    return end;
123                }
124            }
125        }
126    }
127
128    /// Returns how much of the extra gas limit is left and the stop reason,
129    /// unless the extra gas limit was exceeded.
130    ///
131    /// Needed to support account validation gas limit.
132    /// We cannot simply reduce the available gas, as contracts might behave differently
133    /// depending on remaining gas.
134    pub fn resume_with_additional_gas_limit(
135        &mut self,
136        world: &mut W,
137        tracer: &mut T,
138        gas_limit: u32,
139    ) -> Option<(u32, ExecutionEnd)> {
140        let minimum_gas = self.state.total_unspent_gas().saturating_sub(gas_limit);
141
142        let end = unsafe {
143            loop {
144                if let ExecutionStatus::Stopped(end) =
145                    ((*self.state.current_frame.pc).handler)(self, world, tracer)
146                {
147                    break end;
148                }
149
150                if self.state.total_unspent_gas() < minimum_gas {
151                    return None;
152                }
153            }
154        };
155
156        self.state
157            .total_unspent_gas()
158            .checked_sub(minimum_gas)
159            .map(|left| (left, end))
160    }
161
162    /// Creates a VM snapshot. The snapshot can then be rolled back to, or discarded.
163    ///
164    /// # Panics
165    ///
166    /// - Panics if called outside the initial (bootloader) callframe.
167    /// - Panics if this VM already has a snapshot.
168    pub fn make_snapshot(&mut self) {
169        assert!(self.snapshot.is_none(), "VM already has a snapshot");
170        assert!(
171            self.state.previous_frames.is_empty(),
172            "Snapshotting is only allowed in the bootloader"
173        );
174
175        self.snapshot = Some(VmSnapshot {
176            world_snapshot: self.world_diff.external_snapshot(),
177            state_snapshot: self.state.snapshot(),
178        });
179    }
180
181    /// Returns the VM to the state it was in when [`Self::make_snapshot()`] was called.
182    ///
183    /// # Panics
184    ///
185    /// - Panics if this VM doesn't hold a snapshot.
186    /// - Panics if called outside the initial (bootloader) callframe.
187    pub fn rollback(&mut self) {
188        assert!(
189            self.state.previous_frames.is_empty(),
190            "Rolling back is only allowed in the bootloader"
191        );
192
193        let snapshot = self
194            .snapshot
195            .take()
196            .expect("`rollback()` called without a snapshot");
197        self.world_diff.external_rollback(snapshot.world_snapshot);
198        self.state.rollback(snapshot.state_snapshot, |heap| {
199            self.world_diff.is_decommit_page_pinned(heap)
200        });
201        self.delete_history();
202    }
203
204    /// Pops a [previously made](Self::make_snapshot()) snapshot without rolling back to it. This effectively commits
205    /// all changes made up to this point, so that they cannot be rolled back.
206    ///
207    /// # Panics
208    ///
209    /// - Panics if called outside the initial (bootloader) callframe.
210    pub fn pop_snapshot(&mut self) {
211        assert!(
212            self.state.previous_frames.is_empty(),
213            "Popping a snapshot is only allowed in the bootloader"
214        );
215        self.snapshot = None;
216        self.delete_history();
217        self.reclaim_bootloader_returndata_heaps();
218    }
219
220    /// Frees the returndata heaps that accumulated on the bootloader frame while
221    /// the just-committed transaction(s) executed.
222    ///
223    /// Every far-call keeps the heap holding its returndata alive and bubbles it
224    /// up to the caller (see [`Self::pop_frame`]); heaps forwarded all the way to
225    /// the bootloader frame — which never pops during a batch — otherwise live
226    /// until the VM is dropped, so they accumulate across every transaction (the
227    /// dominant heap-memory consumer on large batches).
228    ///
229    /// This is the safe point to release them: the callstack is unwound to the
230    /// bootloader (`previous_frames` is empty), the external snapshot has just
231    /// been discarded (`self.snapshot` is `None`) and history deleted, so no
232    /// rollback can reference these heaps; and a committed transaction's
233    /// returndata is dead once the bootloader moves on. Decommit-pinned code
234    /// pages (shared across transactions by hash) are kept — the same predicate
235    /// [`Self::pop_frame`] uses. Freed chunks return to the heap `ChunkPool` and
236    /// are reused by the next transaction, so peak memory stays at roughly one
237    /// transaction's worth instead of growing with the transaction count.
238    fn reclaim_bootloader_returndata_heaps(&mut self) {
239        // `kept` is owned after the take, so the `retain` closure can borrow
240        // `self.world_diff`/`self.state.heaps` without conflicting with the
241        // borrow of the field it compacts. Retain drops the deallocated heaps
242        // in place, avoiding a second allocation. Reordering is irrelevant here:
243        // there is no live snapshot to consume the tail ordering (see the len()
244        // snapshot / tail-drain rollback path in `Callframe`).
245        let mut kept = std::mem::take(&mut self.state.current_frame.heaps_i_am_keeping_alive);
246        kept.retain(|&heap| {
247            let pinned = self.world_diff.is_decommit_page_pinned(heap);
248            if !pinned {
249                self.state.heaps.deallocate(heap);
250            }
251            pinned
252        });
253        self.state.current_frame.heaps_i_am_keeping_alive = kept;
254    }
255
256    /// This must only be called when it is known that the VM cannot be rolled back,
257    /// so there must not be any external snapshots and the callstack
258    /// should ideally be empty, though in practice it sometimes contains
259    /// a near call inside the bootloader.
260    fn delete_history(&mut self) {
261        self.world_diff.delete_history();
262        self.state.delete_history();
263    }
264}
265
266impl<T: Tracer, W> VirtualMachine<T, W> {
267    #[allow(clippy::too_many_arguments)]
268    pub(crate) fn push_frame<M: TypeLevelCallingMode>(
269        &mut self,
270        code_address: H160,
271        program: Program<T, W>,
272        gas: u32,
273        exception_handler: u16,
274        is_static: bool,
275        is_evm_blob_format: bool,
276        calldata_heap: HeapId,
277        world_before_this_frame: Snapshot,
278    ) {
279        let base_page = self.state.allocate_base_page();
280        let heap_page = heap_page_from_base(base_page);
281        let aux_heap_page = aux_heap_page_from_base(base_page);
282        self.state.heaps.allocate_at(heap_page);
283        self.state.heaps.allocate_at(aux_heap_page);
284
285        let mut new_frame = Callframe::new(
286            if M::VALUE == CallingMode::Delegate {
287                self.state.current_frame.address
288            } else {
289                code_address
290            },
291            code_address,
292            match M::VALUE {
293                CallingMode::Normal => self.state.current_frame.address,
294                CallingMode::Delegate => self.state.current_frame.caller,
295                CallingMode::Mimic => u256_into_address(self.state.registers[15]),
296            },
297            program,
298            self.stack_pool.get(),
299            heap_page,
300            aux_heap_page,
301            calldata_heap,
302            gas,
303            exception_handler,
304            if M::VALUE == CallingMode::Delegate {
305                self.state.current_frame.context_u128
306            } else {
307                self.state.context_u128
308            },
309            is_static,
310            is_evm_blob_format,
311            world_before_this_frame,
312        );
313        self.state.context_u128 = 0;
314
315        std::mem::swap(&mut new_frame, &mut self.state.current_frame);
316        self.state.previous_frames.push(new_frame);
317    }
318
319    /// Pops the current frame, returning the caller's exception handler and world snapshot.
320    ///
321    /// `heap_to_keep` and `keep_window` are the page and the `(start, length)` of the fat pointer
322    /// the frame returns, and must come from the *same* pointer: the page is spared from
323    /// deallocation, and if the dying frame owns it, every chunk outside the window is freed. Pass
324    /// `None`/`None` when no pointer is returned, as on a panic.
325    pub(crate) fn pop_frame(
326        &mut self,
327        heap_to_keep: Option<HeapId>,
328        keep_window: Option<(u32, u32)>,
329    ) -> Option<FrameRemnant> {
330        let mut frame = self.state.previous_frames.pop()?;
331
332        for &heap in [
333            self.state.current_frame.heap,
334            self.state.current_frame.aux_heap,
335        ]
336        .iter()
337        .chain(&self.state.current_frame.heaps_i_am_keeping_alive)
338        {
339            if Some(heap) != heap_to_keep && !self.world_diff.is_decommit_page_pinned(heap) {
340                self.state.heaps.deallocate(heap);
341            }
342        }
343
344        // The kept returndata heap survives, but freeing the chunks outside
345        // `[start, start + length)` is sound only while no live frame can *name* the page. For a
346        // page the dying frame owns, the returned pointer is the only handle left (pointers narrow,
347        // never widen, and every register but r1 is cleared on return), so this is observably
348        // equivalent to keeping the page and caps retained memory at what the callee returned.
349        //
350        // Hence the `heap`/`aux_heap` test: a kernel frame may return a pointer naming *any* page,
351        // including its `calldata_heap`, which belongs to a still-live older frame (the `is_kernel`
352        // branch in `naked_ret`, mirroring zk_evm). That page is read via `HeapRead`, which no
353        // pointer bounds, and zk_evm never frees a page at all, so compacting it would be a silent
354        // consensus divergence. Do not widen the test to `heaps_i_am_keeping_alive`: that list is
355        // filled after the swap below with the *child's* returned page, so it can hold a live
356        // ancestor's heap. Decommit-pinned pages stay intact even when owned, because `Decommit`
357        // materializes into `current_frame.heap` and the pin is their only protection.
358        //
359        // Ownership is necessary but not sufficient, which is why the invariant is "name" rather
360        // than "address through a pointer": `PrecompileCall`'s `memory_page_to_read` and the
361        // `read_heap_byte`/`read_heap_u256` tracer API take a raw page id and see a compacted page
362        // as zeros, including in the owned case compacted here. A kernel frame can hash bytes
363        // outside the window for a different digest than zk_evm; only convention in
364        // `era-contracts` — no shipping caller passes a foreign page id — keeps that out of
365        // production, nothing enforced here. The keep-alive *deallocation* sinks above and in
366        // `reclaim_bootloader_returndata_heaps` share the trigger and remain open: they predate
367        // this and can free a live frame's whole page or panic on a duplicate `deallocate`.
368        if let (Some(heap), Some((start, length))) = (heap_to_keep, keep_window) {
369            // `current_frame` is still the dying frame here — the `mem::swap` is below.
370            let dying = &self.state.current_frame;
371            if (heap == dying.heap || heap == dying.aux_heap)
372                && !self.world_diff.is_decommit_page_pinned(heap)
373            {
374                self.state.heaps.compact_to_window(heap, start, length);
375            }
376        }
377
378        std::mem::swap(&mut self.state.current_frame, &mut frame);
379        let Callframe {
380            exception_handler,
381            world_before_this_frame,
382            stack,
383            ..
384        } = frame;
385
386        self.stack_pool.recycle(stack);
387
388        self.state
389            .current_frame
390            .heaps_i_am_keeping_alive
391            .extend(heap_to_keep);
392
393        Some(FrameRemnant {
394            exception_handler,
395            snapshot: world_before_this_frame,
396        })
397    }
398
399    pub(crate) fn start_new_tx(&mut self) {
400        self.state.transaction_number = self.state.transaction_number.wrapping_add(1);
401        self.world_diff.clear_transient_storage();
402    }
403}
404
405impl<T: fmt::Debug, W: fmt::Debug> VirtualMachine<T, W> {
406    /// Dumps an opaque representation of the current VM state.
407    #[doc(hidden)] // should only be used in tests
408    pub fn dump_state(&self) -> impl PartialEq + fmt::Debug {
409        self.state.clone()
410    }
411}
412
413/// Snapshot of a [`VirtualMachine`].
414#[derive(Debug)]
415pub(crate) struct VmSnapshot {
416    world_snapshot: ExternalSnapshot,
417    state_snapshot: StateSnapshot,
418}