From 43a2a0c8fb7fd83493083195a398cad998becdd8 Mon Sep 17 00:00:00 2001 From: kyscott18 <43524469+kyscott18@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:25:09 +0000 Subject: [PATCH 1/5] perf(evm): skip gas-limit estimation in execute Execute ran pass 1, pass 2, a gas-limit binary search (~15 runs), and a final pass: about 18 EVM runs per accepted mutation. Only simulate needs a gas limit, because it builds the onchain transaction. Execute now runs pass 1 and pass 2 only, and its result has no gas_limit. Simulate returns the new SimulateResult type, which keeps gas_limit. Counter benchmark: evm execute 1.13ms -> 0.20ms per mutation. Harness benchmark: 2,236 -> 3,026 mutations/s. --- packages/typewriter-evm/src/harness.rs | 21 ++++++++++++++++----- packages/typewriter-evm/src/index.test.ts | 2 ++ packages/typewriter-evm/src/index.ts | 6 ++++-- packages/typewriter-evm/src/types.ts | 7 ++++++- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/packages/typewriter-evm/src/harness.rs b/packages/typewriter-evm/src/harness.rs index da20e6cb..b9d077e9 100644 --- a/packages/typewriter-evm/src/harness.rs +++ b/packages/typewriter-evm/src/harness.rs @@ -149,7 +149,9 @@ struct ExecuteOk { #[serde(skip_serializing_if = "Option::is_none")] journal_id: Option, gas_used: u64, - gas_limit: u64, + // Only simulate estimates a gas limit; execute leaves it unset. + #[serde(skip_serializing_if = "Option::is_none")] + gas_limit: Option, output: String, access_list: Vec, slot_writes: Vec, @@ -386,7 +388,7 @@ impl EvmHarness { fn execute(&mut self, params: &ExecuteParams) -> Result { self.ensure_initialized()?; - let mut result = match self.run_two_pass(params, None) { + let mut result = match self.run_two_pass(params, None, false) { Ok(result) => result, Err(error) => { let _ = self.evm.finalize(); @@ -429,7 +431,7 @@ impl EvmHarness { } let cached_access_list = self.merge_journal_access_lists(¶ms.journal_ids); - let result = self.run_two_pass(¶ms.as_execute_params(), cached_access_list); + let result = self.run_two_pass(¶ms.as_execute_params(), cached_access_list, true); // Drop pass 2's journal-state writes so they never reach the DB. let _ = self.evm.finalize(); @@ -517,6 +519,11 @@ impl EvmHarness { // those addresses/slots pre-warmed so the metered gas matches what the // chain will charge for a tx broadcast with the discovered access list. // + // With `estimate_gas`, a successful pass 2 is followed by a gas-limit + // search and a final pass at that limit. Only simulate needs this: it + // builds the transaction that goes onchain. Execute only accepts the + // mutation into local state, so it skips the search (about 15 extra runs). + // // After pass 1, finalize drains the journal state and we discard it so // pass 2 starts from the same DB state. The alternative — // checkpoint_revert — doesn't work because transact_one calls commit_tx @@ -525,6 +532,7 @@ impl EvmHarness { &mut self, params: &ExecuteParams, cached_access_list: Option, + estimate_gas: bool, ) -> Result { let from = parse_address(¶ms.from)?; let to = parse_address(¶ms.to)?; @@ -590,6 +598,7 @@ impl EvmHarness { .map_err(|e| format!("transact (pass 2): {e:?}"))?; let gas_limit = match &result { + ExecutionResult::Success { .. } if !estimate_gas => None, ExecutionResult::Success { .. } => { self.evm.0.inspector.clear(); let _ = self.evm.finalize(); @@ -603,9 +612,11 @@ impl EvmHarness { .evm .inspect_one_tx(build_tx(tx_access_list.clone(), gas_limit)) .map_err(|e| format!("transact (final pass): {e:?}"))?; - gas_limit + Some(gas_limit) + } + ExecutionResult::Revert { .. } | ExecutionResult::Halt { .. } => { + estimate_gas.then(|| result.tx_gas_used()) } - ExecutionResult::Revert { .. } | ExecutionResult::Halt { .. } => result.tx_gas_used(), }; let (success, gas_used, output, revert_data) = match &result { diff --git a/packages/typewriter-evm/src/index.test.ts b/packages/typewriter-evm/src/index.test.ts index d0c643d9..93bc88bb 100644 --- a/packages/typewriter-evm/src/index.test.ts +++ b/packages/typewriter-evm/src/index.test.ts @@ -94,6 +94,8 @@ test("execute against counter succeeds and returns a journal id", async () => { expect(exec.success).toBe(true); expect(exec.journal_id).toBe(1); expect(exec.revert_data).toBeUndefined(); + // Only simulate estimates a gas limit. + expect(exec).not.toHaveProperty("gas_limit"); expect(exec.slot_writes).toEqual([ { address: COUNTER_ADDR, diff --git a/packages/typewriter-evm/src/index.ts b/packages/typewriter-evm/src/index.ts index f3a80cb7..949e2faf 100644 --- a/packages/typewriter-evm/src/index.ts +++ b/packages/typewriter-evm/src/index.ts @@ -12,6 +12,7 @@ import type { Request, Response, SimulateParams, + SimulateResult, } from "./types"; export type { @@ -24,6 +25,7 @@ export type { ReadStorageParams, ReadStorageResult, SimulateParams, + SimulateResult, Spec, } from "./types"; @@ -76,7 +78,7 @@ export type EVM = { ) => Effect.Effect; readonly simulate: ( params: SimulateParams, - ) => Effect.Effect; + ) => Effect.Effect; readonly readStorage: ( params: ReadStorageParams, ) => Effect.Effect; @@ -175,7 +177,7 @@ export const createEVM = (): Effect.Effect => params, })), simulate: (params) => - call("simulate", (id) => ({ + call("simulate", (id) => ({ method: "simulate", id, params, diff --git a/packages/typewriter-evm/src/types.ts b/packages/typewriter-evm/src/types.ts index 9030eba2..d245b706 100644 --- a/packages/typewriter-evm/src/types.ts +++ b/packages/typewriter-evm/src/types.ts @@ -44,7 +44,6 @@ export type ExecuteResult = { success: boolean; journal_id?: number; gas_used: number; - gas_limit: number; output: Hex.Hex; access_list: { address: Address.Address; storageKeys: Hex.Hex[] }[]; slot_writes: { @@ -57,6 +56,12 @@ export type ExecuteResult = { revert_data?: Hex.Hex; }; +// Simulate also estimates the smallest gas limit that lets the call succeed. +// Execute skips that search because accepting a mutation does not need it. +export type SimulateResult = ExecuteResult & { + gas_limit: number; +}; + export type ReadStorageParams = { address: Address.Address; slots: Hex.Hex[]; From 9008e4513da827669caaa8a493a10fc24546cccc Mon Sep 17 00:00:00 2001 From: kyscott18 <43524469+kyscott18@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:37:09 +0000 Subject: [PATCH 2/5] perf(evm): run execute in a single pass Execute ran pass 1 to discover the access list, then pass 2 with that access list pre-warmed. Both passes run with the maximum gas limit, so they produce the same state. Pass 2 only changed gas_used, which the runtime does not read from execute. Execute now runs once, keeps that post-state, and records the touched accounts and slots as the journal's access list. Simulate keeps both passes and the gas-limit search. typewriter-evm execute benchmark: erc20 transfer 15.2us -> 12.1us. Counter benchmark: 32 in flight 2,827 -> 3,264-3,493 mutations/s; open loop at 4,000/s now keeps up (3,970/s, p50 ~280ms -> 10-21ms). --- packages/typewriter-evm/src/harness.rs | 115 +++++++++++++++++-------- 1 file changed, 77 insertions(+), 38 deletions(-) diff --git a/packages/typewriter-evm/src/harness.rs b/packages/typewriter-evm/src/harness.rs index b9d077e9..33e325b5 100644 --- a/packages/typewriter-evm/src/harness.rs +++ b/packages/typewriter-evm/src/harness.rs @@ -388,7 +388,7 @@ impl EvmHarness { fn execute(&mut self, params: &ExecuteParams) -> Result { self.ensure_initialized()?; - let mut result = match self.run_two_pass(params, None, false) { + let mut result = match self.run_tx(params, None, false) { Ok(result) => result, Err(error) => { let _ = self.evm.finalize(); @@ -431,7 +431,7 @@ impl EvmHarness { } let cached_access_list = self.merge_journal_access_lists(¶ms.journal_ids); - let result = self.run_two_pass(¶ms.as_execute_params(), cached_access_list, true); + let result = self.run_tx(¶ms.as_execute_params(), cached_access_list, true); // Drop pass 2's journal-state writes so they never reach the DB. let _ = self.evm.finalize(); @@ -515,20 +515,25 @@ impl EvmHarness { Ok(()) } - // Two-pass execution. Pass 1 discovers the access list; pass 2 runs with - // those addresses/slots pre-warmed so the metered gas matches what the - // chain will charge for a tx broadcast with the discovered access list. + // Execute and simulate share this path. // - // With `estimate_gas`, a successful pass 2 is followed by a gas-limit - // search and a final pass at that limit. Only simulate needs this: it - // builds the transaction that goes onchain. Execute only accepts the - // mutation into local state, so it skips the search (about 15 extra runs). + // Execute (`estimate_gas == false`) runs once with no access list and + // leaves the post-state live. The touched accounts and slots become the + // journal's access list, and gas_used is the gas with cold access. Only + // simulate builds the onchain transaction, so only simulate needs warm + // gas and a gas limit. // - // After pass 1, finalize drains the journal state and we discard it so - // pass 2 starts from the same DB state. The alternative — + // Simulate runs pass 1 to discover the access list, unless the caller + // gives one from cached journals. Pass 2 runs with those addresses/slots + // pre-warmed, so the metered gas matches what the chain charges for a tx + // sent with that access list. On success, a gas-limit search and a final + // pass at that limit follow. + // + // Between passes, finalize drains the journal state and we discard it so + // the next pass starts from the same DB state. The alternative — // checkpoint_revert — doesn't work because transact_one calls commit_tx // after success, which clears the revertable journal log. - fn run_two_pass( + fn run_tx( &mut self, params: &ExecuteParams, cached_access_list: Option, @@ -557,6 +562,22 @@ impl EvmHarness { .build_fill() }; + if !estimate_gas { + // Execute: the discovery pass is the only pass. + self.evm.0.inspector.clear(); + let result = self + .evm + .inspect_one_tx(build_tx(AccessList::default(), u64::MAX)) + .map_err(|e| format!("transact (execute): {e:?}"))?; + let tx_access_list = discovered_access_list( + self.evm.0.ctx.journaled_state.evm_state(), + from, + to, + coinbase, + ); + return Ok(self.execute_ok(&result, None, tx_access_list)); + } + let tx_access_list = match cached_access_list { Some(access_list) => access_list, None => { @@ -566,26 +587,16 @@ impl EvmHarness { .evm .inspect_one_tx(build_tx(AccessList::default(), u64::MAX)) .map_err(|e| format!("transact (pass 1): {e:?}"))?; - let touched = collect_access_list(self.evm.0.ctx.journaled_state.evm_state()); + let access_list = discovered_access_list( + self.evm.0.ctx.journaled_state.evm_state(), + from, + to, + coinbase, + ); self.evm.0.inspector.clear(); // Drain pass 1's journal state without committing it. let _ = self.evm.finalize(); - - // collect_access_list includes every account touched during execution. - // Keep address-only entries for cold callees, but drop addresses that - // are already warm at transaction start. - AccessList( - touched - .0 - .iter() - .filter(|item| { - !item.storage_keys.is_empty() - || (!is_intrinsically_warm(item.address, from, to, coinbase) - && !is_precompile(item.address)) - }) - .cloned() - .collect(), - ) + access_list } }; @@ -598,7 +609,6 @@ impl EvmHarness { .map_err(|e| format!("transact (pass 2): {e:?}"))?; let gas_limit = match &result { - ExecutionResult::Success { .. } if !estimate_gas => None, ExecutionResult::Success { .. } => { self.evm.0.inspector.clear(); let _ = self.evm.finalize(); @@ -606,20 +616,27 @@ impl EvmHarness { self.estimate_gas_limit(params, tx_access_list.clone(), result.tx_gas_used())?; // The estimate attempts finalize their own state, so rerun the - // successful pass and leave its journal state live for execute(). + // successful pass at the estimated limit. self.evm.0.inspector.clear(); result = self .evm .inspect_one_tx(build_tx(tx_access_list.clone(), gas_limit)) .map_err(|e| format!("transact (final pass): {e:?}"))?; - Some(gas_limit) - } - ExecutionResult::Revert { .. } | ExecutionResult::Halt { .. } => { - estimate_gas.then(|| result.tx_gas_used()) + gas_limit } + ExecutionResult::Revert { .. } | ExecutionResult::Halt { .. } => result.tx_gas_used(), }; - let (success, gas_used, output, revert_data) = match &result { + Ok(self.execute_ok(&result, Some(gas_limit), tx_access_list)) + } + + fn execute_ok( + &self, + result: &ExecutionResult, + gas_limit: Option, + tx_access_list: AccessList, + ) -> ExecuteOk { + let (success, gas_used, output, revert_data) = match result { ExecutionResult::Success { output, .. } => { let bytes = match output { Output::Call(b) => b.clone(), @@ -649,7 +666,7 @@ impl EvmHarness { let access_list_wire = encode_access_list(&tx_access_list); let keccak_preimages = self.evm.0.inspector.wire_preimages(); - Ok(ExecuteOk { + ExecuteOk { success, journal_id: None, gas_used, @@ -660,7 +677,7 @@ impl EvmHarness { keccak_preimages, revert_data, tx_access_list, - }) + } } fn merge_journal_access_lists(&self, journal_ids: &[u64]) -> Option { @@ -804,6 +821,28 @@ fn collect_access_list(state: &revm::state::EvmState) -> AccessList { AccessList(items) } +// collect_access_list includes every account touched during execution. Keep +// address-only entries for cold callees, but drop addresses that are already +// warm at transaction start. +fn discovered_access_list( + state: &revm::state::EvmState, + from: Address, + to: Address, + coinbase: Address, +) -> AccessList { + AccessList( + collect_access_list(state) + .0 + .into_iter() + .filter(|item| { + !item.storage_keys.is_empty() + || (!is_intrinsically_warm(item.address, from, to, coinbase) + && !is_precompile(item.address)) + }) + .collect(), + ) +} + fn encode_access_list(list: &AccessList) -> Vec { let mut out: Vec = Vec::new(); for item in &list.0 { From 5fe40fd56c1bc1fda474005c736830b951d079f5 Mon Sep 17 00:00:00 2001 From: kyscott18 <43524469+kyscott18@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:41:34 +0000 Subject: [PATCH 3/5] perf(evm): trace the exact gas limit in simulate Simulate now computes the smallest gas limit that keeps the max-gas path in one traced pass, instead of a binary search and a final rerun. The tracer applies the EIP-150 63/64 rule to each call or create and the EIP-2200 SSTORE sentry. Monad has no refunds. A call that halts uses all of its gas, so simulate now returns an error for it. - Split the execute and simulate result types in Rust and TS. - Parse the tx fields once per request. - Filter precompiles from the access list with the spec's precompile set, so P-256 at 0x100 is no longer included. - Add Rust tests that check each traced limit is the minimum, and run them in the package test script. - Add tests for simulate with a cached journal and for the P-256 access list. --- packages/typewriter-evm/package.json | 2 +- packages/typewriter-evm/src/harness.rs | 943 ++++++++++++------ packages/typewriter-evm/src/index.test.ts | 106 ++ packages/typewriter-evm/src/types.ts | 20 +- .../test/contracts/src/PrecompileCaller.sol | 10 + packages/typewriter-evm/test/utils.ts | 16 + 6 files changed, 783 insertions(+), 314 deletions(-) create mode 100644 packages/typewriter-evm/test/contracts/src/PrecompileCaller.sol diff --git a/packages/typewriter-evm/package.json b/packages/typewriter-evm/package.json index d61ef59d..e2d9db8e 100644 --- a/packages/typewriter-evm/package.json +++ b/packages/typewriter-evm/package.json @@ -10,7 +10,7 @@ "benchmark": "bun run build && bun test ./test/*.benchmark.ts", "build": "napi build --release --output-dir native", "build:debug": "napi build --output-dir native", - "test": "bun run build:debug && bun test --timeout 30000", + "test": "cargo test --lib && bun run build:debug && bun test --timeout 30000", "typecheck": "tsc --noEmit", "lint": "cargo fmt --check && cargo clippy -- -D warnings && forge fmt --check test/contracts" }, diff --git a/packages/typewriter-evm/src/harness.rs b/packages/typewriter-evm/src/harness.rs index 33e325b5..3b7eb89e 100644 --- a/packages/typewriter-evm/src/harness.rs +++ b/packages/typewriter-evm/src/harness.rs @@ -8,8 +8,9 @@ // setBlockContext— update block number, timestamp, basefee, coinbase. // execute — run one tx and store its successful state transition as a // journal identified by a numeric id. -// simulate — temporarily undo the given journal ids, run one tx, then -// re-apply the journals; does not create/modify journals. +// simulate — temporarily undo the given journal ids, run one tx and +// trace its gas limit, then re-apply the journals; does +// not create/modify journals. // readStorage — read raw account storage slots from the in-memory DB. // revertJournals — revert and delete the given journal ids. // pruneJournals — delete the given journal ids without touching state. @@ -21,20 +22,23 @@ use monad_revm::{ MonadCfgEnv, MonadHardfork, }; use revm::{ + bytecode::opcode, context::TxEnv, context_interface::{ - result::{ExecutionResult, Output}, + cfg::GasParams, + result::{ExecutionResult, HaltReason, Output}, transaction::{AccessList, AccessListItem}, - JournalTr, + Host, JournalTr, }, database::InMemoryDB, inspector::InspectEvm, interpreter::{ interpreter::EthInterpreter, - interpreter_types::{Jumps, MemoryTr}, - Interpreter, + interpreter_types::{Jumps, LoopControl, MemoryTr}, + CallInputs, CallOutcome, CallValue, CreateInputs, CreateOutcome, Interpreter, + InterpreterAction, InterpreterResult, }, - primitives::{Address, Bytes, TxKind, B256, U256}, + primitives::{Address, AddressSet, Bytes, TxKind, B256, U256}, state::{AccountInfo, Bytecode}, DatabaseRef, ExecuteCommitEvm, ExecuteEvm, Inspector, }; @@ -89,24 +93,11 @@ struct ExecuteParams { #[derive(Deserialize)] struct SimulateParams { - from: String, - to: String, - data: String, - value: Option, + #[serde(flatten)] + tx: ExecuteParams, journal_ids: Vec, } -impl SimulateParams { - fn as_execute_params(&self) -> ExecuteParams { - ExecuteParams { - from: self.from.clone(), - to: self.to.clone(), - data: self.data.clone(), - value: self.value.clone(), - } - } -} - #[derive(Deserialize)] struct ReadStorageParams { address: String, @@ -143,23 +134,34 @@ fn serialize_response(response: &T) -> String { serde_json::to_string(response).unwrap_or_else(|_| RESPONSE_SERIALIZE_ERROR.into()) } +// Fields that execute and simulate both return. #[derive(Serialize)] -struct ExecuteOk { +struct TxOutcome { success: bool, - #[serde(skip_serializing_if = "Option::is_none")] - journal_id: Option, gas_used: u64, - // Only simulate estimates a gas limit; execute leaves it unset. - #[serde(skip_serializing_if = "Option::is_none")] - gas_limit: Option, output: String, access_list: Vec, - slot_writes: Vec, keccak_preimages: Vec, #[serde(skip_serializing_if = "Option::is_none")] revert_data: Option, - #[serde(skip_serializing)] - tx_access_list: AccessList, +} + +// gas_used is the gas with cold access. Execute does not warm an access list. +#[derive(Serialize)] +struct ExecuteOk { + #[serde(flatten)] + outcome: TxOutcome, + #[serde(skip_serializing_if = "Option::is_none")] + journal_id: Option, + slot_writes: Vec, +} + +// gas_used is the gas with the access list warm, as the chain charges it. +#[derive(Serialize)] +struct SimulateOk { + #[serde(flatten)] + outcome: TxOutcome, + gas_limit: u64, } #[derive(Serialize)] @@ -186,14 +188,16 @@ struct KeccakPreimageEntry { // ----------------------------------------------------------------------------- // Harness -type Evm = monad_revm::api::builder::DefaultMonadEvm< - monad_revm::api::default_ctx::MonadContext, - MappingRecoveryInspector, ->; +type MonadCtx = monad_revm::api::default_ctx::MonadContext; + +type Evm = monad_revm::api::builder::DefaultMonadEvm; + +type TxResult = ExecutionResult; #[derive(Default)] -struct MappingRecoveryInspector { +struct HarnessInspector { keccak_preimages: Vec, + gas: GasLimitTracker, } struct RawKeccakPreimage { @@ -201,7 +205,7 @@ struct RawKeccakPreimage { preimage: Vec, } -impl MappingRecoveryInspector { +impl HarnessInspector { fn clear(&mut self) { self.keccak_preimages.clear(); } @@ -215,20 +219,8 @@ impl MappingRecoveryInspector { }) .collect() } -} - -impl Inspector> - for MappingRecoveryInspector -{ - fn step( - &mut self, - interp: &mut Interpreter, - _context: &mut monad_revm::api::default_ctx::MonadContext, - ) { - if interp.bytecode.opcode() != 0x20 { - return; - } + fn record_keccak_preimage(&mut self, interp: &mut Interpreter) { let stack = interp.stack.data(); if stack.len() < 2 { return; @@ -262,6 +254,212 @@ impl Inspector> } } +impl Inspector for HarnessInspector { + fn step(&mut self, interp: &mut Interpreter, context: &mut MonadCtx) { + let opcode = interp.bytecode.opcode(); + if opcode == opcode::KECCAK256 { + self.record_keccak_preimage(interp); + } + self.gas.step(opcode, interp, context); + } + + fn step_end(&mut self, interp: &mut Interpreter, _context: &mut MonadCtx) { + self.gas.step_end(interp); + } + + fn call(&mut self, context: &mut MonadCtx, inputs: &mut CallInputs) -> Option { + let transfers_value = + matches!(inputs.value, CallValue::Transfer(value) if !value.is_zero()); + let stipend = if transfers_value { + context.gas_params().call_stipend() + } else { + 0 + }; + self.gas.frame_start(inputs.gas_limit, stipend); + None + } + + fn call_end( + &mut self, + context: &mut MonadCtx, + _inputs: &CallInputs, + outcome: &mut CallOutcome, + ) { + self.gas.frame_end(&outcome.result, context.gas_params()); + } + + fn create( + &mut self, + _context: &mut MonadCtx, + inputs: &mut CreateInputs, + ) -> Option { + self.gas.frame_start(inputs.gas_limit(), 0); + None + } + + fn create_end( + &mut self, + context: &mut MonadCtx, + _inputs: &CreateInputs, + outcome: &mut CreateOutcome, + ) { + self.gas.frame_end(&outcome.result, context.gas_params()); + } +} + +// Traces the smallest gas limit that runs a tx on the same path as a run at +// max gas. The gas used is not enough for two reasons: CALL and CREATE +// forward at most 63/64 of the remaining gas (EIP-150), and SSTORE fails +// unless more than the call stipend remains (EIP-2200). Monad gives no +// refunds, so refunds do not change the limit. +// +// Each frame records the most gas it spent at any point (`need`). When a +// child frame ends, its need becomes a requirement on the gas the parent must +// have left before the call forwards gas. Contracts do not branch on gas, so +// every other charge is the same at each gas limit that keeps this path, and +// intrinsic gas plus the top frame's need is the exact minimum. +// +// A child that halts uses all the gas it gets. At max gas that is almost all +// of u64::MAX, so no gas limit that the chain accepts can run the tx. +#[derive(Default)] +struct GasLimitTracker { + enabled: bool, + halted: bool, + frames: Vec, + // The last opcode that `step` saw, so `step_end` knows what ran. + opcode: u8, + // Gas the current frame has left after a CALL or CREATE took the gas it + // forwards. The next `frame_start` uses it. + remaining_after_forward: Option, + top_frame: Option, +} + +#[derive(Clone, Copy)] +struct FrameGas { + gas_limit: u64, + need: u64, + // Gas the parent spent before it forwarded gas to this frame. + parent_spent: u64, + stipend: u64, +} + +impl GasLimitTracker { + fn start(&mut self) { + self.enabled = true; + self.halted = false; + self.frames.clear(); + self.remaining_after_forward = None; + self.top_frame = None; + } + + fn finish(&mut self, tx_gas_limit: u64) -> Result { + self.enabled = false; + if self.halted { + return Err("a call halted and used all its gas; no gas limit can run the tx".into()); + } + let top_frame = self + .top_frame + .ok_or_else(|| "gas trace has no top frame".to_string())?; + let intrinsic_gas = tx_gas_limit - top_frame.gas_limit; + Ok(intrinsic_gas + top_frame.need) + } + + fn step(&mut self, opcode: u8, interp: &mut Interpreter, context: &MonadCtx) { + if !self.enabled || interp.bytecode.is_end() { + return; + } + self.opcode = opcode; + if opcode == opcode::SSTORE { + if let Some(frame) = self.frames.last_mut() { + let sentry = interp.gas.total_gas_spent() + context.gas_params().call_stipend() + 1; + frame.need = frame.need.max(sentry); + } + } + } + + fn step_end(&mut self, interp: &mut Interpreter) { + if !self.enabled { + return; + } + let Some(frame) = self.frames.last_mut() else { + return; + }; + let forwards_gas = matches!( + self.opcode, + opcode::CALL + | opcode::CALLCODE + | opcode::DELEGATECALL + | opcode::STATICCALL + | opcode::CREATE + | opcode::CREATE2 + ) && matches!( + interp.bytecode.action(), + Some(InterpreterAction::NewFrame(_)) + ); + if forwards_gas { + // The spent gas now includes the forwarded gas. The child returns + // the gas it does not use, so frame_end records the requirement. + self.remaining_after_forward = Some(interp.gas.remaining()); + } else { + frame.need = frame.need.max(interp.gas.total_gas_spent()); + } + } + + fn frame_start(&mut self, gas_limit: u64, stipend: u64) { + if !self.enabled { + return; + } + let forwarded = gas_limit - stipend; + let parent_spent = match (self.frames.last(), self.remaining_after_forward.take()) { + (Some(parent), Some(remaining)) => parent.gas_limit - (remaining + forwarded), + _ => 0, + }; + self.frames.push(FrameGas { + gas_limit, + need: 0, + parent_spent, + stipend, + }); + } + + fn frame_end(&mut self, result: &InterpreterResult, gas_params: &GasParams) { + if !self.enabled { + return; + } + let Some(mut frame) = self.frames.pop() else { + return; + }; + // A frame with no bytecode (a precompile or an account with no code) + // has no steps, so its need is the gas it used. + frame.need = frame.need.max(frame.gas_limit - result.gas.remaining()); + if !result.result.is_ok_or_revert() { + self.halted = true; + } + match self.frames.last_mut() { + Some(parent) => { + let child_gas = frame.need.saturating_sub(frame.stipend); + let required = frame.parent_spent + min_gas_to_forward(gas_params, child_gas); + parent.need = parent.need.max(required); + } + None => self.top_frame = Some(frame), + } + } +} + +// The smallest remaining gas from which CALL or CREATE forwards at least +// `child_gas` (EIP-150 keeps 1/64 in the caller). +fn min_gas_to_forward(gas_params: &GasParams, child_gas: u64) -> u64 { + let forwarded = |gas: u64| gas_params.call_stipend_reduction(gas); + let mut gas = child_gas.saturating_add(child_gas / 63); + while gas < u64::MAX && forwarded(gas) < child_gas { + gas += 1; + } + while gas > 0 && forwarded(gas - 1) >= child_gas { + gas -= 1; + } + gas +} + // Per-journal pre/post-image record. REVM's transaction execution clears its // internal journal log on success, so cross-tx isolation has to live in // userland: we capture pre-images on first touch within a journal, post- @@ -285,7 +483,7 @@ impl EvmHarness { pub fn new() -> Self { let mut ctx = monad_context_with_db(InMemoryDB::default()); relax_cfg(&mut ctx.cfg); - let evm = ctx.build_monad_with_inspector(MappingRecoveryInspector::default()); + let evm = ctx.build_monad_with_inspector(HarnessInspector::default()); Self { evm, journals: HashMap::new(), @@ -388,36 +586,50 @@ impl EvmHarness { fn execute(&mut self, params: &ExecuteParams) -> Result { self.ensure_initialized()?; - let mut result = match self.run_tx(params, None, false) { - Ok(result) => result, + let tx = TxRequest::parse(params, self.evm.0.ctx.cfg.0.chain_id)?; + // The discovery pass is the only pass. Its post-state is the + // mutation's state, and the accounts and slots it touched become the + // journal's access list. + let (result, access_list) = match self.discovery_pass(&tx) { + Ok(pass) => pass, Err(error) => { let _ = self.evm.finalize(); return Err(error); } }; - if result.success { - result.slot_writes = collect_slot_writes(self.evm.0.ctx.journaled_state.evm_state()); - let mut journal = Journal::default(); - self.record_into_journal(&mut journal); - journal.access_list = result.tx_access_list.clone(); - // Commit the journaled state into the DB so future reads and - // writes see the post-tx state. - self.flush_journal_to_db(); - let journal_id = self.next_journal_id; - self.next_journal_id += 1; - self.journals.insert(journal_id, journal); - result.journal_id = Some(journal_id); - } else { + let outcome = self.tx_outcome(&result, &access_list); + if !outcome.success { // A failed tx is observable as a rejection, not as local state. // Drain and discard any journal state left by revm. let _ = self.evm.finalize(); + return Ok(ExecuteOk { + outcome, + journal_id: None, + slot_writes: Vec::new(), + }); } - Ok(result) + + let slot_writes = collect_slot_writes(self.evm.0.ctx.journaled_state.evm_state()); + let mut journal = Journal::default(); + self.record_into_journal(&mut journal); + journal.access_list = access_list; + // Commit the journaled state into the DB so future reads and + // writes see the post-tx state. + self.flush_journal_to_db(); + let journal_id = self.next_journal_id; + self.next_journal_id += 1; + self.journals.insert(journal_id, journal); + Ok(ExecuteOk { + outcome, + journal_id: Some(journal_id), + slot_writes, + }) } - fn simulate(&mut self, params: &SimulateParams) -> Result { + fn simulate(&mut self, params: &SimulateParams) -> Result { self.ensure_initialized()?; self.ensure_journal_ids(¶ms.journal_ids)?; + let tx = TxRequest::parse(¶ms.tx, self.evm.0.ctx.cfg.0.chain_id)?; { let db = self.evm.0.ctx.journaled_state.db_mut(); @@ -431,9 +643,9 @@ impl EvmHarness { } let cached_access_list = self.merge_journal_access_lists(¶ms.journal_ids); - let result = self.run_tx(¶ms.as_execute_params(), cached_access_list, true); + let result = self.run_simulate(&tx, cached_access_list); - // Drop pass 2's journal-state writes so they never reach the DB. + // Drop the last pass's journal-state writes so they never reach the DB. let _ = self.evm.finalize(); // Re-apply post-images in caller-provided occurrence order regardless @@ -515,168 +727,96 @@ impl EvmHarness { Ok(()) } - // Execute and simulate share this path. - // - // Execute (`estimate_gas == false`) runs once with no access list and - // leaves the post-state live. The touched accounts and slots become the - // journal's access list, and gas_used is the gas with cold access. Only - // simulate builds the onchain transaction, so only simulate needs warm - // gas and a gas limit. - // - // Simulate runs pass 1 to discover the access list, unless the caller - // gives one from cached journals. Pass 2 runs with those addresses/slots - // pre-warmed, so the metered gas matches what the chain charges for a tx - // sent with that access list. On success, a gas-limit search and a final - // pass at that limit follow. + // Run the tx with no access list at max gas and collect the accounts and + // slots it touched. The post-state stays live; the caller commits or + // discards it. // // Between passes, finalize drains the journal state and we discard it so // the next pass starts from the same DB state. The alternative — // checkpoint_revert — doesn't work because transact_one calls commit_tx // after success, which clears the revertable journal log. - fn run_tx( + fn discovery_pass(&mut self, tx: &TxRequest) -> Result<(TxResult, AccessList), String> { + self.evm.0.inspector.clear(); + let result = self + .evm + .inspect_one_tx(tx.tx_env(AccessList::default(), u64::MAX)) + .map_err(|e| format!("transact (discovery pass): {e:?}"))?; + let journaled_state = &self.evm.0.ctx.journaled_state; + let access_list = discovered_access_list( + journaled_state.evm_state(), + journaled_state.precompile_addresses(), + tx, + self.evm.0.ctx.block.beneficiary, + ); + Ok((result, access_list)) + } + + // Simulate discovers the access list, unless the caller gives one from + // cached journals. The measured pass runs at max gas with those addresses + // and slots pre-warmed, so the metered gas matches what the chain charges + // for a tx sent with that access list. The same pass traces the gas limit. + // The post-state stays live. + fn run_simulate( &mut self, - params: &ExecuteParams, + tx: &TxRequest, cached_access_list: Option, - estimate_gas: bool, - ) -> Result { - let from = parse_address(¶ms.from)?; - let to = parse_address(¶ms.to)?; - let data = parse_bytes(¶ms.data)?; - let value = match params.value.as_deref() { - Some(v) => parse_u256(v)?, - None => U256::ZERO, - }; - let chain_id = self.evm.0.ctx.cfg.0.chain_id; - let coinbase = self.evm.0.ctx.block.beneficiary; - - let build_tx = |access_list: AccessList, gas_limit: u64| { - TxEnv::builder() - .caller(from) - .chain_id(Some(chain_id)) - .kind(TxKind::Call(to)) - .gas_limit(gas_limit) - .gas_price(0) - .value(value) - .data(data.clone()) - .access_list(access_list) - .build_fill() - }; - - if !estimate_gas { - // Execute: the discovery pass is the only pass. - self.evm.0.inspector.clear(); - let result = self - .evm - .inspect_one_tx(build_tx(AccessList::default(), u64::MAX)) - .map_err(|e| format!("transact (execute): {e:?}"))?; - let tx_access_list = discovered_access_list( - self.evm.0.ctx.journaled_state.evm_state(), - from, - to, - coinbase, - ); - return Ok(self.execute_ok(&result, None, tx_access_list)); - } - - let tx_access_list = match cached_access_list { + ) -> Result { + let access_list = match cached_access_list { Some(access_list) => access_list, None => { - // Pass 1 — discover the access list from touched accounts/slots. - self.evm.0.inspector.clear(); - let _ = self - .evm - .inspect_one_tx(build_tx(AccessList::default(), u64::MAX)) - .map_err(|e| format!("transact (pass 1): {e:?}"))?; - let access_list = discovered_access_list( - self.evm.0.ctx.journaled_state.evm_state(), - from, - to, - coinbase, - ); - self.evm.0.inspector.clear(); - // Drain pass 1's journal state without committing it. + let (_, access_list) = self.discovery_pass(tx)?; + // Drain the discovery pass's journal state without committing it. let _ = self.evm.finalize(); access_list } }; - // Measure with pre-warmed access list. - self.evm.0.inspector.clear(); - let mut result = self + self.evm.0.inspector.gas.start(); + let result = self .evm - .inspect_one_tx(build_tx(tx_access_list.clone(), u64::MAX)) - .map_err(|e| format!("transact (pass 2): {e:?}"))?; - - let gas_limit = match &result { - ExecutionResult::Success { .. } => { - self.evm.0.inspector.clear(); - let _ = self.evm.finalize(); - let gas_limit = - self.estimate_gas_limit(params, tx_access_list.clone(), result.tx_gas_used())?; - - // The estimate attempts finalize their own state, so rerun the - // successful pass at the estimated limit. - self.evm.0.inspector.clear(); - result = self - .evm - .inspect_one_tx(build_tx(tx_access_list.clone(), gas_limit)) - .map_err(|e| format!("transact (final pass): {e:?}"))?; - gas_limit - } - ExecutionResult::Revert { .. } | ExecutionResult::Halt { .. } => result.tx_gas_used(), + .inspect_one_tx(tx.tx_env(access_list.clone(), u64::MAX)) + .map_err(|e| format!("transact (simulate): {e:?}")); + let traced_gas_limit = self.evm.0.inspector.gas.finish(u64::MAX); + let result = result?; + + let outcome = self.tx_outcome(&result, &access_list); + let gas_limit = if outcome.success { + // tx_gas_used is at least the calldata floor (EIP-7623), and the + // chain rejects a gas limit below the floor. + traced_gas_limit?.max(result.tx_gas_used()) + } else { + result.tx_gas_used() }; - - Ok(self.execute_ok(&result, Some(gas_limit), tx_access_list)) + Ok(SimulateOk { outcome, gas_limit }) } - fn execute_ok( - &self, - result: &ExecutionResult, - gas_limit: Option, - tx_access_list: AccessList, - ) -> ExecuteOk { - let (success, gas_used, output, revert_data) = match result { + fn tx_outcome(&self, result: &TxResult, access_list: &AccessList) -> TxOutcome { + let (success, output, revert_data) = match result { ExecutionResult::Success { output, .. } => { let bytes = match output { - Output::Call(b) => b.clone(), - Output::Create(b, _) => b.clone(), + Output::Call(b) => b, + Output::Create(b, _) => b, }; - ( - true, - result.tx_gas_used(), - format!("0x{}", hex::encode(&bytes)), - None, - ) + (true, format!("0x{}", hex::encode(bytes)), None) } ExecutionResult::Revert { output, .. } => ( false, - result.tx_gas_used(), format!("0x{}", hex::encode(output)), Some(format!("0x{}", hex::encode(output))), ), - ExecutionResult::Halt { reason, .. } => ( - false, - result.tx_gas_used(), - "0x".into(), - Some(format!("halt: {reason:?}")), - ), + ExecutionResult::Halt { reason, .. } => { + (false, "0x".into(), Some(format!("halt: {reason:?}"))) + } }; - let access_list_wire = encode_access_list(&tx_access_list); - let keccak_preimages = self.evm.0.inspector.wire_preimages(); - - ExecuteOk { + TxOutcome { success, - journal_id: None, - gas_used, - gas_limit, + gas_used: result.tx_gas_used(), output, - access_list: access_list_wire, - slot_writes: Vec::new(), - keccak_preimages, + access_list: encode_access_list(access_list), + keccak_preimages: self.evm.0.inspector.wire_preimages(), revert_data, - tx_access_list, } } @@ -710,74 +850,49 @@ impl EvmHarness { Some(AccessList(items)) } +} - fn estimate_gas_limit( - &mut self, - params: &ExecuteParams, - access_list: AccessList, - gas_used: u64, - ) -> Result { - let mut high = gas_used.max(21_000); - while !self.succeeds_with_gas_limit(params, access_list.clone(), high)? { - high = high - .checked_add((high / 64).max(1_000)) - .ok_or_else(|| "gas estimate overflow".to_string())?; - } - - let mut low = gas_used.saturating_sub(1); - while low + 1 < high { - let mid = low + (high - low) / 2; - if self.succeeds_with_gas_limit(params, access_list.clone(), mid)? { - high = mid; - } else { - low = mid; - } - } - - Ok(high) +impl Default for EvmHarness { + fn default() -> Self { + Self::new() } +} - fn succeeds_with_gas_limit( - &mut self, - params: &ExecuteParams, - access_list: AccessList, - gas_limit: u64, - ) -> Result { - let from = parse_address(¶ms.from)?; - let to = parse_address(¶ms.to)?; - let data = parse_bytes(¶ms.data)?; - let value = match params.value.as_deref() { - Some(v) => parse_u256(v)?, - None => U256::ZERO, - }; - let chain_id = self.evm.0.ctx.cfg.0.chain_id; +// The tx fields parsed once per request. Each pass builds its TxEnv from +// these with its own access list and gas limit. +struct TxRequest { + from: Address, + to: Address, + data: Bytes, + value: U256, + chain_id: u64, +} - let tx = TxEnv::builder() - .caller(from) - .chain_id(Some(chain_id)) - .kind(TxKind::Call(to)) +impl TxRequest { + fn parse(params: &ExecuteParams, chain_id: u64) -> Result { + Ok(Self { + from: parse_address(¶ms.from)?, + to: parse_address(¶ms.to)?, + data: parse_bytes(¶ms.data)?, + value: match params.value.as_deref() { + Some(v) => parse_u256(v)?, + None => U256::ZERO, + }, + chain_id, + }) + } + + fn tx_env(&self, access_list: AccessList, gas_limit: u64) -> TxEnv { + TxEnv::builder() + .caller(self.from) + .chain_id(Some(self.chain_id)) + .kind(TxKind::Call(self.to)) .gas_limit(gas_limit) .gas_price(0) - .value(value) - .data(data) + .value(self.value) + .data(self.data.clone()) .access_list(access_list) - .build_fill(); - - self.evm.0.inspector.clear(); - let result = self - .evm - .inspect_one_tx(tx) - .map_err(|e| format!("transact (estimate): {e:?}"))?; - let success = matches!(result, ExecutionResult::Success { .. }); - let _ = self.evm.finalize(); - self.evm.0.inspector.clear(); - Ok(success) - } -} - -impl Default for EvmHarness { - fn default() -> Self { - Self::new() + .build_fill() } } @@ -802,42 +917,33 @@ fn replay(db: &mut InMemoryDB, journal: &Journal) { // ----------------------------------------------------------------------------- // Conversion helpers -// Walk the journal's post-tx state, collecting every account that was -// touched and the storage slots that were loaded for it. This is what we -// pass to pass 2 as the access list, and what we serialize back to TS. -fn collect_access_list(state: &revm::state::EvmState) -> AccessList { - let mut items: Vec = Vec::new(); - for (address, account) in state.iter() { - let storage_keys: Vec = account - .storage - .keys() - .map(|slot| B256::from(slot.to_be_bytes())) - .collect(); - items.push(AccessListItem { - address: *address, - storage_keys, - }); - } - AccessList(items) -} - -// collect_access_list includes every account touched during execution. Keep +// Every account the tx touched, with the storage slots it loaded. Keep // address-only entries for cold callees, but drop addresses that are already -// warm at transaction start. +// warm at transaction start: the sender, the recipient, the coinbase, and the +// precompiles of the active spec. fn discovered_access_list( state: &revm::state::EvmState, - from: Address, - to: Address, + precompiles: &AddressSet, + tx: &TxRequest, coinbase: Address, ) -> AccessList { + let is_warm = |address: &Address| { + *address == tx.from + || *address == tx.to + || *address == coinbase + || precompiles.contains(address) + }; AccessList( - collect_access_list(state) - .0 - .into_iter() - .filter(|item| { - !item.storage_keys.is_empty() - || (!is_intrinsically_warm(item.address, from, to, coinbase) - && !is_precompile(item.address)) + state + .iter() + .filter(|(address, account)| !account.storage.is_empty() || !is_warm(address)) + .map(|(address, account)| AccessListItem { + address: *address, + storage_keys: account + .storage + .keys() + .map(|slot| B256::from(slot.to_be_bytes())) + .collect(), }) .collect(), ) @@ -846,12 +952,11 @@ fn discovered_access_list( fn encode_access_list(list: &AccessList) -> Vec { let mut out: Vec = Vec::new(); for item in &list.0 { - let keys: Vec = item + let mut keys: Vec = item .storage_keys .iter() .map(|k| format!("0x{}", hex::encode(k.as_slice()))) .collect(); - let mut keys = keys; keys.sort(); out.push(AccessListEntry { address: format!("0x{:x}", item.address), @@ -883,20 +988,6 @@ fn collect_slot_writes(state: &revm::state::EvmState) -> Vec { writes } -fn is_intrinsically_warm( - address: Address, - caller: Address, - to: Address, - coinbase: Address, -) -> bool { - address == caller || address == to || address == coinbase -} - -fn is_precompile(address: Address) -> bool { - let bytes = address.as_slice(); - bytes[..19].iter().all(|byte| *byte == 0) && (1..=10).contains(&bytes[19]) -} - fn parse_address(s: &str) -> Result { let s = s.strip_prefix("0x").unwrap_or(s); let bytes = hex::decode(s).map_err(|e| format!("bad address: {e}"))?; @@ -1008,3 +1099,243 @@ fn err(id: u64, error: String) -> String { error, }) } + +#[cfg(test)] +mod tests { + use super::*; + + const CALLER: u16 = 0xca11; + const CONTRACT: u16 = 0x0c01; + const CALLEE: u16 = 0x0c02; + const INNER: u16 = 0x0c03; + + // Increments slot 0: PUSH1 0 DUP1 SLOAD PUSH1 1 ADD SWAP1 SSTORE STOP. + const COUNTER: &[u8] = &[0x60, 0x00, 0x80, 0x54, 0x60, 0x01, 0x01, 0x90, 0x55, 0x00]; + // Revert target at offset 3, then the body starts at offset 8: + // PUSH1 8 JUMP | JUMPDEST PUSH1 0 DUP1 REVERT | JUMPDEST. + const PRELUDE: &[u8] = &[0x60, 0x08, 0x56, 0x5b, 0x60, 0x00, 0x80, 0xfd, 0x5b]; + // ISZERO PUSH1 3 JUMPI: revert unless the top of the stack is nonzero. + const REQUIRE: &[u8] = &[0x15, 0x60, 0x03, 0x57]; + + fn address(n: u16) -> Address { + let mut bytes = [0u8; 20]; + bytes[18..].copy_from_slice(&n.to_be_bytes()); + Address::from(bytes) + } + + fn push_address(code: &mut Vec, target: u16) { + code.push(0x73); + code.extend_from_slice(address(target).as_slice()); + } + + // CALL target with all gas, no calldata, and `value` wei. + fn call(code: &mut Vec, target: u16, value: u8) { + code.extend_from_slice(&[0x60, 0, 0x60, 0, 0x60, 0, 0x60, 0, 0x60, value]); + push_address(code, target); + code.extend_from_slice(&[0x5a, 0xf1]); + } + + // STATICCALL target with all gas and no calldata. + fn static_call(code: &mut Vec, target: u16) { + code.extend_from_slice(&[0x60, 0, 0x60, 0, 0x60, 0, 0x60, 0]); + push_address(code, target); + code.extend_from_slice(&[0x5a, 0xfa]); + } + + // Calls `target` and reverts when the call fails. + fn call_or_revert(target: u16, value: u8) -> Vec { + let mut code = PRELUDE.to_vec(); + call(&mut code, target, value); + code.extend_from_slice(REQUIRE); + code.push(0x00); + code + } + + fn harness(accounts: &[(u16, &[u8])], storage: &[(u16, u8, u8)]) -> EvmHarness { + let mut init = serde_json::Map::new(); + for (account, code) in accounts { + let account_storage: serde_json::Map = storage + .iter() + .filter(|(owner, _, _)| owner == account) + .map(|(_, slot, value)| (format!("0x{slot:x}"), format!("0x{value:x}").into())) + .collect(); + init.insert( + format!("{:#x}", address(*account)), + serde_json::json!({ + "code": format!("0x{}", hex::encode(code)), + "balance": "0x100", + "storage": account_storage, + }), + ); + } + let params: InitParams = + serde_json::from_value(serde_json::json!({ "accounts": init })).unwrap(); + let mut harness = EvmHarness::new(); + harness.init(¶ms).unwrap(); + harness + } + + fn tx(harness: &EvmHarness, to: u16) -> TxRequest { + TxRequest { + from: address(CALLER), + to: address(to), + data: Bytes::new(), + value: U256::ZERO, + chain_id: harness.evm.0.ctx.cfg.0.chain_id, + } + } + + // Returns the traced gas limit and the gas used at max gas. + fn simulate(harness: &mut EvmHarness, to: u16) -> Result<(u64, u64), String> { + let tx = tx(harness, to); + let result = harness.run_simulate(&tx, Some(AccessList::default())); + let _ = harness.evm.finalize(); + let result = result?; + assert!(result.outcome.success); + Ok((result.gas_limit, result.outcome.gas_used)) + } + + // Runs the tx at `gas_limit` and returns whether it succeeded and the + // value it left in `account`'s slot 0. + fn run_at(harness: &mut EvmHarness, to: u16, gas_limit: u64, account: u16) -> (bool, U256) { + let tx = tx(harness, to); + harness.evm.0.inspector.clear(); + let result = harness + .evm + .inspect_one_tx(tx.tx_env(AccessList::default(), gas_limit)) + .unwrap(); + let slot = harness + .evm + .0 + .ctx + .journaled_state + .evm_state() + .get(&address(account)) + .and_then(|account| account.storage.get(&U256::ZERO)) + .map_or(U256::ZERO, |slot| slot.present_value()); + let _ = harness.evm.finalize(); + (matches!(result, ExecutionResult::Success { .. }), slot) + } + + // The traced limit succeeds and one gas less fails. Returns the limit and + // the gas used. + fn assert_exact(harness: &mut EvmHarness, to: u16) -> (u64, u64) { + let (gas_limit, gas_used) = simulate(harness, to).unwrap(); + assert!(run_at(harness, to, gas_limit, to).0); + assert!(!run_at(harness, to, gas_limit - 1, to).0); + (gas_limit, gas_used) + } + + #[test] + fn sstore_needs_more_than_the_call_stipend() { + // PUSH1 0 SLOAD PUSH1 0 SSTORE STOP: a warm SSTORE costs less than + // the 2,300 gas that must remain before it. + let code = [0x60, 0x00, 0x54, 0x60, 0x00, 0x55, 0x00]; + let mut harness = harness(&[(CONTRACT, &code)], &[]); + let (gas_limit, gas_used) = assert_exact(&mut harness, CONTRACT); + assert!(gas_limit > gas_used); + } + + #[test] + fn call_keeps_one_64th() { + let caller = call_or_revert(CALLEE, 0); + let mut harness = harness(&[(CONTRACT, &caller), (CALLEE, COUNTER)], &[]); + let (gas_limit, gas_used) = assert_exact(&mut harness, CONTRACT); + assert!(gas_limit > gas_used); + } + + #[test] + fn nested_calls() { + let outer = call_or_revert(CALLEE, 0); + let middle = call_or_revert(INNER, 0); + let mut harness = harness( + &[(CONTRACT, &outer), (CALLEE, &middle), (INNER, COUNTER)], + &[], + ); + assert_exact(&mut harness, CONTRACT); + } + + #[test] + fn value_call_gets_the_stipend() { + let caller = call_or_revert(CALLEE, 1); + let mut harness = harness(&[(CONTRACT, &caller), (CALLEE, COUNTER)], &[]); + assert_exact(&mut harness, CONTRACT); + } + + #[test] + fn value_call_to_an_account_with_no_code() { + let caller = call_or_revert(INNER, 1); + let mut harness = harness(&[(CONTRACT, &caller)], &[]); + assert_exact(&mut harness, CONTRACT); + } + + #[test] + fn static_call_to_precompiles() { + for precompile in [0x01, 0x100] { + let mut caller = PRELUDE.to_vec(); + static_call(&mut caller, precompile); + caller.extend_from_slice(REQUIRE); + caller.push(0x00); + let mut harness = harness(&[(CONTRACT, &caller)], &[]); + assert_exact(&mut harness, CONTRACT); + } + } + + #[test] + fn create_keeps_one_64th() { + // Init code: PUSH1 1 PUSH1 0 SSTORE PUSH1 0 PUSH1 0 RETURN. + let init_code = [0x60, 0x01, 0x60, 0x00, 0x55, 0x60, 0x00, 0x60, 0x00, 0xf3]; + let mut caller = PRELUDE.to_vec(); + caller.push(0x69); + caller.extend_from_slice(&init_code); + // PUSH1 0 MSTORE, then CREATE(value 0, offset 22, size 10). + caller.extend_from_slice(&[0x60, 0x00, 0x52, 0x60, 0x0a, 0x60, 0x16, 0x60, 0x00, 0xf0]); + caller.extend_from_slice(REQUIRE); + caller.push(0x00); + let mut harness = harness(&[(CONTRACT, &caller)], &[]); + let (gas_limit, gas_used) = assert_exact(&mut harness, CONTRACT); + assert!(gas_limit > gas_used); + } + + #[test] + fn clearing_a_slot_gives_no_refund() { + // PUSH1 0 PUSH1 0 SSTORE STOP clears slot 0. Ethereum refunds gas + // for this, and gas used then falls below the gas the tx needs. + // Monad gives no refunds. + let code = [0x60, 0x00, 0x60, 0x00, 0x55, 0x00]; + let mut harness = harness(&[(CONTRACT, &code)], &[(CONTRACT, 0, 1)]); + let (gas_limit, gas_used) = assert_exact(&mut harness, CONTRACT); + assert_eq!(gas_limit, gas_used); + } + + #[test] + fn ignored_call_failure_keeps_the_max_gas_path() { + // CALL the counter, POP the result, STOP. One gas below the traced + // limit the counter runs out of gas, but the caller still succeeds on + // a different path. + let mut caller = Vec::new(); + call(&mut caller, CALLEE, 0); + caller.extend_from_slice(&[0x50, 0x00]); + let mut harness = harness(&[(CONTRACT, &caller), (CALLEE, COUNTER)], &[]); + let (gas_limit, _) = simulate(&mut harness, CONTRACT).unwrap(); + assert_eq!( + run_at(&mut harness, CONTRACT, gas_limit, CALLEE), + (true, U256::from(1)) + ); + assert_eq!( + run_at(&mut harness, CONTRACT, gas_limit - 1, CALLEE), + (true, U256::ZERO) + ); + } + + #[test] + fn halting_call_has_no_gas_limit() { + // CALL a contract that runs INVALID with all gas, POP, STOP. The + // child uses almost all of u64::MAX gas. + let mut caller = Vec::new(); + call(&mut caller, CALLEE, 0); + caller.extend_from_slice(&[0x50, 0x00]); + let mut harness = harness(&[(CONTRACT, &caller), (CALLEE, &[0xfe])], &[]); + assert!(simulate(&mut harness, CONTRACT).is_err()); + } +} diff --git a/packages/typewriter-evm/src/index.test.ts b/packages/typewriter-evm/src/index.test.ts index 93bc88bb..66b1512f 100644 --- a/packages/typewriter-evm/src/index.test.ts +++ b/packages/typewriter-evm/src/index.test.ts @@ -13,6 +13,7 @@ const COUNTER_ADDR = "0x0000000000000000000000000000000000000c01" as const; const PAGE_WRITES_ADDR = "0x0000000000000000000000000000000000000c02" as const; const REVERTING_ADDR = "0x0000000000000000000000000000000000000bad" as const; const CALLER = "0x000000000000000000000000000000000000ca11" as const; +const P256_VERIFIER = "0x0000000000000000000000000000000000000100" as const; import { expect, test } from "bun:test"; import { Effect, Exit } from "effect"; @@ -22,11 +23,13 @@ import { TEST_PUBLIC_CLIENT } from "../test/setup"; import { BALANCE_OF_SLOT, callBurnData, + callPrecompileData, deployArtifact, deployTestToken, INITIAL_SUPPLY, loadCallGasCallee, loadCallGasCaller, + loadPrecompileCaller, loadTestToken, mappingSlot, normalizeAccessRecord, @@ -784,6 +787,109 @@ test("harness gas_limit includes EIP-150 call headroom", async () => { expect(harness.gas_used).toBeLessThan(harness.gas_limit); }); +test("simulate with a cached journal matches eth_createAccessList for a call to another contract", async () => { + const calleeArtifact = await loadCallGasCallee(); + const callerArtifact = await loadCallGasCaller(); + const calleeAddr = await deployArtifact(calleeArtifact); + const callerAddr = await deployArtifact(callerArtifact); + const data = callBurnData(calleeAddr, 250n); + + const { accessList: rpcAccessList } = + await TEST_PUBLIC_CLIENT.createAccessList({ + account: SCHEDULER_ADDR, + to: callerAddr, + data, + }); + const rpcGasEstimate = await TEST_PUBLIC_CLIENT.estimateGas({ + account: SCHEDULER_ADDR, + to: callerAddr, + data, + accessList: rpcAccessList, + }); + + const program = Effect.gen(function* () { + const evm = yield* createEVM(); + yield* evm.init({ + chain_id: 31337, + accounts: { + [calleeAddr]: { code: calleeArtifact.deployedBytecode.object }, + [callerAddr]: { code: callerArtifact.deployedBytecode.object }, + }, + }); + const executed = yield* evm.execute({ + from: SCHEDULER_ADDR, + to: callerAddr, + data, + }); + // The journal's access list replaces the discovery pass. + return yield* evm.simulate({ + from: SCHEDULER_ADDR, + to: callerAddr, + data, + journal_ids: [executed.journal_id!], + }); + }); + const harness = await Effect.runPromise(Effect.scoped(program)); + + expect(harness.success).toBe(true); + expect(normalizeAccessRecord(harness.access_list)).toEqual( + normalizeAccessRecord(rpcAccessList), + ); + expect(BigInt(harness.gas_limit)).toEqual(rpcGasEstimate); +}); + +test("access list leaves out the P-256 precompile at 0x100", async () => { + const artifact = await loadPrecompileCaller(); + const callerAddr = await deployArtifact(artifact); + // An invalid signature: the precompile charges its gas and returns no data. + const data = callPrecompileData(P256_VERIFIER, Hex.padLeft("0x", 160)); + + const { accessList: rpcAccessList } = + await TEST_PUBLIC_CLIENT.createAccessList({ + account: SCHEDULER_ADDR, + to: callerAddr, + data, + }); + const rpcGasEstimate = await TEST_PUBLIC_CLIENT.estimateGas({ + account: SCHEDULER_ADDR, + to: callerAddr, + data, + accessList: rpcAccessList, + }); + + const program = Effect.gen(function* () { + const evm = yield* createEVM(); + yield* evm.init({ + chain_id: 31337, + accounts: { + [callerAddr]: { code: artifact.deployedBytecode.object }, + }, + }); + const executed = yield* evm.execute({ + from: SCHEDULER_ADDR, + to: callerAddr, + data, + }); + const simulated = yield* evm.simulate({ + from: SCHEDULER_ADDR, + to: callerAddr, + data, + journal_ids: [], + }); + return { executed, simulated }; + }); + const { executed, simulated } = await Effect.runPromise( + Effect.scoped(program), + ); + + expect(executed.success).toBe(true); + expect(simulated.success).toBe(true); + expect(rpcAccessList).toEqual([]); + expect(executed.access_list).toEqual([]); + expect(simulated.access_list).toEqual([]); + expect(BigInt(simulated.gas_limit)).toEqual(rpcGasEstimate); +}); + test("init twice fails", async () => { const program = Effect.gen(function* () { const evm = yield* createEVM(); diff --git a/packages/typewriter-evm/src/types.ts b/packages/typewriter-evm/src/types.ts index d245b706..7ec294c2 100644 --- a/packages/typewriter-evm/src/types.ts +++ b/packages/typewriter-evm/src/types.ts @@ -40,25 +40,31 @@ export type SimulateParams = ExecuteParams & { journal_ids: number[]; }; -export type ExecuteResult = { +// Fields that execute and simulate both return. +type TxResult = { success: boolean; - journal_id?: number; gas_used: number; output: Hex.Hex; access_list: { address: Address.Address; storageKeys: Hex.Hex[] }[]; + keccak_preimages: { hash: Hex.Hex; preimage: Hex.Hex }[]; + revert_data?: Hex.Hex; +}; + +// gas_used is the gas with cold access. Execute does not warm an access list. +export type ExecuteResult = TxResult & { + journal_id?: number; slot_writes: { address: Address.Address; slot: Hex.Hex; prev_value: Hex.Hex; new_value: Hex.Hex; }[]; - keccak_preimages: { hash: Hex.Hex; preimage: Hex.Hex }[]; - revert_data?: Hex.Hex; }; -// Simulate also estimates the smallest gas limit that lets the call succeed. -// Execute skips that search because accepting a mutation does not need it. -export type SimulateResult = ExecuteResult & { +// gas_used is the gas with the access list warm, as the chain charges it. +// gas_limit is the smallest limit that runs the call on the same path as a +// run at max gas. +export type SimulateResult = TxResult & { gas_limit: number; }; diff --git a/packages/typewriter-evm/test/contracts/src/PrecompileCaller.sol b/packages/typewriter-evm/test/contracts/src/PrecompileCaller.sol new file mode 100644 index 00000000..9ac1d057 --- /dev/null +++ b/packages/typewriter-evm/test/contracts/src/PrecompileCaller.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract PrecompileCaller { + function callPrecompile(address target, bytes calldata data) external view returns (bytes memory) { + (bool ok, bytes memory ret) = target.staticcall(data); + require(ok); + return ret; + } +} diff --git a/packages/typewriter-evm/test/utils.ts b/packages/typewriter-evm/test/utils.ts index 04503cbe..3ef49abb 100644 --- a/packages/typewriter-evm/test/utils.ts +++ b/packages/typewriter-evm/test/utils.ts @@ -105,6 +105,22 @@ export async function loadCallGasCaller(): Promise { ).json()) as ForgeArtifact; } +export async function loadPrecompileCaller(): Promise { + return (await Bun.file( + `${import.meta.dir}/contracts/out/PrecompileCaller.sol/PrecompileCaller.json`, + ).json()) as ForgeArtifact; +} + +export function callPrecompileData( + target: Address.Address, + data: Hex.Hex, +): Hex.Hex { + const callPrecompile = AbiFunction.from( + "function callPrecompile(address target, bytes data) returns (bytes)", + ); + return AbiFunction.encodeData(callPrecompile, [target, data]); +} + export function tokenInit( artifact: ForgeArtifact, tokenAddr: Address.Address = TOKEN_ADDR, From 1321baa9849596ee4c8f13849320c86e4d0ca5dd Mon Sep 17 00:00:00 2001 From: kyscott18 <43524469+kyscott18@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:46:36 +0000 Subject: [PATCH 4/5] test(evm): remove the cross-contract cached-journal test Typewriter apps do not make external calls, and the ERC20 journal test already covers simulate with a cached journal. --- packages/typewriter-evm/src/index.test.ts | 51 ----------------------- 1 file changed, 51 deletions(-) diff --git a/packages/typewriter-evm/src/index.test.ts b/packages/typewriter-evm/src/index.test.ts index 66b1512f..7308d4df 100644 --- a/packages/typewriter-evm/src/index.test.ts +++ b/packages/typewriter-evm/src/index.test.ts @@ -787,57 +787,6 @@ test("harness gas_limit includes EIP-150 call headroom", async () => { expect(harness.gas_used).toBeLessThan(harness.gas_limit); }); -test("simulate with a cached journal matches eth_createAccessList for a call to another contract", async () => { - const calleeArtifact = await loadCallGasCallee(); - const callerArtifact = await loadCallGasCaller(); - const calleeAddr = await deployArtifact(calleeArtifact); - const callerAddr = await deployArtifact(callerArtifact); - const data = callBurnData(calleeAddr, 250n); - - const { accessList: rpcAccessList } = - await TEST_PUBLIC_CLIENT.createAccessList({ - account: SCHEDULER_ADDR, - to: callerAddr, - data, - }); - const rpcGasEstimate = await TEST_PUBLIC_CLIENT.estimateGas({ - account: SCHEDULER_ADDR, - to: callerAddr, - data, - accessList: rpcAccessList, - }); - - const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init({ - chain_id: 31337, - accounts: { - [calleeAddr]: { code: calleeArtifact.deployedBytecode.object }, - [callerAddr]: { code: callerArtifact.deployedBytecode.object }, - }, - }); - const executed = yield* evm.execute({ - from: SCHEDULER_ADDR, - to: callerAddr, - data, - }); - // The journal's access list replaces the discovery pass. - return yield* evm.simulate({ - from: SCHEDULER_ADDR, - to: callerAddr, - data, - journal_ids: [executed.journal_id!], - }); - }); - const harness = await Effect.runPromise(Effect.scoped(program)); - - expect(harness.success).toBe(true); - expect(normalizeAccessRecord(harness.access_list)).toEqual( - normalizeAccessRecord(rpcAccessList), - ); - expect(BigInt(harness.gas_limit)).toEqual(rpcGasEstimate); -}); - test("access list leaves out the P-256 precompile at 0x100", async () => { const artifact = await loadPrecompileCaller(); const callerAddr = await deployArtifact(artifact); From 4bc8675a8cd72e785f62784a31b56bd93ead4584 Mon Sep 17 00:00:00 2001 From: kyscott18 <43524469+kyscott18@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:02:50 +0000 Subject: [PATCH 5/5] refactor(evm): replace the gas tracker with a per-frame reserve Simulate now sets the gas limit to gas_used plus a reserve. Each frame reserves 2,301 gas for the SSTORE sentry and enough that 63/64 of its remaining gas covers each child's gas plus the child's reserve. This removes the per-opcode gas tracking. A call that halts is still an error. Also simplify harness.rs: one Journal type writes pre or post images, access lists merge through ordered sets, and dispatch uses one response helper. The wire format does not change. --- packages/typewriter-evm/src/harness.rs | 1194 +++++++-------------- packages/typewriter-evm/src/index.test.ts | 4 +- packages/typewriter-evm/src/types.ts | 4 +- 3 files changed, 386 insertions(+), 816 deletions(-) diff --git a/packages/typewriter-evm/src/harness.rs b/packages/typewriter-evm/src/harness.rs index 3b7eb89e..31a736e7 100644 --- a/packages/typewriter-evm/src/harness.rs +++ b/packages/typewriter-evm/src/harness.rs @@ -1,21 +1,4 @@ -// Shared EVM harness: wire types, the `EvmHarness` over monad-revm, request -// dispatch, and conversion helpers. The native addon (`lib.rs`) is the only -// caller; it owns one `EvmHarness` per instance and forwards JSON through -// `dispatch_json`. -// -// Operations: -// init — one-shot setup: spec, chain id, block context, accounts. -// setBlockContext— update block number, timestamp, basefee, coinbase. -// execute — run one tx and store its successful state transition as a -// journal identified by a numeric id. -// simulate — temporarily undo the given journal ids, run one tx and -// trace its gas limit, then re-apply the journals; does -// not create/modify journals. -// readStorage — read raw account storage slots from the in-memory DB. -// revertJournals — revert and delete the given journal ids. -// pruneJournals — delete the given journal ids without touching state. - -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use monad_revm::{ api::{builder::MonadBuilder, default_ctx::monad_context_with_db}, @@ -25,7 +8,6 @@ use revm::{ bytecode::opcode, context::TxEnv, context_interface::{ - cfg::GasParams, result::{ExecutionResult, HaltReason, Output}, transaction::{AccessList, AccessListItem}, Host, JournalTr, @@ -34,12 +16,11 @@ use revm::{ inspector::InspectEvm, interpreter::{ interpreter::EthInterpreter, - interpreter_types::{Jumps, LoopControl, MemoryTr}, - CallInputs, CallOutcome, CallValue, CreateInputs, CreateOutcome, Interpreter, - InterpreterAction, InterpreterResult, + interpreter_types::{Jumps, MemoryTr}, + CallInputs, CallOutcome, CreateInputs, CreateOutcome, Interpreter, InterpreterResult, }, - primitives::{Address, AddressSet, Bytes, TxKind, B256, U256}, - state::{AccountInfo, Bytecode}, + primitives::{keccak256, Address, AddressSet, Bytes, TxKind, B256, U256}, + state::{AccountInfo, Bytecode, EvmState}, DatabaseRef, ExecuteCommitEvm, ExecuteEvm, Inspector, }; use serde::{Deserialize, Serialize}; @@ -109,32 +90,6 @@ struct JournalIdsParams { journal_ids: Vec, } -#[derive(Serialize)] -struct OkResponse { - id: u64, - ok: bool, - result: T, -} - -#[derive(Serialize)] -struct ErrorResponse { - id: u64, - ok: bool, - error: String, -} - -#[derive(Serialize)] -struct EmptyResult {} - -const EMPTY_RESULT: EmptyResult = EmptyResult {}; - -const RESPONSE_SERIALIZE_ERROR: &str = r#"{"id":0,"ok":false,"error":"response serialize error"}"#; - -fn serialize_response(response: &T) -> String { - serde_json::to_string(response).unwrap_or_else(|_| RESPONSE_SERIALIZE_ERROR.into()) -} - -// Fields that execute and simulate both return. #[derive(Serialize)] struct TxOutcome { success: bool, @@ -146,7 +101,7 @@ struct TxOutcome { revert_data: Option, } -// gas_used is the gas with cold access. Execute does not warm an access list. +// gas_used is with cold access: execute warms no access list. #[derive(Serialize)] struct ExecuteOk { #[serde(flatten)] @@ -156,7 +111,7 @@ struct ExecuteOk { slot_writes: Vec, } -// gas_used is the gas with the access list warm, as the chain charges it. +// gas_used is with the access list warm, as the chain charges it. #[derive(Serialize)] struct SimulateOk { #[serde(flatten)] @@ -186,290 +141,181 @@ struct KeccakPreimageEntry { } // ----------------------------------------------------------------------------- -// Harness +// Inspector type MonadCtx = monad_revm::api::default_ctx::MonadContext; - type Evm = monad_revm::api::builder::DefaultMonadEvm; - type TxResult = ExecutionResult; +// The gas limit must exceed gas_used in two places: SSTORE needs more than +// the call stipend left (EIP-2200), and a call or create keeps 1/64 of the +// gas it could forward (EIP-150). Gas spent after either point is already in +// gas_used, so each frame needs a reserve: 2,301 for its own SSTOREs, and +// enough that 63/64 of its remaining gas covers each child's gas plus the +// child's reserve. Monad has no refunds, so gas_used is the gas spent. #[derive(Default)] struct HarnessInspector { - keccak_preimages: Vec, - gas: GasLimitTracker, -} - -struct RawKeccakPreimage { - hash: B256, - preimage: Vec, + keccak_preimages: Vec<(B256, Vec)>, + frame_reserves: Vec, + gas_reserve: u64, + // A halted frame uses all the gas it gets, so its cost depends on the + // gas limit. + child_halted: bool, } impl HarnessInspector { fn clear(&mut self) { - self.keccak_preimages.clear(); + *self = Self::default(); } - fn wire_preimages(&self) -> Vec { - self.keccak_preimages - .iter() - .map(|entry| KeccakPreimageEntry { - hash: format!("0x{}", hex::encode(entry.hash.as_slice())), - preimage: format!("0x{}", hex::encode(&entry.preimage)), - }) - .collect() + fn frame_end(&mut self, gas_limit: u64, result: &InterpreterResult, sstore_reserve: u64) { + let Some(mut reserve) = self.frame_reserves.pop() else { + return; + }; + if !result.result.is_ok_or_revert() { + self.child_halted |= !self.frame_reserves.is_empty(); + } + reserve = reserve.max(sstore_reserve); + let need = gas_limit - result.gas.remaining() + reserve; + match self.frame_reserves.last_mut() { + Some(parent) => *parent = (*parent).max(reserve + need.div_ceil(63) + 1), + None => self.gas_reserve = reserve, + } } +} - fn record_keccak_preimage(&mut self, interp: &mut Interpreter) { - let stack = interp.stack.data(); - if stack.len() < 2 { +impl Inspector for HarnessInspector { + fn step(&mut self, interp: &mut Interpreter, _context: &mut MonadCtx) { + if interp.bytecode.opcode() != opcode::KECCAK256 { return; } - let offset = stack[stack.len() - 1]; - let len = stack[stack.len() - 2]; - if len != U256::from(32) && len != U256::from(64) { + let [.., len, offset] = interp.stack.data().as_slice() else { return; - } - - let offset = match usize::try_from(offset) { - Ok(offset) => offset, - Err(_) => return, }; - let len = usize::try_from(len).expect("len checked above"); - let Some(end) = offset.checked_add(len) else { + let (Ok(offset), Ok(len)) = (usize::try_from(*offset), usize::try_from(*len)) else { return; }; - + if len != 32 && len != 64 { + return; + } + // Memory past its current size reads as zeros. let mut preimage = vec![0; len]; let memory_size = interp.memory.size(); if offset < memory_size { - let copied = memory_size.min(end) - offset; + let copied = (memory_size - offset).min(len); preimage[..copied].copy_from_slice(&interp.memory.slice_len(offset, copied)); } - - self.keccak_preimages.push(RawKeccakPreimage { - hash: revm::primitives::keccak256(&preimage), - preimage, - }); - } -} - -impl Inspector for HarnessInspector { - fn step(&mut self, interp: &mut Interpreter, context: &mut MonadCtx) { - let opcode = interp.bytecode.opcode(); - if opcode == opcode::KECCAK256 { - self.record_keccak_preimage(interp); - } - self.gas.step(opcode, interp, context); + self.keccak_preimages.push((keccak256(&preimage), preimage)); } - fn step_end(&mut self, interp: &mut Interpreter, _context: &mut MonadCtx) { - self.gas.step_end(interp); + fn call(&mut self, _context: &mut MonadCtx, _inputs: &mut CallInputs) -> Option { + self.frame_reserves.push(0); + None } - fn call(&mut self, context: &mut MonadCtx, inputs: &mut CallInputs) -> Option { - let transfers_value = - matches!(inputs.value, CallValue::Transfer(value) if !value.is_zero()); - let stipend = if transfers_value { - context.gas_params().call_stipend() - } else { + fn call_end(&mut self, context: &mut MonadCtx, inputs: &CallInputs, outcome: &mut CallOutcome) { + // Precompiles have fixed costs and no SSTORE. + let sstore_reserve = if outcome.was_precompile_called { 0 + } else { + context.gas_params().call_stipend() + 1 }; - self.gas.frame_start(inputs.gas_limit, stipend); - None - } - - fn call_end( - &mut self, - context: &mut MonadCtx, - _inputs: &CallInputs, - outcome: &mut CallOutcome, - ) { - self.gas.frame_end(&outcome.result, context.gas_params()); + self.frame_end(inputs.gas_limit, &outcome.result, sstore_reserve); } fn create( &mut self, _context: &mut MonadCtx, - inputs: &mut CreateInputs, + _inputs: &mut CreateInputs, ) -> Option { - self.gas.frame_start(inputs.gas_limit(), 0); + self.frame_reserves.push(0); None } fn create_end( &mut self, context: &mut MonadCtx, - _inputs: &CreateInputs, + inputs: &CreateInputs, outcome: &mut CreateOutcome, ) { - self.gas.frame_end(&outcome.result, context.gas_params()); + let sstore_reserve = context.gas_params().call_stipend() + 1; + self.frame_end(inputs.gas_limit(), &outcome.result, sstore_reserve); } } -// Traces the smallest gas limit that runs a tx on the same path as a run at -// max gas. The gas used is not enough for two reasons: CALL and CREATE -// forward at most 63/64 of the remaining gas (EIP-150), and SSTORE fails -// unless more than the call stipend remains (EIP-2200). Monad gives no -// refunds, so refunds do not change the limit. -// -// Each frame records the most gas it spent at any point (`need`). When a -// child frame ends, its need becomes a requirement on the gas the parent must -// have left before the call forwards gas. Contracts do not branch on gas, so -// every other charge is the same at each gas limit that keeps this path, and -// intrinsic gas plus the top frame's need is the exact minimum. -// -// A child that halts uses all the gas it gets. At max gas that is almost all -// of u64::MAX, so no gas limit that the chain accepts can run the tx. -#[derive(Default)] -struct GasLimitTracker { - enabled: bool, - halted: bool, - frames: Vec, - // The last opcode that `step` saw, so `step_end` knows what ran. - opcode: u8, - // Gas the current frame has left after a CALL or CREATE took the gas it - // forwards. The next `frame_start` uses it. - remaining_after_forward: Option, - top_frame: Option, -} +// ----------------------------------------------------------------------------- +// Harness -#[derive(Clone, Copy)] -struct FrameGas { - gas_limit: u64, - need: u64, - // Gas the parent spent before it forwarded gas to this frame. - parent_spent: u64, - stipend: u64, +// revm clears its own journal when a tx succeeds, so each tx's pre/post images +// are kept here to revert or temporarily undo it later. +#[derive(Default)] +struct Journal { + accounts: HashMap, + storage: HashMap<(Address, U256), (U256, U256)>, + access_list: AccessList, } -impl GasLimitTracker { - fn start(&mut self) { - self.enabled = true; - self.halted = false; - self.frames.clear(); - self.remaining_after_forward = None; - self.top_frame = None; - } - - fn finish(&mut self, tx_gas_limit: u64) -> Result { - self.enabled = false; - if self.halted { - return Err("a call halted and used all its gas; no gas limit can run the tx".into()); - } - let top_frame = self - .top_frame - .ok_or_else(|| "gas trace has no top frame".to_string())?; - let intrinsic_gas = tx_gas_limit - top_frame.gas_limit; - Ok(intrinsic_gas + top_frame.need) - } - - fn step(&mut self, opcode: u8, interp: &mut Interpreter, context: &MonadCtx) { - if !self.enabled || interp.bytecode.is_end() { - return; - } - self.opcode = opcode; - if opcode == opcode::SSTORE { - if let Some(frame) = self.frames.last_mut() { - let sentry = interp.gas.total_gas_spent() + context.gas_params().call_stipend() + 1; - frame.need = frame.need.max(sentry); +impl Journal { + fn from_state(state: &EvmState, access_list: AccessList) -> Self { + let mut journal = Journal { + access_list, + ..Default::default() + }; + for (address, account) in state { + journal + .accounts + .insert(*address, (account.original_info(), account.info.clone())); + for (slot, value) in &account.storage { + journal.storage.insert( + (*address, *slot), + (value.original_value(), value.present_value()), + ); } } + journal } - fn step_end(&mut self, interp: &mut Interpreter) { - if !self.enabled { - return; + fn write(&self, db: &mut InMemoryDB, post: bool) { + for (address, (pre, after)) in &self.accounts { + db.insert_account_info(*address, if post { after } else { pre }.clone()); } - let Some(frame) = self.frames.last_mut() else { - return; - }; - let forwards_gas = matches!( - self.opcode, - opcode::CALL - | opcode::CALLCODE - | opcode::DELEGATECALL - | opcode::STATICCALL - | opcode::CREATE - | opcode::CREATE2 - ) && matches!( - interp.bytecode.action(), - Some(InterpreterAction::NewFrame(_)) - ); - if forwards_gas { - // The spent gas now includes the forwarded gas. The child returns - // the gas it does not use, so frame_end records the requirement. - self.remaining_after_forward = Some(interp.gas.remaining()); - } else { - frame.need = frame.need.max(interp.gas.total_gas_spent()); - } - } - - fn frame_start(&mut self, gas_limit: u64, stipend: u64) { - if !self.enabled { - return; + for ((address, slot), (pre, after)) in &self.storage { + let _ = db.insert_account_storage(*address, *slot, if post { *after } else { *pre }); } - let forwarded = gas_limit - stipend; - let parent_spent = match (self.frames.last(), self.remaining_after_forward.take()) { - (Some(parent), Some(remaining)) => parent.gas_limit - (remaining + forwarded), - _ => 0, - }; - self.frames.push(FrameGas { - gas_limit, - need: 0, - parent_spent, - stipend, - }); } +} - fn frame_end(&mut self, result: &InterpreterResult, gas_params: &GasParams) { - if !self.enabled { - return; - } - let Some(mut frame) = self.frames.pop() else { - return; - }; - // A frame with no bytecode (a precompile or an account with no code) - // has no steps, so its need is the gas it used. - frame.need = frame.need.max(frame.gas_limit - result.gas.remaining()); - if !result.result.is_ok_or_revert() { - self.halted = true; - } - match self.frames.last_mut() { - Some(parent) => { - let child_gas = frame.need.saturating_sub(frame.stipend); - let required = frame.parent_spent + min_gas_to_forward(gas_params, child_gas); - parent.need = parent.need.max(required); - } - None => self.top_frame = Some(frame), - } - } +struct TxRequest { + from: Address, + to: Address, + data: Bytes, + value: U256, + chain_id: u64, } -// The smallest remaining gas from which CALL or CREATE forwards at least -// `child_gas` (EIP-150 keeps 1/64 in the caller). -fn min_gas_to_forward(gas_params: &GasParams, child_gas: u64) -> u64 { - let forwarded = |gas: u64| gas_params.call_stipend_reduction(gas); - let mut gas = child_gas.saturating_add(child_gas / 63); - while gas < u64::MAX && forwarded(gas) < child_gas { - gas += 1; - } - while gas > 0 && forwarded(gas - 1) >= child_gas { - gas -= 1; +impl TxRequest { + fn parse(params: &ExecuteParams, chain_id: u64) -> Result { + Ok(Self { + from: parse_address(¶ms.from)?, + to: parse_address(¶ms.to)?, + data: parse_bytes(¶ms.data)?, + value: params.value.as_deref().map_or(Ok(U256::ZERO), parse_u256)?, + chain_id, + }) } - gas -} -// Per-journal pre/post-image record. REVM's transaction execution clears its -// internal journal log on success, so cross-tx isolation has to live in -// userland: we capture pre-images on first touch within a journal, post- -// images each time. revertJournals writes pre-images back; simulate -// un-applies uncommitted journals, runs the simulation, re-applies them. -#[derive(Default)] -struct Journal { - accounts: HashMap, - storage: HashMap<(Address, U256), (U256, U256)>, - access_list: AccessList, + fn tx_env(&self, access_list: AccessList) -> TxEnv { + TxEnv::builder() + .caller(self.from) + .chain_id(Some(self.chain_id)) + .kind(TxKind::Call(self.to)) + .gas_limit(u64::MAX) + .gas_price(0) + .value(self.value) + .data(self.data.clone()) + .access_list(access_list) + .build_fill() + } } pub struct EvmHarness { @@ -479,13 +325,18 @@ pub struct EvmHarness { initialized: bool, } +impl Default for EvmHarness { + fn default() -> Self { + Self::new() + } +} + impl EvmHarness { pub fn new() -> Self { let mut ctx = monad_context_with_db(InMemoryDB::default()); relax_cfg(&mut ctx.cfg); - let evm = ctx.build_monad_with_inspector(HarnessInspector::default()); Self { - evm, + evm: ctx.build_monad_with_inspector(HarnessInspector::default()), journals: HashMap::new(), next_journal_id: 1, initialized: false, @@ -497,42 +348,36 @@ impl EvmHarness { return Err("init already called".into()); } if let Some(spec) = params.spec.as_deref() { - let s = parse_spec(spec).ok_or_else(|| format!("unknown spec: {spec}"))?; - let mut cfg = MonadCfgEnv::new_with_spec(s); + let spec = parse_spec(spec).ok_or_else(|| format!("unknown spec: {spec}"))?; + let mut cfg = MonadCfgEnv::new_with_spec(spec); relax_cfg(&mut cfg); self.evm.0.ctx.cfg = cfg; - self.evm.0.ctx.journaled_state.set_spec_id(s.into()); + self.evm.0.ctx.journaled_state.set_spec_id(spec.into()); } - if let Some(id) = params.chain_id { - self.evm.0.ctx.cfg.0.chain_id = id; + if let Some(chain_id) = params.chain_id { + self.evm.0.ctx.cfg.0.chain_id = chain_id; } if let Some(block) = ¶ms.block { self.apply_block_params(block)?; } - if let Some(accounts) = ¶ms.accounts { - let db = self.evm.0.ctx.journaled_state.db_mut(); - for (addr_str, acc) in accounts { - let address = parse_address(addr_str)?; - let mut info = AccountInfo { - balance: match acc.balance.as_deref() { - Some(b) => parse_u256(b)?, - None => U256::ZERO, - }, - nonce: acc.nonce.unwrap_or(0), - ..Default::default() - }; - if let Some(code_hex) = acc.code.as_deref() { - info = info.with_code(Bytecode::new_raw(parse_bytes(code_hex)?)); - } - db.insert_account_info(address, info); - if let Some(storage) = &acc.storage { - for (slot_str, value_str) in storage { - let slot = parse_u256(slot_str)?; - let value = parse_u256(value_str)?; - db.insert_account_storage(address, slot, value) - .map_err(|e| format!("insert_account_storage: {e:?}"))?; - } - } + let db = self.evm.0.ctx.journaled_state.db_mut(); + for (address, account) in params.accounts.iter().flatten() { + let address = parse_address(address)?; + let mut info = AccountInfo { + balance: account + .balance + .as_deref() + .map_or(Ok(U256::ZERO), parse_u256)?, + nonce: account.nonce.unwrap_or(0), + ..Default::default() + }; + if let Some(code) = account.code.as_deref() { + info = info.with_code(Bytecode::new_raw(parse_bytes(code)?)); + } + db.insert_account_info(address, info); + for (slot, value) in account.storage.iter().flatten() { + db.insert_account_storage(address, parse_u256(slot)?, parse_u256(value)?) + .map_err(|e| format!("insert_account_storage: {e:?}"))?; } } self.initialized = true; @@ -544,39 +389,33 @@ impl EvmHarness { self.apply_block_params(params) } - fn apply_block_params(&mut self, block: &BlockParams) -> Result<(), String> { - let b = &mut self.evm.0.ctx.block; - if let Some(n) = &block.number { - b.number = parse_u256(n)?; + fn apply_block_params(&mut self, params: &BlockParams) -> Result<(), String> { + let block = &mut self.evm.0.ctx.block; + if let Some(number) = ¶ms.number { + block.number = parse_u256(number)?; } - if let Some(t) = &block.timestamp { - b.timestamp = parse_u256(t)?; + if let Some(timestamp) = ¶ms.timestamp { + block.timestamp = parse_u256(timestamp)?; } - if let Some(f) = &block.basefee { - b.basefee = parse_u256(f)?.to::(); + if let Some(basefee) = ¶ms.basefee { + block.basefee = parse_u256(basefee)?.to::(); } - if let Some(c) = &block.coinbase { - b.beneficiary = parse_address(c)?; + if let Some(coinbase) = ¶ms.coinbase { + block.beneficiary = parse_address(coinbase)?; } Ok(()) } fn revert_journals(&mut self, params: &JournalIdsParams) -> Result<(), String> { - self.ensure_initialized()?; self.ensure_journal_ids(¶ms.journal_ids)?; - let db = self.evm.0.ctx.journaled_state.db_mut(); for journal_id in params.journal_ids.iter().rev() { - let journal = self - .journals - .remove(journal_id) - .expect("journal ids validated above"); - rewind(db, &journal); + let journal = self.journals.remove(journal_id).expect("validated above"); + journal.write(self.evm.0.ctx.journaled_state.db_mut(), false); } Ok(()) } fn prune_journals(&mut self, params: &JournalIdsParams) -> Result<(), String> { - self.ensure_initialized()?; self.ensure_journal_ids(¶ms.journal_ids)?; for journal_id in ¶ms.journal_ids { self.journals.remove(journal_id); @@ -587,9 +426,6 @@ impl EvmHarness { fn execute(&mut self, params: &ExecuteParams) -> Result { self.ensure_initialized()?; let tx = TxRequest::parse(params, self.evm.0.ctx.cfg.0.chain_id)?; - // The discovery pass is the only pass. Its post-state is the - // mutation's state, and the accounts and slots it touched become the - // journal's access list. let (result, access_list) = match self.discovery_pass(&tx) { Ok(pass) => pass, Err(error) => { @@ -599,8 +435,6 @@ impl EvmHarness { }; let outcome = self.tx_outcome(&result, &access_list); if !outcome.success { - // A failed tx is observable as a rejection, not as local state. - // Drain and discard any journal state left by revm. let _ = self.evm.finalize(); return Ok(ExecuteOk { outcome, @@ -609,13 +443,11 @@ impl EvmHarness { }); } - let slot_writes = collect_slot_writes(self.evm.0.ctx.journaled_state.evm_state()); - let mut journal = Journal::default(); - self.record_into_journal(&mut journal); - journal.access_list = access_list; - // Commit the journaled state into the DB so future reads and - // writes see the post-tx state. - self.flush_journal_to_db(); + let state = self.evm.0.ctx.journaled_state.evm_state(); + let slot_writes = collect_slot_writes(state); + let journal = Journal::from_state(state, access_list); + let state = self.evm.finalize(); + self.evm.commit(state); let journal_id = self.next_journal_id; self.next_journal_id += 1; self.journals.insert(journal_id, journal); @@ -627,87 +459,34 @@ impl EvmHarness { } fn simulate(&mut self, params: &SimulateParams) -> Result { - self.ensure_initialized()?; self.ensure_journal_ids(¶ms.journal_ids)?; let tx = TxRequest::parse(¶ms.tx, self.evm.0.ctx.cfg.0.chain_id)?; - - { - let db = self.evm.0.ctx.journaled_state.db_mut(); - for journal_id in params.journal_ids.iter().rev() { - let journal = self - .journals - .get(journal_id) - .expect("journal ids validated above"); - rewind(db, journal); - } + for journal_id in params.journal_ids.iter().rev() { + self.journals[journal_id].write(self.evm.0.ctx.journaled_state.db_mut(), false); } - let cached_access_list = self.merge_journal_access_lists(¶ms.journal_ids); let result = self.run_simulate(&tx, cached_access_list); - - // Drop the last pass's journal-state writes so they never reach the DB. let _ = self.evm.finalize(); - - // Re-apply post-images in caller-provided occurrence order regardless - // of simulate's success — logical state must be restored. - { - let db = self.evm.0.ctx.journaled_state.db_mut(); - for journal_id in ¶ms.journal_ids { - let journal = self - .journals - .get(journal_id) - .expect("journal ids validated above"); - replay(db, journal); - } + for journal_id in ¶ms.journal_ids { + self.journals[journal_id].write(self.evm.0.ctx.journaled_state.db_mut(), true); } - result } fn read_storage(&self, params: &ReadStorageParams) -> Result, String> { let address = parse_address(¶ms.address)?; let db = self.evm.0.ctx.journaled_state.db(); - let mut out = BTreeMap::new(); - for slot_str in ¶ms.slots { - let slot = parse_u256(slot_str)?; - let value = db - .storage_ref(address, slot) - .map_err(|e| format!("storage_ref: {e:?}"))?; - out.insert(format_u256(slot), format_u256(value)); - } - Ok(out) - } - - // Walk the journal's post-tx state and merge it into the output journal: - // - record (pre, post) on first sighting of an account/slot - // - update only `post` on subsequent sightings (`pre` is already - // the journal's earliest-known pre-image). - fn record_into_journal(&mut self, journal: &mut Journal) { - let state = self.evm.0.ctx.journaled_state.evm_state(); - for (addr, account) in state.iter() { - let post_info = account.info.clone(); - let pre_info = account.original_info(); - journal - .accounts - .entry(*addr) - .and_modify(|(_, post)| *post = post_info.clone()) - .or_insert((pre_info, post_info)); - - for (slot, slot_state) in account.storage.iter() { - let pre = slot_state.original_value(); - let post = slot_state.present_value(); - journal - .storage - .entry((*addr, *slot)) - .and_modify(|(_, p)| *p = post) - .or_insert((pre, post)); - } - } - } - - fn flush_journal_to_db(&mut self) { - let state = self.evm.finalize(); - self.evm.commit(state); + params + .slots + .iter() + .map(|slot| { + let slot = parse_u256(slot)?; + let value = db + .storage_ref(address, slot) + .map_err(|e| format!("storage_ref: {e:?}"))?; + Ok((format_u256(slot), format_u256(value))) + }) + .collect() } fn ensure_initialized(&self) -> Result<(), String> { @@ -719,28 +498,25 @@ impl EvmHarness { } fn ensure_journal_ids(&self, journal_ids: &[u64]) -> Result<(), String> { - for journal_id in journal_ids { - if !self.journals.contains_key(journal_id) { - return Err(format!("unknown journal id: {journal_id}")); - } + self.ensure_initialized()?; + match journal_ids + .iter() + .find(|id| !self.journals.contains_key(id)) + { + Some(id) => Err(format!("unknown journal id: {id}")), + None => Ok(()), } - Ok(()) } - // Run the tx with no access list at max gas and collect the accounts and - // slots it touched. The post-state stays live; the caller commits or - // discards it. - // - // Between passes, finalize drains the journal state and we discard it so - // the next pass starts from the same DB state. The alternative — - // checkpoint_revert — doesn't work because transact_one calls commit_tx - // after success, which clears the revertable journal log. + // Leaves the post-state live for the caller to commit or drop. Passes + // drop state with finalize, not checkpoint_revert: transact_one commits + // the tx on success, which clears revm's revert log. fn discovery_pass(&mut self, tx: &TxRequest) -> Result<(TxResult, AccessList), String> { self.evm.0.inspector.clear(); let result = self .evm - .inspect_one_tx(tx.tx_env(AccessList::default(), u64::MAX)) - .map_err(|e| format!("transact (discovery pass): {e:?}"))?; + .inspect_one_tx(tx.tx_env(AccessList::default())) + .map_err(|e| format!("transact (discovery): {e:?}"))?; let journaled_state = &self.evm.0.ctx.journaled_state; let access_list = discovered_access_list( journaled_state.evm_state(), @@ -751,11 +527,6 @@ impl EvmHarness { Ok((result, access_list)) } - // Simulate discovers the access list, unless the caller gives one from - // cached journals. The measured pass runs at max gas with those addresses - // and slots pre-warmed, so the metered gas matches what the chain charges - // for a tx sent with that access list. The same pass traces the gas limit. - // The post-state stays live. fn run_simulate( &mut self, tx: &TxRequest, @@ -765,26 +536,23 @@ impl EvmHarness { Some(access_list) => access_list, None => { let (_, access_list) = self.discovery_pass(tx)?; - // Drain the discovery pass's journal state without committing it. let _ = self.evm.finalize(); access_list } }; self.evm.0.inspector.clear(); - self.evm.0.inspector.gas.start(); let result = self .evm - .inspect_one_tx(tx.tx_env(access_list.clone(), u64::MAX)) - .map_err(|e| format!("transact (simulate): {e:?}")); - let traced_gas_limit = self.evm.0.inspector.gas.finish(u64::MAX); - let result = result?; - + .inspect_one_tx(tx.tx_env(access_list.clone())) + .map_err(|e| format!("transact (simulate): {e:?}"))?; let outcome = self.tx_outcome(&result, &access_list); + let inspector = &self.evm.0.inspector; + if outcome.success && inspector.child_halted { + return Err("simulate: a call halted and used all its gas".into()); + } let gas_limit = if outcome.success { - // tx_gas_used is at least the calldata floor (EIP-7623), and the - // chain rejects a gas limit below the floor. - traced_gas_limit?.max(result.tx_gas_used()) + result.tx_gas_used() + inspector.gas_reserve } else { result.tx_gas_used() }; @@ -794,28 +562,32 @@ impl EvmHarness { fn tx_outcome(&self, result: &TxResult, access_list: &AccessList) -> TxOutcome { let (success, output, revert_data) = match result { ExecutionResult::Success { output, .. } => { - let bytes = match output { - Output::Call(b) => b, - Output::Create(b, _) => b, - }; - (true, format!("0x{}", hex::encode(bytes)), None) + let (Output::Call(bytes) | Output::Create(bytes, _)) = output; + (true, format_bytes(bytes), None) + } + ExecutionResult::Revert { output, .. } => { + (false, format_bytes(output), Some(format_bytes(output))) } - ExecutionResult::Revert { output, .. } => ( - false, - format!("0x{}", hex::encode(output)), - Some(format!("0x{}", hex::encode(output))), - ), ExecutionResult::Halt { reason, .. } => { (false, "0x".into(), Some(format!("halt: {reason:?}"))) } }; - TxOutcome { success, gas_used: result.tx_gas_used(), output, access_list: encode_access_list(access_list), - keccak_preimages: self.evm.0.inspector.wire_preimages(), + keccak_preimages: self + .evm + .0 + .inspector + .keccak_preimages + .iter() + .map(|(hash, preimage)| KeccakPreimageEntry { + hash: format_bytes(hash.as_slice()), + preimage: format_bytes(preimage), + }) + .collect(), revert_data, } } @@ -824,105 +596,32 @@ impl EvmHarness { if journal_ids.is_empty() { return None; } - - let mut items: Vec = Vec::new(); + let mut merged: BTreeMap> = BTreeMap::new(); for journal_id in journal_ids { - let journal = self - .journals - .get(journal_id) - .expect("journal ids validated above"); - for item in &journal.access_list.0 { - match items - .iter_mut() - .find(|merged| merged.address == item.address) - { - Some(merged) => { - for key in &item.storage_keys { - if !merged.storage_keys.contains(key) { - merged.storage_keys.push(*key); - } - } - } - None => items.push(item.clone()), - } + for item in &self.journals[journal_id].access_list.0 { + merged + .entry(item.address) + .or_default() + .extend(&item.storage_keys); } } - - Some(AccessList(items)) - } -} - -impl Default for EvmHarness { - fn default() -> Self { - Self::new() - } -} - -// The tx fields parsed once per request. Each pass builds its TxEnv from -// these with its own access list and gas limit. -struct TxRequest { - from: Address, - to: Address, - data: Bytes, - value: U256, - chain_id: u64, -} - -impl TxRequest { - fn parse(params: &ExecuteParams, chain_id: u64) -> Result { - Ok(Self { - from: parse_address(¶ms.from)?, - to: parse_address(¶ms.to)?, - data: parse_bytes(¶ms.data)?, - value: match params.value.as_deref() { - Some(v) => parse_u256(v)?, - None => U256::ZERO, - }, - chain_id, - }) - } - - fn tx_env(&self, access_list: AccessList, gas_limit: u64) -> TxEnv { - TxEnv::builder() - .caller(self.from) - .chain_id(Some(self.chain_id)) - .kind(TxKind::Call(self.to)) - .gas_limit(gas_limit) - .gas_price(0) - .value(self.value) - .data(self.data.clone()) - .access_list(access_list) - .build_fill() - } -} - -fn rewind(db: &mut InMemoryDB, journal: &Journal) { - for (addr, (pre, _post)) in journal.accounts.iter() { - db.insert_account_info(*addr, pre.clone()); - } - for ((addr, slot), (pre, _post)) in journal.storage.iter() { - let _ = db.insert_account_storage(*addr, *slot, *pre); - } -} - -fn replay(db: &mut InMemoryDB, journal: &Journal) { - for (addr, (_pre, post)) in journal.accounts.iter() { - db.insert_account_info(*addr, post.clone()); - } - for ((addr, slot), (_pre, post)) in journal.storage.iter() { - let _ = db.insert_account_storage(*addr, *slot, *post); + Some(AccessList( + merged + .into_iter() + .map(|(address, keys)| AccessListItem { + address, + storage_keys: keys.into_iter().collect(), + }) + .collect(), + )) } } // ----------------------------------------------------------------------------- // Conversion helpers -// Every account the tx touched, with the storage slots it loaded. Keep -// address-only entries for cold callees, but drop addresses that are already -// warm at transaction start: the sender, the recipient, the coinbase, and the -// precompiles of the active spec. fn discovered_access_list( - state: &revm::state::EvmState, + state: &EvmState, precompiles: &AddressSet, tx: &TxRequest, coinbase: Address, @@ -950,76 +649,75 @@ fn discovered_access_list( } fn encode_access_list(list: &AccessList) -> Vec { - let mut out: Vec = Vec::new(); - for item in &list.0 { - let mut keys: Vec = item - .storage_keys - .iter() - .map(|k| format!("0x{}", hex::encode(k.as_slice()))) - .collect(); - keys.sort(); - out.push(AccessListEntry { - address: format!("0x{:x}", item.address), - storage_keys: keys, - }); - } - out.sort_by(|a, b| a.address.cmp(&b.address)); - out + let mut entries: Vec = list + .0 + .iter() + .map(|item| { + let mut storage_keys: Vec = item + .storage_keys + .iter() + .map(|key| format_bytes(key.as_slice())) + .collect(); + storage_keys.sort(); + AccessListEntry { + address: format!("{:#x}", item.address), + storage_keys, + } + }) + .collect(); + entries.sort_by(|a, b| a.address.cmp(&b.address)); + entries } -fn collect_slot_writes(state: &revm::state::EvmState) -> Vec { - let mut writes: Vec = Vec::new(); - for (address, account) in state.iter() { - for (slot, slot_state) in account.storage.iter() { - let pre = slot_state.original_value(); - let post = slot_state.present_value(); - if pre == post { - continue; - } - writes.push(SlotWriteEntry { - address: format!("0x{:x}", address), - slot: format_u256(*slot), - prev_value: format_u256(pre), - new_value: format_u256(post), - }); - } - } +fn collect_slot_writes(state: &EvmState) -> Vec { + let mut writes: Vec = state + .iter() + .flat_map(|(address, account)| { + account + .storage + .iter() + .filter(|(_, value)| value.original_value() != value.present_value()) + .map(move |(slot, value)| SlotWriteEntry { + address: format!("{address:#x}"), + slot: format_u256(*slot), + prev_value: format_u256(value.original_value()), + new_value: format_u256(value.present_value()), + }) + }) + .collect(); writes.sort_by(|a, b| a.address.cmp(&b.address).then(a.slot.cmp(&b.slot))); writes } fn parse_address(s: &str) -> Result { - let s = s.strip_prefix("0x").unwrap_or(s); - let bytes = hex::decode(s).map_err(|e| format!("bad address: {e}"))?; - if bytes.len() != 20 { - return Err(format!("bad address length: {}", bytes.len())); - } - let mut arr = [0u8; 20]; - arr.copy_from_slice(&bytes); - Ok(Address::from(arr)) + let bytes = + hex::decode(s.strip_prefix("0x").unwrap_or(s)).map_err(|e| format!("bad address: {e}"))?; + Address::try_from(bytes.as_slice()).map_err(|_| format!("bad address length: {}", bytes.len())) } fn parse_bytes(s: &str) -> Result { - let s = s.strip_prefix("0x").unwrap_or(s); - let bytes = hex::decode(s).map_err(|e| format!("bad bytes: {e}"))?; - Ok(Bytes::from(bytes)) + hex::decode(s.strip_prefix("0x").unwrap_or(s)) + .map(Bytes::from) + .map_err(|e| format!("bad bytes: {e}")) } fn parse_u256(s: &str) -> Result { - let s = s.strip_prefix("0x").unwrap_or(s); - if s.is_empty() { - return Ok(U256::ZERO); + match s.strip_prefix("0x").unwrap_or(s) { + "" => Ok(U256::ZERO), + digits => U256::from_str_radix(digits, 16).map_err(|e| format!("bad u256: {e}")), } - U256::from_str_radix(s, 16).map_err(|e| format!("bad u256: {e}")) } fn format_u256(value: U256) -> String { - format!("0x{}", hex::encode(value.to_be_bytes::<32>())) + format_bytes(&value.to_be_bytes::<32>()) +} + +fn format_bytes(bytes: &[u8]) -> String { + format!("0x{}", hex::encode(bytes)) } -// typewriter handles sequencing, gas, and balance accounting upstream — the harness -// just executes bytecode. Disable revm's tx-level checks so we don't have to -// stage them in to make every execute go through. +// The runtime orders txs and accounts for gas and balances itself, so these +// checks would only reject txs it has already accepted. fn relax_cfg(cfg: &mut MonadCfgEnv) { cfg.0.disable_nonce_check = true; cfg.0.disable_balance_check = true; @@ -1040,64 +738,56 @@ fn parse_spec(s: &str) -> Option { // ----------------------------------------------------------------------------- // Dispatch -// Parse one JSON request, run it against the harness, and serialize the -// response back to JSON. This is the single entry point the native addon -// calls; it never returns `Err` so the FFI boundary stays infallible and the -// protocol (id-correlated ok/error envelopes) is owned entirely in Rust. -pub fn dispatch_json(harness: &mut EvmHarness, request_json: &str) -> String { - match serde_json::from_str::(request_json) { - Ok(req) => dispatch(harness, req), - Err(e) => err(0, format!("parse error: {e}")), - } +#[derive(Serialize)] +struct Response { + id: u64, + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, } -fn dispatch(harness: &mut EvmHarness, req: Request) -> String { - match req { - Request::Init { id, params } => match harness.init(¶ms) { - Ok(()) => ok(id, &EMPTY_RESULT), - Err(e) => err(id, e), - }, - Request::SetBlockContext { id, params } => match harness.set_block_context(¶ms) { - Ok(()) => ok(id, &EMPTY_RESULT), - Err(e) => err(id, e), - }, - Request::Execute { id, params } => match harness.execute(¶ms) { - Ok(r) => ok(id, &r), - Err(e) => err(id, e), - }, - Request::Simulate { id, params } => match harness.simulate(¶ms) { - Ok(r) => ok(id, &r), - Err(e) => err(id, e), - }, - Request::ReadStorage { id, params } => match harness.read_storage(¶ms) { - Ok(r) => ok(id, &r), - Err(e) => err(id, e), - }, - Request::RevertJournals { id, params } => match harness.revert_journals(¶ms) { - Ok(()) => ok(id, &EMPTY_RESULT), - Err(e) => err(id, e), - }, - Request::PruneJournals { id, params } => match harness.prune_journals(¶ms) { - Ok(()) => ok(id, &EMPTY_RESULT), - Err(e) => err(id, e), - }, +#[derive(Serialize)] +struct EmptyResult {} + +// Never fails, so the FFI boundary stays infallible. +pub fn dispatch_json(harness: &mut EvmHarness, request_json: &str) -> String { + let request = match serde_json::from_str::(request_json) { + Ok(request) => request, + Err(e) => return respond::(0, Err(format!("parse error: {e}"))), + }; + let empty = |result: Result<(), String>| result.map(|()| EmptyResult {}); + match request { + Request::Init { id, params } => respond(id, empty(harness.init(¶ms))), + Request::SetBlockContext { id, params } => { + respond(id, empty(harness.set_block_context(¶ms))) + } + Request::Execute { id, params } => respond(id, harness.execute(¶ms)), + Request::Simulate { id, params } => respond(id, harness.simulate(¶ms)), + Request::ReadStorage { id, params } => respond(id, harness.read_storage(¶ms)), + Request::RevertJournals { id, params } => { + respond(id, empty(harness.revert_journals(¶ms))) + } + Request::PruneJournals { id, params } => { + respond(id, empty(harness.prune_journals(¶ms))) + } } } -fn ok(id: u64, result: &T) -> String { - serialize_response(&OkResponse { +fn respond(id: u64, result: Result) -> String { + let (result, error) = match result { + Ok(result) => (Some(result), None), + Err(error) => (None, Some(error)), + }; + let response = Response { id, - ok: true, + ok: error.is_none(), result, - }) -} - -fn err(id: u64, error: String) -> String { - serialize_response(&ErrorResponse { - id, - ok: false, error, - }) + }; + serde_json::to_string(&response) + .unwrap_or_else(|_| r#"{"id":0,"ok":false,"error":"response serialize error"}"#.into()) } #[cfg(test)] @@ -1109,13 +799,11 @@ mod tests { const CALLEE: u16 = 0x0c02; const INNER: u16 = 0x0c03; - // Increments slot 0: PUSH1 0 DUP1 SLOAD PUSH1 1 ADD SWAP1 SSTORE STOP. + // PUSH1 0 DUP1 SLOAD PUSH1 1 ADD SWAP1 SSTORE STOP. const COUNTER: &[u8] = &[0x60, 0x00, 0x80, 0x54, 0x60, 0x01, 0x01, 0x90, 0x55, 0x00]; - // Revert target at offset 3, then the body starts at offset 8: - // PUSH1 8 JUMP | JUMPDEST PUSH1 0 DUP1 REVERT | JUMPDEST. - const PRELUDE: &[u8] = &[0x60, 0x08, 0x56, 0x5b, 0x60, 0x00, 0x80, 0xfd, 0x5b]; - // ISZERO PUSH1 3 JUMPI: revert unless the top of the stack is nonzero. - const REQUIRE: &[u8] = &[0x15, 0x60, 0x03, 0x57]; + const CALL: u8 = 0xf1; + const STATICCALL: u8 = 0xfa; + const GAS: &[u8] = &[0x5a]; fn address(n: u16) -> Address { let mut bytes = [0u8; 20]; @@ -1123,219 +811,101 @@ mod tests { Address::from(bytes) } - fn push_address(code: &mut Vec, target: u16) { + // Calls `target` with no calldata and the `gas` operand. + fn call(op: u8, target: u16, gas: &[u8]) -> Vec { + let mut code = [0x60, 0x00].repeat(if op == CALL { 5 } else { 4 }); code.push(0x73); code.extend_from_slice(address(target).as_slice()); + code.extend_from_slice(gas); + code.push(op); + code } - // CALL target with all gas, no calldata, and `value` wei. - fn call(code: &mut Vec, target: u16, value: u8) { - code.extend_from_slice(&[0x60, 0, 0x60, 0, 0x60, 0, 0x60, 0, 0x60, value]); - push_address(code, target); - code.extend_from_slice(&[0x5a, 0xf1]); - } - - // STATICCALL target with all gas and no calldata. - fn static_call(code: &mut Vec, target: u16) { - code.extend_from_slice(&[0x60, 0, 0x60, 0, 0x60, 0, 0x60, 0]); - push_address(code, target); - code.extend_from_slice(&[0x5a, 0xfa]); - } - - // Calls `target` and reverts when the call fails. - fn call_or_revert(target: u16, value: u8) -> Vec { - let mut code = PRELUDE.to_vec(); - call(&mut code, target, value); - code.extend_from_slice(REQUIRE); - code.push(0x00); + // Reverts unless the call succeeded, then stops. + fn require_success(mut code: Vec) -> Vec { + let revert = u8::try_from(code.len() + 5).unwrap(); + code.extend_from_slice(&[0x15, 0x60, revert, 0x57, 0x00, 0x5b, 0x60, 0x00, 0x80, 0xfd]); code } - fn harness(accounts: &[(u16, &[u8])], storage: &[(u16, u8, u8)]) -> EvmHarness { - let mut init = serde_json::Map::new(); - for (account, code) in accounts { - let account_storage: serde_json::Map = storage - .iter() - .filter(|(owner, _, _)| owner == account) - .map(|(_, slot, value)| (format!("0x{slot:x}"), format!("0x{value:x}").into())) - .collect(); - init.insert( - format!("{:#x}", address(*account)), - serde_json::json!({ - "code": format!("0x{}", hex::encode(code)), - "balance": "0x100", - "storage": account_storage, - }), - ); - } - let params: InitParams = - serde_json::from_value(serde_json::json!({ "accounts": init })).unwrap(); + fn harness(accounts: &[(u16, &[u8])]) -> EvmHarness { + let accounts: serde_json::Map = accounts + .iter() + .map(|(account, code)| { + ( + format!("{:#x}", address(*account)), + serde_json::json!({ "code": format_bytes(code) }), + ) + }) + .collect(); + let params = serde_json::from_value(serde_json::json!({ "accounts": accounts })).unwrap(); let mut harness = EvmHarness::new(); harness.init(¶ms).unwrap(); harness } - fn tx(harness: &EvmHarness, to: u16) -> TxRequest { + fn tx(harness: &EvmHarness) -> TxRequest { TxRequest { from: address(CALLER), - to: address(to), + to: address(CONTRACT), data: Bytes::new(), value: U256::ZERO, chain_id: harness.evm.0.ctx.cfg.0.chain_id, } } - // Returns the traced gas limit and the gas used at max gas. - fn simulate(harness: &mut EvmHarness, to: u16) -> Result<(u64, u64), String> { - let tx = tx(harness, to); + fn simulate(harness: &mut EvmHarness) -> Result { + let tx = tx(harness); let result = harness.run_simulate(&tx, Some(AccessList::default())); let _ = harness.evm.finalize(); - let result = result?; - assert!(result.outcome.success); - Ok((result.gas_limit, result.outcome.gas_used)) + result } - // Runs the tx at `gas_limit` and returns whether it succeeded and the - // value it left in `account`'s slot 0. - fn run_at(harness: &mut EvmHarness, to: u16, gas_limit: u64, account: u16) -> (bool, U256) { - let tx = tx(harness, to); - harness.evm.0.inspector.clear(); - let result = harness - .evm - .inspect_one_tx(tx.tx_env(AccessList::default(), gas_limit)) - .unwrap(); - let slot = harness - .evm - .0 - .ctx - .journaled_state - .evm_state() - .get(&address(account)) - .and_then(|account| account.storage.get(&U256::ZERO)) - .map_or(U256::ZERO, |slot| slot.present_value()); + fn succeeds_at(harness: &mut EvmHarness, gas_limit: u64) -> bool { + let mut tx_env = tx(harness).tx_env(AccessList::default()); + tx_env.gas_limit = gas_limit; + let result = harness.evm.inspect_one_tx(tx_env).unwrap(); let _ = harness.evm.finalize(); - (matches!(result, ExecutionResult::Success { .. }), slot) - } - - // The traced limit succeeds and one gas less fails. Returns the limit and - // the gas used. - fn assert_exact(harness: &mut EvmHarness, to: u16) -> (u64, u64) { - let (gas_limit, gas_used) = simulate(harness, to).unwrap(); - assert!(run_at(harness, to, gas_limit, to).0); - assert!(!run_at(harness, to, gas_limit - 1, to).0); - (gas_limit, gas_used) - } - - #[test] - fn sstore_needs_more_than_the_call_stipend() { - // PUSH1 0 SLOAD PUSH1 0 SSTORE STOP: a warm SSTORE costs less than - // the 2,300 gas that must remain before it. - let code = [0x60, 0x00, 0x54, 0x60, 0x00, 0x55, 0x00]; - let mut harness = harness(&[(CONTRACT, &code)], &[]); - let (gas_limit, gas_used) = assert_exact(&mut harness, CONTRACT); - assert!(gas_limit > gas_used); + matches!(result, ExecutionResult::Success { .. }) } - #[test] - fn call_keeps_one_64th() { - let caller = call_or_revert(CALLEE, 0); - let mut harness = harness(&[(CONTRACT, &caller), (CALLEE, COUNTER)], &[]); - let (gas_limit, gas_used) = assert_exact(&mut harness, CONTRACT); - assert!(gas_limit > gas_used); - } - - #[test] - fn nested_calls() { - let outer = call_or_revert(CALLEE, 0); - let middle = call_or_revert(INNER, 0); - let mut harness = harness( - &[(CONTRACT, &outer), (CALLEE, &middle), (INNER, COUNTER)], - &[], - ); - assert_exact(&mut harness, CONTRACT); - } - - #[test] - fn value_call_gets_the_stipend() { - let caller = call_or_revert(CALLEE, 1); - let mut harness = harness(&[(CONTRACT, &caller), (CALLEE, COUNTER)], &[]); - assert_exact(&mut harness, CONTRACT); + // The tx fails at gas_used and succeeds at the simulated limit. + fn assert_reserve_needed(accounts: &[(u16, &[u8])]) { + let mut harness = harness(accounts); + let result = simulate(&mut harness).unwrap(); + assert!(result.outcome.success); + assert!(!succeeds_at(&mut harness, result.outcome.gas_used)); + assert!(succeeds_at(&mut harness, result.gas_limit)); } #[test] - fn value_call_to_an_account_with_no_code() { - let caller = call_or_revert(INNER, 1); - let mut harness = harness(&[(CONTRACT, &caller)], &[]); - assert_exact(&mut harness, CONTRACT); + fn reserve_covers_sstore_sentry() { + // PUSH1 0 SLOAD PUSH1 0 SSTORE STOP: the warm no-op SSTORE costs less + // than the 2,301 gas that must remain before it. + assert_reserve_needed(&[(CONTRACT, &[0x60, 0x00, 0x54, 0x60, 0x00, 0x55, 0x00])]); } #[test] - fn static_call_to_precompiles() { + fn reserve_covers_precompile_calls() { for precompile in [0x01, 0x100] { - let mut caller = PRELUDE.to_vec(); - static_call(&mut caller, precompile); - caller.extend_from_slice(REQUIRE); - caller.push(0x00); - let mut harness = harness(&[(CONTRACT, &caller)], &[]); - assert_exact(&mut harness, CONTRACT); + let code = require_success(call(STATICCALL, precompile, GAS)); + assert_reserve_needed(&[(CONTRACT, &code)]); } } #[test] - fn create_keeps_one_64th() { - // Init code: PUSH1 1 PUSH1 0 SSTORE PUSH1 0 PUSH1 0 RETURN. - let init_code = [0x60, 0x01, 0x60, 0x00, 0x55, 0x60, 0x00, 0x60, 0x00, 0xf3]; - let mut caller = PRELUDE.to_vec(); - caller.push(0x69); - caller.extend_from_slice(&init_code); - // PUSH1 0 MSTORE, then CREATE(value 0, offset 22, size 10). - caller.extend_from_slice(&[0x60, 0x00, 0x52, 0x60, 0x0a, 0x60, 0x16, 0x60, 0x00, 0xf0]); - caller.extend_from_slice(REQUIRE); - caller.push(0x00); - let mut harness = harness(&[(CONTRACT, &caller)], &[]); - let (gas_limit, gas_used) = assert_exact(&mut harness, CONTRACT); - assert!(gas_limit > gas_used); + fn reserve_covers_nested_calls() { + let outer = require_success(call(CALL, CALLEE, GAS)); + let middle = require_success(call(CALL, INNER, GAS)); + assert_reserve_needed(&[(CONTRACT, &outer), (CALLEE, &middle), (INNER, COUNTER)]); } #[test] - fn clearing_a_slot_gives_no_refund() { - // PUSH1 0 PUSH1 0 SSTORE STOP clears slot 0. Ethereum refunds gas - // for this, and gas used then falls below the gas the tx needs. - // Monad gives no refunds. - let code = [0x60, 0x00, 0x60, 0x00, 0x55, 0x00]; - let mut harness = harness(&[(CONTRACT, &code)], &[(CONTRACT, 0, 1)]); - let (gas_limit, gas_used) = assert_exact(&mut harness, CONTRACT); - assert_eq!(gas_limit, gas_used); - } - - #[test] - fn ignored_call_failure_keeps_the_max_gas_path() { - // CALL the counter, POP the result, STOP. One gas below the traced - // limit the counter runs out of gas, but the caller still succeeds on - // a different path. - let mut caller = Vec::new(); - call(&mut caller, CALLEE, 0); - caller.extend_from_slice(&[0x50, 0x00]); - let mut harness = harness(&[(CONTRACT, &caller), (CALLEE, COUNTER)], &[]); - let (gas_limit, _) = simulate(&mut harness, CONTRACT).unwrap(); - assert_eq!( - run_at(&mut harness, CONTRACT, gas_limit, CALLEE), - (true, U256::from(1)) - ); - assert_eq!( - run_at(&mut harness, CONTRACT, gas_limit - 1, CALLEE), - (true, U256::ZERO) - ); - } - - #[test] - fn halting_call_has_no_gas_limit() { - // CALL a contract that runs INVALID with all gas, POP, STOP. The - // child uses almost all of u64::MAX gas. - let mut caller = Vec::new(); - call(&mut caller, CALLEE, 0); - caller.extend_from_slice(&[0x50, 0x00]); - let mut harness = harness(&[(CONTRACT, &caller), (CALLEE, &[0xfe])], &[]); - assert!(simulate(&mut harness, CONTRACT).is_err()); + fn halted_call_is_an_error() { + // ecrecover with 1 gas halts; the caller ignores the failure. + let mut code = call(STATICCALL, 0x01, &[0x60, 0x01]); + code.push(0x00); + let mut harness = harness(&[(CONTRACT, &code)]); + assert!(simulate(&mut harness).is_err()); } } diff --git a/packages/typewriter-evm/src/index.test.ts b/packages/typewriter-evm/src/index.test.ts index 7308d4df..16494bab 100644 --- a/packages/typewriter-evm/src/index.test.ts +++ b/packages/typewriter-evm/src/index.test.ts @@ -783,7 +783,7 @@ test("harness gas_limit includes EIP-150 call headroom", async () => { expect(normalizeAccessRecord(harness.access_list)).toEqual( normalizeAccessRecord(rpcAccessList), ); - expect(BigInt(harness.gas_limit)).toEqual(rpcGasEstimate); + expect(BigInt(harness.gas_limit)).toBeGreaterThanOrEqual(rpcGasEstimate); expect(harness.gas_used).toBeLessThan(harness.gas_limit); }); @@ -836,7 +836,7 @@ test("access list leaves out the P-256 precompile at 0x100", async () => { expect(rpcAccessList).toEqual([]); expect(executed.access_list).toEqual([]); expect(simulated.access_list).toEqual([]); - expect(BigInt(simulated.gas_limit)).toEqual(rpcGasEstimate); + expect(BigInt(simulated.gas_limit)).toBeGreaterThanOrEqual(rpcGasEstimate); }); test("init twice fails", async () => { diff --git a/packages/typewriter-evm/src/types.ts b/packages/typewriter-evm/src/types.ts index 7ec294c2..45a2c16d 100644 --- a/packages/typewriter-evm/src/types.ts +++ b/packages/typewriter-evm/src/types.ts @@ -62,8 +62,8 @@ export type ExecuteResult = TxResult & { }; // gas_used is the gas with the access list warm, as the chain charges it. -// gas_limit is the smallest limit that runs the call on the same path as a -// run at max gas. +// gas_limit is gas_used plus a reserve for the SSTORE sentry and the 1/64 +// that each call keeps, so the call takes the same path as at max gas. export type SimulateResult = TxResult & { gas_limit: number; };