Fix #1082: resolve symbolic-key SLOAD over a ConcreteStore - #1083
Merged
Conversation
gustavo-grieco
force-pushed
the
fix-1082-symbolic-key-sload
branch
from
July 23, 2026 09:03
a9abf66 to
b68e625
Compare
readStorage returned an unresolved abstract SLoad for a symbolic slot over a ConcreteStore, so the symbolic interpreter forked on an effectively-known value and could fail to terminate (observed through echidna's verification mode: 0 SMT queries, frozen coverage). Standalone, the unresolved read drags the whole concrete store into the fork: a symbolic read over an unrelated mapping populated with 600 entries does not complete within minutes. Two sound, scalable pieces (NOT the ITE-over-all-entries proposed in the issue, which trades non-termination for OOM on populated stores): 1. An empty ConcreteStore reads 0 at every slot, including symbolic ones. This lives in readStorage' (the total variant used by the interpreter's internal-storage path and by expression simplification), NOT in readStorage: accessStorage relies on readStorage's Nothing/SLoad results to drive lazy RPC slot fetching for external contracts, whose ConcreteStore is a partial cache rather than the full storage. A new unit test pins that contract. 2. For a populated store, at read time (OpSload) filter the ConcreteStore to entries in the read's mapping, using recorded keccak preimages: a concrete key whose preimage identifies a different mapping slot cannot equal the symbolic read slot (keccak injectivity, consistent with the existing store-simplification assumptions). A read of an unwritten/unrelated mapping filters to empty -> Lit 0, before any fork. Keys with unknown preimage are kept (conservative/sound). Tests: - Expr unit tests for the empty-store rule, the preserved readStorage Nothing contract, and filterStoreByReadSlot (drop/keep/ignore). The existing word-eq-bug expectation is updated to Lit 0, fulfilling its "should be simplified to (Lit 0)" TODO. - Foundry tests: the issue's empty-store repro and a 600-entry unrelated-mapping read (does not complete without the fix, ~1s with it) in pass/; a same-mapping read in fail/ guards filter soundness (entries kept -> still falsifiable, counterexample validated concretely). Verified: the issue's prove_symbolic_key verifies in under a second; unrelated-mapping reads go from 1.44s (60 entries) / >120s, killed (600 entries) to 0.07s / ~1s; same-mapping reads still falsify with a validated counterexample; concrete-slot misses still return Nothing so the RPC fetch path is unchanged by construction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gustavo-grieco
force-pushed
the
fix-1082-symbolic-key-sload
branch
from
July 23, 2026 10:06
b68e625 to
11ec8fd
Compare
Collaborator
Author
|
CI fix: the |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1082.
Problem
readStoragereturns an unresolved abstractSLoadfor a symbolic slot over aConcreteStore, even though the store's contents are fully known. The symbolic interpreter then forks on an effectively-known value. Two symptoms:ConcreteStore, then a symbolic mapping read never resolves: 0 SMT queries, frozen coverage).hevm test, a symbolic-key read of a never-written mapping over a store holding 600 entries of an unrelated mapping does not complete within 120s. All those entries are dragged into the fork and the SMT encoding even though none of them can alias the read.Fix
Two pieces. Notably this is not the ITE-over-all-entries fold proposed in the issue: that would inline the whole store into every symbolic read and trades non-termination for OOM on populated stores.
An empty
ConcreteStorereads 0 at every slot, including symbolic ones. AConcreteStoreis total (unwritten slots are 0), so with no entries every read is 0. This lives inreadStorage'(the total variant used by the interpreter's internal-storage path and by expression simplification), not inreadStorage:accessStoragerelies onreadStorage'sNothing/SLoadresults to drive lazy RPC slot fetching forexternalcontracts, whoseConcreteStoreis a partial cache rather than the full storage. Folding the rule intoreadStoragewould make every first read of an unfetched slot on a forked contract silently return 0 instead of issuingPleaseFetchSlot. A new unit test (readStorage-keeps-nothing-on-concrete-miss) pins that contract.At read time (
OpSload), filter theConcreteStoreto entries that can belong to the read's mapping, using the keccak preimages recorded during concrete execution: a concrete key whose known preimage identifies a different mapping slot cannot equal the symbolic read slot, by keccak injectivity — the same assumption family the store simplifier already uses (idsDontMatch,Lit < 256vsKeccak). A read of an unwritten or unrelated mapping filters to empty →Lit 0, before any fork. Keys with unknown preimage are kept (conservative, sound). The filtered store is only used for the one read expression and is never written back.This incidentally fulfills the existing
word-eq-bugtest'sTODO: This should be simplified to (Lit 0)— its expectation is updated accordingly.Tests
test/EVM/Expr/ExprTests.hs): empty-store rule for symbolic slots, preservedNothing-on-concrete-miss contract ofreadStorage, write-stripping down to an empty base, andfilterStoreByReadSlotdrop/keep/ignore cases (using a realkeccak'preimage).test/EVM/Test/FoundryTests.hs):pass/symbolicKeySload.sol: the issue's literal empty-store repro, plus a 600-entry populated mapping with a symbolic read of an untouched mapping. Without the fix the latter does not complete (this is the regression signal); with it, ~1s. Both contracts usebool public constant IS_TESTdeliberately — a scalar state var would keep a preimage-less slot in the store and route the read through the solver instead of resolving to 0.fail/symbolicKeySload.sol: soundness guard — a symbolic read of the same mapping that holds concrete entries (10, enough to be representative while keeping the SMT query small) must keep them, so the assert is falsifiable. hevm finds and concretely validates the counterexample. If the filter ever unsoundly dropped same-mapping entries, this test would start "passing".Measurements (arm64 macOS, z3)
prove_symbolic_key(empty store)Notes
keccakPreImgsare only recorded during concrete execution (OpSha3), so the filter benefits exactly the flows that hit this bug: concrete deployment/setUp()followed by symbolic execution (hevm test, echidna verification mode). During pure symbolic execution the preimage map is empty and the filter keeps everything (status quo).ConcreteStore; reads overSStorewrite-chains are left to the existing simplification/decomposition machinery.k < 256keys for keccak-shaped reads would mirror the existing small-lit-vs-keccak assumption; not needed for the cases here.🤖 Generated with Claude Code