Repository navigation
perf(evm): single-pass execute and reserve-based simulate gas limit - #62
Conversation
| 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)]); |
There was a problem hiding this comment.
The gas tracker was replaced, but the tests for CREATE and calls that send value were deleted without replacements. The remaining call tests all send zero value, so mistakes in those gas rules could pass unnoticed. Keep tests that run these transactions at the returned gas_limit and check that they succeed. This is non-blocking test feedback.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/typewriter-evm/src/harness.rs
Line: 897-900
Comment:
**Gas tests lose coverage**
The gas tracker was replaced, but the tests for `CREATE` and calls that send value were deleted without replacements. The remaining call tests all send zero value, so mistakes in those gas rules could pass unnoticed. Keep tests that run these transactions at the returned `gas_limit` and check that they succeed. This is non-blocking test feedback.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
4bc8675 to
eb9a408
Compare
| let gas_limit = if outcome.success { | ||
| result.tx_gas_used() + inspector.gas_reserve |
There was a problem hiding this comment.
simulate can report success but return a gas limit that makes the transaction revert. For example, a contract requiring gasleft() > 100_000 passes the run at u64::MAX, but a simple frame gets only 2,301 gas added to its measured cost. The returned limit then fails the same check.
The old search tested whether the transaction succeeded at the chosen limit. The new reserve only covers call forwarding and the SSTORE check. Keep a fallback that finds a working limit for contracts that check remaining gas.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/typewriter-evm/src/harness.rs
Line: 534-535
Comment:
**Returned gas limit can fail**
`simulate` can report success but return a gas limit that makes the transaction revert. For example, a contract requiring `gasleft() > 100_000` passes the run at `u64::MAX`, but a simple frame gets only 2,301 gas added to its measured cost. The returned limit then fails the same check.
The old search tested whether the transaction succeeded at the chosen limit. The new reserve only covers call forwarding and the `SSTORE` check. Keep a fallback that finds a working limit for contracts that check remaining gas.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.eb9a408 to
2d95825
Compare
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).
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.
Typewriter apps do not make external calls, and the ERC20 journal test already covers simulate with a cached journal.
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.
…me 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
d7d935f to
4b1217f
Compare
Results carried success: boolean and revert_data, and a halt came back as revert_data "halt: <reason>", 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Summary
Execute
executeruns once, keeps that post-state, and records the touched accounts and slots as the journal's access list.execute'sgas_usedis the gas with cold access. No runtime code reads it.Simulate
simulateruns the discovery pass (skipped when the journals give a cached access list), then one pass at max gas with the access list warm. The binary search and the final pass are removed.gas_limit = gas_used + reserve. Each frame reserves 2,301 gas for the SSTORE sentry (EIP-2200), and enough that 63/64 of its remaining gas covers each child's gas plus the child's reserve (EIP-150). Precompiles get no SSTORE reserve. Monad has no refunds, sogas_usedis the gas spent.u64::MAX, which the node rejects. Typewriter contracts never catch a halt in a nested call (a failed call reverts the tx), andharness.rsdocuments that the gas limit relies on this.Other
ExecuteResultandSimulateResultare separate types in Rust and TS.SimulateResultno longer hasjournal_idorslot_writes.harness.rsis simpler: oneJournaltype writes pre or post images, access lists merge through ordered sets, and dispatch uses one response helper.run_tx, which always takes the run's state out of revm's journal (finalize), so no path can leave state behind.executecommits the state it gets back.Address,U256,U64,Bytes,B256,B512, revm'sAccessList) andMonadHardfork, and serde does the hex. The hand-written parse and format helpers and thehexcrate are gone. Slots and values go out asB256, so the wire format is unchanged. Bad params fail while parsing the request, so dispatch reads the request id on its own to answer with it.Infallible) matchOk(()), journal writes takeImage::PreorImage::Postinstead of a bool, and item comments are doc comments.typewriter-evmtest script now also runscargo test --lib.Interface
createEVM(initParams)builds the harness with its init params. Theinitrequest, theinitializedflag, and the "init already called" error are removed.slot_writesis{ slot, value }[]and holds only writes to the called contract.keccak_preimagesisHex[], the 64-byte KECCAK256 inputs (a mapping key and the mapping's slot). The inspector no longer records 32-byte inputs or hashes them. Onlyexecutereturns them.executeandsimulateno longer returnoutput.status: "success" | "revert" | "halt"in place ofsuccessandrevert_data. A revert has hexdata, a halt has areason(e.g.InvalidFEOpcode), andjournal_idis only on a successfulexecute. Before, a halt came back asrevert_data: "halt: <reason>", a string typed asHex.typewriteraddsMutationHaltedErrorandSettlementSimulationHaltedError, exported from the package root, so a halt is no longer reported as an unknown revert.typewriter.tsanddb-query.tsuse the new shapes, andenqueueMutationhas a TODO: the enqueue's journal is never pruned.Results
Same machine, PR head before these changes (
9008e45) against4bc8675:packages/typewriter/test/harness.benchmark.ts(3 runs)typewriter-evmsimulate, discovered, burn 5000typewriter-evmsimulate, cached, burn 5000The harness benchmark shows no measurable change: simulate does not limit it.
typewriter-evmexecute throughput varied from 99k to 133k calls/s between runs, so it gives no clear result.Test plan
bun run lint,bun run typecheck,bun run testgas_usedand succeeds atgas_limitfor an SSTORE sentry, precompile calls (0x01, 0x100), and nested calls, and a caught halt in a call with a fixed gas budget.eth_createAccessList.executereports a halt (0xfe) asstatus: "halt", separate from a revert.createEVMfails with an unknown spec (serde's unknown-variant error).packages/typewritertests pass against Postgres (27 pass), and the simulation test passes withSEED=0x01.🤖 Generated with Claude Code