A relational small-step / big-step semantics of the Ethereum Virtual
Machine in Lean 4, mirroring the structure of
NethermindEth/EVMYulLean
but expressed as Prop-valued inductive relations rather than
executable functions, so that reasoning is more direct.
Provenance. This package was mostly AI-generated (Claude) in collaboration with a human reviewer. Treat the design and proofs as a draft: the structure has been thought through, the build is green, the demo runs, and a substantial portion of the soundness lemmas are closed — but expect rough edges, especially in the deferred proof obligations. Not for production use.
What's in: foundation types, Operation ADT (incl. EIP-8024
DUPN/SWAPN/EXCHANGE) and bytecode decoder, halted-state flag +
ExecutionResult, small-step relation Step (success + exception
rules), big-step relation Eval + reflexive-transitive closure
Steps, executable shadow stepF (now total — folds in-frame
exceptions into halt := .Exception e) with soundness theorem
stepFE_sound : stepFE s = .ok s' → Step s s' (no sorry), real
Keccak-256 (Crypto/Keccak256.lean, wired via @[implemented_by]),
the four call-family opcodes CALL / CALLCODE / DELEGATECALL /
STATICCALL with a per-call-frame stack, EIP-150 forwarding, value
stipend, and static-mode guard on CALL, and a transaction-execution
layer EvmSemantics.Tx (Tx.Transaction + Tx.execute) that wraps
stepF with intrinsic-gas charging, sender-nonce bump, value
transfer, address-collision check, and the YP Λ contract-creation
deploy step (EIP-3541 / EIP-170 / G_codedeposit).
Demo (Main.lean) runs PUSH1 5 ; PUSH1 3 ; ADD ; STOP through the
executable shadow, producing stack [8] and halt = Success. Confirms
the relation/executable pair is at least internally consistent on a
trivial program.
Eleven CI conformance suites run against committed baselines
(.github/*-expected-failures.txt, kept in lockstep with real runner output).
Every suite is clean — zero correctness failures and zero crashes. The only
non-passing entries are report-only VMTests incons, the same two performance
walltimeout incons in the blockchain/static suites, and one out-of-scope
trie-iterator incon:
| Suite | Runner | fail | incon | crash |
|---|---|---|---|---|
| Legacy VMTests | vmtests |
0 | 7¹ | 0 |
| Legacy GeneralStateTests (curated) | statetests |
0 | 0 | 0 |
Modern GeneralStateTests (ethereum/tests) |
gstatetests |
0 | 0 | 0 |
| EEST Osaka state_tests | gstatetests |
0 | 0 | 0 |
| EEST static + historical state_tests | gstatetests |
0 | 2² | 0 |
| EEST transaction_tests | txtests |
0 | 0 | 0 |
TransactionTests (ethereum/tests) |
txtests |
0 | 0 | 0 |
| EEST blockchain_tests | blockchaintests |
0 | 2² | 0 |
| EEST blockchain_tests (Engine API) | blockchaintests_engine |
0 | 2² | 0 |
RLPTests (ethereum/tests) |
rlptests |
0 | 0 | 0 |
TrieTests (ethereum/tests) |
trietests |
0 | 1³ | 0 |
¹ Long-standing report-only single-frame evaluator gaps (OOG/fuel-exhausted
tests plus a few arithmetic/jumpdest edge cases) — documented, not regressions.
² test_run_until_out_of_gas_walltimeout / test_valid_walltimeout (the same
two tests in each suite that runs them): the evaluator's throughput trips the
per-test wall-clock cap under CI load; test_valid passes standalone. Perf,
not correctness.
³ trietestnextprev: trie iterator (next/prev) semantics, out of scope
for a root-hash MPT.
See VMTESTS.md for the full breakdown, per-suite corpus/gating details, and
baseline-refresh procedure.
- Multi-frame EVM: all arithmetic, comparison/bitwise, KECCAK256,
environmental reads, block-context reads, memory, storage (incl.
transient), stack manipulation (POP, PUSH0–PUSH32, DUP1–16, SWAP1–16),
control flow (JUMP, JUMPI, JUMPDEST, PC, GAS), halts (STOP, RETURN,
REVERT, INVALID), logging (LOG0–LOG4), EIP-8024 (DUPN, SWAPN, EXCHANGE),
and the four call-family opcodes
CALL/CALLCODE/DELEGATECALL/STATICCALLwith EIP-150 63/64 forwarding, value stipend (CALL/CALLCODE only), depth/balance pre-check,returnDataclearing on pre-execution failure, and a list-backed call-frame stack with three resume rules (callReturnSuccess/callReturnRevert/callReturnException). The four kinds share aCallKind-parameterised callee-env /enterCallskeleton; per-kind axes (address/caller/weiValue/permitStateMutation/ value transfer) live inCallKind.calleeXxxprojections. SELFDESTRUCTis implemented: baseG_selfdestruct = 5000+Gas.selfDestructSurcharge(25000 if the beneficiary is empty and self has non-zero balance), credit-then-debit transfer so a self-beneficiary correctly burns the balance, marks self inSubstate.selfDestructSet, and adds the 24000 refund on Constantinople.CREATE/CREATE2are implemented: baseG_create = 32000, memory expansion, depth + balance pre-check, EIP-150 63/64 forwarding, init-code execution in a new frame (Frame.createAddr := some newAddr), and code deposit atG_codedeposit = 200per deployed byte via the newresumeCreateSuccessrule (insufficient-deposit-gas → exception-rollback). CREATE derivesnewAddrfromkeccak256(rlp([sender, sender.nonce]))[12:]via a minimal RLP encoder (EvmSemantics.Rlp, items:[20-byte address, uint nonce], short-list path only). CREATE2 derivesnewAddrfromkeccak256(0xff || sender || salt || keccak256(initcode))[12:]and additionally paysGas.create2HashCost = 6·⌈|initcode|/32⌉. Address-collision detection is enforced via aBool-valuedAccount.isContracthelper (stricter thanisEmpty— excludes balance), with a dedicatedStep.createCollision/Step.create2Collisionconstructor pair (caller's nonce bumped, push 0, no transfer, no frame).- Transaction processing (YP
Υ) lives inEvmSemantics.Tx: a fork-agnosticTx.Transactionrecord (sender / recipient / value / data / gasLimit / gasPrice) plusTx.execute, which handles intrinsic-gas charging (fork- and create-aware: EIP-2028, EIP-3860, Homestead-onwardsG_txcreate), sender-nonce bump, value transfer, address-collision check for create-txs, the fueledstepFloop, the YPΛdeploy step (EIP-3541 reserved-prefix / EIP-170 max-code-size /G_codedeposit), and the YP §6.3 tx-level gas accounting: on success, the sender is refunded the unspent gas plus the substate refund counter (capped per EIP-3529:gasUsed/2pre-London,gasUsed/5after) and the coinbase receives the rest; on revert, the world rolls back but the unspent gas is still returned; on exception, all gas goes to the coinbase. The per-fork PoW block reward (5/3/2 ETHfor Frontier/Byzantium/Constantinople-era;0post-Paris) is credited to the coinbase on every non-fuelExhaustedoutcome. - Precompiled contracts (YP §9):
EvmSemantics.EVM.Precompileexports two definitions held in lockstep:isPrecompile : Fork → AccountAddress → Bool(the per-fork membership predicate) andrun : (fork) → (addr) → (input) → (childGas) → (h : isPrecompile fork addr = true) → Result(total only on the subsetisPrecompileaccepts — no.notAPrecompilearm, because the precondition rules that case out). Dispatch keys off each frame'sExecutionEnv.codeAddr(the borrowed-from address recorded at frame entry), and covers every entry path:CALL/STATICCALL(wherecodeAddr = tgt = address),CALLCODE/DELEGATECALL(wherecodeAddr = tgt ≠ address), and a transaction whosetois itself a precompile address (whereTx.buildInitStatesetscodeAddr := tx.recipient). The full YP + modern precompile set is implemented: 0x01ecrecover, 0x02sha256, 0x03ripemd160, 0x04identity(Frontier+); the Byzantium+ set 0x05modexp(EIP-198), 0x06ecadd/ 0x07ecmul(EIP-196) / 0x08ecpairing(EIP-197) alt_bn128 (re-priced by EIP-1108 at Istanbul); 0x09blake2f(EIP-152, Istanbul+); 0x0A KZG point evaluation (EIP-4844, Cancun); and the Prague BLS12-381 set (EIP-2537: G1/G2 add + MSM, pairing check, and the Fp→G1 / Fp2→G2 maps). Adding a new precompile is a synchronized edit toisPrecompileandrun(the totality proof enforces they stay aligned). The spec side inStep.leanexposes the same dispatch via two generic rules (Step.precompileSuccess/precompileOog); the rules mutate the frame'shaltso the existingresumeByHaltmachinery (success copy, exception snapshot-rollback) handles the rest. - Block validation and the full precompile set are implemented — the
EEST
blockchain_testsjob exercises chain execution + consensus and passes with zero correctness failures (see the Conformance status table above). The EESTblockchain_tests_enginejob additionally drives the same chains as Engine-APInewPayloadenvelopes, decoding each transaction from raw EIP-2718 RLP and ECDSA-recovering the sender (and each EIP-7702 authorization's authority) rather than reading a pre-decoded sender from the fixture. - Gas: parameterised by EVM hard fork (
EvmSemantics.Fork, threaded throughExecutionEnv.fork).Gas.baseCost fork opreturns the static Yellow-Paper fee per fork (Constantinoplematches the legacy ethereum/tests corpus — Frontier-era SLOAD = 50, EXP per-byte = 10;Cancunuses the modern warm-priced reads and Spurious-Dragon EXP). All major dynamic costs are also modelled: memory expansion (chargeMem/chargeMem2, Yellow-Paper quadratic),Gas.sstoreCost(pre-EIP-1283 for Constantinople / EIP-2200 for Cancun, with the EIP-2200 stipend sentry viaGas.sstoreSentry),Gas.copyWordCost,Gas.keccakWordCost,Gas.logDataCost,Gas.expByteCost. The relationalStepRunning.outOfGastakes acost : Natwitness bounded above byGas.totalCost s op— the exact staged charge the executable makes (base + memory expansion + dynamic costs + cold surcharges + CALL-family surcharge/forwarding, withSSTORE's EIP-2200 sentry as a cost floor) — so an OOG transition is derivable exactly when the op genuinely cannot be afforded. EIP-2929 cold/warm access pricing is now fully modelled (accessedAccounts/accessedStorageKeyssets inSubstate, warm-seeded per tx inTx.execute), coveringBALANCE/EXTCODESIZE/EXTCODECOPY/EXTCODEHASH/SLOAD/SSTORE/ the CALL family. The one area kept non-gas-comparable is the dynamic CALL-family surcharge interactions across nested frames (pending an audit).SELFDESTRUCT,CREATE, andCREATE2are now gas-comparable: SELFDESTRUCT uses Frontier rules on theConstantinoplefork (cost 0, noG_newaccountsurcharge — same convention as our Frontier-rate SLOAD=50 and EXP=10), modern values onCancun. The call family pays base fee + memory expansion + value surcharge viaGas.callSurcharge(CALL also pays the new-account portion when applicable; DELEGATECALL / STATICCALL pay zero surcharge) + 63/64 forwarding viaGas.allButOneSixtyFourth. Schedule changes need to stay in lockstep acrossStep,stepF, the soundness proof, andVMRunner.gasComparableOpcode. - World state: modelled as plain functions, not hash maps —
Storage = UInt256 → UInt256,AccountMap = AccountAddress → Account, address sets asα → Prop. This trades enumerability for clean algebraic reasoning (Function.update, extensionality,simp). - Address space:
AccountAddress = Fin (2^160)— the real 20-byte EVM address space.
EvmSemantics.lean -- root re-exports
Main.lean -- demo executable
EvmSemantics/
Data/
UInt256.lean -- 256-bit words, modular arithmetic
-- (the operand stack is plain `List UInt256`)
State/
Account.lean -- AccountAddress, Storage, Account, AccountMap
BlockHeader.lean -- block-context fields read by BLOCK ops
ExecutionEnv.lean -- per-frame execution environment I
Substate.lean -- accrued substate A (logs, accessed sets, refunds)
Machine/
MachineState.lean -- machine state μ (gas, memory, returnData)
SharedState.lean -- world+machine bundle
EVM/
Operation.lean -- 14-constructor Operation ADT, + EIP-8024
Decode.lean -- byte → Operation + immediate decoder
Gas.lean -- gas cost (real base fees; dynamic parts stubbed)
Exception.lean -- 8-variant ExecutionException
State.lean -- EVM.State (pc, stack, halt, ...)
Halted.lean -- ExecutionResult + State.toResult
Step.lean -- Step wrapper + StepRunning/StepReturn rules
BigStep.lean -- reflexive-transitive Steps, big-step Eval
StepF.lean -- executable shadow, split by Operation group
Equiv.lean -- soundness lemmas (helper + headline)
StepDeterminism.lean -- completeness + step_deterministic
StepComplete/ -- per-opcode completeness cases
lake build # compile library + executable
.lake/build/bin/evm_semanticsA lake exe cache get is recommended after the first lake update to
fetch Mathlib's precompiled .olean artifacts. The cold build is
~10 minutes; cached, ~30 seconds.
lakefile.toml registers Batteries' runLinter script as the project's
lint driver — the same one Mathlib uses for its own CI gate. Run it with:
lake lintIt runs the Batteries lint suite (missing doc-strings, simpNF, unused
arguments, dangerous instances, etc.) on every declaration under the
EvmSemantics namespace.
There is intentionally no scripts/nolints.json allow-list file —
all findings are addressed in source: short doc-strings everywhere, and
@[nolint unusedArguments] / attribute [nolint ...] annotations on
the handful of intentional exceptions (Gas.sstoreCost's ignored _original,
stepF's State.consumeGas proof-witness _h, the auto-derived Repr.repr
declarations from deriving Repr, the trivial Keccak.injEq from a
single-constructor deriving DecidableEq, and the inner-loop helpers
generated by let rec).
CI (.github/workflows/ci.yml) runs both lake build (gated to fail
on any warning) and lake lint on every push and PR.
Step : EVM.State → EVM.State → Prop(small-step). A thin wrapper with two constructors —running(guards aStepRunningderivation withs.halt = .Running) andreturning(wraps aStepReturn). The per-opcode logic lives in:StepRunning— 90 constructors (81 success, one per opcode, + 9 generic exception constructors parametric over the operation). Noh_runningpremise on any of them; the guard is consumed once on theStep.runningwrapper.StepReturn— 3callReturn*constructors for popping the caller frame when a child halts. Each pins the concrete halt kind and the non-empty call stack.
Eval : EVM.State → ExecutionResult → Prop(big-step). Defined as the reflexive-transitive closure ofStepending in a halted state, projected viaState.toResultto a flatsuccess | returned _ | reverted _ | exception _sum.stepF : State → Except ExecutionException State(executable shadow). MirrorsStepopcode-by-opcode. Split into per-group helpers (stepF.stopArith,stepF.compBit, …) so each piece is small and individually reasoned about.
Most success constructors of StepRunning follow this anatomy (stop carries
only h_op — and stackless reads omit h_stack):
| add (s : State) (a b : UInt256) (rest : List UInt256)
(h_op : s.decodedOp = some .ADD)
(h_gas : Gas.baseCost s.fork .ADD ≤ s.gasAvailable)
(h_stack : s.stack = a :: b :: rest)
: StepRunning s
{ s with
stack := (a + b) :: rest
pc := s.pc.succ
gasAvailable := s.gasAvailable - Gas.baseCost s.fork .ADD }The post-state is a flat { s with ... } record update — every field the
opcode touches is named directly. Downstream proofs can read e.g.
sf.gasAvailable or sf.stack by rfl (no nested consumeGas /
replaceStackAndIncrPC calls to unfold), which is the format expected by
Hoare-triple-style reasoning. (gasAvailable is a Nat, so the gas premise is
a plain Nat ≤; the operand stack is List UInt256. s.decodedOp is the
op-only projection of s.decoded — pushN is the one rule that uses the full
s.decoded, since it consumes the PUSH immediate.)
For opcodes with a dynamic gas piece (memory-expansion delta, per-word copy
cost, EIP-2200 SSTORE schedule, value-transfer surcharge, …), the rule
bundles the static base and all dynamic components into a single
Gas.<op>Total (or Gas.<op>Committed for the CALL/CREATE families). The
same identifier appears on both sides of the rule — once in the gas premise
Gas.<op>Total s … ≤ s.gasAvailable and once in the post-state's
gasAvailable := s.gasAvailable - Gas.<op>Total s …. Bundling everything in
one named total keeps the rule short and makes gasAvailable projectable
without thinking about evaluation order:
| keccak256 (s : State) (offset size : UInt256) (rest : List UInt256)
(h_op : s.decodedOp = some .KECCAK256)
(h_stack : s.stack = offset :: size :: rest)
(h_gas : Gas.keccakTotal s offset size ≤ s.gasAvailable)
: StepRunning s
{ s with
stack := EvmSemantics.keccak256
(MachineState.readPadded s.memory
offset.toNat size.toNat) :: rest
pc := s.pc.succ
gasAvailable := s.gasAvailable - Gas.keccakTotal s offset size
activeWords := s.activeWordsAfterUInt256 offset.toNat size.toNat }The Gas.<op>Total (resp. Gas.<op>Committed) functions live in
EVM/Gas.lean next to the schedule constants — they take the pre-execution
State and the opcode's stack arguments and return a Nat. The executable
shadow stepF charges the same gas via consumeGas + consumeMemExp in
chained form ((g - base) - memDelta) - kwc; the equivalence proof
(EVM/Equiv.lean) bridges between the chained and bundled forms via
Nat.sub_add_eq (handled inside grind).
The EVM.State carries a halt : HaltKind field. The Step.running
wrapper carries s.halt = .Running as its precondition, and each
StepReturn constructor pins a concrete non-Running halt kind via
h_halt, so a done state (halted with empty call stack) has no
successors under Step (proven uniformly via Step.not_from_done).
This keeps Step as a plain binary relation while still letting Eval
emit a structured result.
EVM/Equiv.lean establishes stepF_sound : ¬ s.isDone → Step s (stepF s)
without any sorry: stepF is total (in-frame exceptions fold into
halt := .Exception e), and on any non-done state the transition it
takes — success or exception — is backed by a Step derivation
(underlying combined statement: stepFE_sound, covering both the
.ok and .error outcomes of the Except-valued stepFE). The
proof is layered:
- Headline theorems
stepFE_sound_ok'/stepFE_sound_error'— unfoldstepFE, split on halt/precompile/decode/stack-cap/gas, then dispatch to the per-helper soundness lemmas based on the top-levelOperationconstructor. - Per-helper soundness lemmas — all 14 closed in both directions:
stopArith_sound,compBit_sound,keccak_sound,env_sound,block_sound,system_sound,stackMemFlow_sound,push_sound,log_sound,dup_sound,swap_sound,dupN_sound,swapN_sound,exchange_sound, plus their*_sound_errormirrors for the exception paths (everyOutOfGassite presents the actual failed charging stage, bounded byGas.totalCost). - Supporting lemma
popN_correct(inStepF.lean) — by induction onk, shows that ifpopN stk k = some (topics, rest)thentopics.length = kandstk = topics ++ rest. Used bylog_soundto recover the list-of-topics witness needed byStepRunning.log.
A small design tweak was needed to make the proof go through:
StepRunning.pushN now takes the immediate-width as an explicit parameter
(immWidth : Nat) rather than tying it to k.val, sidestepping a
decoder invariant that would otherwise need a separate lemma.
EVM/StepDeterminism.lean closes the converse direction:
step_complete : Step s s' → stepF s = s' — every relational transition
is exactly the one the executable computes. Determinism is an immediate
corollary (step_deterministic : Step s s₁ → Step s s₂ → s₁ = s₂), and
together with stepF_sound, Step is exactly the graph of stepF on
non-done states (step_iff_stepF). What makes this true is that every
StepRunning rule carries premises mirroring stepF's check order —
the stack-overflow guard (h_cap), the base fee, and the per-opcode
State.oogReach / State.underflowReach / State.staticReach
predicates on the exception rules — so from any state at most one
exception kind is derivable, and success rules cannot fire where the
executable would halt exceptionally. The per-constructor completeness
cases live in EVM/StepComplete/ (one file per opcode group, shared
evaluation lemmas in Dispatch.lean); the axiom footprint of the
headline theorems is pinned to [propext, Classical.choice, Quot.sound]
by #guard_msgs checks, as in Equiv.lean.
- The opcode list, state-record layout, and per-instruction semantics follow NethermindEth/EVMYulLean closely. Anything ported verbatim should be attributed to that project.
- The Yellow Paper section numbers cited in comments correspond to the Cancun-era Ethereum spec.
Apache2, as specified in LICENSE-APACHE2.