diff --git a/Cargo.lock b/Cargo.lock index 224b40d8..e44eb8f5 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 74cd7cf8..b882e1b9 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/package.json b/packages/typewriter-evm/package.json index d61ef59d..e2d9db8e 100644 --- a/packages/typewriter-evm/package.json +++ b/packages/typewriter-evm/package.json @@ -10,7 +10,7 @@ "benchmark": "bun run build && bun test ./test/*.benchmark.ts", "build": "napi build --release --output-dir native", "build:debug": "napi build --output-dir native", - "test": "bun run build:debug && bun test --timeout 30000", + "test": "cargo test --lib && bun run build:debug && bun test --timeout 30000", "typecheck": "tsc --noEmit", "lint": "cargo fmt --check && cargo clippy -- -D warnings && forge fmt --check test/contracts" }, diff --git a/packages/typewriter-evm/src/harness.rs b/packages/typewriter-evm/src/harness.rs index b9d077e9..52c1af00 100644 --- a/packages/typewriter-evm/src/harness.rs +++ b/packages/typewriter-evm/src/harness.rs @@ -1,41 +1,29 @@ -// 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, 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}, MonadCfgEnv, MonadHardfork, }; use revm::{ + bytecode::opcode, context::TxEnv, context_interface::{ - result::{ExecutionResult, Output}, + result::{ExecutionResult, HaltReason}, transaction::{AccessList, AccessListItem}, - JournalTr, + Host, JournalTr, }, database::InMemoryDB, inspector::InspectEvm, interpreter::{ interpreter::EthInterpreter, interpreter_types::{Jumps, MemoryTr}, - Interpreter, + CallInputs, CallOutcome, CreateInputs, CreateOutcome, Interpreter, InterpreterResult, }, - primitives::{Address, Bytes, TxKind, B256, U256}, - state::{AccountInfo, Bytecode}, + primitives::{ + alloy_primitives::{B512, U64}, + Address, Bytes, TxKind, B256, U256, + }, + state::{AccountInfo, Bytecode, EvmState}, DatabaseRef, ExecuteCommitEvm, ExecuteEvm, Inspector, }; use serde::{Deserialize, Serialize}; @@ -46,7 +34,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 }, @@ -57,60 +44,67 @@ 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)] 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, - slots: Vec, + address: Address, + slots: Vec, } #[derive(Deserialize)] @@ -118,155 +112,177 @@ 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 OkResponse { - id: u64, - ok: bool, - result: T, +struct TxOutcome { + #[serde(flatten)] + status: TxStatus, + gas_used: u64, + /// Sorted by address, then storage key. + access_list: AccessList, } -#[derive(Serialize)] -struct ErrorResponse { - id: u64, - ok: bool, - error: String, +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)] -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()) +#[serde(tag = "status", rename_all = "snake_case")] +enum TxStatus { + Success, + 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. #[derive(Serialize)] struct ExecuteOk { - success: bool, + #[serde(flatten)] + outcome: TxOutcome, #[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, + /// 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, } +/// gas_used is with the access list warm, as the chain charges it. #[derive(Serialize)] -struct AccessListEntry { - address: String, - #[serde(rename = "storageKeys")] - storage_keys: Vec, +struct SimulateOk { + #[serde(flatten)] + outcome: TxOutcome, + gas_limit: u64, } #[derive(Serialize)] -struct SlotWriteEntry { - address: String, - slot: String, - prev_value: String, - new_value: String, -} - -#[derive(Serialize)] -struct KeccakPreimageEntry { - hash: String, - preimage: String, +struct SlotWrite { + slot: B256, + value: B256, } // ----------------------------------------------------------------------------- -// Harness - -type Evm = monad_revm::api::builder::DefaultMonadEvm< - monad_revm::api::default_ctx::MonadContext, - MappingRecoveryInspector, ->; - +// 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. 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 MappingRecoveryInspector { - keccak_preimages: Vec, -} - -struct RawKeccakPreimage { - hash: B256, - preimage: Vec, +struct HarnessInspector { + keccak_preimages: Vec, + frame_reserves: Vec, + gas_reserve: u64, } -impl MappingRecoveryInspector { +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() - } -} - -impl Inspector> - for MappingRecoveryInspector -{ - fn step( - &mut self, - interp: &mut Interpreter, - _context: &mut monad_revm::api::default_ctx::MonadContext, - ) { - if interp.bytecode.opcode() != 0x20 { + /// 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, } + } +} - 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 { + // 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; }; - - let mut preimage = vec![0; len]; + // Memory past its current size reads as zeros. + let mut preimage = B512::ZERO; let memory_size = interp.memory.size(); if offset < memory_size { - let copied = memory_size.min(end) - offset; + let copied = (memory_size - offset).min(64); preimage[..copied].copy_from_slice(&interp.memory.slice_len(offset, copied)); } + self.keccak_preimages.push(preimage); + } - self.keccak_preimages.push(RawKeccakPreimage { - hash: revm::primitives::keccak256(&preimage), - preimage, - }); + fn call(&mut self, _context: &mut MonadCtx, _inputs: &mut CallInputs) -> Option { + self.frame_reserves.push(0); + None + } + + 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.frame_end(inputs.gas_limit, &outcome.result, sstore_reserve); + } + + fn create( + &mut self, + _context: &mut MonadCtx, + _inputs: &mut CreateInputs, + ) -> Option { + self.frame_reserves.push(0); + None + } + + fn create_end( + &mut self, + context: &mut MonadCtx, + inputs: &CreateInputs, + outcome: &mut CreateOutcome, + ) { + let sstore_reserve = context.gas_params().call_stipend() + 1; + self.frame_end(inputs.gas_limit(), &outcome.result, sstore_reserve); } } -// 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. +// ----------------------------------------------------------------------------- +// 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. #[derive(Default)] struct Journal { accounts: HashMap, @@ -274,111 +290,132 @@ struct Journal { access_list: AccessList, } +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 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), 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, + } + } +} + pub struct EvmHarness { evm: Evm, journals: HashMap, next_journal_id: u64, - initialized: bool, } impl EvmHarness { - pub fn new() -> Self { + pub fn from_json(params_json: &str) -> Result { + let params = serde_json::from_str(params_json).map_err(|e| format!("parse error: {e}"))?; + Ok(Self::new(¶ms)) + } + + fn new(params: &InitParams) -> Self { let mut ctx = monad_context_with_db(InMemoryDB::default()); relax_cfg(&mut ctx.cfg); - let evm = ctx.build_monad_with_inspector(MappingRecoveryInspector::default()); - Self { - evm, + let mut harness = Self { + evm: ctx.build_monad_with_inspector(HarnessInspector::default()), journals: HashMap::new(), next_journal_id: 1, - initialized: false, - } + }; + harness.init(params); + 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 s = parse_spec(spec).ok_or_else(|| format!("unknown spec: {spec}"))?; - let mut cfg = MonadCfgEnv::new_with_spec(s); + 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; - 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)?; + self.set_block_context(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 ¶ms.accounts { + let mut info = AccountInfo { + balance: account.balance, + nonce: account.nonce, + ..Default::default() + }; + 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 { + let Ok(()) = db.insert_account_storage(address, slot, value); } } - 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, block: &BlockParams) -> Result<(), String> { - let b = &mut self.evm.0.ctx.block; - if let Some(n) = &block.number { - b.number = parse_u256(n)?; + fn set_block_context(&mut self, params: &BlockParams) { + let block = &mut self.evm.0.ctx.block; + if let Some(number) = params.number { + block.number = number; } - if let Some(t) = &block.timestamp { - b.timestamp = parse_u256(t)?; + if let Some(timestamp) = params.timestamp { + block.timestamp = timestamp; } - if let Some(f) = &block.basefee { - b.basefee = parse_u256(f)?.to::(); + if let Some(basefee) = params.basefee { + block.basefee = basefee.to(); } - if let Some(c) = &block.coinbase { - b.beneficiary = parse_address(c)?; + if let Some(coinbase) = params.coinbase { + block.beneficiary = 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(), Image::Pre); } 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); @@ -386,586 +423,408 @@ impl EvmHarness { Ok(()) } - fn execute(&mut self, params: &ExecuteParams) -> Result { - self.ensure_initialized()?; - let mut result = match self.run_two_pass(params, None, false) { - Ok(result) => result, - 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 { - // 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(); + 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, + journal_id: None, + slot_writes: Vec::new(), + keccak_preimages, + }); } - Ok(result) + + 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; + self.journals.insert(journal_id, journal); + Ok(ExecuteOk { + outcome, + journal_id: Some(journal_id), + slot_writes, + keccak_preimages, + }) } - fn simulate(&mut self, params: &SimulateParams) -> Result { - self.ensure_initialized()?; + fn simulate(&mut self, params: &SimulateParams) -> Result { 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 - .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(), Image::Pre); } - 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); - - // Drop pass 2'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); - } + 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(); - 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); - } - - fn ensure_initialized(&self) -> Result<(), String> { - if self.initialized { - Ok(()) - } else { - Err("not initialized".into()) - } + params + .slots + .iter() + .map(|&slot| { + let Ok(value) = db.storage_ref(params.address, slot); + (slot.into(), value.into()) + }) + .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> { - for journal_id in journal_ids { - if !self.journals.contains_key(journal_id) { - return Err(format!("unknown journal id: {journal_id}")); - } + match journal_ids + .iter() + .find(|id| !self.journals.contains_key(id)) + { + Some(id) => Err(format!("unknown journal id: {id}")), + None => Ok(()), } - 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. - // - // With `estimate_gas`, a successful pass 2 is followed by a gas-limit - // search and a final pass at that limit. Only simulate needs this: it - // builds the transaction that goes onchain. Execute only accepts the - // mutation into local state, so it skips the search (about 15 extra runs). - // - // After pass 1, finalize drains the journal state and we discard it so - // pass 2 starts from the same DB state. The alternative — - // checkpoint_revert — doesn't work because transact_one calls commit_tx - // after success, which clears the revertable journal log. - fn run_two_pass( + /// 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, - params: &ExecuteParams, - 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; + tx: &ExecuteParams, + access_list: AccessList, + ) -> Result<(TxResult, EvmState), String> { + self.evm.0.inspector.clear(); + 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)) + } - 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() + /// 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: &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| { + *address == tx.from + || *address == tx.to + || *address == coinbase + || precompiles.contains(address) }; + // 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) + } - let tx_access_list = match cached_access_list { + fn run_simulate( + &mut self, + tx: &ExecuteParams, + cached_access_list: Option, + ) -> 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 touched = collect_access_list(self.evm.0.ctx.journaled_state.evm_state()); - 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(), - ) - } - }; - - // Measure with pre-warmed access list. - - self.evm.0.inspector.clear(); - let mut 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 { .. } if !estimate_gas => None, - 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 and leave its journal state live for execute(). - 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()) + let (_, state) = self.run_tx(tx, AccessList::default())?; + self.discovered_access_list(tx, &state) } }; - let (success, gas_used, output, revert_data) = match &result { - ExecutionResult::Success { output, .. } => { - let bytes = match output { - Output::Call(b) => b.clone(), - Output::Create(b, _) => b.clone(), - }; - ( - true, - result.tx_gas_used(), - 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:?}")), - ), + 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 + } else { + result.tx_gas_used() }; - - let access_list_wire = encode_access_list(&tx_access_list); - let keccak_preimages = self.evm.0.inspector.wire_preimages(); - - Ok(ExecuteOk { - success, - journal_id: None, - gas_used, - gas_limit, - output, - access_list: access_list_wire, - slot_writes: Vec::new(), - keccak_preimages, - revert_data, - tx_access_list, - }) + Ok(SimulateOk { outcome, gas_limit }) } fn merge_journal_access_lists(&self, journal_ids: &[u64]) -> Option { 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)) + Some(AccessList( + merged + .into_iter() + .map(|(address, keys)| AccessListItem { + address, + storage_keys: keys.into_iter().collect(), + }) + .collect(), + )) } +} - 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())?; - } +// ----------------------------------------------------------------------------- +// Conversion helpers - 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; - } - } +fn tx_outcome(result: &TxResult, access_list: AccessList) -> TxOutcome { + let status = match result { + ExecutionResult::Success { .. } => TxStatus::Success, + ExecutionResult::Revert { output, .. } => TxStatus::Revert { + data: output.clone(), + }, + ExecutionResult::Halt { reason, .. } => TxStatus::Halt { + reason: format!("{reason:?}"), + }, + }; + TxOutcome { + status, + gas_used: result.tx_gas_used(), + access_list, + } +} + +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() + .filter(|(_, value)| value.original_value() != value.present_value()) + .map(|(&slot, value)| SlotWrite { + slot: slot.into(), + value: value.present_value().into(), + }) + .collect(); + writes.sort_by_key(|write| write.slot); + writes +} - Ok(high) - } +/// 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; + cfg.0.disable_base_fee = true; + cfg.0.tx_gas_limit_cap = Some(u64::MAX); +} - 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; +// ----------------------------------------------------------------------------- +// Dispatch - let tx = TxEnv::builder() - .caller(from) - .chain_id(Some(chain_id)) - .kind(TxKind::Call(to)) - .gas_limit(gas_limit) - .gas_price(0) - .value(value) - .data(data) - .access_list(access_list) - .build_fill(); +#[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, +} - 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) - } +#[derive(Serialize)] +struct EmptyResult {} + +#[derive(Deserialize)] +struct RequestId { + id: u64, } -impl Default for EvmHarness { - fn default() -> Self { - Self::new() +/// 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) => { + // 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 } => { + 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, Ok(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 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 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: error.is_none(), + result, + error, + }; + serde_json::to_string(&response) + .unwrap_or_else(|_| r#"{"id":0,"ok":false,"error":"response serialize error"}"#.into()) } -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); +#[cfg(test)] +mod tests { + use super::*; + + const CALLER: u16 = 0xca11; + const CONTRACT: u16 = 0x0c01; + const CALLEE: u16 = 0x0c02; + const INNER: u16 = 0x0c03; + + /// 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; + const GAS: &[u8] = &[0x5a]; + + fn address(n: u16) -> Address { + let mut bytes = [0u8; 20]; + bytes[18..].copy_from_slice(&n.to_be_bytes()); + Address::from(bytes) } -} -// ----------------------------------------------------------------------------- -// Conversion helpers + /// 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 + } -// 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, - }); + /// 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 } - AccessList(items) -} -fn encode_access_list(list: &AccessList) -> Vec { - let mut out: Vec = Vec::new(); - for item in &list.0 { - let keys: Vec = item - .storage_keys + fn harness(accounts: &[(u16, &[u8])]) -> EvmHarness { + let accounts = accounts .iter() - .map(|k| format!("0x{}", hex::encode(k.as_slice()))) + .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 mut keys = keys; - 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 -} - -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), - }); - } + EvmHarness::new(&InitParams { + spec: None, + chain_id: None, + block: None, + accounts, + }) } - writes.sort_by(|a, b| a.address.cmp(&b.address).then(a.slot.cmp(&b.slot))); - 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}"))?; - if bytes.len() != 20 { - return Err(format!("bad address length: {}", bytes.len())); + fn tx() -> ExecuteParams { + ExecuteParams { + from: address(CALLER), + to: address(CONTRACT), + data: Bytes::new(), + value: U256::ZERO, + } } - let mut arr = [0u8; 20]; - arr.copy_from_slice(&bytes); - Ok(Address::from(arr)) -} - -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)) -} -fn parse_u256(s: &str) -> Result { - let s = s.strip_prefix("0x").unwrap_or(s); - if s.is_empty() { - return Ok(U256::ZERO); + fn simulate(harness: &mut EvmHarness) -> Result { + harness.run_simulate(&tx(), Some(AccessList::default())) } - 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>())) -} - -// 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. -fn relax_cfg(cfg: &mut MonadCfgEnv) { - cfg.0.disable_nonce_check = true; - cfg.0.disable_balance_check = true; - cfg.0.disable_base_fee = true; - cfg.0.tx_gas_limit_cap = Some(u64::MAX); -} + fn succeeds_at(harness: &mut EvmHarness, gas_limit: u64) -> bool { + 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(); + matches!(result, ExecutionResult::Success { .. }) + } -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, + /// 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)); } -} -// ----------------------------------------------------------------------------- -// Dispatch + #[test] + 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); + } -// 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}")), + #[test] + 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])]); } -} -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), - }, + #[test] + fn reserve_covers_precompile_calls() { + for precompile in [0x01, 0x100] { + let code = require_success(call(STATICCALL, precompile, GAS)); + assert_reserve_needed(&[(CONTRACT, &code)]); + } } -} -fn ok(id: u64, result: &T) -> String { - serialize_response(&OkResponse { - id, - ok: true, - result, - }) -} + #[test] + 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)]); + } -fn err(id: u64, error: String) -> String { - serialize_response(&ErrorResponse { - id, - ok: false, - error, - }) + #[test] + 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)]); + let result = simulate(&mut harness).unwrap(); + 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 e6af7db1..66dfb9a1 100644 --- a/packages/typewriter-evm/src/index.test.ts +++ b/packages/typewriter-evm/src/index.test.ts @@ -8,25 +8,31 @@ 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; 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 { BALANCE_OF_SLOT, callBurnData, + callPrecompileData, deployArtifact, deployTestToken, INITIAL_SUPPLY, loadCallGasCallee, loadCallGasCaller, + loadPrecompileCaller, loadTestToken, mappingSlot, normalizeAccessRecord, @@ -41,7 +47,19 @@ import { transferData, USER_ADDR, } from "../test/utils"; -import { createEVM } 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: { @@ -51,8 +69,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 }, @@ -72,16 +89,15 @@ 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); }); 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, @@ -91,18 +107,14 @@ 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([ { - address: COUNTER_ADDR, slot: "0x0000000000000000000000000000000000000000000000000000000000000000", - prev_value: - "0x0000000000000000000000000000000000000000000000000000000000000000", - new_value: + value: "0x0000000000000000000000000000000000000000000000000000000000000001", }, ]); @@ -110,8 +122,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, @@ -126,16 +137,15 @@ 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 () => { 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({ @@ -143,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({ @@ -155,13 +165,12 @@ 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 () => { 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" }); @@ -174,13 +183,12 @@ 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 () => { 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, @@ -189,14 +197,13 @@ 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 () => { const program = Effect.gen(function* () { - const evm = yield* createEVM(); - yield* evm.init({ + const evm = yield* createEVM({ accounts: { [REVERTING_ADDR]: { code: WRITE_THEN_REVERT_CODE }, }, @@ -210,14 +217,30 @@ 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 () => { 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" }); }); @@ -234,8 +257,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({ @@ -251,15 +273,14 @@ 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 () => { // 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, @@ -277,22 +298,21 @@ 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 () => { // 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" }); @@ -306,20 +326,19 @@ 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 () => { 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, @@ -328,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({ @@ -340,13 +359,12 @@ 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 () => { 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, @@ -367,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({ @@ -379,13 +397,12 @@ 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 () => { 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] }); }); @@ -395,8 +412,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, @@ -410,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, @@ -424,13 +440,12 @@ 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 () => { 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] }); }); @@ -440,8 +455,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", @@ -466,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 () => { @@ -476,8 +490,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, @@ -490,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, @@ -512,7 +525,7 @@ test("execute e2e with compiled Solmate ERC20 bytecode", async () => { "totalSupplySlot": "0x0000000000000000000000000000000000000000000000000000000000000002", "userBalanceSlot": "0x9c35da83f88043b3115f30d93beacec49ca14b6238430bdff196a249c29baa80", }, - "success": true, + "status": "success", } `); }); @@ -523,8 +536,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, @@ -534,20 +546,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 () => { @@ -556,8 +566,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, @@ -593,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, @@ -608,8 +617,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, @@ -619,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 }; }); @@ -630,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, @@ -649,7 +657,7 @@ test("simulate e2e temporarily rewinds optimistic Solmate ERC20 journals", async ], }, ], - "optimisticSuccess": true, + "optimisticStatus": "success", "simulatedAccessList": [ { "address": "0x0000000000000000000000000000000000000e20", @@ -659,7 +667,7 @@ test("simulate e2e temporarily rewinds optimistic Solmate ERC20 journals", async ], }, ], - "simulatedSuccess": true, + "simulatedStatus": "success", "slots": { "recipientBalanceSlot": "0x961ec03a078fec1e350bb1ca3bff1afa4bae5fb83d9d8382550c2fd26a7d7527", "schedulerBalanceSlot": "0xc651ee22c6951bb8b5bd29e8210fb394645a94315fe10eff2cc73de1aa75c137", @@ -685,8 +693,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, @@ -722,8 +729,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, @@ -760,8 +766,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 }, @@ -780,19 +785,76 @@ 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); }); -test("init twice fails", async () => { +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({}); - yield* evm.init({}); + const evm = yield* createEVM({ + 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.status).toBe("success"); + expect(simulated.status).toBe("success"); + expect(rpcAccessList).toEqual([]); + expect(executed.access_list).toEqual([]); + expect(simulated.access_list).toEqual([]); + expect(BigInt(simulated.gas_limit)).toBeGreaterThanOrEqual(rpcGasEstimate); +}); + +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: 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", + }), + ), + ); }); test("an interrupted execute does not desync subsequent calls", async () => { @@ -805,8 +867,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( @@ -828,7 +889,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 () => { @@ -836,8 +897,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" }), @@ -848,8 +908,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]); }); @@ -857,8 +917,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 dbe52149..35ac1584 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 f013e13a..65d1f6a8 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 d245b706..d34c7460 100644 --- a/packages/typewriter-evm/src/types.ts +++ b/packages/typewriter-evm/src/types.ts @@ -40,27 +40,29 @@ export type SimulateParams = ExecuteParams & { journal_ids: number[]; }; -export type ExecuteResult = { - success: boolean; - journal_id?: number; +type TxResult = { gas_used: number; - output: Hex.Hex; access_list: { address: Address.Address; storageKeys: Hex.Hex[] }[]; - 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 & { +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 & { + // 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. 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; @@ -74,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/contracts/src/PrecompileCaller.sol b/packages/typewriter-evm/test/contracts/src/PrecompileCaller.sol new file mode 100644 index 00000000..9ac1d057 --- /dev/null +++ b/packages/typewriter-evm/test/contracts/src/PrecompileCaller.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract PrecompileCaller { + function callPrecompile(address target, bytes calldata data) external view returns (bytes memory) { + (bool ok, bytes memory ret) = target.staticcall(data); + require(ok); + return ret; + } +} diff --git a/packages/typewriter-evm/test/execute.benchmark.ts b/packages/typewriter-evm/test/execute.benchmark.ts index fe2de4d9..7d8b7ddc 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 }, }, @@ -41,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 bb7ab329..ac96d459 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 }, }, @@ -84,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); @@ -107,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); @@ -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]: { @@ -173,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); @@ -196,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-evm/test/utils.ts b/packages/typewriter-evm/test/utils.ts index 04503cbe..3ef49abb 100644 --- a/packages/typewriter-evm/test/utils.ts +++ b/packages/typewriter-evm/test/utils.ts @@ -105,6 +105,22 @@ export async function loadCallGasCaller(): Promise { ).json()) as ForgeArtifact; } +export async function loadPrecompileCaller(): Promise { + return (await Bun.file( + `${import.meta.dir}/contracts/out/PrecompileCaller.sol/PrecompileCaller.json`, + ).json()) as ForgeArtifact; +} + +export function callPrecompileData( + target: Address.Address, + data: Hex.Hex, +): Hex.Hex { + const callPrecompile = AbiFunction.from( + "function callPrecompile(address target, bytes data) returns (bytes)", + ); + return AbiFunction.encodeData(callPrecompile, [target, data]); +} + export function tokenInit( artifact: ForgeArtifact, tokenAddr: Address.Address = TOKEN_ADDR, diff --git a/packages/typewriter/src/db-query.ts b/packages/typewriter/src/db-query.ts index cc67c80d..3e0d4ee9 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/index.ts b/packages/typewriter/src/index.ts index 4e02ccfe..c8aede56 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 c8e3060a..0342a3de 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; @@ -315,17 +327,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", )<{ @@ -342,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; @@ -380,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), }), @@ -414,6 +429,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({ @@ -463,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 }; } } @@ -522,32 +537,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 +800,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); @@ -1001,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({ @@ -1194,7 +1203,7 @@ export function createTypewriterEffect< enqueueMutation({ app, evm, mutation: enqueuedMutation }), ); - if (executeResult.success === false) { + if (executeResult.status !== "success") { continue; }