# Three approaches to deterministic EVM testing

**Question.** How should a team choose among isolated EVM tests, a pinned chain fork, and a private execution-client chain when it needs repeatable EVM tests?

**Scope and evidence.** This is a comparison of test *setups*, using Forge/Anvil and Geth as documented examples. It is not a benchmark or a claim that one framework is universally correct. Sources are primary project documentation, Solidity documentation, and an Ethereum Improvement Proposal, checked on 25 September 2026. Labels below separate documented **facts**, my **inferences**, and remaining **uncertainty**.

## What “deterministic” needs to mean

For this report, a test is deterministic when the same checked-in inputs and execution setup produce the same asserted contract result on every clean run. **Inference:** the inputs to control include contract bytecode and EVM rules, initial account/storage state, call or transaction order, and any block/transaction fields the contract reads. Solidity exposes `block.timestamp`, `block.number`, `block.basefee`, `block.prevrandao`, `msg.sender`, and `tx.gasprice` to contracts, so these are potentially observable inputs rather than background details. [Solidity global variables](https://docs.soliditylang.org/en/latest/units-and-global-variables.html) Foundry separately documents strict compiler-version selection and an `evm_version` setting; it recommends pinning the latter when compatibility matters. [Foundry compiler configuration](https://www.getfoundry.sh/reference/config/solidity-compiler)

“Same asserted result” is a narrower target than identical block hashes, receipts, gas totals, or timing. **Inference:** choose the target before calling a suite deterministic; a state assertion can repeat even when a node-created block timestamp does not.

## Comparison

| Approach | Documented mechanism | Determinism boundary and best fit (inference) |
| --- | --- | --- |
| **1. Isolated in-process EVM** (for example, Forge unit tests with mocked collaborators) | Forge runs `setUp` before each test and says unit/fuzz tests do not share state modified by another test. Cheatcodes let tests change blockchain state and block number. [Forge writing tests](https://getfoundry.sh/forge/writing-tests) · [Foundry cheatcodes](https://getfoundry.sh/forge/cheatcodes) | Strongest control over fixtures and scenario ordering; best default for contract logic, edge cases, and failure paths. It covers only the external behavior represented by the mocks or fixtures. |
| **2. Pinned historical fork** (for example, Forge/Anvil against a fixed block) | Foundry can create a fork from an RPC endpoint at a specified block; omitting the block uses `latest`. Anvil documents forking at a specified block. [Foundry `createFork`](https://getfoundry.sh/reference/cheatcodes/create-fork) · [Anvil overview](https://www.getfoundry.sh/anvil/index.html) | Useful when the test depends on actual deployed code, balances, or storage at a historical chain state. The fork block can repeat, but the test still depends on the RPC provider and the availability/identity of its historical state. |
| **3. Private execution-client chain** (for example, Geth `--dev`) | Geth dev mode starts a single local node without external peers, uses a testing genesis, and produces blocks on demand by default. Geth documents ephemeral and persistent data directories and a custom genesis option. [Geth developer mode](https://geth.ethereum.org/docs/developers/dapp-developer/dev-mode) | Useful for testing the application's transaction submission, receipt handling, and JSON-RPC integration against Geth itself. It needs more setup control than an in-process test and does not reproduce public-chain state or consensus behavior. |

### 1. Isolated in-process EVM

**Fact.** A Forge test gets the same `setUp` state as other tests in its suite, rather than inheriting another test's mutations; Forge exposes cheatcodes for altering the environment. [Forge writing tests](https://getfoundry.sh/forge/writing-tests) · [Foundry cheatcodes](https://getfoundry.sh/forge/cheatcodes)

**Inference.** Make all collaborators explicit: deploy local fixtures or mocks, fix accounts and balances, set any block fields the assertion depends on, and pin compiler/tool versions plus the EVM hard fork. A failure then points to a small, controlled state transition. A mock can, however, encode the wrong behavior for a token, oracle, bridge, or proxy. This approach therefore cannot by itself establish compatibility with those live contracts.

### 2. Pinned historical fork

**Fact.** Foundry's fork API accepts an endpoint and block number; its transaction-hash variant starts at the transaction's block and replays transactions earlier in that block. Anvil documents a `--fork-block-number` workflow and state dump/load capability. [Foundry `createFork`](https://getfoundry.sh/reference/cheatcodes/create-fork) · [Anvil overview](https://www.getfoundry.sh/anvil/index.html)

**Inference.** Pin the chain ID, historical block number **and expected block hash**, contract addresses, tool versions, and the RPC endpoint's required capabilities. Query the block hash before running and fail if it differs. A block number alone is an imperfect identity near a chain reorganization: EIP-1898 explains why state queries by block hash give a coherent view across reorgs. This check reduces that risk; it does not make an RPC-backed fork self-contained. [EIP-1898](https://eips.ethereum.org/EIPS/eip-1898)

**Uncertainty.** The cited Foundry pages do not establish that every provider retains every historical state slot needed by a particular suite, or that a local cache contains a complete offline copy. Treat provider availability and offline replay as properties to test for the chosen provider and workflow, not as guarantees of “pinning a block.”

### 3. Private execution-client chain

**Fact.** Geth's dev mode simulates a consensus client and mines on demand when transactions are pending; it has no external peers. It can start fresh each session, retain data with `--datadir`, or use a custom genesis derived from `geth --dev dumpgenesis`. [Geth developer mode](https://geth.ethereum.org/docs/developers/dapp-developer/dev-mode)

**Inference.** Start from a clean data directory and fixed genesis/accounts, submit transactions serially, wait for each receipt before the next dependent action, and pin the Geth binary and chain configuration. This exercises more of the real node/RPC/transaction path than an in-process test. Default on-demand block creation means tests that assert exact timestamps, block hashes, or fee values need additional controls or should assert only the state and receipt properties that the harness actually fixes. Since Geth says this mode *simulates* consensus, passing here is not evidence that a public network's consensus or peer behavior will match. [Geth developer mode](https://geth.ethereum.org/docs/developers/dapp-developer/dev-mode)

## Decision and reproducibility check

**Recommendation (inference).** Put contract rules in approach 1; add approach 2 for behavior that depends on existing deployed contracts or historical state; add approach 3 when the application itself relies on node RPC, nonce handling, mining, or receipts. These layers answer different questions, so a failure in one should not be treated as proof about the others.

Before accepting any suite as deterministic, record its tool/compiler/EVM versions, initial state or genesis, account keys or addresses, transaction ordering, and relevant block inputs. For a fork, record the chain, block number, block hash, and provider requirements. Run from a clean state twice and compare the *declared* assertions. **Inference:** compare block hashes or exact gas only if those outputs were explicitly made part of the controlled test target.

## Unanswered questions for the adopting team

1. Which chain and hard-fork rules must the tests represent, and is the required output identical contract state, receipts, gas, or byte-for-byte blocks?
2. Which deployed contracts require fork testing, and can the chosen provider serve their historical state at the selected block during offline or restricted CI runs?
3. Is Geth-specific RPC behavior sufficient, or is cross-client behavior itself in scope? This report did not run a cross-client comparison.

**Limit.** This is a sourced design comparison, not an empirical timing/reliability test or a verified recipe for any particular repository. No EVM suite was executed for this report.
