This repository is an executable Lean semantics of the Solidity 0.8.35 source
language. Its external-world model is expressed in the same interaction
monad that ../evm-compiler (Solidus) uses for its Yul source semantics and
EVM target semantics, on the same Lean toolchain and EVMYulLean pin, so a future
Solidity→Yul lowering can be stated as a refinement without re-plumbing the
boundary.
This branch is only the Solidity source semantics. There is no
Solidity→Yul lowering, no ForwardRel proofs, and no compiler artifacts
(the former EvmYul library, Spine/L0x layout, generated Yul, bytecode,
and EVM parity harnesses are all removed). The end state is a single
lean_lib SolidCore.
It is an executable, differentially-validated semantics of Solidity 0.8.35. The verification is a differential corpus, not theorems:
- 99 paired Forge/pinned-solc cases (
tests/forge-harness/), covering real OpenZeppelin, WETH9, solmate, Uniswap, and Compound sources, plus targeted frontier cases. - 426 Lean
#evalassertions checked against the pinned-solc/Foundry-EVM observable behavior of those cases. - ~311 pinned-solc rejection lanes (
solc_rejects) paired against the common typechecker's acceptedness witnesses.
There are no proofs on this branch — no theorem relates this semantics to
compiled Yul or EVM bytecode. The corpus is the whole of the evidence. Any
statement stronger than "this executable model agrees with pinned solc 0.8.35
on the corpus" is out of scope until the lowering project begins. The intended
future direction is sketched in docs/compile-to-yul-readiness.md; it is a
study, not a claim.
Matched to evm-compiler exactly so the shared types are literally identical:
- Lean toolchain:
leanprover/lean4:v4.28.0. - EVMYulLean:
danrobinson/EVMYulLean @ 3c5c44a6as a real Lake git dependency.State,SharedState,Substate,UInt256, account/log/block types come from the pin — no local shims. The former localEvmYul.UInt256/Keccak compatibility modules and theexternal/nethermindsubmodule are gone. - Keccak: the pinned
EvmYul.SpongeHash.Keccak256is FFI/opaque and unusable from a total interpreter, so this repo keeps a repo-owned pure Keccak (SolidCore/Solidity/Keccak.lean) with a corpus-gated byte-parity witness against the pinned FFI hash (lake exe keccakParity). This is the one deliberate local implementation of a pinned primitive, and it is checked, not silently shimmed.
Single library SolidCore (lakefile.lean, @[default_target] lean_lib SolidCore), plus one executable keccakParity for the byte-parity witness.
SolidCore/Solidity/
Ast.lean -- surface AST (imports only Shared; no ABI/Interpreter dep)
Interface.lean -- surface elaboration + solc-AST import surface
TypeCheck.lean -- the common (acceptedness) typechecker
Checked.lean -- checked executable projection + checked entry points
Interpreter.lean -- the one fuel-based interpreter; SolI monad + choke points
ABI.lean -- ABI encode/decode/dispatch
Keccak.lean -- repo-owned pure Keccak (byte-parity-checked vs the pin)
Interaction.lean -- bridge: re-exports the shared interaction alphabet
Shared/ -- EVM-primitive foundations (Word, Account, Block, Call,
External, Log, Precompile), folded in from the former
SharedSemantics library
SolidCore/Witness/
Interface.lean, TypeCheck.lean, Checked.lean, RationalConstants.lean
-- the entire example/witness corpus, out of the semantics
Splitting the interpreter's big mutual block across files is not possible in
Lean, so Interpreter.lean stays one file for the mutual core; "no monolith"
means no mixed-concern file (examples, typechecker, ABI, and the AST all live
in their own modules), not an arbitrary line limit.
A Solidity execution is an interaction tree over the shared query alphabet.
SolidCore/Solidity/Interaction.lean re-exports, under stable names, the exact
EvmCompiler.Simulation.* declarations:
Interaction -- free monad: done (final state) | request (query) (answer → k)
Query/Answer -- resource (gas|msize) | external (OpenWorld, ExternalRequest)
OpenWorld -- accounts (nonce, balance, storage, transient, code) + substate
ForwardRel -- the composition relation Solidus's theorems are stated in
These come from the sibling ../evm-interaction Lake path dependency — a
byte-identical verbatim extraction of evm-compiler's Simulation sources. A
hash-check (scripts/check_shared_interaction_hashes.py, which reads
../evm-compiler read-only) guarantees the extracted files stay byte-for-byte
identical to evm-compiler's copies, so the alphabet cannot drift. This repo
programs against the real composition alphabet from day one; evm-compiler
adopts the package whenever its own freeze is next rev'd.
The interpreter's monad is
SolI α := EvmCompiler.Simulation.Interaction SolidityFailure α
(Interpreter.lean ~1804). The only request nodes are external calls
(call/staticcall/delegatecall/callcode, low- and high-level, precompile
staticcalls to addresses 1/2/3) and contract creations (new, create/create2).
Everything else — reverts, panics, control flow, fuel exhaustion — is a
Solidity-owned leaf value: reverts stay Result.reverted values and only
.outOfFuel (the distinguished truncation failure, mirroring Yul's
.OutOfFuel) escapes Stmt.eval as a throw. Environment reads Yul also takes
from shared state (block/tx fields, blockhash) are state reads, not queries,
matching the alphabet exactly.
The corpus does not execute a real second contract (closed-world multi-contract
execution is out of scope by design — reentrancy and this.f() are
environment-answered). Fixture answers are supplied by scripted responders:
a per-case ordered list of oracle rows, built by
responderOfResults [callRows] [createRows] and folded fail-closed by
SolI.runWith. A witness entry named …UnderResponder N (responderOfResults …)
answers each external query from the rows and fails closed on any
unanticipated request (ResponderFailure.unmatched, carrying the request for an
expected-vs-actual diff) rather than silently continuing on a failed call. This
makes "the fixture intends this call to fail" (an explicit success := false
row) and "the fixture never anticipated this request" distinguishable.
The boundary is shape-identical to Yul's installYulShared. Every
Query.external carries a real OpenWorld snapshot of the caller's state
at emit (snapshotWorld, the mirror of ofYulShared): the self account from
the live State (storage/transient verbatim, selfBalance, selfNonce, code
from Context), other accounts from the environment-fact view, and the
substate (log series, selfdestruct set). On resume the answered
CallResponse.postWorld/CreateResponse.postWorld is adopted wholesale
(adoptWorld): the self-account fields land on the existing State fields as
ordinary slot-keyed writes (no new representation), and the answered world is
retained verbatim in State.envWorld? as the live view of other accounts,
substate extras, and created accounts. Env-fact reads (.balance/.code/
.codehash of other addresses, created-accounts set) route through
State.env*, consulting the adopted world first and the Context seed maps
only pre-adoption. The round-trip law
snapshotWorld context (adoptWorld w context s) = w (proved for every w in
SolidCore/Solidity/AdoptionLaws.lean, the mirror of
ofYulShared_installYulShared) is the algebraic heart: adoption loses nothing
the environment said. Because typed reads of non-canonical adopted words are
total and solc-faithful (bool truthiness on the low byte, enum
mask-then-use-site-Panic(0x21), address/bytesN masking, packed intN
sign-extension — Ty.storageValueFromWord?), no environment well-formedness
hypothesis is needed anywhere: a reentrant callee that rewrote our account is
just one particular answer, and the quantifier already covers it. Responder
rows carry an optional PostDelta (model-level slot-keyed writes) to express
what a reentering callee did; the A2 value-transfer debit is folded into the
responder's echo answer and arrives through adoption (balance flows only
through adoption). A row that carries no delta answers the echo world
(postWorld := sent world), which adoption maps to the identity — so the whole
corpus is behavior-preserving.
- Storage layout is IN scope (spec-owned). Unlike compiler memory layout,
Solidity's storage layout (slots, packing, mapping/array slot derivation) is
documented language spec that upgradeable-contract and cross-contract tools
rely on. The repo models slots/packing/paths and can materialize a word-
addressed storage snapshot for the
OpenWorlda query carries. - Memory layout remains OUT of scope. Memory stays abstract; it reaches queries only through ABI-encoded calldata bytes, which the ABI layer already concretizes.
- The external world is the shared query alphabet, not oracles. External
calls/creates emit
Query.external; non-self accounts are reads of anOpenWorld-shaped environment, not ad-hoc oracle-record lookups. The old oracle-recordContextfields are deleted.
The conformance corpus and the typechecker-acceptedness (solc_rejects) lanes
are frozen regression suites, not a growing coverage target. New lanes are
added only to pin a discovered bug — as the rational-constants lane does
(the A1 over-reject) and the planned recursion/function-boundary lane will — and
never to extend coverage. The open-ended acceptedness audit of the previous
roadmap is closed, not continued. See tests/README.md for the same rule.
Deferred semantic gaps (intra-frame balance accounting, gasleft as a resource
query, create initCode being source-canonical rather than compiled bytecode,
internal-function recursion / deep call-nesting beyond the inline budget) are
recorded explicitly in ROADMAP.md's "Known semantic gaps" registry and in
docs/DECISIONS.md, not silently absent. Each is fixed with paired Forge lanes
when it is picked up.