Skip to content

perf(evm): single-pass execute and reserve-based simulate gas limit - #62

Merged
kyscott18 merged 12 commits into
mainfrom
perf/execute-single-pass
Oct 26, 2026
Merged

kyscott18 merged 12 commits into
mainfrom
perf/execute-single-pass

Conversation

@kyscott18

@kyscott18 kyscott18 commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Execute

  • execute runs once, keeps that post-state, and records the touched accounts and slots as the journal's access list.
  • execute's gas_used is the gas with cold access. No runtime code reads it.

Simulate

  • simulate runs 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, so gas_used is the gas spent.
  • At that limit, the tx takes the same path as at max gas. The limit can be up to about 2,300 gas above the true minimum.
  • A halted call uses all the gas it is given, and its reserve counts that gas. So a caught halt in a call with a fixed gas budget gets a gas limit that works. A caught halt in a call that forwards all its gas would give a gas limit near u64::MAX, which the node rejects. Typewriter contracts never catch a halt in a nested call (a failed call reverts the tx), and harness.rs documents that the gas limit relies on this.

Other

  • The access list leaves out all precompiles of the active spec. Before, the filter covered only 0x01–0x0a, so P-256 at 0x100 was in the access list.
  • ExecuteResult and SimulateResult are separate types in Rust and TS. SimulateResult no longer has journal_id or slot_writes.
  • harness.rs is simpler: one Journal type writes pre or post images, access lists merge through ordered sets, and dispatch uses one response helper.
  • Every EVM run goes through run_tx, which always takes the run's state out of revm's journal (finalize), so no path can leave state behind. execute commits the state it gets back.
  • The wire structs hold alloy types (Address, U256, U64, Bytes, B256, B512, revm's AccessList) and MonadHardfork, and serde does the hex. The hand-written parse and format helpers and the hex crate are gone. Slots and values go out as B256, 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.
  • DB writes that cannot fail (Infallible) match Ok(()), journal writes take Image::Pre or Image::Post instead of a bool, and item comments are doc comments.
  • The typewriter-evm test script now also runs cargo test --lib.

Interface

  • createEVM(initParams) builds the harness with its init params. The init request, the initialized flag, and the "init already called" error are removed.
  • slot_writes is { slot, value }[] and holds only writes to the called contract.
  • keccak_preimages is Hex[], the 64-byte KECCAK256 inputs (a mapping key and the mapping's slot). The inspector no longer records 32-byte inputs or hashes them. Only execute returns them.
  • execute and simulate no longer return output.
  • Results carry status: "success" | "revert" | "halt" in place of success and revert_data. A revert has hex data, a halt has a reason (e.g. InvalidFEOpcode), and journal_id is only on a successful execute. Before, a halt came back as revert_data: "halt: <reason>", a string typed as Hex.
  • typewriter adds MutationHaltedError and SettlementSimulationHaltedError, exported from the package root, so a halt is no longer reported as an unknown revert.
  • typewriter.ts and db-query.ts use the new shapes, and enqueueMutation has a TODO: the enqueue's journal is never pruned.

Results

Same machine, PR head before these changes (9008e45) against 4bc8675:

before after
packages/typewriter/test/harness.benchmark.ts (3 runs) 3,765–3,775 mutations/s 3,694–3,847 mutations/s
typewriter-evm simulate, discovered, burn 5000 5.15 ms 3.30 ms
typewriter-evm simulate, cached, burn 5000 3.72 ms 1.43 ms

The harness benchmark shows no measurable change: simulate does not limit it. typewriter-evm execute 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 test
  • Rust tests: the tx fails at gas_used and succeeds at gas_limit for an SSTORE sentry, precompile calls (0x01, 0x100), and nested calls, and a caught halt in a call with a fixed gas budget.
  • The access list leaves out 0x100 and matches eth_createAccessList.
  • execute reports a halt (0xfe) as status: "halt", separate from a revert.
  • A request with bad params is answered with its own id.
  • createEVM fails with an unknown spec (serde's unknown-variant error). packages/typewriter tests pass against Postgres (27 pass), and the simulation test passes with SEED=0x01.

🤖 Generated with Claude Code

Comment thread packages/typewriter-evm/src/harness.rs Outdated
@kyscott18 kyscott18 changed the title perf(evm): run execute in a single pass perf(evm): single-pass execute and reserve-based simulate gas limit Oct 13, 2026
Comment on lines +897 to +900
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)]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

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!

Fix in Claude Code

@kyscott18
kyscott18 force-pushed the perf/execute-single-pass branch from 4bc8675 to eb9a408 Compare October 23, 2026 21:21
Comment thread packages/typewriter-evm/src/harness.rs Outdated
Comment on lines +534 to +535
let gas_limit = if outcome.success {
result.tx_gas_used() + inspector.gas_reserve

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

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.

Fix in Claude Code

@kyscott18
kyscott18 force-pushed the perf/execute-single-pass branch from eb9a408 to 2d95825 Compare October 23, 2026 21:35
kyscott18 and others added 6 commits October 26, 2026 20:25
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>
@kyscott18
kyscott18 force-pushed the perf/execute-single-pass branch from d7d935f to 4b1217f Compare October 26, 2026 20:44
kyscott18 and others added 6 commits October 26, 2026 20:58
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>
@kyscott18
kyscott18 merged commit 89ab221 into main Oct 26, 2026
7 checks passed
@kyscott18
kyscott18 deleted the perf/execute-single-pass branch October 26, 2026 21:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant