Skip to content

fix(l1): seed the hashers of every map keyed by contract- or block-chosen values - #7408

Merged
ilitteri merged 14 commits into
mainfrom
fix/seed-evm-keyed-hashers
Oct 16, 2026
Merged

ilitteri merged 14 commits into
mainfrom
fix/seed-evm-keyed-hashers

Conversation

@ilitteri

@ilitteri ilitteri commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

#7373 seeded the transient storage map because contracts choose its keys and rustc-hash 2.x is unseeded. The same holds for many more maps on the execution and validation path: storage slots, touched addresses, precompile calldata and (address, slot) tuples are all chosen by contract code or by the block producer. rustc-hash hashes a key of 17 to 32 bytes as multiply_mix(SEED2 ^ word(bytes[len - 16..len - 8]), ..) ^ len, with SEED2 a public constant and multiply_mix(0, _) == 0, so keys that carry SEED2 in that word all hash to len, land in one hashbrown bucket, and turn every lookup into a scan: O(n) per access, O(n²) per block, at flat opcode gas.

Description

Swaps hashers; no map changes shape, capacity hints or semantics. Where a seeded map's order would leave the process, the code now sorts first (below). Every seeded map now uses SeededHashMap / SeededHashSet from the new ethrex_common::seeded_hash module. SeededState wraps foldhash's RandomState (seeded per process and per map, as #7373 used it) and its hasher feeds a 32-byte key as two u128 writes and a 20-byte key as a u128 plus a u32, which foldhash folds with two multiplies instead of the four its byte path spends. That detail is what keeps the change perf-neutral: with plain foldhash::HashMap the SSTORE microbenchmark was 21% slower, because a warm SSTORE hashes several H256 and Address keys. The resistance is the same either way, since every multiply still mixes in a seed the key does not know. TransientStorage moves to the same type, so the whole tree hashes these keys one way, and foldhash becomes a dependency of ethrex-common alone.

Seeded:

  • LEVM: LevmAccount.storage, CacheDB, both CallFrameBackup maps, the Substate accessed-address and accessed-slot sets (now the public aliases AccessedAddresses and AccessedStorageSlots), PrepareRegionBackupMarker, VM.storage_original_values, GeneralizedDatabase.accessed_accounts, codes and code_metadata, the CachingDatabase account, storage and code caches and its code-prefetch dedup set, the per-precompile calldata map of the precompile cache, and TransientStorage.
  • ethrex-common: AccountUpdate.added_storage, BalSynthesisItem.added_storage and synthesize_bal_updates, BalAddressIndex.addr_to_idx and slot_idx_by_account, and the recorder's tx_initial_storage.
  • LEVM backend: the BAL validation sets (unread_storage_reads, unaccessed_pure_accounts, pure_reads, canonical) and the validate_tx_execution parameters.
  • ethrex-blockchain: the merkleizer's per-account storage and BalSynthesisItem maps, the witness accumulator and hashed-address cache, the prewarmer's walked-account and walked-slot sets, StoreVmDatabase's account-state cache and batch code-read map, ReplayedVmDatabase's updates, and the code-hash dedup both witness paths share (unique_code_hashes).
  • ethrex-storage: the Store's CodeCache and code_metadata_cache, and BlockDataBuffer.codes, which keep code across blocks; the trie diff-layer node maps, the deep-reorg overlay's four maps, the bloom filters in front of both, and the reconciliation key set in commit_to_disk.

A keccak output is no safer as a key than its preimage: whoever picks the preimage (a CREATE2 salt, the trailing bytes of deployed code) can grind it until the hash lands in a chosen bucket, at about one keccak per bucket of the table for each key. That is why code hashes are seeded: a contract deployed once can be called again for 100 gas, and every call looks its code up by hash, in the per-block maps and in the Store caches behind them. What keeps a map on FxHash safe is the gas a block pays for each key it adds or looks up. Left on FxHash on that ground: created and self-destructed accounts (each entry costs a CREATE or a SELFDESTRUCT, and only SELFDESTRUCT looks them up), EIP-7702 authority sets (each entry costs an authorization), recovered senders and transaction hashes (each costs a transaction), and maps keyed by block access index, which a block assigns by position rather than choosing. Also still on FxHash: the merkleizer's per-shard maps keyed by hashed address, which get one entry per account the block modifies and are probed once per entry. The trie diff layers are seeded on the same ground: their keys are the paths a block's state writes touch, a contract picks those with ordinary stores, and a cold read probes the chain once per layer for up to 128 layers on every block while the layers live, so a crafted set of paths is paid for once and probed for as long as it stays cached. What the Store still keeps on FxHash is keyed by block hashes, transaction hashes, block numbers and state roots, each of which costs a block or a transaction, or belongs to the in-memory backend that only serves the memory datadir used by dev and tests. Out of scope here and worth a separate look: the mempool's maps keyed by transaction hash and sender are bounded by the pool's limits but sit on a path peers feed directly. rustc-hash's own FxSeededState would not have helped the seeded ones: its byte path ignores the seed, so crafted byte-string keys still collapse.

Consensus-visible bytes are unchanged: every iteration over a seeded map feeds a trie, a BTreeMap, the sorted and deduplicated witness, a validation check whose verdict does not depend on order, or the message of a BAL rejection, which names the same offender whatever order the maps walk in (below). The L2 store serialises Vec<AccountUpdate> with bincode only to decode it again. Guest programs hash with the same SeededState. In a zkVM foldhash finds no clock and no address randomization, so its seeds follow from the guest program and its input: crafting keys against a guest takes replaying it up to the map under attack, which is harder than against FxHash's public constants but possible.

One iteration order mattered without changing any bytes. get_state_transitions lists a block's account updates in CacheDB order, and the host records the witness while applying that list to the state trie: a removal that leaves a branch with one child reads that child, while an insert into the branch first avoids the read. With FxHash the host and the guest built the list in the same order. With seeded maps the host could insert first and the guest remove first, and the guest would need a node the witness never recorded, so proving failed with a missing node. apply_account_updates_from_trie_with_witness and GuestProgramState::apply_account_updates now apply the updates in address order, so both sides touch the same nodes whatever order the lists come in. test/tests/blockchain/witness_update_order_tests.rs records a witness under each input order and replays it on a guest applying each input order, once with the removed account sorting first and once with the inserted one first; it fails if either side stops sorting. Storage updates need no sort: the guest applies inserts before deletes, which reads a subset of the nodes any host order reads. Two more tests in the same file record a storage witness under every order of an account's changes, a delete and an insert in one branch and two deletes from one branch, and replay it under every guest order; the first fails if the guest stops putting inserts first. The sweep for the same shape found nothing else: the BAL builder sorts, the tracers emit BTreeMaps, the merkleizer and prefetcher are order-free, and the L2 committer only reapplies stored updates to a trie.

Rejections name the same offender on every node. The BAL checks used to stop at whichever offender a walk over a seeded set or over CacheDB met first, so the same invalid block got a different message on every node and every run. The checks of leftover declared reads and pure-access accounts now name the lowest leftover. validate_tx_execution's execution-to-BAL pass, validate_bal_withdrawal_index's DB-to-BAL pass and validate_bal_covers' backstop go through first_failure_by_key, which walks a map once in its own order and, only if something failed, walks it again in key order and returns the lowest failure; the per-slot loops inside them do the same. A valid block pays no sort. The levm runner's state diff and state_v2's storage mismatch report sort what they print, by address and by slot.

Tests: seeded_hash's unit tests check that H256, U256, Address and byte-string keys varying in either part spread across 4096 buckets, and that two maps hash the same key apart; they no longer assert that keys collide under FxHash, so a rustc-hash release cannot break them. test/tests/levm/seeded_hashers_tests.rs takes each public map as production code builds it and checks that it hashes a key differently from a fresh instance of its hasher, which no unseeded hasher does, so reverting any public map's hasher fails a test; the private maps (precompile cache, warming caches, account-state cache, replayed updates, trie layers and overlay) get the same check as unit tests next to them. unique_code_hashes has a unit test pinning first-read order. The lowest-offender rule is covered by two block-import tests (256 extraneous reads, 256 extraneous accounts), two validate_tx_execution tests (255 accounts absent from the BAL, 255 unrecorded storage writes) and unit tests of first_failure_by_key; the validate_tx_execution tests fail if the helper stops sorting.

Lockfiles: relative to main, every workspace that builds the changed crates moves the foldhash 0.2.0 edge from ethrex-levm to ethrex-common (make check-cargo-lock passes). The tooling state_v2 runner now compares and reports storage as BTreeMaps sorted by slot, and drops its now-unused rustc-hash dependency.

Benchmarks on the same machine (Apple M-series laptop), main vs. this branch, hyperfine with 30 interleaved runs each, median and standard deviation in ms. They were taken before the code-hash maps and the Store's code caches were seeded and have not been re-run since:

LEVM benchmark main branch delta
BubbleSort 1308.4 (13.7) 1294.3 (12.6) -1.1%
ERC20Approval 498.4 (2.6) 496.2 (6.4) -0.5%
ERC20Transfer 125.7 (1.2) 126.2 (1.3) +0.4%
Fibonacci 134.7 (1.7) 132.7 (1.4) -1.5%
ManyHashes 6.2 (0.1) 6.3 (0.2) +1.6%
SstoreBench_no_opt 40.1 (2.8) 41.2 (0.8) +2.8%
block pipeline main branch delta
import of l2-1k-erc20.rlp (1110 blocks, s) 25.48 (1.10) 25.20 (0.43) -1.1%

The import row is the blocks-exec perf CI recipe (ethrex --network fixtures/genesis/perf-ci.json --force import fixtures/blockchain/l2-1k-erc20.rlp --removedb on a scratch datadir, hyperfine, 30 interleaved runs per side). The trie-layer seeding was measured on its own with the same recipe, 20 interleaved runs per side, against the commit before it: medians 24.89 s (sd 2.92, one 37.8 s outlier) before and 24.23 s (sd 0.84) after.

build_block_benchmark (Ggas/s, higher is better, 30 samples) main branch
round 1 3.14 [2.98, 3.28] 3.34 [3.24, 3.43]
round 2 3.48 [3.42, 3.53] 3.40 [3.31, 3.47]
mean of both rounds 3.31 3.37 (+1.8%)

benches/build_block_benchmark does not run on main as checked in: its setup funds each account with u64::MAX, below the cost of 1000 transactions at max_fee_per_gas = u64::MAX, and its 192 x 1000 transactions trip the mempool's 90% occupancy gap gate at the default pool size. The rows above come from the unmodified benchmark body with two setup-only changes applied identically to both sides and not committed here: genesis balance U256::MAX and max_mempool_size: 1_000_000. Its round-to-round swing on main alone (3.14 to 3.48) is wider than the difference between the sides.

Checklist

  • Updated STORE_SCHEMA_VERSION (crates/storage/lib.rs) if the PR includes breaking changes to the Store requiring a re-sync.

…he block producer, as #7373 did for transient storage. rustc-hash 2.x is unseeded and hashes a key of 17 to 32 bytes as multiply_mix(SEED2 ^ word(bytes[len-16..len-8]), ..) ^ len, with SEED2 public and multiply_mix(0, _) == 0, so storage slots, touched addresses, precompile calldata and (address, slot) tuples can all be crafted to share one hashbrown bucket: every lookup becomes a scan and a block becomes O(n^2) work at flat opcode gas.

Now on foldhash::HashMap / HashSet (seeded per process and per map): in LEVM, LevmAccount.storage, CacheDB, both CallFrameBackup maps, the Substate accessed-address and accessed-slot sets (new aliases AccessedAddresses and AccessedStorageSlots), PrepareRegionBackupMarker, VM.storage_original_values, GeneralizedDatabase.accessed_accounts, the CachingDatabase account and storage caches, and the per-precompile calldata map of the precompile cache; in ethrex-common, AccountUpdate.added_storage, BalSynthesisItem.added_storage with synthesize_bal_updates, the addr_to_idx and slot_idx_by_account maps of BalAddressIndex, and the recorder's tx_initial_storage; in the LEVM backend, the BAL validation sets (unread_storage_reads, unaccessed_pure_accounts, pure_reads, canonical) and the validate_tx_execution parameters; in ethrex-blockchain, the merkleizer's per-account storage and BalSynthesisItem maps, the witness accumulator and hashed-address cache, the prewarmer's walked-account and walked-slot sets, StoreVmDatabase's account-state cache and ReplayedVmDatabase's updates. Maps keyed by keccak outputs (code hashes, created and self-destructed contract addresses, EIP-7702 authorities, transaction hashes and senders), by block access indices, or by hashed trie keys stay on FxHash: nobody can steer those keys into a bucket.

No iteration over a seeded map reaches consensus-visible bytes: each one feeds a trie, a BTreeMap, the sorted and deduplicated witness, a validation check whose verdict is order-free, or the choice of which BAL error to report first. The L2 store serialises Vec<AccountUpdate> with bincode only to decode it again. Guests keep foldhash's own RandomState, as #7373 left it.

test/tests/levm/seeded_hashers_tests.rs builds, for every key shape, keys that provably collide under FxHash and checks that the real types hash them apart; the private maps get the same check as unit tests next to them, so reverting any hasher fails a test. The lockfiles of every workspace that builds the changed crates gain only the foldhash dependency edge; tooling's state_v2 runner compares storage through the new type and drops its now-unused rustc-hash dependency.
… of foldhash's HashMap directly, and feed fixed-size keys to foldhash as words. The previous commit cost the SSTORE microbenchmark 21% (39.9 to 48.2 ms over 5433 warm SSTOREs x 200 runs): foldhash's byte path spends four folded multiplies on a 32-byte key where FxHash spends three, and a warm SSTORE hashes several H256 and Address keys. SeededState wraps foldhash's RandomState and its hasher turns a 32-byte write into two u128 writes and a 20-byte write into a u128 plus a u32, which foldhash folds with two multiplies; every other write delegates unchanged. The attack resistance is the same, since each multiply still mixes in a seed the key does not know. With it the same benchmark reads 40.1 vs 41.2 ms, inside its own run-to-run spread, and the other LEVM benchmarks move by at most 1.6% either way.

TransientStorage moves to the same type, so every seeded map in the tree hashes the same way, and foldhash is now a dependency of ethrex-common alone: the levm, vm, blockchain, test and tooling state_v2 manifests drop it, and the lockfiles follow. The module carries unit tests for H256, U256, Address and byte-string keys that collide under FxHash and hash apart here, plus one that equal keys hash equal on both the word and the byte path.
…the backend's. Every use sits in rayon-only functions, so a build without the feature flagged the import as unused under -D warnings.
@ilitteri ilitteri added levm Lambda EVM implementation L1 Ethereum client labels Oct 14, 2026
@ilitteri
ilitteri requested a review from a team as a code owner October 14, 2026 16:00
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

⚠️ Known Issues — intentionally skipped tests

Source: docs/known_issues.md

rpc-compat log-bearing cases excluded

Where: KNOWN_EXCLUDED_TESTS in .github/scripts/check-hive-results.sh counts out
eight hive rpc-compat cases — the four eth_getLogs cases, eth_getBlockReceipts/get-block-receipts-latest,
and three eth_getTransactionReceipt cases. They are exactly the cases whose recorded
response contains at least one log object; every case with an empty log array still runs.
Note this leaves eth_getLogs with no rpc-compat coverage at all, since all four of its
cases are in the set.

Why: ethrex populates blockTimestamp on log objects, as geth, besu, nethermind, reth
and erigon all do. hive's rpc-compat compares responses byte-exactly (jsondiff.FullMatch;
the lenient checkJSONStructure path applies only to cases upstream marks speconly), and
the corpus is pinned to execution-apis d08382ae (2025-02-10), whose recordings predate the
field — it entered the schema in execution-apis#639 and the fixtures in #846 (2026-07-22).
So the extra key cannot match, and this is a property of the pin rather than of the response.

The pin cannot move, and this is not temporary. The pin sits one commit before
execution-apis#627, which moved the test chain to a pre-merge genesis: the current corpus has
~36 proof-of-work blocks before its terminal total difficulty. ethrex does not support
pre-merge chains and will not, so importing that chain.rlp fails at block 1 —
validate_block_header has no pre-London base-fee path. Every revision carrying
blockTimestamp in its fixtures also carries that chain, so there is no revision that
satisfies both. Nor can the corpus be patched locally: rpc-compat's Dockerfile clones
ethereum/execution-apis by hard-coded URL, so the branch buildarg cannot point at a fork.

Coverage: the field itself is pinned by
block_timestamp_is_on_the_log_and_not_on_the_receipt in
crates/networking/rpc/types/receipt.rs, which asserts it is present on each log and absent
from the receipt level.

Removal: delete the entries if ethrex ever gains pre-merge chain import, or if upstream
marks these cases speconly so they are type-checked instead of compared byte-for-byte.


The stateless schema id does not identify the encoding

Where: STATELESS_INPUT_SCHEMA_ID in crates/common/types/stateless_ssz.rs.

Upstream keeps the stateless input schema id at 0x1501
(fork_index 0x15 << 8 | revision 0x01) across incompatible body changes. Four
encodings have now shipped under it: tests-zkevm@v0.6.2, then #3248 + #3278,
then #3356, which moved state, codes and public_keys from SszList to
ProgressiveList, then #3652 in tests-zkevm@v21.0.1, which removed
public_keys outright without bumping the revision. ethrex speaks the last one.

The consequence is that the 2-byte prefix cannot be used to detect a stale or
mismatched bundle. A wrong-dialect input is accepted by the id check and then
fails later — in SSZ decode, or on a root that does not match — rather than being
rejected up front for what it is. only_amsterdam_schema_id_decodes therefore
proves less than its name suggests.

Worth raising upstream: a revision field that does not move across a body change
provides no version negotiation at all.


ZisK guest program hash changes with the unsync_cell gate

Where: crates/common/types/block.rs, transaction.rs.

The gate on the single-threaded unsync_cell::OnceCell moved from
all(feature = "eip-8025", target_arch = "riscv64") to
all(feature = "zisk", target_arch = "riscv64") when the eip-8025 feature was removed.

The guest ELFs were previously built --features "<zkvm>-build-elf,ci", which never enabled
eip-8025, so they compiled the atomic once_cell variant. bin/zisk/Cargo.toml does enable
ethrex-common/zisk, so the ZisK guest now compiles the unsafe impl Sync cell instead.
That changes the ELF bytes and therefore the program hash and verification key.

This is intended (the guest is single-threaded, so the unsync cell is sound and cheaper), but it
is a VK change rather than a no-op refactor, and the diffstat presents it as a file rename
(eip8025_cell.rs → unsync_cell.rs). Anyone pinning a ZisK VK across this change must
re-register it. The stateless-validator crate now forwards ethrex-common/zisk from its own
zisk feature so the two ZisK guests do not disagree on the cell type.


Release signing key is an unprotected repository secret

Where: .github/workflows/tag_release.yaml.

MINISIGN_SECRET_KEY is a plain repository secret. There is no environment: on
finalize-release or dry-run-release-assets, and gh api repos/lambdaclass/ethrex/rulesets
shows only branch-targeted rulesets, so the github.ref_type == 'tag' condition is a workflow
check rather than an enforced boundary: anyone who can push a tag can reach the signing key.

This is a repository-settings change, not a code change, so it is recorded here rather than
fixed in the tree. Recommended:

  1. Move MINISIGN_SECRET_KEY / MINISIGN_PASSWORD into a GitHub Environment with required
    reviewers, and add environment: to the two jobs that sign.
  2. Add a ruleset targeting refs/tags/v* restricting who may create release tags.

Until then, the compromise of that key is silent and durable: signatures would still verify
against the committed .github/minisign.pub.

@github-actions github-actions Bot removed the levm Lambda EVM implementation label Oct 14, 2026
@ethrex-project-sync ethrex-project-sync Bot moved this to In Review in ethrex_l1 Oct 14, 2026
@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Review: PR 7408 — seeded hasher for attacker-chosen map keys

I reviewed the diff only. I did not build it or run the tests. The approach is sound. Replacing FxHash with a per-process, per-map seeded foldhash for maps whose keys contracts or block producers choose closes a real hash-flooding gap. The ZEROING_WORD collision tests are good, and each one first asserts that the keys still collide under FxHash, so it fails loudly if rustc-hash changes. The remaining points are mostly things to check.

Points to check

  1. Iteration order is now non-deterministic across runs. FxHashMap iterated the same way on every run. SeededHashMap doesn't. GeneralizedDatabase::get_state_transitions (gen_db.rs, around line 580) iterates current_accounts_state to build the Vec<AccountUpdate>. added_storage is also iterated in store.rs:2836 and store.rs:2927.

    • Anything that depends on the order of those vectors or maps is affected. That includes witness or prover-input serialization, the Serialize impl on AccountUpdate, and test snapshots.
    • A grep of crates/l2 and crates/guest-program/src found no sorting or direct added_storage use, so I found no consumer that depends on order. Please confirm that for the zkVM guest inputs and the L2 state diff encoding. If any consumer does depend on order, sort at that boundary.
    • Consensus is unaffected as long as trie insertion is order-independent, which it should be.
  2. SeededHasher::write special-cases by length only (seeded_hash.rs, around lines 49–70).

    • Any Hash impl that issues a raw 32-byte or 20-byte write takes the word path. That is harmless for correctness, because equal keys still hash equal. The word path also keeps the seed mixed into every multiply, so the attack resistance holds. It does mean a 32-byte Bytes calldata key hashes differently from the same bytes under a different type, which is fine.
    • Consider a short comment saying the split_first_chunk fallthrough branches are unreachable for those lengths.
    • write_length_prefix is not overridden. It defaults to write_usize, which is forwarded, so this is fine.
  3. Per-map seed cost on hot paths. CallFrameBackup and Substate create SeededHashMaps for every call frame, and RandomState::default() runs on each one. foldhash's default is cheap, but FxHashMap::default() was free. Please confirm the benchmarks, for example a call-heavy workload, show no regression.

  4. zkVM guests now depend on foldhash through ethrex-common. Before this PR only levm used it, but levm is already in the guests, so that dependency was already there. Please confirm that foldhash::fast::RandomState seeding behaves under no_std/zkVM targets (sp1, openvm, zisk). I can't tell from the diff whether it gets runtime entropy or falls back to a fixed seed there. The fallback is harmless in a proving context, but it should be intentional.

  5. account.storage.into_iter().collect() in utils.rs rebuilds the whole map. That is fine for test and util paths, but make sure account_to_levm_account isn't on a hot path.

Minor

  • call_frame.rs: the doc comment "Both maps are seeded: the\n accounts and slots…" wraps awkwardly. Reflow it.
  • The account_state_cache in blockchain/vm.rs still uses Arc<RwLock<..>> around a randomly seeded map. That is fine.
  • The PrecompileCacheEntries outer map stays on FxHashMap keyed by Address. The precompile addresses are fixed, so this is OK.

The rest of the diff is mechanical type substitution and lockfile churn, and it looks consistent. The #[cfg(feature = "rayon")] gating on the prewarm imports is correct.


Automated review by Claude (Anthropic) · sonnet · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

Looks good overall: this PR is narrowly targeted, the threat model is well understood, and replacing unseeded FxHash* only where keys are adversary-controlled is the right fix. I did not spot an EVM-consensus, gas-accounting, trie, or RLP correctness regression in the touched logic.

  • No blocking issues found

    • The new SeededHashMap/SeededHashSet adoption is scoped to attacker-influenced keys such as addresses, slots, calldata, and BAL indices, while keccak-keyed caches remain on FxHash*; that separation is sound and avoids unnecessary perf regressions.
    • The custom SeededHasher::write fast paths in crates/common/seeded_hash.rs:45 preserve Hasher semantics for the touched key shapes and fall back to foldhash’s byte path for all other lengths.
    • The consensus-sensitive code paths I checked only change container hashers, not iteration order assumptions or encoding formats, so state-root / BAL / replay behavior should remain unchanged.
    • The regression coverage added in test/tests/levm/seeded_hashers_tests.rs:1 is strong and directly exercises the intended DoS shape.
  • Minor note

    • crates/common/seeded_hash.rs:45 relies on Hash implementations for H256/Address/U256 continuing to emit the same write patterns called out in the comment. That’s reasonable today, but it is a maintenance coupling rather than a bug. The tests mitigate it well.
  • Why I’m comfortable

    • crates/common/types/account_update.rs:15, crates/common/types/block_access_list.rs:600, crates/vm/levm/src/db/mod.rs:26, and crates/vm/levm/src/account.rs:20 all move attacker-chosen map keys onto seeded hashers in the hot paths most exposed to collision attacks.
    • crates/vm/levm/src/precompiles.rs:385 keeps the outer precompile map on FxHashMap and only seeds calldata-keyed inner maps, which matches the threat model and keeps lookups cheap.
    • crates/blockchain/prewarm.rs:554 and crates/blockchain/blockchain.rs:497 harden mempool/block-producer-controlled structures without altering trie-opening or merkleization logic.

If you want, I can do a second pass focused only on the LEVM-side touched structures and revert/rollback behavior.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

I'll review this PR which replaces FxHashMap/FxHashSet with a new SeededHashMap/SeededHashSet based on foldhash to mitigate hash collision attacks on attacker-controlled keys.

Security Assessment

Critical: Hash collision attack mitigation — well addressed. The PR correctly identifies that rustc-hash 2.x uses a public constant SEED2 that allows attackers to craft colliding keys for 17-32 byte inputs (storage slots, addresses, calldata). The foldhash-based replacement with per-process/per-map seeding and word-path optimization for fixed-size keys is sound.

Code Review

crates/common/seeded_hash.rs — Core Implementation

Line 64-82: write method has unreachable if let fallthrough bug

fn write(&mut self, bytes: &[u8]) {
    match bytes.len() {
        32 => {
            if let Some((lo, rest)) = bytes.split_first_chunk::<16>()
                && let Some(hi) = rest.first_chunk::<16>()
            {
                self.0.write_u128(u128::from_ne_bytes(*lo));
                self.0.write_u128(u128::from_ne_bytes(*hi));
                return;  // <-- returns on success
            }
        }  // <-- falls through to self.0.write(bytes) on failure!
        20 => { /* same pattern */ }
        _ => {}
    }
    self.0.write(bytes);  // <-- executed if split_first_chunk fails (impossible but unsound)
}

split_first_chunk::<16>() on a 32-byte slice and split_first_chunk::<16>() on the remaining 16 bytes, followed by first_chunk::<16>() — these are infallible for these exact lengths. The if let is misleading; if optimization fails, the code silently falls through to the byte path, which would produce different hash values for the same key depending on compiler optimization.

Fix: Use unwrap() or expect() with #[inline] to make the infallibility explicit, or restructure to avoid the fallthrough:

32 => {
    let (lo, rest) = bytes.split_first_chunk::<16>().expect("32 >= 16");
    let hi = rest.first_chunk::<16>().expect("16 >= 16");
    self.0.write_u128(u128::from_ne_bytes(*lo));
    self.0.write_u128(u128::from_ne_bytes(*hi));
}

Same issue at line 72-79 for the 20-byte path.

Line 57: #[derive(Default)] on SeededState — seeding concern

foldhash::fast::RandomState::default() seeds from the OS RNG. This is correct for security. However, Default is implemented via derive, which calls foldhash::fast::RandomState::default(). Verify this doesn't create reproducibility issues for debugging — acceptable as-is since determinism isn't required for this use case.

Line 44-45: Type aliases use std::collections::HashMap

Good — this preserves the standard library's hashbrown implementation with the custom hasher, maintaining O(1) average case with proper Robin Hood hashing.

crates/common/seeded_hash.rs — Tests

Line 170-172: hashes_colliding_byte_strings_apart test is incomplete

assert_eq!(distinct_hashes(&SeededState::default(), &long), long.len());

This only asserts the seeded side. The comment explains why FxHash isn't asserted (40-byte path differs), but the test should explicitly document this or use a different assertion pattern. Minor.

Line 186-193: equal_keys_hash_equal_on_both_paths test

Good sanity check for determinism within a single SeededState instance. However, it doesn't test that different SeededState instances produce different hashes (the per-map seeding property). Consider adding:

let state2 = SeededState::default();
assert_ne!(state.hash_one(key), state2.hash_one(key)); // probabilistic, may flake

Actually, this would be flaky. Skip.

crates/vm/levm/src/db/gen_db.rs — Type Alias Change

Line 30-33: CacheDB type alias changed

pub type CacheDB = SeededHashMap<Address, LevmAccount>;

This propagates to many call sites. The change from FxHashMap to SeededHashMap is correct here since transaction execution chooses addresses.

However, line 322-323 in new_with_account_state:

let levm_accounts: CacheDB = current_accounts_state
    .into_iter()
    .map(|(address, account)| { ... })
    .collect();

This collects from FxHashMap<Address, Account> into SeededHashMap. The .collect() works due to FromIterator, but there's a subtle performance hit: collecting into a HashMap with Default::default() hasher starts with default capacity. The original FxHashMap collection had the same issue, so no regression.

crates/vm/levm/src/db/mod.rs — CachingDatabase

Line 173-174: AccountCache and StorageCache type aliases

type AccountCache = SeededHashMap<Address, AccountState>;
type StorageCache = SeededHashMap<(Address, H256), U256>;

Correct: transactions choose which accounts and slots get cached.

Line 175: CodeCache remains FxHashMap<H256, Code>

Correct per the module documentation: "Code is keyed by its keccak hash, which nobody can steer into a bucket."

crates/vm/levm/src/environment.rs — TransientStorage

Line 15: TransientStorage changed from foldhash::HashMap to SeededHashMap

Wait — this was already using foldhash::HashMap directly. The change to SeededHashMap (which is std::collections::HashMap<K, V, SeededState>) is slightly different: foldhash::HashMap uses foldhash::fast::RandomState directly, while SeededHashMap uses the wrapper SeededState with the custom write method.

This is actually an improvement — the custom write method optimizes 20/32-byte keys through the word path. However, verify that foldhash::HashMap<(Address, U256), U256> and SeededHashMap<(Address, U256), U256> have compatible APIs. They should, both being HashMap with RandomState-like builders.

crates/vm/levm/src/precompiles.rs — PrecompileCache

Line 384-385: Inner map changed to SeededHashMap

map: FxHashMap<Address, SeededHashMap<Bytes, (Bytes, u64)>>,

Outer map keyed by precompile address (fixed, attacker can't control) — correctly stays FxHashMap. Inner map keyed by calldata — correctly changed to SeededHashMap.

crates/vm/levm/src/vm.rs — AccessedAddresses/StorageSlots

Line 50-55: New type aliases

pub type AccessedAddresses = SeededHashSet<Address>;
pub type AccessedStorageSlots = SeededHashMap<Address, SeededHashSet<H256>>;

Good. These are used for EIP-2929 warm/cold gas accounting. Attackers could exploit collisions to make cold accesses appear warm, stealing gas. The seeded hasher closes this.

Line 96-97: Substate fields

accessed_addresses: AccessedAddresses,
accessed_storage_slots: AccessedStorageSlots,

Correct.

crates/blockchain/blockchain.rs — WorkerRequest and Merkleization

Line 500: storage: SeededHashMap<H256, U256>

Storage slots chosen by contracts — correct.

Line 943: optimistic_updates: Option<SeededHashMap<Address, BalSynthesisItem>>

Addresses from block producer's BAL — correct.

Line 1443: hashed_address_cache: SeededHashMap<Address, H256>

Addresses chosen by transactions — correct.

crates/blockchain/prewarm.rs — Prewarming Caches

Line 405-406: walked_accounts and walked_slots

let mut walked_accounts: SeededHashMap<Address, (H256, H256)> = Default::default();
let mut walked_slots: SeededHashSet<(Address, H256)> = Default::default();

Correct — pending transactions choose these keys.

crates/blockchain/vm.rs — ReplayedVmDatabase

Line 304: updates: Arc<SeededHashMap<Address, AccountUpdate>>

Correct — replayed call chooses addresses.

Line 484: AccountStateCache type alias

type AccountStateCache = SeededHashMap<Address, Option<AccountStateCacheEntry>>;

Correct — executing transactions choose addresses.

crates/common/types/account_update.rs — added_storage

Line 14: pub added_storage: SeededHashMap<H256, U256>

Correct — contracts choose slot keys.

crates/common/types/block_access_list.rs — Multiple changes

Line 600: addr_to_idx: SeededHashMap<Address, usize>

Block producer chooses addresses — correct.

Line 606: slot_idx_by_account: Vec<SeededHashMap<H256, usize>>

Contracts choose slots — correct.

Line 856: tx_initial_storage: SeededHashMap<(Address, U256), U256>

Correct.

crates/vm/backends/levm/mod.rs — validate_tx_execution signature change

Line 2262: current_state: &CacheDB parameter

Previously &FxHashMap<Address, LevmAccount>, now &CacheDB (which is SeededHashMap<Address, LevmAccount>). This is a semantic change in the function signature but equivalent in behavior since CacheDB is the type alias.

Wait — checking the call sites in test/tests/blockchain/bal_validate_tx_execution_tests.rs:

Line 129-130 and similar:

let mut current_state = CacheDB::default();

Previously FxHashMap<Address, LevmAccount>. The test changes look correct.

Test Files — bal_validate_tx_execution_tests.rs

Multiple lines: &Default::default() for pure_reads and tx_initial

These were previously &FxHashSet::default() and &FxHashMap::default(). Now &Default::default() with type inferred from context. This is idiomatic Rust but verify the type inference works — it should since the function parameter types are explicit.

Line 847: let mut tx_initial: SeededHashMap<(Address, U256), U256> = Default::default();

Explicit type annotation required here since it's passed to a function with multiple Default bounds? Actually no, the function parameter is explicit. The annotation is for clarity — acceptable.

test/tests/levm/seeded_hashers_tests.rs — New comprehensive tests

Line 243: calldata_and_slot_tuples_collide_under_fxhash

Good sanity check that the attack keys actually collide under FxHash.

Line 166-167: call_frame_backup_hashes_colliding_keys_apart

Tests both original_accounts_info and original_account_storage_slots. The nested map test is thorough.

Cargo.toml Changes — Dependency Movement

crates/common/Cargo.toml line 41: foldhash.workspace = true added

crates/vm/levm/Cargo.toml line 24: foldhash.workspace = true removed

Correct — foldhash is now only needed in ethrex-common where SeededState is defined. Other crates use SeededHashMap/SeededHashSet from ethrex-common.

tooling/ef_tests/state_v2/Cargo.toml line 28: rustc-hash = "2.1.1" removed

Good cleanup — was likely unused or now replaced.

Issues Found

1. Critical: seeded_hash.rs write() fallthrough bug (lines 64-82)

The if let chains for 32-byte and 20-byte paths have unreachable fallthrough to self.0.write(bytes) if the chunk splitting fails. While infallible for these exact lengths, this is a code smell and potential future hazard. Use unwrap() or direct indexing.

2. Moderate: u128::from_ne_bytes endianness assumption

Line 66: u128::from_ne_bytes(*lo) — this assumes native endianness matches foldhash's expectation. foldhash's write_u128 takes a u128 value; the byte-to-integer conversion must match how foldhash interprets the bytes. Since foldhash's integer path is designed for integer values (not byte strings), and the original byte string's hash should be preserved, this needs care.

Actually, looking at foldhash's implementation: write_u128 mixes the integer value directly. For a 32-byte key that represents an H256 or U256, we want the hash to depend on all bits. Using from_ne_bytes treats the byte sequence as a native-endian integer. Since H256/U256 are Ethereum types with defined byte layouts, and the goal is collision resistance (not cross-platform hash consistency), native endian is fine — the seed makes it unpredictable anyway.

However, cross-platform determinism for the same seed is lost. Two machines with different endianness would produce different hashes for the same key with the same SeededState. This is acceptable for a per-process seed but should be documented.

3. Minor: Missing write_i* methods on SeederHasher

The Hasher trait also has write_i8, write_i16, write_i32, write_i64, write_i128, and write_isize. These delegate to the unsigned variants by default in the trait, so they're covered. No issue.

4. Minor: SeededHasher doesn't implement Debug

The outer SeededState derives Debug, but SeededHasher (line 54) doesn't. This is fine since hasher instances are typically short-lived.

Performance Considerations

  1. Word path for 20/32-byte keys: The optimization to use write_u128/write_u32 instead of write(bytes) reduces multiplies from 4 to 2 for 32 bytes. This is significant for storage-heavy workloads.

  2. Per-map seeding overhead: Each SeededHashMap::default() creates a new RandomState with OS RNG seeding. For hot paths creating many small maps, consider if with_hasher with a cached state would help. The current usage patterns (long-lived caches, per-block structures) seem fine.

  3. CacheDB type alias change: No runtime overhead change — same HashMap structure, different hasher.

Consensus Safety

No consensus-critical changes detected. The hasher change affects performance and DoS resistance, not EVM semantics. Hash values are not part of consensus (state roots use Keccak-256, not hash map hashes).

Summary

Severity Issue Location
Moderate write() fallthrough pattern — use explicit unwrap crates/common/seeded_hash.rs:64-82
Minor Document endianness assumption crates/common/seeded_hash.rs module docs
Minor hashes_colliding_byte_strings_apart incomplete assertion crates/common/seeded_hash.rs:170

The PR is well-structured, thoroughly tested, and addresses a genuine security concern. The main fix needed is making the write() method's infallible paths explicit rather than using if let with silent fallthrough.


Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt

@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Lines of code report

Total lines added: 322
Total lines removed: 3
Total lines changed: 325

Detailed view
+-------------------------------------------------------+-------+------+
| File                                                  | Lines | Diff |
+-------------------------------------------------------+-------+------+
| ethrex/crates/blockchain/blockchain.rs                | 3706  | -1   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/blockchain/prewarm.rs                   | 589   | +2   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/blockchain/vm.rs                        | 1278  | +20  |
+-------------------------------------------------------+-------+------+
| ethrex/crates/common/common.rs                        | 22    | +1   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/common/seeded_hash.rs                   | 137   | +137 |
+-------------------------------------------------------+-------+------+
| ethrex/crates/common/types/block_access_list.rs       | 1749  | +1   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/common/types/block_execution_witness.rs | 934   | +2   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/storage/block_data_buffer.rs            | 144   | +1   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/storage/layering.rs                     | 1201  | +30  |
+-------------------------------------------------------+-------+------+
| ethrex/crates/storage/store.rs                        | 6228  | +2   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/vm/backends/levm/mod.rs                 | 4194  | +64  |
+-------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/runner/src/main.rs              | 285   | +3   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/db/gen_db.rs                | 799   | -2   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/db/mod.rs                   | 426   | +39  |
+-------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/environment.rs              | 101   | +1   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/precompiles.rs              | 1334  | +16  |
+-------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/vm.rs                       | 2217  | +3   |
+-------------------------------------------------------+-------+------+

@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Benchmark Results Comparison

Benchmark Results: MstoreBench

Command Mean [s] Min [s] Max [s] Relative
main_revm_MstoreBench 288.2 ± 5.3 281.6 298.5 1.60 ± 0.05
main_levm_MstoreBench 207.5 ± 81.2 178.4 438.3 1.15 ± 0.45
pr_levm_MstoreBench 180.3 ± 4.0 176.2 189.2 1.00
Detailed Results

Benchmark Results: BubbleSort

Command Mean [s] Min [s] Max [s] Relative
main_revm_BubbleSort 3.255 ± 0.032 3.224 3.334 1.12 ± 0.02
main_levm_BubbleSort 2.922 ± 0.050 2.867 3.011 1.01 ± 0.02
pr_revm_BubbleSort 3.255 ± 0.028 3.231 3.321 1.13 ± 0.02
pr_levm_BubbleSort 2.893 ± 0.045 2.847 2.997 1.00

Benchmark Results: ERC20Approval

Command Mean [s] Min [s] Max [s] Relative
main_revm_ERC20Approval 1.059 ± 0.054 1.037 1.213 1.07 ± 0.06
main_levm_ERC20Approval 1.006 ± 0.033 0.980 1.093 1.02 ± 0.03
pr_revm_ERC20Approval 1.076 ± 0.093 1.039 1.340 1.09 ± 0.09
pr_levm_ERC20Approval 0.987 ± 0.007 0.979 1.002 1.00

Benchmark Results: ERC20Mint

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Mint 143.6 ± 5.9 140.3 158.4 1.04 ± 0.04
main_levm_ERC20Mint 138.6 ± 1.5 137.1 141.6 1.00
pr_revm_ERC20Mint 142.9 ± 1.8 141.1 146.2 1.03 ± 0.02
pr_levm_ERC20Mint 140.9 ± 8.2 136.9 163.7 1.02 ± 0.06

Benchmark Results: ERC20Transfer

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Transfer 251.6 ± 5.1 247.3 261.1 1.03 ± 0.02
main_levm_ERC20Transfer 246.3 ± 1.4 243.3 247.9 1.01 ± 0.01
pr_revm_ERC20Transfer 255.1 ± 7.4 247.3 266.5 1.04 ± 0.03
pr_levm_ERC20Transfer 244.2 ± 1.9 242.4 249.2 1.00

Benchmark Results: Factorial

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Factorial 236.0 ± 5.6 231.1 245.3 1.00
main_levm_Factorial 246.1 ± 1.6 244.0 249.2 1.04 ± 0.03
pr_revm_Factorial 239.1 ± 4.5 231.1 246.5 1.01 ± 0.03
pr_levm_Factorial 247.4 ± 1.6 244.0 249.0 1.05 ± 0.03

Benchmark Results: FactorialRecursive

Command Mean [s] Min [s] Max [s] Relative
main_revm_FactorialRecursive 1.709 ± 0.029 1.651 1.745 1.01 ± 0.03
main_levm_FactorialRecursive 9.585 ± 0.048 9.538 9.695 5.65 ± 0.13
pr_revm_FactorialRecursive 1.695 ± 0.039 1.618 1.745 1.00
pr_levm_FactorialRecursive 9.580 ± 0.043 9.504 9.655 5.65 ± 0.13

Benchmark Results: Fibonacci

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Fibonacci 232.9 ± 6.7 225.7 246.6 1.09 ± 0.03
main_levm_Fibonacci 213.1 ± 0.9 211.8 214.6 1.00
pr_revm_Fibonacci 236.6 ± 9.1 225.8 247.4 1.11 ± 0.04
pr_levm_Fibonacci 214.2 ± 3.8 211.4 221.4 1.01 ± 0.02

Benchmark Results: FibonacciRecursive

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_FibonacciRecursive 913.7 ± 13.7 900.9 937.1 1.35 ± 0.03
main_levm_FibonacciRecursive 677.3 ± 8.9 667.7 693.6 1.00
pr_revm_FibonacciRecursive 910.5 ± 6.8 898.7 923.7 1.34 ± 0.02
pr_levm_FibonacciRecursive 694.3 ± 49.5 666.1 813.1 1.03 ± 0.07

Benchmark Results: ManyHashes

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ManyHashes 9.1 ± 0.1 9.0 9.4 1.00 ± 0.02
main_levm_ManyHashes 10.2 ± 0.2 10.0 10.5 1.12 ± 0.03
pr_revm_ManyHashes 9.1 ± 0.2 9.0 9.5 1.00
pr_levm_ManyHashes 10.2 ± 0.2 9.9 10.6 1.12 ± 0.03

Benchmark Results: MstoreBench

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_MstoreBench 288.2 ± 5.3 281.6 298.5 1.60 ± 0.05
main_levm_MstoreBench 207.5 ± 81.2 178.4 438.3 1.15 ± 0.45
pr_revm_MstoreBench 282.0 ± 2.9 278.2 287.5 1.56 ± 0.04
pr_levm_MstoreBench 180.3 ± 4.0 176.2 189.2 1.00

Benchmark Results: Push

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Push 323.1 ± 5.0 316.5 334.4 1.31 ± 0.02
main_levm_Push 251.4 ± 12.3 245.3 285.6 1.02 ± 0.05
pr_revm_Push 321.6 ± 5.5 316.0 331.2 1.30 ± 0.03
pr_levm_Push 247.2 ± 2.7 243.7 251.6 1.00

Benchmark Results: SstoreBench_no_opt

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_SstoreBench_no_opt 178.8 ± 2.8 174.3 181.5 2.22 ± 0.04
main_levm_SstoreBench_no_opt 80.5 ± 0.5 79.8 81.3 1.00 ± 0.01
pr_revm_SstoreBench_no_opt 177.6 ± 2.5 174.4 181.2 2.21 ± 0.03
pr_levm_SstoreBench_no_opt 80.4 ± 0.5 80.0 81.4 1.00

ilitteri and others added 10 commits October 14, 2026 13:45
…mismatch field wider than rustfmt's line limit, and the tooling workspace is formatted separately from the root one.
… the host witness path and in the guest. get_state_transitions lists the updates in CacheDB iteration order, which is now seeded per map, so the host recorded the witness under one order while the guest replayed under another. A removal that leaves a branch with one child reads that child, and an insert into that branch first avoids the read: a host that inserted first produced a witness that a guest removing first could not replay, and proving failed with a missing node. With FxHash both sides built the list in the same order by construction. apply_account_updates_from_trie_with_witness and GuestProgramState::apply_account_updates now sort by address before applying, so the two sides touch the same nodes whatever order the lists come in. The new test records a witness under each input order and replays it on a guest applying each input order, once with the removed account sorting first and once with the inserted one first, so it fails if either side stops sorting. Storage tries were already safe: the guest applies inserts before deletes, which reads a subset of the nodes any interleaving reads.
…ss accounts its block never touched. Both checklists are seeded sets, so iter().next() named a different entry on every node and every run for the same invalid block. min() is only reached once the block has already failed, so valid blocks pay nothing. Two tests inject 256 extraneous reads and 256 extraneous accounts and expect the lowest of each. The levm runner's state diff and state_v2's storage mismatch report also sort what they print, by address and by slot.

The per-transaction checks in validate_tx_execution still return the first offender their maps yield. Every BAL rejection maps to the same exception class, so this changes only which entry a message names, and fixing it there would cost a sort on every transaction of a valid block.
…them on FxHash. A keccak output is no safer as a key than its preimage: a deployer can grind trailing bytecode until the code hash lands in a chosen bucket, at about one keccak per bucket for each key, and a contract deployed once can be called again for 100 gas, with every call looking its code up in GeneralizedDatabase::codes. codes, code_metadata, the CachingDatabase code cache and its prefetch dedup set, StoreVmDatabase's batch read map and the witness's code dedup set now hash with SeededState. None of them is iterated, so their order never leaves the process. created_accounts and selfdestruct_set stay on FxHash, since each entry costs a CREATE or a SELFDESTRUCT. The seeded_hash module docs now say so, note that a walk over a seeded map must be sorted before its order leaves the process, and spell out what zkVM guests get: foldhash finds no clock or address randomization there, so its seeds follow from the guest program and its input.

The tests no longer assert that keys collide under FxHash, which tied them to rustc-hash's SEED2 and would fail on a rustc-hash release with nothing wrong in production. seeded_hash's own tests check that H256 and U256 keys varying in either half, addresses varying in either part, and byte strings on both paths spread across 4096 buckets, which catches a hasher that drops part of a key where the old 64-bit distinctness check did not. The per-map tests check that each map hashes a key differently from a fresh instance of its hasher, which no unseeded hasher does, and replace the five copies of the key-crafting helpers; the StoreVmDatabase test reads the real account_state_cache field instead of a fresh AccountStateCache, and the test that only exercised FxHash is gone. SeededHasher::write branches on the lengths it handles instead of falling through arms that cannot run.
… witness paths. generate_witness_from_account_updates, which the block import pipeline runs when it also collects a witness, still deduplicated with an FxHashSet after the previous commit seeded the same set in generate_witness_for_blocks_with_fee_configs. The logger records one entry per code read and a deployer can grind its code hash into a chosen bucket, the same reason the other code-hash maps are seeded. Both functions now call unique_code_hashes, which keeps each hash once in first-read order; a unit test pins that order.
…eeded the per-block ones. CodeCache, code_metadata_cache and BlockDataBuffer.codes still hashed with FxHash, and they outlive the block that fills them: contracts whose code hashes a deployer ground into one bucket stay cached across blocks, and every block that loads one of them for 2600 gas probes all the others while holding the code cache's lock. The per-block maps sit in front of these caches and do not shield them, since each block loads every contract it touches from the Store once. All three now hash with SeededState. The random eviction picks a victim by IndexMap position, which follows insertion order rather than the hasher, and the one walk over BlockDataBuffer.codes feeds database writes keyed by hash, so no order leaves the process.
…t. It had been inserted between that comment and its function, so rustdoc gave unique_code_hashes the comment about attaching the stateless validator, and attach_stateless_validator lost the explanation of why it must stay out of Evm's constructor.
…alk seeded state. validate_tx_execution's execution-to-BAL pass, validate_bal_withdrawal_index's DB-to-BAL pass and validate_bal_covers' backstop walked CacheDB and each account's storage and returned the first mismatch they met, so an invalid block with several named a different one on every node and every run. They now go through first_failure_by_key, which walks the map once in its own order and, only if something failed, sorts the keys and walks again, returning the failure of the lowest key. The per-slot loops go through it too, so a message names the lowest account and then its lowest slot. A valid block still walks each map once with no sort, which is what the previous commit expected this fix to cost. The checks only read, so the second walk sees what the first did.

Three unit tests cover the helper: a failing map names its lowest failure even behind a passing key, a passing map is walked once, and nested maps name the lowest outer key and then the lowest inner one. Two validate_tx_execution tests inject 255 accounts absent from the BAL and 255 unrecorded storage writes and expect the lowest of each; both fail if the helper stops sorting.
…of an account's storage changes. added_storage is a seeded map and the host applies it in that map's order, so the order it records the witness under now changes on every run; the guest applies its inserts before its deletes, which must read a subset of the nodes any host order reads. host_witness now records the storage nodes as well and builds the witness the way the RPC path does, through RpcExecutionWitness::into_execution_witness. Two scenarios run every host order against every guest order, each forced by rebuilding the map until it iterates in that order: a delete and an insert in one branch, which fails if the guest stops putting inserts first, and two deletes from one branch, which pins that deletes read the same nodes in either order.
… filters in ethrex-storage. Their keys are the trie paths a block's state writes touch, which a contract picks with ordinary stores, and a cold read probes each layer's map once for up to 128 layers on every block while the layers live, so a crafted set of paths would be paid for once and probed repeatedly. The maps the storage crate still keeps on FxHash are keyed by block hashes, transaction hashes, block numbers and state roots, each of which costs a block or a transaction to produce, or belong to the in-memory backend that only serves the memory datadir. A unit test in layering.rs checks that paths sharing one FxHash bucket are hashed apart by the layer and overlay maps, so reverting either hasher fails it.
@ilitteri
ilitteri added this pull request to the merge queue Oct 15, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to Branch Protection failures Oct 15, 2026
You're not authorized to push to this branch. Visit "About protected branches" for more information.
ilitteri added a commit that referenced this pull request Oct 15, 2026
@ilitteri
ilitteri added this pull request to the merge queue Oct 16, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to Branch Protection failures Oct 16, 2026
You're not authorized to push to this branch. Visit "About protected branches" for more information.
@ilitteri
ilitteri merged commit 077b83a into main Oct 16, 2026
74 checks passed
@ilitteri
ilitteri deleted the fix/seed-evm-keyed-hashers branch October 16, 2026 19:41
@github-project-automation github-project-automation Bot moved this from In Review to Done in ethrex_l1 Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L1 Ethereum client

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants