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}