From b6804a27f4c100e4f079b6a21efb221a1744994a 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 01/12] 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 b9d077e..33e325b 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 d0aea7fe58eae6d45fae1756e69187e9ceac0ca9 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 02/12] 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 d61ef59..e2d9db8 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 33e325b..3b7eb89 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 e6af7db..9c451ea 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, Fiber, Scheduler } 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 d245b70..7ec294c 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 0000000..9ac1d05 --- /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 04503cb..3ef49ab 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 a857449446d90bcc9515b9728402dabaa6b298b7 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 03/12] 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 9c451ea..57fc49e 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 d0e35c12c90b89eba23beb395c9e28f24ef45369 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 04/12] 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 3b7eb89..31a736e 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 57fc49e..a036965 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 7ec294c..45a2c16 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; }; From 941e8c7c7e6e15fd01b196ec45291153bc5ab666 Mon Sep 17 00:00:00 2001 From: kyscott18 <43524469+kyscott18@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:10:23 +0000 Subject: [PATCH 05/12] refactor(evm): init in the constructor and return only what the runtime reads - `createEVM(initParams)` builds the harness with its init params, so there is no `init` request, `initialized` flag, or "init already called" error. - `slot_writes` holds `{ slot, value }` for writes to the called contract. - `keccak_preimages` holds the 64-byte KECCAK256 inputs as hex, without hashes, and only execute returns them. - Execute and simulate no longer return `output`. - Note in `enqueueMutation` that the enqueue's journal is never pruned. Co-Authored-By: Claude Opus 5.5 --- packages/typewriter-evm/src/harness.rs | 167 +++++++----------- packages/typewriter-evm/src/index.test.ts | 131 ++++++-------- packages/typewriter-evm/src/index.ts | 26 ++- packages/typewriter-evm/src/lib.rs | 21 +-- packages/typewriter-evm/src/types.ts | 13 +- .../typewriter-evm/test/execute.benchmark.ts | 3 +- .../typewriter-evm/test/simulate.benchmark.ts | 6 +- packages/typewriter/src/db-query.ts | 2 +- packages/typewriter/src/typewriter.ts | 44 ++--- 9 files changed, 155 insertions(+), 258 deletions(-) diff --git a/packages/typewriter-evm/src/harness.rs b/packages/typewriter-evm/src/harness.rs index 31a736e..4f4de5f 100644 --- a/packages/typewriter-evm/src/harness.rs +++ b/packages/typewriter-evm/src/harness.rs @@ -8,7 +8,7 @@ use revm::{ bytecode::opcode, context::TxEnv, context_interface::{ - result::{ExecutionResult, HaltReason, Output}, + result::{ExecutionResult, HaltReason}, transaction::{AccessList, AccessListItem}, Host, JournalTr, }, @@ -19,7 +19,7 @@ use revm::{ interpreter_types::{Jumps, MemoryTr}, CallInputs, CallOutcome, CreateInputs, CreateOutcome, Interpreter, InterpreterResult, }, - primitives::{keccak256, Address, AddressSet, Bytes, TxKind, B256, U256}, + primitives::{Address, AddressSet, Bytes, TxKind, B256, U256}, state::{AccountInfo, Bytecode, EvmState}, DatabaseRef, ExecuteCommitEvm, ExecuteEvm, Inspector, }; @@ -31,7 +31,6 @@ use serde::{Deserialize, Serialize}; #[derive(Deserialize)] #[serde(tag = "method", rename_all = "camelCase")] enum Request { - Init { id: u64, params: InitParams }, SetBlockContext { id: u64, params: BlockParams }, Execute { id: u64, params: ExecuteParams }, Simulate { id: u64, params: SimulateParams }, @@ -94,9 +93,7 @@ struct JournalIdsParams { struct TxOutcome { success: bool, gas_used: u64, - output: String, access_list: Vec, - keccak_preimages: Vec, #[serde(skip_serializing_if = "Option::is_none")] revert_data: Option, } @@ -108,7 +105,10 @@ struct ExecuteOk { outcome: TxOutcome, #[serde(skip_serializing_if = "Option::is_none")] journal_id: Option, + // Writes to the called contract. slot_writes: Vec, + // The 64-byte KECCAK256 inputs: a mapping key and the mapping's slot. + keccak_preimages: Vec, } // gas_used is with the access list warm, as the chain charges it. @@ -128,16 +128,8 @@ struct AccessListEntry { #[derive(Serialize)] struct SlotWriteEntry { - address: String, slot: String, - prev_value: String, - new_value: String, -} - -#[derive(Serialize)] -struct KeccakPreimageEntry { - hash: String, - preimage: String, + value: String, } // ----------------------------------------------------------------------------- @@ -155,7 +147,7 @@ type TxResult = ExecutionResult; // child's reserve. Monad has no refunds, so gas_used is the gas spent. #[derive(Default)] struct HarnessInspector { - keccak_preimages: Vec<(B256, Vec)>, + keccak_preimages: Vec<[u8; 64]>, frame_reserves: Vec, gas_reserve: u64, // A halted frame uses all the gas it gets, so its cost depends on the @@ -192,20 +184,18 @@ impl Inspector for HarnessInspector { let [.., len, offset] = interp.stack.data().as_slice() else { return; }; - let (Ok(offset), Ok(len)) = (usize::try_from(*offset), usize::try_from(*len)) else { + // Only a mapping slot hashes 64 bytes: the key and the mapping's slot. + let (Ok(offset), Ok(64)) = (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 mut preimage = [0; 64]; let memory_size = interp.memory.size(); if offset < memory_size { - let copied = (memory_size - offset).min(len); + let copied = (memory_size - offset).min(64); preimage[..copied].copy_from_slice(&interp.memory.slice_len(offset, copied)); } - self.keccak_preimages.push((keccak256(&preimage), preimage)); + self.keccak_preimages.push(preimage); } fn call(&mut self, _context: &mut MonadCtx, _inputs: &mut CallInputs) -> Option { @@ -322,31 +312,27 @@ pub struct EvmHarness { evm: Evm, journals: HashMap, next_journal_id: u64, - initialized: bool, } -impl Default for EvmHarness { - fn default() -> Self { - Self::new() +impl EvmHarness { + pub fn from_json(params_json: &str) -> Result { + let params = serde_json::from_str(params_json).map_err(|e| format!("parse error: {e}"))?; + Self::new(¶ms) } -} -impl EvmHarness { - pub fn new() -> Self { + fn new(params: &InitParams) -> Result { let mut ctx = monad_context_with_db(InMemoryDB::default()); relax_cfg(&mut ctx.cfg); - Self { + let mut harness = Self { evm: ctx.build_monad_with_inspector(HarnessInspector::default()), journals: HashMap::new(), next_journal_id: 1, - initialized: false, - } + }; + harness.init(params)?; + Ok(harness) } fn init(&mut self, params: &InitParams) -> Result<(), String> { - if self.initialized { - return Err("init already called".into()); - } if let Some(spec) = params.spec.as_deref() { let spec = parse_spec(spec).ok_or_else(|| format!("unknown spec: {spec}"))?; let mut cfg = MonadCfgEnv::new_with_spec(spec); @@ -358,7 +344,7 @@ impl EvmHarness { self.evm.0.ctx.cfg.0.chain_id = chain_id; } if let Some(block) = ¶ms.block { - self.apply_block_params(block)?; + self.set_block_context(block)?; } let db = self.evm.0.ctx.journaled_state.db_mut(); for (address, account) in params.accounts.iter().flatten() { @@ -380,16 +366,10 @@ impl EvmHarness { .map_err(|e| format!("insert_account_storage: {e:?}"))?; } } - self.initialized = true; Ok(()) } fn set_block_context(&mut self, params: &BlockParams) -> Result<(), String> { - self.ensure_initialized()?; - self.apply_block_params(params) - } - - 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)?; @@ -424,7 +404,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)?; let (result, access_list) = match self.discovery_pass(&tx) { Ok(pass) => pass, @@ -433,18 +412,27 @@ impl EvmHarness { return Err(error); } }; - let outcome = self.tx_outcome(&result, &access_list); + let outcome = tx_outcome(&result, &access_list); + let keccak_preimages = self + .evm + .0 + .inspector + .keccak_preimages + .iter() + .map(|preimage| format_bytes(preimage)) + .collect(); if !outcome.success { let _ = self.evm.finalize(); return Ok(ExecuteOk { outcome, journal_id: None, slot_writes: Vec::new(), + keccak_preimages, }); } let state = self.evm.0.ctx.journaled_state.evm_state(); - let slot_writes = collect_slot_writes(state); + let slot_writes = collect_slot_writes(state, tx.to); let journal = Journal::from_state(state, access_list); let state = self.evm.finalize(); self.evm.commit(state); @@ -455,6 +443,7 @@ impl EvmHarness { outcome, journal_id: Some(journal_id), slot_writes, + keccak_preimages, }) } @@ -489,16 +478,7 @@ impl EvmHarness { .collect() } - fn ensure_initialized(&self) -> Result<(), String> { - if self.initialized { - Ok(()) - } else { - Err("not initialized".into()) - } - } - fn ensure_journal_ids(&self, journal_ids: &[u64]) -> Result<(), String> { - self.ensure_initialized()?; match journal_ids .iter() .find(|id| !self.journals.contains_key(id)) @@ -546,7 +526,7 @@ impl EvmHarness { .evm .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 outcome = 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()); @@ -559,39 +539,6 @@ impl EvmHarness { Ok(SimulateOk { outcome, gas_limit }) } - fn tx_outcome(&self, result: &TxResult, access_list: &AccessList) -> TxOutcome { - let (success, output, revert_data) = match result { - ExecutionResult::Success { output, .. } => { - 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::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 - .keccak_preimages - .iter() - .map(|(hash, preimage)| KeccakPreimageEntry { - hash: format_bytes(hash.as_slice()), - preimage: format_bytes(preimage), - }) - .collect(), - revert_data, - } - } - fn merge_journal_access_lists(&self, journal_ids: &[u64]) -> Option { if journal_ids.is_empty() { return None; @@ -620,6 +567,20 @@ impl EvmHarness { // ----------------------------------------------------------------------------- // Conversion helpers +fn tx_outcome(result: &TxResult, access_list: &AccessList) -> TxOutcome { + let (success, revert_data) = match result { + ExecutionResult::Success { .. } => (true, None), + ExecutionResult::Revert { output, .. } => (false, Some(format_bytes(output))), + ExecutionResult::Halt { reason, .. } => (false, Some(format!("halt: {reason:?}"))), + }; + TxOutcome { + success, + gas_used: result.tx_gas_used(), + access_list: encode_access_list(access_list), + revert_data, + } +} + fn discovered_access_list( state: &EvmState, precompiles: &AddressSet, @@ -669,23 +630,20 @@ fn encode_access_list(list: &AccessList) -> Vec { entries } -fn collect_slot_writes(state: &EvmState) -> Vec { - let mut writes: Vec = state +fn collect_slot_writes(state: &EvmState, address: Address) -> Vec { + let Some(account) = state.get(&address) else { + return Vec::new(); + }; + let mut writes: Vec = account + .storage .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()), - }) + .filter(|(_, value)| value.original_value() != value.present_value()) + .map(|(slot, value)| SlotWriteEntry { + slot: format_u256(*slot), + value: format_u256(value.present_value()), }) .collect(); - writes.sort_by(|a, b| a.address.cmp(&b.address).then(a.slot.cmp(&b.slot))); + writes.sort_by(|a, b| a.slot.cmp(&b.slot)); writes } @@ -759,7 +717,6 @@ pub fn dispatch_json(harness: &mut EvmHarness, request_json: &str) -> String { }; 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))) } @@ -839,9 +796,7 @@ mod tests { }) .collect(); let params = serde_json::from_value(serde_json::json!({ "accounts": accounts })).unwrap(); - let mut harness = EvmHarness::new(); - harness.init(¶ms).unwrap(); - harness + EvmHarness::new(¶ms).unwrap() } fn tx(harness: &EvmHarness) -> TxRequest { diff --git a/packages/typewriter-evm/src/index.test.ts b/packages/typewriter-evm/src/index.test.ts index a036965..bd8ce6f 100644 --- a/packages/typewriter-evm/src/index.test.ts +++ b/packages/typewriter-evm/src/index.test.ts @@ -17,7 +17,7 @@ const P256_VERIFIER = "0x0000000000000000000000000000000000000100" as const; import { expect, test } from "bun:test"; import { Effect, Exit, Fiber, Scheduler } from "effect"; -import { Hex } from "ox"; +import { Hash, Hex } from "ox"; import { createStorageView } from "storage-layout"; import { TEST_PUBLIC_CLIENT } from "../test/setup"; import { @@ -44,7 +44,7 @@ import { transferData, USER_ADDR, } from "../test/utils"; -import { createEVM } from "./index"; +import { createEVM, EvmCallError, type Spec } from "./index"; const initWithCounter = { accounts: { @@ -54,8 +54,7 @@ const initWithCounter = { const runSamePageWrites = (spec?: "MonadNine" | "MonadTen") => Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init({ + const evm = yield* createEVM({ spec, accounts: { [PAGE_WRITES_ADDR]: { code: SAME_PAGE_WRITES_CODE }, @@ -83,8 +82,7 @@ test("default spec enables MonadTen MIP-8 page storage gas", async () => { test("execute against counter succeeds and returns a journal id", async () => { const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init(initWithCounter); + const evm = yield* createEVM(initWithCounter); const exec = yield* evm.execute({ from: CALLER, to: COUNTER_ADDR, @@ -101,11 +99,8 @@ test("execute against counter succeeds and returns a journal id", async () => { expect(exec).not.toHaveProperty("gas_limit"); expect(exec.slot_writes).toEqual([ { - address: COUNTER_ADDR, slot: "0x0000000000000000000000000000000000000000000000000000000000000000", - prev_value: - "0x0000000000000000000000000000000000000000000000000000000000000000", - new_value: + value: "0x0000000000000000000000000000000000000000000000000000000000000001", }, ]); @@ -113,8 +108,7 @@ test("execute against counter succeeds and returns a journal id", async () => { test("two executes create sequential journals", async () => { const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init(initWithCounter); + const evm = yield* createEVM(initWithCounter); const first = yield* evm.execute({ from: CALLER, to: COUNTER_ADDR, @@ -137,8 +131,7 @@ test("two executes create sequential journals", async () => { test("revertJournals drops writes; next journal starts from pre-revert state", async () => { const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init(initWithCounter); + const evm = yield* createEVM(initWithCounter); // Journal 1: increment, revert. const reverted = yield* evm.execute({ @@ -163,8 +156,7 @@ test("revertJournals drops writes; next journal starts from pre-revert state", a test("sequential journals keep local state", async () => { const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init(initWithCounter); + const evm = yield* createEVM(initWithCounter); yield* evm.execute({ from: CALLER, to: COUNTER_ADDR, data: "0x" }); @@ -182,8 +174,7 @@ test("sequential journals keep local state", async () => { test("execute opens a journal automatically", async () => { const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init(initWithCounter); + const evm = yield* createEVM(initWithCounter); return yield* evm.execute({ from: CALLER, to: COUNTER_ADDR, @@ -198,8 +189,7 @@ test("execute opens a journal automatically", async () => { test("failed execute discards journal writes", async () => { const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init({ + const evm = yield* createEVM({ accounts: { [REVERTING_ADDR]: { code: WRITE_THEN_REVERT_CODE }, }, @@ -219,8 +209,7 @@ test("failed execute discards journal writes", async () => { test("execute returns access_list with the counter address + slot 0", async () => { const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init(initWithCounter); + const evm = yield* createEVM(initWithCounter); return yield* evm.execute({ from: CALLER, to: COUNTER_ADDR, data: "0x" }); }); @@ -237,8 +226,7 @@ test("execute returns access_list with the counter address + slot 0", async () = test("simulate does not mutate state — counter stays at 0 after simulate", async () => { const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init(initWithCounter); + const evm = yield* createEVM(initWithCounter); // simulate has no journal requirement and no persistence. const sim = yield* evm.simulate({ @@ -261,8 +249,7 @@ test("simulate rewinds and re-applies selected journals", async () => { // Two optimistic journals each increment. After both, slot is at 2. // simulate must un-apply the provided journals, run, then re-apply them. const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init(initWithCounter); + const evm = yield* createEVM(initWithCounter); const first = yield* evm.execute({ from: CALLER, @@ -294,8 +281,7 @@ test("simulate only rewinds the provided journal ids", async () => { // First journal: slot 0 -> 1. Second journal: slot 1 -> 2. // simulate receives only the second id, so it runs against slot value 1. const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init(initWithCounter); + const evm = yield* createEVM(initWithCounter); yield* evm.execute({ from: CALLER, to: COUNTER_ADDR, data: "0x" }); @@ -321,8 +307,7 @@ test("simulate only rewinds the provided journal ids", async () => { test("revertJournals works on a journal", async () => { const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init(initWithCounter); + const evm = yield* createEVM(initWithCounter); const accepted = yield* evm.execute({ from: CALLER, @@ -348,8 +333,7 @@ test("revertJournals works on a journal", async () => { test("revertJournals reverts multiple journals", async () => { const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init(initWithCounter); + const evm = yield* createEVM(initWithCounter); const first = yield* evm.execute({ from: CALLER, @@ -387,8 +371,7 @@ test("revertJournals reverts multiple journals", async () => { test("revertJournals with an unknown journal id fails", async () => { const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init(initWithCounter); + const evm = yield* createEVM(initWithCounter); return yield* evm.revertJournals({ journal_ids: [5] }); }); @@ -398,8 +381,7 @@ test("revertJournals with an unknown journal id fails", async () => { test("pruneJournals drops selected journals", async () => { const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init(initWithCounter); + const evm = yield* createEVM(initWithCounter); const first = yield* evm.execute({ from: CALLER, @@ -432,8 +414,7 @@ test("pruneJournals drops selected journals", async () => { test("pruneJournals with an unknown journal id fails", async () => { const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init(initWithCounter); + const evm = yield* createEVM(initWithCounter); return yield* evm.pruneJournals({ journal_ids: [1] }); }); @@ -443,8 +424,7 @@ test("pruneJournals with an unknown journal id fails", async () => { test("setBlockContext updates block parameters", async () => { const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init({ + const evm = yield* createEVM({ block: { number: "0x1", timestamp: "0x64", @@ -479,8 +459,7 @@ test("execute e2e with compiled Solmate ERC20 bytecode", async () => { const data = transferData(USER_ADDR, TRANSFER_AMOUNT); const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init(params); + const evm = yield* createEVM(params); const exec = yield* evm.execute({ from: SCHEDULER_ADDR, to: TOKEN_ADDR, @@ -526,8 +505,7 @@ test("execute reports mapping keccak preimages for touched slots", async () => { const userBalanceSlot = mappingSlot(USER_ADDR, BALANCE_OF_SLOT); const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init(params); + const evm = yield* createEVM(params); return yield* evm.execute({ from: SCHEDULER_ADDR, to: TOKEN_ADDR, @@ -537,20 +515,18 @@ test("execute reports mapping keccak preimages for touched slots", async () => { const exec = await Effect.runPromise(Effect.scoped(program)); - expect(exec.keccak_preimages).toContainEqual({ - hash: schedulerBalanceSlot, - preimage: Hex.concat( - Hex.padLeft(SCHEDULER_ADDR, 32), - Hex.fromNumber(BALANCE_OF_SLOT, { size: 32 }), - ).toLowerCase() as Hex.Hex, - }); - expect(exec.keccak_preimages).toContainEqual({ - hash: userBalanceSlot, - preimage: Hex.concat( + // Each preimage hashes to the mapping slot it names. + const slots = exec.keccak_preimages.map((preimage) => + Hash.keccak256(preimage), + ); + expect(slots).toContain(schedulerBalanceSlot); + expect(slots).toContain(userBalanceSlot); + expect(exec.keccak_preimages).toContain( + Hex.concat( Hex.padLeft(USER_ADDR, 32), Hex.fromNumber(BALANCE_OF_SLOT, { size: 32 }), ).toLowerCase() as Hex.Hex, - }); + ); }); test("readStorage decodes committed token state through storage proxy", async () => { @@ -559,8 +535,7 @@ test("readStorage decodes committed token state through storage proxy", async () const data = transferData(USER_ADDR, TRANSFER_AMOUNT); const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init(params); + const evm = yield* createEVM(params); const exec = yield* evm.execute({ from: SCHEDULER_ADDR, to: TOKEN_ADDR, @@ -611,8 +586,7 @@ test("simulate e2e temporarily rewinds optimistic Solmate ERC20 journals", async const recipientBalanceSlot = mappingSlot(RECIPIENT_ADDR, BALANCE_OF_SLOT); const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init(params); + const evm = yield* createEVM(params); const optimistic = yield* evm.execute({ from: SCHEDULER_ADDR, to: TOKEN_ADDR, @@ -688,8 +662,7 @@ test("harness access list matches eth_createAccessList for Solmate ERC20 transfe const { params } = tokenInit(artifact, tokenAddr); const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init(params); + const evm = yield* createEVM(params); return yield* evm.simulate({ from: SCHEDULER_ADDR, to: tokenAddr, @@ -725,8 +698,7 @@ test("harness gas_limit is sufficient for Solmate ERC20 transfer", async () => { const { params } = tokenInit(artifact, tokenAddr); const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init(params); + const evm = yield* createEVM(params); return yield* evm.simulate({ from: SCHEDULER_ADDR, to: tokenAddr, @@ -763,8 +735,7 @@ test("harness gas_limit includes EIP-150 call headroom", async () => { }); const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init({ + const evm = yield* createEVM({ chain_id: 31337, accounts: { [calleeAddr]: { code: calleeArtifact.deployedBytecode.object }, @@ -807,8 +778,7 @@ test("access list leaves out the P-256 precompile at 0x100", async () => { }); const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init({ + const evm = yield* createEVM({ chain_id: 31337, accounts: { [callerAddr]: { code: artifact.deployedBytecode.object }, @@ -839,15 +809,19 @@ test("access list leaves out the P-256 precompile at 0x100", async () => { expect(BigInt(simulated.gas_limit)).toBeGreaterThanOrEqual(rpcGasEstimate); }); -test("init twice fails", async () => { - const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init({}); - yield* evm.init({}); - }); +test("createEVM fails with an unknown spec", async () => { + const program = createEVM({ spec: "MonadZero" as Spec }); const exit = await Effect.runPromiseExit(Effect.scoped(program)); - expect(Exit.isFailure(exit)).toBe(true); + expect(exit).toEqual( + Exit.fail( + new EvmCallError({ + method: "init", + message: "init failed: unknown spec: MonadZero", + cause: "unknown spec: MonadZero", + }), + ), + ); }); test("an interrupted execute does not desync subsequent calls", async () => { @@ -860,8 +834,7 @@ test("an interrupted execute does not desync subsequent calls", async () => { // We hammer many zero-timeout calls so at least one is interrupted in that // window, then assert a normal execute still succeeds. const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init(initWithCounter); + const evm = yield* createEVM(initWithCounter); for (let i = 0; i < 100; i++) { yield* Effect.ignore( @@ -891,8 +864,7 @@ test("concurrent execute submissions serialize into sequential journals", async // run one at a time and each opens the next sequential journal id rather // than racing or interleaving. const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init(initWithCounter); + const evm = yield* createEVM(initWithCounter); return yield* Effect.all( Array.from({ length: 5 }, () => evm.execute({ from: CALLER, to: COUNTER_ADDR, data: "0x" }), @@ -912,8 +884,7 @@ test("runSync completes while another call is paused mid-call", async () => { // A fiber can yield to the scheduler at any op. Nothing in a call may wait // on another call, or runSync on a storage read fails with AsyncFiberError. const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init(initWithCounter); + const evm = yield* createEVM(initWithCounter); const read = evm.readStorage({ address: COUNTER_ADDR, slots: ["0x0"] }); // A budget below 3 yields before any progress, so the fiber never ends. for (let ops = 3; ops <= 32; ops++) { diff --git a/packages/typewriter-evm/src/index.ts b/packages/typewriter-evm/src/index.ts index dbe5214..35ac158 100644 --- a/packages/typewriter-evm/src/index.ts +++ b/packages/typewriter-evm/src/index.ts @@ -39,7 +39,7 @@ type NativeEvmHandle = { }; type NativeAddon = { - readonly NativeEvm: new () => NativeEvmHandle; + readonly NativeEvm: new (paramsJson: string) => NativeEvmHandle; }; // ----------------------------------------------------------------------------- @@ -51,7 +51,7 @@ export class EvmCrashed extends Data.TaggedError("EvmCrashed")<{ }> {} export class EvmCallError extends Data.TaggedError("EvmCallError")<{ - method: Request["method"]; + method: Request["method"] | "init"; message: string; cause: string; }> {} @@ -67,7 +67,6 @@ export type EvmError = EvmCrashed | EvmCallError | EvmProtocolError; // Public API export type EVM = { - readonly init: (params: InitParams) => Effect.Effect; readonly setBlockContext: ( params: BlockParams, ) => Effect.Effect; @@ -107,9 +106,22 @@ function loadNativeAddon(): NativeAddon { // `Effect.runSync` on a call always completes. Keep native methods synchronous: // an async native method needs calls to be serialized again. The native harness // is freed when the handle is garbage collected. -export const createEVM = (): Effect.Effect => +export const createEVM = ( + initParams: InitParams, +): Effect.Effect => Effect.gen(function* () { - const native = new (loadNativeAddon().NativeEvm)(); + const NativeEvm = loadNativeAddon().NativeEvm; + const native = yield* Effect.try({ + try: () => new NativeEvm(JSON.stringify(initParams)), + catch: (e) => { + const cause = e instanceof Error ? e.message : String(e); + return new EvmCallError({ + method: "init", + message: `init failed: ${cause}`, + cause, + }); + }, + }); const idCounterRef = yield* Ref.make(1); const call = ( @@ -157,10 +169,6 @@ export const createEVM = (): Effect.Effect => }); const evm: EVM = { - init: (params) => - call("init", (id) => ({ method: "init", id, params })).pipe( - Effect.asVoid, - ), setBlockContext: (params) => call("setBlockContext", (id) => ({ method: "setBlockContext", diff --git a/packages/typewriter-evm/src/lib.rs b/packages/typewriter-evm/src/lib.rs index f013e13..65d1f6a 100644 --- a/packages/typewriter-evm/src/lib.rs +++ b/packages/typewriter-evm/src/lib.rs @@ -1,10 +1,8 @@ // napi-rs addon: the production transport for typewriter-evm. // -// The native class owns one `EvmHarness` (see `harness.rs`) and exposes a -// single `call` method that forwards a JSON request and returns a JSON -// response. The JSON protocol is intentionally preserved across the FFI -// boundary so the Rust harness stays transport-agnostic and the TypeScript -// client owns one instance per `createEVM()`. +// The native class owns one `EvmHarness` (see `harness.rs`). The constructor +// takes the JSON init params, and `call` forwards a JSON request and returns +// a JSON response. The TypeScript client owns one instance per `createEVM()`. mod harness; @@ -16,19 +14,12 @@ pub struct NativeEvm { harness: EvmHarness, } -impl Default for NativeEvm { - fn default() -> Self { - Self::new() - } -} - #[napi] impl NativeEvm { #[napi(constructor)] - pub fn new() -> Self { - Self { - harness: EvmHarness::new(), - } + pub fn new(params_json: String) -> napi::Result { + let harness = EvmHarness::from_json(¶ms_json).map_err(napi::Error::from_reason)?; + Ok(Self { harness }) } #[napi] diff --git a/packages/typewriter-evm/src/types.ts b/packages/typewriter-evm/src/types.ts index 45a2c16..43a4115 100644 --- a/packages/typewriter-evm/src/types.ts +++ b/packages/typewriter-evm/src/types.ts @@ -44,21 +44,17 @@ export type SimulateParams = ExecuteParams & { type TxResult = { success: boolean; 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; - }[]; + // Writes to the called contract. + slot_writes: { slot: Hex.Hex; value: Hex.Hex }[]; + // The 64-byte KECCAK256 inputs: a mapping key and the mapping's slot. + keccak_preimages: Hex.Hex[]; }; // gas_used is the gas with the access list warm, as the chain charges it. @@ -80,7 +76,6 @@ export type JournalIdsParams = { }; export type Request = - | { method: "init"; id: number; params: InitParams } | { method: "setBlockContext"; id: number; params: BlockParams } | { method: "execute"; id: number; params: ExecuteParams } | { method: "simulate"; id: number; params: SimulateParams } diff --git a/packages/typewriter-evm/test/execute.benchmark.ts b/packages/typewriter-evm/test/execute.benchmark.ts index fe2de4d..e53cec4 100644 --- a/packages/typewriter-evm/test/execute.benchmark.ts +++ b/packages/typewriter-evm/test/execute.benchmark.ts @@ -24,8 +24,7 @@ function formatMs(durationMs: number): string { test(`typewriter-evm execute accepts ${MUTATION_COUNT.toLocaleString()} calls`, async () => { const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init({ + const evm = yield* createEVM({ accounts: { [COUNTER_ADDR]: { code: COUNTER_CODE }, }, diff --git a/packages/typewriter-evm/test/simulate.benchmark.ts b/packages/typewriter-evm/test/simulate.benchmark.ts index bb7ab32..e6268dc 100644 --- a/packages/typewriter-evm/test/simulate.benchmark.ts +++ b/packages/typewriter-evm/test/simulate.benchmark.ts @@ -70,8 +70,7 @@ function callBurnLargeData(burnCount: bigint): Hex.Hex { test("typewriter-evm simulate scales with optimistic journal count", async () => { const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init({ + const evm = yield* createEVM({ accounts: { [SLOT_WRITER_ADDR]: { code: SLOT_WRITER_CODE }, }, @@ -153,8 +152,7 @@ test("typewriter-evm simulate scales with EVM workload", async () => { const callerArtifact = await loadCallGasCaller(); const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init({ + const evm = yield* createEVM({ chain_id: 31337, accounts: { [CALL_BURN_CALLEE_ADDR]: { diff --git a/packages/typewriter/src/db-query.ts b/packages/typewriter/src/db-query.ts index cc67c80..3e0d4ee 100644 --- a/packages/typewriter/src/db-query.ts +++ b/packages/typewriter/src/db-query.ts @@ -154,7 +154,7 @@ export function insertStorageSlots( executeResult.slot_writes.map((write) => ({ execution_index: mutation.executionIndex, slot: write.slot, - value: write.new_value, + value: write.value, })), ); diff --git a/packages/typewriter/src/typewriter.ts b/packages/typewriter/src/typewriter.ts index c8e3060..c6f6417 100644 --- a/packages/typewriter/src/typewriter.ts +++ b/packages/typewriter/src/typewriter.ts @@ -315,17 +315,6 @@ export function batchBlockToEvent( } as BlockEvent<"batch">; } -/** - * The 64-byte keccak256 preimages of an execution: the inputs that hash a - * mapping key with its slot. Shorter preimages give dynamic array data slots, - * which enumeration does not need. The EVM writes them as lowercase hex. - */ -function mappingPreimages(executeResult: ExecuteResult): readonly Hex.Hex[] { - return executeResult.keccak_preimages - .map(({ preimage }) => preimage) - .filter((preimage) => Hex.size(preimage) === 64); -} - export class EncodeMutationError extends Data.TaggedError( "EncodeMutationError", )<{ @@ -414,6 +403,8 @@ export function enqueueMutation(params: { mutation: EnqueuedMutation; }): Effect.Effect { return Effect.gen(function* () { + // TODO(kyle) prune the enqueue's journal. Nothing reverts or replays it, + // so it stays in the EVM's journal map for the life of the process. const enqueueCalldata = encodeEnqueueCalldata(params.mutation); return yield* params.evm.execute({ @@ -522,32 +513,21 @@ function createRuntimeState( }), ); - const evm = yield* createEVM(); - if (code === "0x") { yield* Effect.logWarning( `no contract code at app.address=${app.address}; ` + "revm code load skipped - runtime will continue but revm has no contract bytecode", ); - yield* evm.init({ - chain_id: app.chainId, - accounts: { - [app.address]: { - storage: initialAccountStorage, - }, - }, - }); - } else { - yield* evm.init({ - chain_id: app.chainId, - accounts: { - [app.address]: { - code, - storage: initialAccountStorage, - }, - }, - }); } + const evm = yield* createEVM({ + chain_id: app.chainId, + accounts: { + [app.address]: { + code: code === "0x" ? undefined : code, + storage: initialAccountStorage, + }, + }, + }); const slotCache = new Map(); const invalidateStorageCache = (slots?: readonly Hex.Hex[]) => { @@ -796,7 +776,7 @@ export function createTypewriterEffect< ); const newPreimages: Hex.Hex[] = []; - for (const preimage of mappingPreimages(executeResult)) { + for (const preimage of executeResult.keccak_preimages) { if (preimages.has(preimage)) continue; preimages.add(preimage); newPreimages.push(preimage); From 4b1217fbf0c1added064804277995eecb5ca0bc6 Mon Sep 17 00:00:00 2001 From: kyscott18 <43524469+kyscott18@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:02:35 +0000 Subject: [PATCH 06/12] fix(evm): let simulate succeed when a caught call halts A halted frame uses all the gas it is given, and frame_end already counts that gas in its need. So a call with a fixed gas budget that halts and is caught costs the same at any gas limit, and the reserve covers it. Co-Authored-By: Claude Opus 5.5 --- packages/typewriter-evm/src/harness.rs | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/typewriter-evm/src/harness.rs b/packages/typewriter-evm/src/harness.rs index 4f4de5f..483bcaf 100644 --- a/packages/typewriter-evm/src/harness.rs +++ b/packages/typewriter-evm/src/harness.rs @@ -144,15 +144,13 @@ type TxResult = ExecutionResult; // 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. +// child's reserve. Monad has no refunds, so gas_used is the gas spent. A +// halted frame uses all the gas it is given, which its need already counts. #[derive(Default)] struct HarnessInspector { keccak_preimages: Vec<[u8; 64]>, 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 { @@ -164,9 +162,6 @@ impl HarnessInspector { 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() { @@ -527,12 +522,8 @@ impl EvmHarness { .inspect_one_tx(tx.tx_env(access_list.clone())) .map_err(|e| format!("transact (simulate): {e:?}"))?; let outcome = 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 { - result.tx_gas_used() + inspector.gas_reserve + result.tx_gas_used() + self.evm.0.inspector.gas_reserve } else { result.tx_gas_used() }; @@ -856,11 +847,13 @@ mod tests { } #[test] - fn halted_call_is_an_error() { + fn reserve_covers_caught_halt() { // 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()); + let result = simulate(&mut harness).unwrap(); + assert!(result.outcome.success); + assert!(succeeds_at(&mut harness, result.gas_limit)); } } From ef65dfdcf8f7dbd36af6b12520524c4c3831c40e Mon Sep 17 00:00:00 2001 From: kyscott18 <43524469+kyscott18@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:58:38 +0000 Subject: [PATCH 07/12] refactor(evm): report tx outcomes as success, revert, or halt Results carried success: boolean and revert_data, and a halt came back as revert_data "halt: ", a string typed as Hex that typewriter decoded as an unknown revert. Results now carry status: - success, plus journal_id from execute - revert, with data - halt, with reason, e.g. InvalidFEOpcode typewriter adds MutationHaltedError and SettlementSimulationHaltedError, and decodeRevert always gets hex data. The simulate gas limit relies on Typewriter contracts never catching a halt in a nested call; harness.rs now says so. Co-Authored-By: Claude Opus 5.5 --- packages/typewriter-evm/src/harness.rs | 49 ++++++-- packages/typewriter-evm/src/index.test.ts | 117 +++++++++++------- packages/typewriter-evm/src/types.ts | 16 +-- .../typewriter-evm/test/execute.benchmark.ts | 2 +- .../typewriter-evm/test/simulate.benchmark.ts | 8 +- packages/typewriter/src/index.ts | 2 + packages/typewriter/src/typewriter.ts | 55 ++++++-- 7 files changed, 167 insertions(+), 82 deletions(-) diff --git a/packages/typewriter-evm/src/harness.rs b/packages/typewriter-evm/src/harness.rs index 483bcaf..1f9dccf 100644 --- a/packages/typewriter-evm/src/harness.rs +++ b/packages/typewriter-evm/src/harness.rs @@ -91,11 +91,26 @@ struct JournalIdsParams { #[derive(Serialize)] struct TxOutcome { - success: bool, + #[serde(flatten)] + status: TxStatus, gas_used: u64, access_list: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - revert_data: Option, +} + +impl TxOutcome { + fn success(&self) -> bool { + matches!(self.status, TxStatus::Success) + } +} + +// A halt is a failure other than REVERT, such as out of gas or an invalid +// opcode. It returns no data and uses all the gas the frame was given. +#[derive(Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +enum TxStatus { + Success, + Revert { data: String }, + Halt { reason: String }, } // gas_used is with cold access: execute warms no access list. @@ -146,6 +161,11 @@ type TxResult = ExecutionResult; // 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. A // halted frame uses all the gas it is given, which its need already counts. +// +// Typewriter contracts never catch a halt in a nested call: a failed call +// reverts the tx. The limit relies on this. Simulate runs at u64::MAX gas, so +// a caught halt in a call that forwards all its gas burns nearly all of it, +// and gas_limit comes out near u64::MAX, which the node rejects. #[derive(Default)] struct HarnessInspector { keccak_preimages: Vec<[u8; 64]>, @@ -416,7 +436,7 @@ impl EvmHarness { .iter() .map(|preimage| format_bytes(preimage)) .collect(); - if !outcome.success { + if !outcome.success() { let _ = self.evm.finalize(); return Ok(ExecuteOk { outcome, @@ -522,7 +542,7 @@ impl EvmHarness { .inspect_one_tx(tx.tx_env(access_list.clone())) .map_err(|e| format!("transact (simulate): {e:?}"))?; let outcome = tx_outcome(&result, &access_list); - let gas_limit = if outcome.success { + let gas_limit = if outcome.success() { result.tx_gas_used() + self.evm.0.inspector.gas_reserve } else { result.tx_gas_used() @@ -559,16 +579,19 @@ impl EvmHarness { // Conversion helpers fn tx_outcome(result: &TxResult, access_list: &AccessList) -> TxOutcome { - let (success, revert_data) = match result { - ExecutionResult::Success { .. } => (true, None), - ExecutionResult::Revert { output, .. } => (false, Some(format_bytes(output))), - ExecutionResult::Halt { reason, .. } => (false, Some(format!("halt: {reason:?}"))), + let status = match result { + ExecutionResult::Success { .. } => TxStatus::Success, + ExecutionResult::Revert { output, .. } => TxStatus::Revert { + data: format_bytes(output), + }, + ExecutionResult::Halt { reason, .. } => TxStatus::Halt { + reason: format!("{reason:?}"), + }, }; TxOutcome { - success, + status, gas_used: result.tx_gas_used(), access_list: encode_access_list(access_list), - revert_data, } } @@ -819,7 +842,7 @@ mod tests { fn assert_reserve_needed(accounts: &[(u16, &[u8])]) { let mut harness = harness(accounts); let result = simulate(&mut harness).unwrap(); - assert!(result.outcome.success); + assert!(result.outcome.success()); assert!(!succeeds_at(&mut harness, result.outcome.gas_used)); assert!(succeeds_at(&mut harness, result.gas_limit)); } @@ -853,7 +876,7 @@ mod tests { code.push(0x00); let mut harness = harness(&[(CONTRACT, &code)]); let result = simulate(&mut harness).unwrap(); - assert!(result.outcome.success); + assert!(result.outcome.success()); assert!(succeeds_at(&mut harness, result.gas_limit)); } } diff --git a/packages/typewriter-evm/src/index.test.ts b/packages/typewriter-evm/src/index.test.ts index bd8ce6f..80ab1b2 100644 --- a/packages/typewriter-evm/src/index.test.ts +++ b/packages/typewriter-evm/src/index.test.ts @@ -8,10 +8,13 @@ const COUNTER_CODE = "0x60008054600101905500" as const; const SAME_PAGE_WRITES_CODE = "0x6001600055600160015500" as const; // Writes slot 0, then reverts. Failed executions must not persist writes. const WRITE_THEN_REVERT_CODE = "0x600160005560006000fd" as const; +// Runs INVALID (0xfe), which halts rather than reverts. +const HALT_CODE = "0xfe" as const; const COUNTER_ADDR = "0x0000000000000000000000000000000000000c01" as const; const PAGE_WRITES_ADDR = "0x0000000000000000000000000000000000000c02" as const; const REVERTING_ADDR = "0x0000000000000000000000000000000000000bad" as const; +const HALTING_ADDR = "0x0000000000000000000000000000000000000ba1" as const; const CALLER = "0x000000000000000000000000000000000000ca11" as const; const P256_VERIFIER = "0x0000000000000000000000000000000000000100" as const; @@ -44,7 +47,19 @@ import { transferData, USER_ADDR, } from "../test/utils"; -import { createEVM, EvmCallError, type Spec } from "./index"; +import { + createEVM, + EvmCallError, + type ExecuteResult, + type Spec, +} from "./index"; + +function journalId(result: ExecuteResult): number { + if (result.status !== "success") { + throw new Error(`expected success, got ${result.status}`); + } + return result.journal_id; +} const initWithCounter = { accounts: { @@ -74,8 +89,8 @@ test("default spec enables MonadTen MIP-8 page storage gas", async () => { Effect.runPromise(Effect.scoped(runSamePageWrites())), ]); - expect(monadTen.success).toBe(true); - expect(defaultSpec.success).toBe(true); + expect(monadTen.status).toBe("success"); + expect(defaultSpec.status).toBe("success"); expect(defaultSpec.gas_used).toBe(monadTen.gas_used); expect(monadTen.gas_used).toBeLessThan(monadNine.gas_used); }); @@ -92,9 +107,8 @@ test("execute against counter succeeds and returns a journal id", async () => { }); const exec = await Effect.runPromise(Effect.scoped(program)); - expect(exec.success).toBe(true); - expect(exec.journal_id).toBe(1); - expect(exec.revert_data).toBeUndefined(); + expect(exec.status).toBe("success"); + expect(journalId(exec)).toBe(1); // Only simulate estimates a gas limit. expect(exec).not.toHaveProperty("gas_limit"); expect(exec.slot_writes).toEqual([ @@ -123,10 +137,10 @@ test("two executes create sequential journals", async () => { }); const { first, second } = await Effect.runPromise(Effect.scoped(program)); - expect(first.success).toBe(true); - expect(first.journal_id).toBe(1); - expect(second.success).toBe(true); - expect(second.journal_id).toBe(2); + expect(first.status).toBe("success"); + expect(journalId(first)).toBe(1); + expect(second.status).toBe("success"); + expect(journalId(second)).toBe(2); }); test("revertJournals drops writes; next journal starts from pre-revert state", async () => { @@ -139,7 +153,7 @@ test("revertJournals drops writes; next journal starts from pre-revert state", a to: COUNTER_ADDR, data: "0x", }); - yield* evm.revertJournals({ journal_ids: [reverted.journal_id!] }); + yield* evm.revertJournals({ journal_ids: [journalId(reverted)] }); // Journal 2: increment, commit. Slot should go 0 -> 1, not 1 -> 2. const exec = yield* evm.execute({ @@ -151,7 +165,7 @@ test("revertJournals drops writes; next journal starts from pre-revert state", a }); const exec = await Effect.runPromise(Effect.scoped(program)); - expect(exec.success).toBe(true); + expect(exec.status).toBe("success"); }); test("sequential journals keep local state", async () => { @@ -169,7 +183,7 @@ test("sequential journals keep local state", async () => { }); const exec = await Effect.runPromise(Effect.scoped(program)); - expect(exec.success).toBe(true); + expect(exec.status).toBe("success"); }); test("execute opens a journal automatically", async () => { @@ -183,8 +197,8 @@ test("execute opens a journal automatically", async () => { }); const exec = await Effect.runPromise(Effect.scoped(program)); - expect(exec.success).toBe(true); - expect(exec.journal_id).toBe(1); + expect(exec.status).toBe("success"); + expect(journalId(exec)).toBe(1); }); test("failed execute discards journal writes", async () => { @@ -203,8 +217,25 @@ test("failed execute discards journal writes", async () => { }); const exec = await Effect.runPromise(Effect.scoped(program)); - expect(exec.success).toBe(false); - expect(exec.revert_data).toBe("0x"); + expect(exec).toMatchObject({ status: "revert", data: "0x" }); +}); + +test("execute reports a halt separately from a revert", async () => { + const program = Effect.gen(function* () { + const evm = yield* createEVM({ + accounts: { + [HALTING_ADDR]: { code: HALT_CODE }, + }, + }); + return yield* evm.execute({ + from: CALLER, + to: HALTING_ADDR, + data: "0x", + }); + }); + + const exec = await Effect.runPromise(Effect.scoped(program)); + expect(exec).toMatchObject({ status: "halt", reason: "InvalidFEOpcode" }); }); test("execute returns access_list with the counter address + slot 0", async () => { @@ -242,7 +273,7 @@ test("simulate does not mutate state — counter stays at 0 after simulate", asy }); const sim = await Effect.runPromise(Effect.scoped(program)); - expect(sim.success).toBe(true); + expect(sim.status).toBe("success"); }); test("simulate rewinds and re-applies selected journals", async () => { @@ -267,14 +298,14 @@ test("simulate rewinds and re-applies selected journals", async () => { from: CALLER, to: COUNTER_ADDR, data: "0x", - journal_ids: [first.journal_id!, second.journal_id!], + journal_ids: [journalId(first), journalId(second)], }); return sim; }); const sim = await Effect.runPromise(Effect.scoped(program)); - expect(sim.success).toBe(true); + expect(sim.status).toBe("success"); }); test("simulate only rewinds the provided journal ids", async () => { @@ -295,14 +326,14 @@ test("simulate only rewinds the provided journal ids", async () => { from: CALLER, to: COUNTER_ADDR, data: "0x", - journal_ids: [second.journal_id!], + journal_ids: [journalId(second)], }); return sim; }); const sim = await Effect.runPromise(Effect.scoped(program)); - expect(sim.success).toBe(true); + expect(sim.status).toBe("success"); }); test("revertJournals works on a journal", async () => { @@ -316,7 +347,7 @@ test("revertJournals works on a journal", async () => { }); // Reverting the journal should restore slot to 0. - yield* evm.revertJournals({ journal_ids: [accepted.journal_id!] }); + yield* evm.revertJournals({ journal_ids: [journalId(accepted)] }); // Start a new journal and increment; should go 0 -> 1. const exec = yield* evm.execute({ @@ -328,7 +359,7 @@ test("revertJournals works on a journal", async () => { }); const exec = await Effect.runPromise(Effect.scoped(program)); - expect(exec.success).toBe(true); + expect(exec.status).toBe("success"); }); test("revertJournals reverts multiple journals", async () => { @@ -354,7 +385,7 @@ test("revertJournals reverts multiple journals", async () => { }); yield* evm.revertJournals({ - journal_ids: [first.journal_id!, second.journal_id!, third.journal_id!], + journal_ids: [journalId(first), journalId(second), journalId(third)], }); const exec = yield* evm.execute({ @@ -366,7 +397,7 @@ test("revertJournals reverts multiple journals", async () => { }); const exec = await Effect.runPromise(Effect.scoped(program)); - expect(exec.success).toBe(true); + expect(exec.status).toBe("success"); }); test("revertJournals with an unknown journal id fails", async () => { @@ -395,10 +426,10 @@ test("pruneJournals drops selected journals", async () => { data: "0x", }); - yield* evm.pruneJournals({ journal_ids: [first.journal_id!] }); + yield* evm.pruneJournals({ journal_ids: [journalId(first)] }); // Reverting the remaining journal should restore slot to 1. - yield* evm.revertJournals({ journal_ids: [second.journal_id!] }); + yield* evm.revertJournals({ journal_ids: [journalId(second)] }); const exec = yield* evm.execute({ from: CALLER, @@ -409,7 +440,7 @@ test("pruneJournals drops selected journals", async () => { }); const exec = await Effect.runPromise(Effect.scoped(program)); - expect(exec.success).toBe(true); + expect(exec.status).toBe("success"); }); test("pruneJournals with an unknown journal id fails", async () => { @@ -449,7 +480,7 @@ test("setBlockContext updates block parameters", async () => { }); const exec = await Effect.runPromise(Effect.scoped(program)); - expect(exec.success).toBe(true); + expect(exec.status).toBe("success"); }); test("execute e2e with compiled Solmate ERC20 bytecode", async () => { @@ -472,7 +503,7 @@ test("execute e2e with compiled Solmate ERC20 bytecode", async () => { expect({ accessList: normalizeAccessRecord(exec.access_list), - success: exec.success, + status: exec.status, slots: { schedulerBalanceSlot, totalSupplySlot: TOTAL_SUPPLY_SLOT, @@ -494,7 +525,7 @@ test("execute e2e with compiled Solmate ERC20 bytecode", async () => { "totalSupplySlot": "0x0000000000000000000000000000000000000000000000000000000000000002", "userBalanceSlot": "0x9c35da83f88043b3115f30d93beacec49ca14b6238430bdff196a249c29baa80", }, - "success": true, + "status": "success", } `); }); @@ -571,7 +602,7 @@ test("readStorage decodes committed token state through storage proxy", async () const result = await Effect.runPromise(Effect.scoped(program)); expect(result).toEqual({ - exec: expect.objectContaining({ success: true }), + exec: expect.objectContaining({ status: "success" }), recipientBalance: 0n, schedulerBalance: INITIAL_SUPPLY - TRANSFER_AMOUNT, totalSupply: INITIAL_SUPPLY, @@ -596,7 +627,7 @@ test("simulate e2e temporarily rewinds optimistic Solmate ERC20 journals", async from: SCHEDULER_ADDR, to: TOKEN_ADDR, data: transferData(RECIPIENT_ADDR, SIMULATE_AMOUNT), - journal_ids: [optimistic.journal_id!], + journal_ids: [journalId(optimistic)], }); return { optimistic, simulated }; }); @@ -607,9 +638,9 @@ test("simulate e2e temporarily rewinds optimistic Solmate ERC20 journals", async expect({ optimisticAccessList: normalizeAccessRecord(optimistic.access_list), - optimisticSuccess: optimistic.success, + optimisticStatus: optimistic.status, simulatedAccessList: normalizeAccessRecord(simulated.access_list), - simulatedSuccess: simulated.success, + simulatedStatus: simulated.status, slots: { recipientBalanceSlot, schedulerBalanceSlot, @@ -626,7 +657,7 @@ test("simulate e2e temporarily rewinds optimistic Solmate ERC20 journals", async ], }, ], - "optimisticSuccess": true, + "optimisticStatus": "success", "simulatedAccessList": [ { "address": "0x0000000000000000000000000000000000000e20", @@ -636,7 +667,7 @@ test("simulate e2e temporarily rewinds optimistic Solmate ERC20 journals", async ], }, ], - "simulatedSuccess": true, + "simulatedStatus": "success", "slots": { "recipientBalanceSlot": "0x961ec03a078fec1e350bb1ca3bff1afa4bae5fb83d9d8382550c2fd26a7d7527", "schedulerBalanceSlot": "0xc651ee22c6951bb8b5bd29e8210fb394645a94315fe10eff2cc73de1aa75c137", @@ -801,8 +832,8 @@ test("access list leaves out the P-256 precompile at 0x100", async () => { Effect.scoped(program), ); - expect(executed.success).toBe(true); - expect(simulated.success).toBe(true); + expect(executed.status).toBe("success"); + expect(simulated.status).toBe("success"); expect(rpcAccessList).toEqual([]); expect(executed.access_list).toEqual([]); expect(simulated.access_list).toEqual([]); @@ -856,7 +887,7 @@ test("an interrupted execute does not desync subsequent calls", async () => { }); const exec = await Effect.runPromise(Effect.scoped(program)); - expect(exec.success).toBe(true); + expect(exec.status).toBe("success"); }); test("concurrent execute submissions serialize into sequential journals", async () => { @@ -875,8 +906,8 @@ test("concurrent execute submissions serialize into sequential journals", async const results = await Effect.runPromise(Effect.scoped(program)); expect(results.length).toBe(5); - expect(results.every((r) => r.success)).toBe(true); - const journalIds = results.map((r) => r.journal_id!).sort((a, b) => a - b); + expect(results.every((r) => r.status === "success")).toBe(true); + const journalIds = results.map((r) => journalId(r)).sort((a, b) => a - b); expect(journalIds).toEqual([1, 2, 3, 4, 5]); }); diff --git a/packages/typewriter-evm/src/types.ts b/packages/typewriter-evm/src/types.ts index 43a4115..d34c746 100644 --- a/packages/typewriter-evm/src/types.ts +++ b/packages/typewriter-evm/src/types.ts @@ -40,29 +40,29 @@ export type SimulateParams = ExecuteParams & { journal_ids: number[]; }; -// Fields that execute and simulate both return. type TxResult = { - success: boolean; gas_used: number; access_list: { address: Address.Address; storageKeys: Hex.Hex[] }[]; - revert_data?: Hex.Hex; }; -// gas_used is the gas with cold access. Execute does not warm an access list. +type Revert = { status: "revert"; data: Hex.Hex }; +// A failure other than REVERT, such as out of gas or an invalid opcode. +type Halt = { status: "halt"; reason: string }; + export type ExecuteResult = TxResult & { - journal_id?: number; // Writes to the called contract. slot_writes: { slot: Hex.Hex; value: Hex.Hex }[]; // The 64-byte KECCAK256 inputs: a mapping key and the mapping's slot. keccak_preimages: Hex.Hex[]; -}; +} & ({ status: "success"; journal_id: number } | Revert | Halt); // gas_used is the gas with the access list warm, as the chain charges it. // 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. +// that each call keeps, so the call takes the same path as at max gas. It +// assumes no nested call halts and is caught (see harness.rs). export type SimulateResult = TxResult & { gas_limit: number; -}; +} & ({ status: "success" } | Revert | Halt); export type ReadStorageParams = { address: Address.Address; diff --git a/packages/typewriter-evm/test/execute.benchmark.ts b/packages/typewriter-evm/test/execute.benchmark.ts index e53cec4..7d8b7dd 100644 --- a/packages/typewriter-evm/test/execute.benchmark.ts +++ b/packages/typewriter-evm/test/execute.benchmark.ts @@ -40,7 +40,7 @@ test(`typewriter-evm execute accepts ${MUTATION_COUNT.toLocaleString()} calls`, to: COUNTER_ADDR, data: "0x", }); - if (result.success !== true) { + if (result.status !== "success") { throw new Error("execute failed during benchmark"); } mutationDurationsMs[index] = performance.now() - mutationStart; diff --git a/packages/typewriter-evm/test/simulate.benchmark.ts b/packages/typewriter-evm/test/simulate.benchmark.ts index e6268dc..ac96d45 100644 --- a/packages/typewriter-evm/test/simulate.benchmark.ts +++ b/packages/typewriter-evm/test/simulate.benchmark.ts @@ -83,7 +83,7 @@ test("typewriter-evm simulate scales with optimistic journal count", async () => to: SLOT_WRITER_ADDR, data: writeSlotData(index), }); - if (result.success !== true || result.journal_id === undefined) { + if (result.status !== "success") { throw new Error("execute failed while preparing simulate benchmark"); } journalIds.push(result.journal_id); @@ -106,7 +106,7 @@ test("typewriter-evm simulate scales with optimistic journal count", async () => data: writeSlotData(JOURNAL_COUNT + iteration), journal_ids: selectedJournalIds, }); - if (result.success !== true) { + if (result.status !== "success") { throw new Error("simulate failed during benchmark"); } durationsMs.push(performance.now() - startedAtMs); @@ -171,7 +171,7 @@ test("typewriter-evm simulate scales with EVM workload", async () => { to: CALL_BURN_CALLER_ADDR, data: callBurnLargeData(burnCount), }); - if (result.success !== true || result.journal_id === undefined) { + if (result.status !== "success") { throw new Error("callBurn execute failed while preparing benchmark"); } journalIdsByBurnCount.set(burnCount, result.journal_id); @@ -194,7 +194,7 @@ test("typewriter-evm simulate scales with EVM workload", async () => { journal_ids: mode === "cached" ? [journalIdsByBurnCount.get(burnCount)!] : [], }); - if (result.success !== true) { + if (result.status !== "success") { throw new Error("callBurn simulate failed during benchmark"); } durationsMs.push(performance.now() - startedAtMs); diff --git a/packages/typewriter/src/index.ts b/packages/typewriter/src/index.ts index 4e02ccf..c8aede5 100644 --- a/packages/typewriter/src/index.ts +++ b/packages/typewriter/src/index.ts @@ -61,10 +61,12 @@ export { EncodeMutationError, type ExecuteError, type ExecuteMutationError, + MutationHaltedError, MutationRevertedError, type ProgramError, ProgramStoppedError, SettlementRevertedError, + SettlementSimulationHaltedError, SettlementSimulationRevertedError, SignTransactionError, TypewriterStoppedError, diff --git a/packages/typewriter/src/typewriter.ts b/packages/typewriter/src/typewriter.ts index c6f6417..0342a3d 100644 --- a/packages/typewriter/src/typewriter.ts +++ b/packages/typewriter/src/typewriter.ts @@ -166,6 +166,17 @@ export class SettlementSimulationRevertedError extends Data.TaggedError( } } +/** A settlement transaction halted when simulated in revm. */ +export class SettlementSimulationHaltedError extends Data.TaggedError( + "SettlementSimulationHaltedError", +)<{ + reason: string; +}> { + override get message() { + return `revm simulate halted: ${this.reason}`; + } +} + /** A settlement transaction was mined but reverted onchain. */ export class SettlementRevertedError extends Data.TaggedError( "SettlementRevertedError", @@ -198,6 +209,7 @@ export type ProgramError = | DbError | EvmError | SettlementSimulationRevertedError + | SettlementSimulationHaltedError | SettlementRevertedError | SignTransactionError | ProgramStoppedError; @@ -331,10 +343,22 @@ export class MutationRevertedError extends Data.TaggedError( } } +/** A mutation halted when executed in revm, e.g. on an invalid opcode. */ +export class MutationHaltedError extends Data.TaggedError( + "MutationHaltedError", +)<{ + reason: string; +}> { + override get message() { + return `revm execute halted: ${this.reason}`; + } +} + export type ExecuteMutationError = | EncodeMutationError | EvmError - | MutationRevertedError; + | MutationRevertedError + | MutationHaltedError; export function executeMutation(params: { app: TypewriterApp; @@ -369,22 +393,24 @@ export function executeMutation(params: { data: mutationCalldata, }); - if (executeResult.success === false) { + if (executeResult.status === "revert") { return yield* new MutationRevertedError( - decodeRevert(params.app.abi, executeResult.revert_data), + decodeRevert(params.app.abi, executeResult.data), ); } + if (executeResult.status === "halt") { + return yield* new MutationHaltedError({ reason: executeResult.reason }); + } const acceptedMutation = updateMutationToAccepted(params.mutation, { executionIndex: params.executionIndex, - journalId: executeResult.journal_id!, + journalId: executeResult.journal_id, }); yield* Effect.logDebug("executed mutation").pipe( Effect.annotateLogs({ executionIndex: acceptedMutation.executionIndex, name: acceptedMutation.name, - success: executeResult.success, slotWriteCount: executeResult.slot_writes.length, duration: durationMs(executeStartedAtMs), }), @@ -454,20 +480,18 @@ export function decodeEnqueuedMutation(params: { /** Revert data from revm, decoded against the app's ABI when it matches. */ type Revert = { - data: Hex.Hex | undefined; + data: Hex.Hex; /** The custom error's name, or `Error`/`Panic` for the built-in ones. */ errorName: string | undefined; args: readonly unknown[] | undefined; }; -function decodeRevert(abi: Abi, data: Hex.Hex | undefined): Revert { - if (data === undefined) - return { data, errorName: undefined, args: undefined }; +function decodeRevert(abi: Abi, data: Hex.Hex): Revert { try { const { errorName, args } = decodeErrorResult({ abi, data }); return { data, errorName, args }; } catch { - // Empty data, a selector missing from the ABI, or a halt reason. + // Empty revert data, or a selector that isn't in the ABI. return { data, errorName: undefined, args: undefined }; } } @@ -981,11 +1005,16 @@ export function createTypewriterEffect< journal_ids: speculativeJournalIds, }); const duration = durationMs(simulationStartedAtMs); - if (simulateResult.success === false) { + if (simulateResult.status === "revert") { return yield* new SettlementSimulationRevertedError( - decodeRevert(app.abi, simulateResult.revert_data), + decodeRevert(app.abi, simulateResult.data), ); } + if (simulateResult.status === "halt") { + return yield* new SettlementSimulationHaltedError({ + reason: simulateResult.reason, + }); + } yield* Effect.logDebug("simulated transaction submission").pipe( Effect.annotateLogs({ @@ -1174,7 +1203,7 @@ export function createTypewriterEffect< enqueueMutation({ app, evm, mutation: enqueuedMutation }), ); - if (executeResult.success === false) { + if (executeResult.status !== "success") { continue; } From 7d37fb7799b196c227551860d9a1a174bfddd6b7 Mon Sep 17 00:00:00 2001 From: kyscott18 <43524469+kyscott18@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:15:04 +0000 Subject: [PATCH 08/12] refactor(evm): take every run's state out of revm's journal in one place execute, simulate, and the discovery pass each called finalize on their own paths, including error paths, and a missed call would leave a run's state live for the next one. run_tx now runs the tx and always finalizes, and execute commits the state it returns. The access list is built from that returned state. Co-Authored-By: Claude Opus 5.5 --- packages/typewriter-evm/src/harness.rs | 120 +++++++++++-------------- 1 file changed, 51 insertions(+), 69 deletions(-) diff --git a/packages/typewriter-evm/src/harness.rs b/packages/typewriter-evm/src/harness.rs index 1f9dccf..5f08337 100644 --- a/packages/typewriter-evm/src/harness.rs +++ b/packages/typewriter-evm/src/harness.rs @@ -19,7 +19,7 @@ use revm::{ interpreter_types::{Jumps, MemoryTr}, CallInputs, CallOutcome, CreateInputs, CreateOutcome, Interpreter, InterpreterResult, }, - primitives::{Address, AddressSet, Bytes, TxKind, B256, U256}, + primitives::{Address, Bytes, TxKind, B256, U256}, state::{AccountInfo, Bytecode, EvmState}, DatabaseRef, ExecuteCommitEvm, ExecuteEvm, Inspector, }; @@ -420,13 +420,8 @@ impl EvmHarness { fn execute(&mut self, params: &ExecuteParams) -> Result { let tx = TxRequest::parse(params, self.evm.0.ctx.cfg.0.chain_id)?; - let (result, access_list) = match self.discovery_pass(&tx) { - Ok(pass) => pass, - Err(error) => { - let _ = self.evm.finalize(); - return Err(error); - } - }; + let (result, state) = self.run_tx(&tx, AccessList::default())?; + let access_list = self.discovered_access_list(&tx, &state); let outcome = tx_outcome(&result, &access_list); let keccak_preimages = self .evm @@ -437,7 +432,6 @@ impl EvmHarness { .map(|preimage| format_bytes(preimage)) .collect(); if !outcome.success() { - let _ = self.evm.finalize(); return Ok(ExecuteOk { outcome, journal_id: None, @@ -446,10 +440,8 @@ impl EvmHarness { }); } - let state = self.evm.0.ctx.journaled_state.evm_state(); - let slot_writes = collect_slot_writes(state, tx.to); - let journal = Journal::from_state(state, access_list); - let state = self.evm.finalize(); + let slot_writes = collect_slot_writes(&state, tx.to); + let journal = Journal::from_state(&state, access_list); self.evm.commit(state); let journal_id = self.next_journal_id; self.next_journal_id += 1; @@ -470,7 +462,6 @@ impl EvmHarness { } let cached_access_list = self.merge_journal_access_lists(¶ms.journal_ids); let result = self.run_simulate(&tx, cached_access_list); - let _ = self.evm.finalize(); for journal_id in ¶ms.journal_ids { self.journals[journal_id].write(self.evm.0.ctx.journaled_state.db_mut(), true); } @@ -503,23 +494,49 @@ impl EvmHarness { } } - // 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> { + // Runs the tx and takes its post-state out of revm's journal, even when + // the run fails, so no run leaves state behind for the next one. Execute + // commits the state it gets back; every other run drops it. finalize is + // the only way to drop it: transact_one commits the tx on success, which + // clears revm's revert log. + fn run_tx( + &mut self, + tx: &TxRequest, + access_list: AccessList, + ) -> Result<(TxResult, EvmState), String> { self.evm.0.inspector.clear(); - let result = self - .evm - .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(), - journaled_state.precompile_addresses(), - tx, - self.evm.0.ctx.block.beneficiary, - ); - Ok((result, access_list)) + let result = self.evm.inspect_one_tx(tx.tx_env(access_list)); + let state = self.evm.finalize(); + let result = result.map_err(|e| format!("transact: {e:?}"))?; + Ok((result, state)) + } + + // Lists the accounts and slots the tx touched, leaving out accounts the + // chain already warms: the sender, the recipient, the coinbase, and + // precompiles. + fn discovered_access_list(&self, tx: &TxRequest, state: &EvmState) -> AccessList { + let precompiles = self.evm.0.ctx.journaled_state.precompile_addresses(); + let coinbase = self.evm.0.ctx.block.beneficiary; + let is_warm = |address: &Address| { + *address == tx.from + || *address == tx.to + || *address == coinbase + || precompiles.contains(address) + }; + AccessList( + 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(), + ) } fn run_simulate( @@ -530,17 +547,12 @@ impl EvmHarness { let access_list = match cached_access_list { Some(access_list) => access_list, None => { - let (_, access_list) = self.discovery_pass(tx)?; - let _ = self.evm.finalize(); - access_list + let (_, state) = self.run_tx(tx, AccessList::default())?; + self.discovered_access_list(tx, &state) } }; - self.evm.0.inspector.clear(); - let result = self - .evm - .inspect_one_tx(tx.tx_env(access_list.clone())) - .map_err(|e| format!("transact (simulate): {e:?}"))?; + let (result, _) = self.run_tx(tx, access_list.clone())?; let outcome = tx_outcome(&result, &access_list); let gas_limit = if outcome.success() { result.tx_gas_used() + self.evm.0.inspector.gas_reserve @@ -595,34 +607,6 @@ fn tx_outcome(result: &TxResult, access_list: &AccessList) -> TxOutcome { } } -fn discovered_access_list( - state: &EvmState, - precompiles: &AddressSet, - tx: &TxRequest, - coinbase: Address, -) -> AccessList { - let is_warm = |address: &Address| { - *address == tx.from - || *address == tx.to - || *address == coinbase - || precompiles.contains(address) - }; - AccessList( - 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(), - ) -} - fn encode_access_list(list: &AccessList) -> Vec { let mut entries: Vec = list .0 @@ -825,9 +809,7 @@ mod tests { fn simulate(harness: &mut EvmHarness) -> Result { let tx = tx(harness); - let result = harness.run_simulate(&tx, Some(AccessList::default())); - let _ = harness.evm.finalize(); - result + harness.run_simulate(&tx, Some(AccessList::default())) } fn succeeds_at(harness: &mut EvmHarness, gas_limit: u64) -> bool { From 4187a00e27bfaaf1b85503c1b162b69d6b39df32 Mon Sep 17 00:00:00 2001 From: kyscott18 <43524469+kyscott18@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:15:23 +0000 Subject: [PATCH 09/12] refactor(evm): make the harness's failure cases explicit - InMemoryDB's storage writes return Result<(), Infallible>. Match Ok(()) so the compiler proves they cannot fail, instead of ignoring the result in Journal::write and mapping an error that cannot happen in init. - A basefee past u64::MAX passed to setBlockContext panicked in U256::to. It is now an error. Co-Authored-By: Claude Opus 5.5 --- packages/typewriter-evm/src/harness.rs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/typewriter-evm/src/harness.rs b/packages/typewriter-evm/src/harness.rs index 5f08337..3b46324 100644 --- a/packages/typewriter-evm/src/harness.rs +++ b/packages/typewriter-evm/src/harness.rs @@ -285,7 +285,8 @@ impl Journal { db.insert_account_info(*address, if post { after } else { pre }.clone()); } for ((address, slot), (pre, after)) in &self.storage { - let _ = db.insert_account_storage(*address, *slot, if post { *after } else { *pre }); + let Ok(()) = + db.insert_account_storage(*address, *slot, if post { *after } else { *pre }); } } } @@ -377,8 +378,8 @@ impl EvmHarness { } 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:?}"))?; + let Ok(()) = + db.insert_account_storage(address, parse_u256(slot)?, parse_u256(value)?); } } Ok(()) @@ -393,7 +394,8 @@ impl EvmHarness { block.timestamp = parse_u256(timestamp)?; } if let Some(basefee) = ¶ms.basefee { - block.basefee = parse_u256(basefee)?.to::(); + block.basefee = u64::try_from(parse_u256(basefee)?) + .map_err(|_| format!("basefee does not fit in u64: {basefee}"))?; } if let Some(coinbase) = ¶ms.coinbase { block.beneficiary = parse_address(coinbase)?; @@ -829,6 +831,17 @@ mod tests { assert!(succeeds_at(&mut harness, result.gas_limit)); } + #[test] + fn basefee_past_u64_is_an_error() { + let params = BlockParams { + number: None, + timestamp: None, + basefee: Some(format!("{:#x}", U256::MAX)), + coinbase: None, + }; + assert!(harness(&[]).set_block_context(¶ms).is_err()); + } + #[test] fn reserve_covers_sstore_sentry() { // PUSH1 0 SLOAD PUSH1 0 SSTORE STOP: the warm no-op SSTORE costs less From a33ee3a69a2be28eb92e1e75dbc7a48b0ffc903e Mon Sep 17 00:00:00 2001 From: kyscott18 <43524469+kyscott18@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:16:02 +0000 Subject: [PATCH 10/12] refactor(evm): name which image a journal writes Journal::write took post: bool, so write(db, false) did not say it undoes the tx. It now takes Image::Pre or Image::Post. Co-Authored-By: Claude Opus 5.5 --- packages/typewriter-evm/src/harness.rs | 33 +++++++++++++++++++------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/typewriter-evm/src/harness.rs b/packages/typewriter-evm/src/harness.rs index 3b46324..99d30a4 100644 --- a/packages/typewriter-evm/src/harness.rs +++ b/packages/typewriter-evm/src/harness.rs @@ -280,13 +280,28 @@ impl Journal { journal } - 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()); + fn write(&self, db: &mut InMemoryDB, image: Image) { + for (address, images) in &self.accounts { + db.insert_account_info(*address, image.pick(images).clone()); } - for ((address, slot), (pre, after)) in &self.storage { - let Ok(()) = - db.insert_account_storage(*address, *slot, if post { *after } else { *pre }); + for ((address, slot), images) in &self.storage { + let Ok(()) = db.insert_account_storage(*address, *slot, *image.pick(images)); + } + } +} + +// Which side of a tx a journal writes: Pre undoes the tx, Post redoes it. +#[derive(Clone, Copy)] +enum Image { + Pre, + Post, +} + +impl Image { + fn pick(self, (pre, post): &(T, T)) -> &T { + match self { + Image::Pre => pre, + Image::Post => post, } } } @@ -407,7 +422,7 @@ impl EvmHarness { self.ensure_journal_ids(¶ms.journal_ids)?; for journal_id in params.journal_ids.iter().rev() { let journal = self.journals.remove(journal_id).expect("validated above"); - journal.write(self.evm.0.ctx.journaled_state.db_mut(), false); + journal.write(self.evm.0.ctx.journaled_state.db_mut(), Image::Pre); } Ok(()) } @@ -460,12 +475,12 @@ impl EvmHarness { self.ensure_journal_ids(¶ms.journal_ids)?; let tx = TxRequest::parse(¶ms.tx, self.evm.0.ctx.cfg.0.chain_id)?; for journal_id in params.journal_ids.iter().rev() { - self.journals[journal_id].write(self.evm.0.ctx.journaled_state.db_mut(), false); + self.journals[journal_id].write(self.evm.0.ctx.journaled_state.db_mut(), Image::Pre); } let cached_access_list = self.merge_journal_access_lists(¶ms.journal_ids); let result = self.run_simulate(&tx, cached_access_list); for journal_id in ¶ms.journal_ids { - self.journals[journal_id].write(self.evm.0.ctx.journaled_state.db_mut(), true); + self.journals[journal_id].write(self.evm.0.ctx.journaled_state.db_mut(), Image::Post); } result } From 249d6218771b7275fab0060c876ea03aa2deef3e Mon Sep 17 00:00:00 2001 From: kyscott18 <43524469+kyscott18@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:16:23 +0000 Subject: [PATCH 11/12] docs(evm): use doc comments in harness.rs and explain the reserve math Comments on items are now /// doc comments, so they show on hover. frame_end and ensure_journal_ids get comments on why they work. Co-Authored-By: Claude Opus 5.5 --- packages/typewriter-evm/src/harness.rs | 79 ++++++++++++++------------ 1 file changed, 43 insertions(+), 36 deletions(-) diff --git a/packages/typewriter-evm/src/harness.rs b/packages/typewriter-evm/src/harness.rs index 99d30a4..549d948 100644 --- a/packages/typewriter-evm/src/harness.rs +++ b/packages/typewriter-evm/src/harness.rs @@ -103,8 +103,8 @@ impl TxOutcome { } } -// A halt is a failure other than REVERT, such as out of gas or an invalid -// opcode. It returns no data and uses all the gas the frame was given. +/// A halt is a failure other than REVERT, such as out of gas or an invalid +/// opcode. It returns no data and uses all the gas the frame was given. #[derive(Serialize)] #[serde(tag = "status", rename_all = "snake_case")] enum TxStatus { @@ -113,20 +113,20 @@ enum TxStatus { Halt { reason: String }, } -// gas_used is with cold access: execute warms no access list. +/// gas_used is with cold access: execute warms no access list. #[derive(Serialize)] struct ExecuteOk { #[serde(flatten)] outcome: TxOutcome, #[serde(skip_serializing_if = "Option::is_none")] journal_id: Option, - // Writes to the called contract. + /// Writes to the called contract. slot_writes: Vec, - // The 64-byte KECCAK256 inputs: a mapping key and the mapping's slot. + /// The 64-byte KECCAK256 inputs: a mapping key and the mapping's slot. keccak_preimages: Vec, } -// gas_used is 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)] @@ -154,18 +154,18 @@ 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. A -// halted frame uses all the gas it is given, which its need already counts. -// -// Typewriter contracts never catch a halt in a nested call: a failed call -// reverts the tx. The limit relies on this. Simulate runs at u64::MAX gas, so -// a caught halt in a call that forwards all its gas burns nearly all of it, -// and gas_limit comes out near u64::MAX, which the node rejects. +/// 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. A +/// halted frame uses all the gas it is given, which its need already counts. +/// +/// Typewriter contracts never catch a halt in a nested call: a failed call +/// reverts the tx. The limit relies on this. Simulate runs at u64::MAX gas, so +/// a caught halt in a call that forwards all its gas burns nearly all of it, +/// and gas_limit comes out near u64::MAX, which the node rejects. #[derive(Default)] struct HarnessInspector { keccak_preimages: Vec<[u8; 64]>, @@ -178,14 +178,19 @@ impl HarnessInspector { *self = Self::default(); } + /// Ends a call or create frame and raises its caller's reserve to cover it. fn frame_end(&mut self, gas_limit: u64, result: &InterpreterResult, sstore_reserve: u64) { let Some(mut reserve) = self.frame_reserves.pop() else { return; }; reserve = reserve.max(sstore_reserve); + // The gas this frame needs to be given: what it spent plus its reserve. let need = gas_limit - result.gas.remaining() + reserve; match self.frame_reserves.last_mut() { + // The caller keeps 1/64 of its gas back from the call, so to give + // `need` it must hold this frame's reserve plus about need/63 more. Some(parent) => *parent = (*parent).max(reserve + need.div_ceil(63) + 1), + // The top frame's reserve is the tx's. None => self.gas_reserve = reserve, } } @@ -251,8 +256,8 @@ impl Inspector for HarnessInspector { // ----------------------------------------------------------------------------- // Harness -// 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. +/// 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, @@ -290,7 +295,7 @@ impl Journal { } } -// Which side of a tx a journal writes: Pre undoes the tx, Post redoes it. +/// Which side of a tx a journal writes: Pre undoes the tx, Post redoes it. #[derive(Clone, Copy)] enum Image { Pre, @@ -501,6 +506,8 @@ impl EvmHarness { .collect() } + /// Checks every id before a request changes anything, so a request with an + /// unknown id has no effect. fn ensure_journal_ids(&self, journal_ids: &[u64]) -> Result<(), String> { match journal_ids .iter() @@ -511,11 +518,11 @@ impl EvmHarness { } } - // Runs the tx and takes its post-state out of revm's journal, even when - // the run fails, so no run leaves state behind for the next one. Execute - // commits the state it gets back; every other run drops it. finalize is - // the only way to drop it: transact_one commits the tx on success, which - // clears revm's revert log. + /// Runs the tx and takes its post-state out of revm's journal, even when + /// the run fails, so no run leaves state behind for the next one. Execute + /// commits the state it gets back; every other run drops it. finalize is + /// the only way to drop it: transact_one commits the tx on success, which + /// clears revm's revert log. fn run_tx( &mut self, tx: &TxRequest, @@ -528,9 +535,9 @@ impl EvmHarness { Ok((result, state)) } - // Lists the accounts and slots the tx touched, leaving out accounts the - // chain already warms: the sender, the recipient, the coinbase, and - // precompiles. + /// Lists the accounts and slots the tx touched, leaving out accounts the + /// chain already warms: the sender, the recipient, the coinbase, and + /// precompiles. fn discovered_access_list(&self, tx: &TxRequest, state: &EvmState) -> AccessList { let precompiles = self.evm.0.ctx.journaled_state.precompile_addresses(); let coinbase = self.evm.0.ctx.block.beneficiary; @@ -689,8 +696,8 @@ fn format_bytes(bytes: &[u8]) -> String { format!("0x{}", hex::encode(bytes)) } -// The runtime orders txs and accounts for gas and balances itself, so these -// checks would only reject txs it has already accepted. +/// 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; @@ -724,7 +731,7 @@ struct Response { #[derive(Serialize)] struct EmptyResult {} -// Never fails, so the FFI boundary stays infallible. +/// 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, @@ -771,7 +778,7 @@ mod tests { const CALLEE: u16 = 0x0c02; const INNER: u16 = 0x0c03; - // 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]; const CALL: u8 = 0xf1; const STATICCALL: u8 = 0xfa; @@ -783,7 +790,7 @@ mod tests { Address::from(bytes) } - // Calls `target` with no calldata and the `gas` operand. + /// 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); @@ -793,7 +800,7 @@ mod tests { code } - // Reverts unless the call succeeded, then stops. + /// 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]); @@ -837,7 +844,7 @@ mod tests { matches!(result, ExecutionResult::Success { .. }) } - // The tx fails at gas_used and succeeds at the simulated limit. + /// 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(); From 4e8602f0571bbfc30d84f0cfda290d52420e7c50 Mon Sep 17 00:00:00 2001 From: kyscott18 <43524469+kyscott18@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:27:12 +0000 Subject: [PATCH 12/12] refactor(evm): use alloy types for the harness's wire format The wire structs held hex strings, and harness.rs parsed and formatted them by hand. They now hold the alloy types revm re-exports (Address, U256, U64, Bytes, B256, B512, AccessList) and MonadHardfork, and serde does the hex. This enables monad-revm's serde feature and drops the hex crate. - Slots and values go out as B256, since U256 serializes without leading zeros and the runtime matches slots as 32-byte hex. The wire format is unchanged: the TS snapshots of access lists, slot writes, and storage reads pass as before. - Optional fields with a zero default use #[serde(default)], so init, setBlockContext, and readStorage no longer return errors. - Bad params now fail while parsing the request, so dispatch reads the request id on its own to answer with it. - An unknown spec reports serde's unknown-variant error. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 11 +- packages/typewriter-evm/Cargo.toml | 3 +- packages/typewriter-evm/src/harness.rs | 399 +++++++++------------- packages/typewriter-evm/src/index.test.ts | 6 +- 4 files changed, 180 insertions(+), 239 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 224b40d..e44eb8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -710,6 +710,9 @@ name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] [[package]] name = "bitvec" @@ -719,6 +722,7 @@ checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", + "serde", "tap", "wyz", ] @@ -1204,6 +1208,9 @@ name = "either" version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] [[package]] name = "elliptic-curve" @@ -1900,6 +1907,9 @@ name = "nonmax" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" +dependencies = [ + "serde", +] [[package]] name = "nu-ansi-term" @@ -3148,7 +3158,6 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" name = "typewriter-evm" version = "0.1.0" dependencies = [ - "hex", "monad-revm", "napi", "napi-derive", diff --git a/packages/typewriter-evm/Cargo.toml b/packages/typewriter-evm/Cargo.toml index 74cd7cf..b882e1b 100644 --- a/packages/typewriter-evm/Cargo.toml +++ b/packages/typewriter-evm/Cargo.toml @@ -8,10 +8,9 @@ crate-type = ["cdylib"] path = "src/lib.rs" [dependencies] -monad-revm = { git = "https://github.com/category-labs/monad-revm", tag = "v0.7.0", features = ["optional_balance_check", "optional_no_base_fee"] } +monad-revm = { git = "https://github.com/category-labs/monad-revm", tag = "v0.7.0", features = ["serde", "optional_balance_check", "optional_no_base_fee"] } napi = { version = "=3.9.0", default-features = false, features = ["napi8"] } napi-derive = "=3.5.6" revm = { version = "42.0.1", default-features = false, features = ["std", "optional_balance_check", "optional_no_base_fee"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -hex = "0.4" diff --git a/packages/typewriter-evm/src/harness.rs b/packages/typewriter-evm/src/harness.rs index 549d948..52c1af0 100644 --- a/packages/typewriter-evm/src/harness.rs +++ b/packages/typewriter-evm/src/harness.rs @@ -19,7 +19,10 @@ use revm::{ interpreter_types::{Jumps, MemoryTr}, CallInputs, CallOutcome, CreateInputs, CreateOutcome, Interpreter, InterpreterResult, }, - primitives::{Address, Bytes, TxKind, B256, U256}, + primitives::{ + alloy_primitives::{B512, U64}, + Address, Bytes, TxKind, B256, U256, + }, state::{AccountInfo, Bytecode, EvmState}, DatabaseRef, ExecuteCommitEvm, ExecuteEvm, Inspector, }; @@ -41,34 +44,54 @@ enum Request { #[derive(Deserialize)] struct InitParams { - spec: Option, + spec: Option, chain_id: Option, block: Option, - accounts: Option>, + #[serde(default)] + accounts: BTreeMap, } #[derive(Deserialize)] struct BlockParams { - number: Option, - timestamp: Option, - basefee: Option, - coinbase: Option, + number: Option, + timestamp: Option, + basefee: Option, + coinbase: Option
, } #[derive(Deserialize)] struct AccountParams { - balance: Option, - nonce: Option, - code: Option, - storage: Option>, + #[serde(default)] + balance: U256, + #[serde(default)] + nonce: u64, + code: Option, + #[serde(default)] + storage: BTreeMap, } #[derive(Deserialize)] struct ExecuteParams { - from: String, - to: String, - data: String, - value: Option, + from: Address, + to: Address, + data: Bytes, + #[serde(default)] + value: U256, +} + +impl ExecuteParams { + fn tx_env(&self, chain_id: u64, access_list: AccessList) -> TxEnv { + TxEnv::builder() + .caller(self.from) + .chain_id(Some(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() + } } #[derive(Deserialize)] @@ -80,8 +103,8 @@ struct SimulateParams { #[derive(Deserialize)] struct ReadStorageParams { - address: String, - slots: Vec, + address: Address, + slots: Vec, } #[derive(Deserialize)] @@ -89,12 +112,15 @@ struct JournalIdsParams { journal_ids: Vec, } +/// Slots and values are B256, not U256: U256 serializes without leading +/// zeros, and the runtime matches slots as 32-byte hex. #[derive(Serialize)] struct TxOutcome { #[serde(flatten)] status: TxStatus, gas_used: u64, - access_list: Vec, + /// Sorted by address, then storage key. + access_list: AccessList, } impl TxOutcome { @@ -109,8 +135,14 @@ impl TxOutcome { #[serde(tag = "status", rename_all = "snake_case")] enum TxStatus { Success, - Revert { data: String }, - Halt { reason: String }, + Revert { + data: Bytes, + }, + /// The HaltReason's Debug name, e.g. `InvalidFEOpcode`. HaltReason's own + /// serde form is an object for some reasons, such as out of gas. + Halt { + reason: String, + }, } /// gas_used is with cold access: execute warms no access list. @@ -120,10 +152,10 @@ struct ExecuteOk { outcome: TxOutcome, #[serde(skip_serializing_if = "Option::is_none")] journal_id: Option, - /// Writes to the called contract. - slot_writes: Vec, + /// Writes to the called contract, sorted by slot. + slot_writes: Vec, /// The 64-byte KECCAK256 inputs: a mapping key and the mapping's slot. - keccak_preimages: Vec, + keccak_preimages: Vec, } /// gas_used is with the access list warm, as the chain charges it. @@ -135,16 +167,9 @@ struct SimulateOk { } #[derive(Serialize)] -struct AccessListEntry { - address: String, - #[serde(rename = "storageKeys")] - storage_keys: Vec, -} - -#[derive(Serialize)] -struct SlotWriteEntry { - slot: String, - value: String, +struct SlotWrite { + slot: B256, + value: B256, } // ----------------------------------------------------------------------------- @@ -168,7 +193,7 @@ type TxResult = ExecutionResult; /// and gas_limit comes out near u64::MAX, which the node rejects. #[derive(Default)] struct HarnessInspector { - keccak_preimages: Vec<[u8; 64]>, + keccak_preimages: Vec, frame_reserves: Vec, gas_reserve: u64, } @@ -209,7 +234,7 @@ impl Inspector for HarnessInspector { return; }; // Memory past its current size reads as zeros. - let mut preimage = [0; 64]; + let mut preimage = B512::ZERO; let memory_size = interp.memory.size(); if offset < memory_size { let copied = (memory_size - offset).min(64); @@ -311,39 +336,6 @@ impl Image { } } -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: params.value.as_deref().map_or(Ok(U256::ZERO), parse_u256)?, - chain_id, - }) - } - - 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 { evm: Evm, journals: HashMap, @@ -353,10 +345,10 @@ pub struct EvmHarness { impl EvmHarness { pub fn from_json(params_json: &str) -> Result { let params = serde_json::from_str(params_json).map_err(|e| format!("parse error: {e}"))?; - Self::new(¶ms) + Ok(Self::new(¶ms)) } - fn new(params: &InitParams) -> Result { + fn new(params: &InitParams) -> Self { let mut ctx = monad_context_with_db(InMemoryDB::default()); relax_cfg(&mut ctx.cfg); let mut harness = Self { @@ -364,13 +356,12 @@ impl EvmHarness { journals: HashMap::new(), next_journal_id: 1, }; - harness.init(params)?; - Ok(harness) + harness.init(params); + harness } - fn init(&mut self, params: &InitParams) -> Result<(), String> { - if let Some(spec) = params.spec.as_deref() { - let spec = parse_spec(spec).ok_or_else(|| format!("unknown spec: {spec}"))?; + fn init(&mut self, params: &InitParams) { + if let Some(spec) = params.spec { let mut cfg = MonadCfgEnv::new_with_spec(spec); relax_cfg(&mut cfg); self.evm.0.ctx.cfg = cfg; @@ -380,47 +371,39 @@ impl EvmHarness { self.evm.0.ctx.cfg.0.chain_id = chain_id; } if let Some(block) = ¶ms.block { - self.set_block_context(block)?; + self.set_block_context(block); } let db = self.evm.0.ctx.journaled_state.db_mut(); - for (address, account) in params.accounts.iter().flatten() { - let address = parse_address(address)?; + for (&address, account) in ¶ms.accounts { let mut info = AccountInfo { - balance: account - .balance - .as_deref() - .map_or(Ok(U256::ZERO), parse_u256)?, - nonce: account.nonce.unwrap_or(0), + balance: account.balance, + nonce: account.nonce, ..Default::default() }; - if let Some(code) = account.code.as_deref() { - info = info.with_code(Bytecode::new_raw(parse_bytes(code)?)); + if let Some(code) = &account.code { + info = info.with_code(Bytecode::new_raw(code.clone())); } db.insert_account_info(address, info); - for (slot, value) in account.storage.iter().flatten() { - let Ok(()) = - db.insert_account_storage(address, parse_u256(slot)?, parse_u256(value)?); + for (&slot, &value) in &account.storage { + let Ok(()) = db.insert_account_storage(address, slot, value); } } - Ok(()) } - fn set_block_context(&mut self, params: &BlockParams) -> Result<(), String> { + fn set_block_context(&mut self, params: &BlockParams) { let block = &mut self.evm.0.ctx.block; - if let Some(number) = ¶ms.number { - block.number = parse_u256(number)?; + if let Some(number) = params.number { + block.number = number; } - if let Some(timestamp) = ¶ms.timestamp { - block.timestamp = parse_u256(timestamp)?; + if let Some(timestamp) = params.timestamp { + block.timestamp = timestamp; } - if let Some(basefee) = ¶ms.basefee { - block.basefee = u64::try_from(parse_u256(basefee)?) - .map_err(|_| format!("basefee does not fit in u64: {basefee}"))?; + if let Some(basefee) = params.basefee { + block.basefee = basefee.to(); } - if let Some(coinbase) = ¶ms.coinbase { - block.beneficiary = parse_address(coinbase)?; + if let Some(coinbase) = params.coinbase { + block.beneficiary = coinbase; } - Ok(()) } fn revert_journals(&mut self, params: &JournalIdsParams) -> Result<(), String> { @@ -440,19 +423,11 @@ impl EvmHarness { Ok(()) } - fn execute(&mut self, params: &ExecuteParams) -> Result { - let tx = TxRequest::parse(params, self.evm.0.ctx.cfg.0.chain_id)?; - let (result, state) = self.run_tx(&tx, AccessList::default())?; - let access_list = self.discovered_access_list(&tx, &state); - let outcome = tx_outcome(&result, &access_list); - let keccak_preimages = self - .evm - .0 - .inspector - .keccak_preimages - .iter() - .map(|preimage| format_bytes(preimage)) - .collect(); + fn execute(&mut self, tx: &ExecuteParams) -> Result { + let (result, state) = self.run_tx(tx, AccessList::default())?; + let access_list = self.discovered_access_list(tx, &state); + let outcome = tx_outcome(&result, access_list.clone()); + let keccak_preimages = std::mem::take(&mut self.evm.0.inspector.keccak_preimages); if !outcome.success() { return Ok(ExecuteOk { outcome, @@ -478,30 +453,25 @@ impl EvmHarness { fn simulate(&mut self, params: &SimulateParams) -> Result { self.ensure_journal_ids(¶ms.journal_ids)?; - let tx = TxRequest::parse(¶ms.tx, self.evm.0.ctx.cfg.0.chain_id)?; for journal_id in params.journal_ids.iter().rev() { self.journals[journal_id].write(self.evm.0.ctx.journaled_state.db_mut(), Image::Pre); } let cached_access_list = self.merge_journal_access_lists(¶ms.journal_ids); - let result = self.run_simulate(&tx, cached_access_list); + let result = self.run_simulate(¶ms.tx, cached_access_list); for journal_id in ¶ms.journal_ids { self.journals[journal_id].write(self.evm.0.ctx.journaled_state.db_mut(), Image::Post); } result } - fn read_storage(&self, params: &ReadStorageParams) -> Result, String> { - let address = parse_address(¶ms.address)?; + fn read_storage(&self, params: &ReadStorageParams) -> BTreeMap { let db = self.evm.0.ctx.journaled_state.db(); 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))) + .map(|&slot| { + let Ok(value) = db.storage_ref(params.address, slot); + (slot.into(), value.into()) }) .collect() } @@ -525,11 +495,12 @@ impl EvmHarness { /// clears revm's revert log. fn run_tx( &mut self, - tx: &TxRequest, + tx: &ExecuteParams, access_list: AccessList, ) -> Result<(TxResult, EvmState), String> { self.evm.0.inspector.clear(); - let result = self.evm.inspect_one_tx(tx.tx_env(access_list)); + let tx_env = tx.tx_env(self.evm.0.ctx.cfg.0.chain_id, access_list); + let result = self.evm.inspect_one_tx(tx_env); let state = self.evm.finalize(); let result = result.map_err(|e| format!("transact: {e:?}"))?; Ok((result, state)) @@ -538,7 +509,7 @@ impl EvmHarness { /// Lists the accounts and slots the tx touched, leaving out accounts the /// chain already warms: the sender, the recipient, the coinbase, and /// precompiles. - fn discovered_access_list(&self, tx: &TxRequest, state: &EvmState) -> AccessList { + fn discovered_access_list(&self, tx: &ExecuteParams, state: &EvmState) -> AccessList { let precompiles = self.evm.0.ctx.journaled_state.precompile_addresses(); let coinbase = self.evm.0.ctx.block.beneficiary; let is_warm = |address: &Address| { @@ -547,25 +518,27 @@ impl EvmHarness { || *address == coinbase || precompiles.contains(address) }; - AccessList( - 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(), - ) + // EvmState is a HashMap, so sort for a stable order. + let mut items: Vec = state + .iter() + .filter(|(address, account)| !account.storage.is_empty() || !is_warm(address)) + .map(|(&address, account)| { + let mut storage_keys: Vec = + account.storage.keys().map(|&slot| slot.into()).collect(); + storage_keys.sort(); + AccessListItem { + address, + storage_keys, + } + }) + .collect(); + items.sort_by_key(|item| item.address); + AccessList(items) } fn run_simulate( &mut self, - tx: &TxRequest, + tx: &ExecuteParams, cached_access_list: Option, ) -> Result { let access_list = match cached_access_list { @@ -577,7 +550,7 @@ impl EvmHarness { }; let (result, _) = self.run_tx(tx, access_list.clone())?; - let outcome = tx_outcome(&result, &access_list); + let outcome = tx_outcome(&result, access_list); let gas_limit = if outcome.success() { result.tx_gas_used() + self.evm.0.inspector.gas_reserve } else { @@ -614,11 +587,11 @@ impl EvmHarness { // ----------------------------------------------------------------------------- // Conversion helpers -fn tx_outcome(result: &TxResult, access_list: &AccessList) -> TxOutcome { +fn tx_outcome(result: &TxResult, access_list: AccessList) -> TxOutcome { let status = match result { ExecutionResult::Success { .. } => TxStatus::Success, ExecutionResult::Revert { output, .. } => TxStatus::Revert { - data: format_bytes(output), + data: output.clone(), }, ExecutionResult::Halt { reason, .. } => TxStatus::Halt { reason: format!("{reason:?}"), @@ -627,75 +600,27 @@ fn tx_outcome(result: &TxResult, access_list: &AccessList) -> TxOutcome { TxOutcome { status, gas_used: result.tx_gas_used(), - access_list: encode_access_list(access_list), + access_list, } } -fn encode_access_list(list: &AccessList) -> Vec { - 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: &EvmState, address: Address) -> Vec { +fn collect_slot_writes(state: &EvmState, address: Address) -> Vec { let Some(account) = state.get(&address) else { return Vec::new(); }; - let mut writes: Vec = account + let mut writes: Vec = account .storage .iter() .filter(|(_, value)| value.original_value() != value.present_value()) - .map(|(slot, value)| SlotWriteEntry { - slot: format_u256(*slot), - value: format_u256(value.present_value()), + .map(|(&slot, value)| SlotWrite { + slot: slot.into(), + value: value.present_value().into(), }) .collect(); - writes.sort_by(|a, b| a.slot.cmp(&b.slot)); + writes.sort_by_key(|write| write.slot); writes } -fn parse_address(s: &str) -> Result { - 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 { - 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 { - 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}")), - } -} - -fn format_u256(value: U256) -> String { - format_bytes(&value.to_be_bytes::<32>()) -} - -fn format_bytes(bytes: &[u8]) -> String { - format!("0x{}", hex::encode(bytes)) -} - /// 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) { @@ -705,16 +630,6 @@ fn relax_cfg(cfg: &mut MonadCfgEnv) { cfg.0.tx_gas_limit_cap = Some(u64::MAX); } -fn parse_spec(s: &str) -> Option { - match s { - "MonadEight" => Some(MonadHardfork::MonadEight), - "MonadNine" => Some(MonadHardfork::MonadNine), - "MonadTen" => Some(MonadHardfork::MonadTen), - "MonadNext" => Some(MonadHardfork::MonadNext), - _ => None, - } -} - // ----------------------------------------------------------------------------- // Dispatch @@ -731,20 +646,32 @@ struct Response { #[derive(Serialize)] struct EmptyResult {} +#[derive(Deserialize)] +struct RequestId { + id: u64, +} + /// 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}"))), + Err(e) => { + // Bad params fail the whole parse. Answer with the request's id + // when it has one, so the caller can match the error to its call. + let id = + serde_json::from_str::(request_json).map_or(0, |request| request.id); + return respond::(id, Err(format!("parse error: {e}"))); + } }; let empty = |result: Result<(), String>| result.map(|()| EmptyResult {}); match request { Request::SetBlockContext { id, params } => { - respond(id, empty(harness.set_block_context(¶ms))) + harness.set_block_context(¶ms); + respond(id, Ok(EmptyResult {})) } 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::ReadStorage { id, params } => respond(id, Ok(harness.read_storage(¶ms))), Request::RevertJournals { id, params } => { respond(id, empty(harness.revert_journals(¶ms))) } @@ -808,36 +735,42 @@ mod tests { } fn harness(accounts: &[(u16, &[u8])]) -> EvmHarness { - let accounts: serde_json::Map = accounts + let accounts = accounts .iter() - .map(|(account, code)| { - ( - format!("{:#x}", address(*account)), - serde_json::json!({ "code": format_bytes(code) }), - ) + .map(|&(account, code)| { + let account_params = AccountParams { + balance: U256::ZERO, + nonce: 0, + code: Some(Bytes::copy_from_slice(code)), + storage: BTreeMap::new(), + }; + (address(account), account_params) }) .collect(); - let params = serde_json::from_value(serde_json::json!({ "accounts": accounts })).unwrap(); - EvmHarness::new(¶ms).unwrap() + EvmHarness::new(&InitParams { + spec: None, + chain_id: None, + block: None, + accounts, + }) } - fn tx(harness: &EvmHarness) -> TxRequest { - TxRequest { + fn tx() -> ExecuteParams { + ExecuteParams { from: address(CALLER), to: address(CONTRACT), data: Bytes::new(), value: U256::ZERO, - chain_id: harness.evm.0.ctx.cfg.0.chain_id, } } fn simulate(harness: &mut EvmHarness) -> Result { - let tx = tx(harness); - harness.run_simulate(&tx, Some(AccessList::default())) + harness.run_simulate(&tx(), Some(AccessList::default())) } fn succeeds_at(harness: &mut EvmHarness, gas_limit: u64) -> bool { - let mut tx_env = tx(harness).tx_env(AccessList::default()); + let chain_id = harness.evm.0.ctx.cfg.0.chain_id; + let mut tx_env = tx().tx_env(chain_id, AccessList::default()); tx_env.gas_limit = gas_limit; let result = harness.evm.inspect_one_tx(tx_env).unwrap(); let _ = harness.evm.finalize(); @@ -854,14 +787,12 @@ mod tests { } #[test] - fn basefee_past_u64_is_an_error() { - let params = BlockParams { - number: None, - timestamp: None, - basefee: Some(format!("{:#x}", U256::MAX)), - coinbase: None, - }; - assert!(harness(&[]).set_block_context(¶ms).is_err()); + fn bad_params_answer_with_the_request_id() { + let request = r#"{"method":"readStorage","id":7,"params":{"address":"0x12","slots":[]}}"#; + let response: serde_json::Value = + serde_json::from_str(&dispatch_json(&mut harness(&[]), request)).unwrap(); + assert_eq!(response["id"], 7); + assert_eq!(response["ok"], false); } #[test] diff --git a/packages/typewriter-evm/src/index.test.ts b/packages/typewriter-evm/src/index.test.ts index 80ab1b2..66dfb9a 100644 --- a/packages/typewriter-evm/src/index.test.ts +++ b/packages/typewriter-evm/src/index.test.ts @@ -848,8 +848,10 @@ test("createEVM fails with an unknown spec", async () => { Exit.fail( new EvmCallError({ method: "init", - message: "init failed: unknown spec: MonadZero", - cause: "unknown spec: MonadZero", + message: + "init failed: parse error: unknown variant `MonadZero`, expected one of `MonadEight`, `MonadNine`, `MonadTen`, `MonadNext` at line 1 column 19", + cause: + "parse error: unknown variant `MonadZero`, expected one of `MonadEight`, `MonadNine`, `MonadTen`, `MonadNext` at line 1 column 19", }), ), );