Skip to content

evm: run multi-account scenarios with per-step state - #375

Merged
gnidan merged 4 commits into
mainfrom
evm-scenarios
Oct 9, 2026
Merged

gnidan merged 4 commits into
mainfrom
evm-scenarios

Conversation

@gnidan

@gnidan gnidan commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

@ethdebug/evm could run one contract, deployed from one fixed account, in a block whose fields are all zero, and its machine state mixed a trace step's stack and memory with the executor's current storage. That is not enough to replay a story across several accounts and transactions, or to show the state at an earlier point of a trace. This adds what that needs.

Transactions

  • deploy({ from, create, value }) returns the new contract's address; call({ from, to, input, value }) calls any address; fund(address, balance) sets a balance. getStorage, setStorage and getCode take an optional address (default: the contract deployed last, as before). The legacy deploy(bytecode) and execute(options, handler) still work.
  • A block option per call (number, timestamp, prevrandao, coinbase, base fee, blob base fee, gas limit) and a chainId executor option. BLOCKHASH of a recent block is keccak256 of its number.
  • endTransaction() clears transient storage, the warm address/slot sets and the slots' original values (EIP-2200), as at a transaction boundary. deploy(options) and call end their transaction; execute does not, so existing callers see the same gas. Gas still differs from a chain's (no intrinsic gas, no pre-warmed addresses).

Steps and state

  • A TraceStep is the event only: pc, opcode, op, gasRemaining, gasCost, depth, address, codeAddress.
  • createTrace({ memory }) records one transaction. trace.stateAt(i) gives the complete StepState at step i: stack, memory, storage and transient readers, calldata, return data and code, all from that step. Storage written in a frame that later reverts is undone from the revert on. Storage the transaction never touched is read from the executor only while it is unchanged; after that, the read throws instead of mixing in the present.
  • memory: "full" (default) copies memory at every step, "changed" copies only when it changed, "none" records none, and then stateAt has no memory, so passing it to createMachineState is a type error.
  • createMachineState(state, { pc, opcode, traceIndex }) adapts a complete state and nothing else. executor.currentState() gives the present, for callers that want it.
  • A frame handler gets enter/exit events for every call and create (depth, addresses, caller, calldata, value, return data, reverted, and the first/last step index); trace.frames keeps them.

Breaking: TraceStep loses stack/memory, createMachineState no longer takes an executor, and createTraceCollector is gone. The in-repo callers (bugc's tests and helpers, the web's trace drawer) are updated here. @ethdebug/programs-react has its own TraceStep (hex strings, per-step storage); a follow-up will align it with this one.

Browser: no Buffer is left, so the package runs without a polyfill. Bundled for the browser (esbuild, minified), it is 314.8 KB / 99.7 KB gzip, up from 309.3 KB / 97.5 KB, and consumers no longer need the 8.7 KB gzip polyfill. A recorded trace with stateAt runs in headless Chromium with no Buffer defined.

The tests cover calls between two contracts from three accounts, block fields and BLOCKHASH, exact gas costs, transient storage, cold slots and original values across transactions, stateAt across nested frames and a reverted middle frame, DELEGATECALL, CREATE and CREATE2 frames, the memory policies over every memory-writing instruction, handler errors, recovery from a run that throws, and a determinism digest.

@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-09 01:08 UTC

gnidan added a commit to ethdebug/argot-post-2026-10 that referenced this pull request Oct 9, 2026
gnidan added 2 commits October 8, 2026 20:37
A trace step is now only the event (what ran and where). The state at
a step comes from a recorded trace, `createTrace().stateAt(index)`,
complete and from that step only; `createMachineState` adapts such a
state and no longer reads the executor's current state.

Also fixes CREATE2 frames under tracing, `gasCost` (the static fee was
counted twice), original storage values across transactions, BLOCKHASH
in a numbered block, checkpoints left open by a run that throws, and
handler errors (now the run's rejection).
…policy

A call or create that fails before its message starts now clears the step state's return data, as the EVM does. A trace with a non-literal memory policy gives states that may lack memory, which createMachineState rejects until narrowed. A trace's step state carries its pc, opcode and index, so createMachineState(trace.stateAt(i)) is right without options.
@gnidan
gnidan merged commit ba31f0a into main Oct 9, 2026
7 checks passed
@gnidan
gnidan deleted the evm-scenarios branch October 9, 2026 01:03
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