Skip to content

fix(symbolic): register folded mapping slots as keccak preimages - #18340

Open
figtracer wants to merge 4 commits into
mablr/evm2-migrationfrom
fig/symbolic-folded-slot-preimages
Open

figtracer wants to merge 4 commits into
mablr/evm2-migrationfrom
fig/symbolic-folded-slot-preimages

Conversation

@figtracer

Copy link
Copy Markdown
Member

Why

With the optimizer on, solc folds keccak256(abi.encode(K, slot)) for a constant key into a PUSH32. Under forge test --symbolic that constant counts as a fixed slot that never aliases a write through a symbolic key, so m[o] = 5; assert(m[K] == 0) passes although o == K breaks it. Found by symdiff (Tempo, OPT-01); the check_/test_ twins disagree before and agree after.

function check_const_key(address o) public {
    m[o] = 5;
    assert(m[0xD00D] == 0);
}
function test_const_key() public { check_const_key(address(0xD00D)); }
[PASS] check_const_key(address)          // optimizer runs 200
[FAIL: panic: assertion failed (0x01)] test_const_key()

Change

New runtime/folded_slots.rs: before a run, for every cached account whose code matches a known artifact with a storage layout, hash each mapping root (also inside structs, one nested level) with every candidate key of a fitting type and record the preimage in concrete_keccak_preimages. Candidate keys are the contract's PUSH immediates plus source literals from the fuzz literals dictionary, so constants solc folded away still count. ExecutorView::with_project wires the known contracts and literals at the four runner call sites; forge test adds storageLayout to extra_output when symbolic mode is on. Cases folded into symbolic_keccak_known_preimage_bases_do_not_alias.

Downstream

Base mablr/evm2-migration. Adds a foundry-compilers dependency to the symbolic crate for the layout types and requests storageLayout output in symbolic runs. A registered preimage is a real one, so this adds no unsoundness; ERC-1967 and ERC-7201 constant slots stay fixed slots. Limits: a key that appears in neither the literals nor the bytecode, and a root reached only through assembly (keccak("ns.slot")), are still missed. Cost is keys times roots keccaks per deployed contract per run, not measured on a large project.

With the optimizer on, solc folds the slot of a mapping entry at a constant key into a PUSH32, and the engine counted that constant as a fixed slot that a write through a symbolic key never reaches, so `m[o] = 5; assert(m[K] == 0)` passed. Before a run, hash the mapping roots of each deployed contract's storage layout (requested as extra output for `--symbolic`) with the source literals and the contract's PUSH immediates, one nested level deep, and record the results as concrete preimages. Constants that no candidate produces, such as ERC-1967 and ERC-7201 roots, stay fixed slots.

@mattsse mattsse left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address the inline finding.

Comment on lines +98 to +100
for &key in keys.iter().filter(|&&key| key_fits(key_ty, key)) {
let hash = record_mapping_preimage(cx, key, slot);
register_mapping_slots(cx, layout, hash, value_id, keys, levels - 1);

@mattsse mattsse Nov 10, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please avoid eagerly materializing every candidate-key pair for nested mappings. For mapping(uint256 => mapping(uint256 => uint256)), this iterates all keys again for each outer key and retains N² preimages on every symbolic run, even if the property never accesses the mapping. In a local dev build of this head, a pure assert(x == x) test with that unused mapping and 2,000 unused integer constants took 7.74s and 3.06 GB peak RSS; with 400 constants it took 0.28s and 213 MB. Discovery also has no deadline/cancellation check, so larger projects can exhaust memory or overrun frontier-search budgets. Please avoid retaining the full Cartesian product and honor the deadline while scanning.

The nested level of a folded mapping slot is a product over the candidate keys, and registering it eagerly retained N² preimages on every run, even when the property never touched the mapping. Register the first level eagerly and search the nested level only for a large constant slot the run loads or stores, once per slot, recording only the match. Both scans stop at the run deadline.
A probe of the same constant slot in another contract could suppress nested mapping preimage discovery. Cache only completed searches for a contract-slot pair. The integration test fails without this fix and finds a replayed counterexample with it.
@figtracer
figtracer requested a review from mattsse November 10, 2026 03:24

This branch has not been deployed

No deployments
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.

2 participants