fix: check each RPC transaction hash against its own encoding in verify_block_integrity - #196
Conversation
Deletes the second sources of truth and the dead parameters/accessors the /simplify review found, with no observable change on canonical-block paths. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CHVpMMX9N69sUNbuKgpBVY
Deletes the `writer` parameter threaded through `validate_block`, `validate_block_deriving_updates`, `replay_block` and `verify_and_replay`, and with it EIP-3155 trace output. A feature deletion, not a cleanup. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CHVpMMX9N69sUNbuKgpBVY
…s_used from the executor Both are definitional rewrites of security-critical derivations, argued in the PR body: `trie_hash()` is `keccak256(encoded_2718())`, and mega-evm's `gas_used` is the last receipt's cumulative gas. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CHVpMMX9N69sUNbuKgpBVY
Claude review status
✅ Review clean Last reviewed: New this round: 0 finding(s), 0 question(s) · Resolved this round: 1 · Open questions: 0 |
Codecov Report✅ All modified and coverable lines are covered by tests. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e62e3e14ae
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…code PR #195 was squash-merged as 7ca2253; this branch carried the pre-squash snapshots of #194 and #195, which had drifted from their final form during review. Ten of the twelve conflicted files are untouched by this PR's own commit and were resolved to main's version. The other two (executor.rs, rpc_client.rs) were taken from main with this PR's hunks re-applied on top: #195's final form inlined `execute_transactions` back into `replay_block`, so the `gas_used` change now lives there. The merge result differs from main by exactly this PR's diff. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… doc comment back The new `replayed_gas_used_matches_the_mainnet_header` test was inserted between the neighbouring test's doc comment and its `#[test]`, so both comments attached to the new test and the old one lost its documentation. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…n-hash check `verify_replay_outputs` already fails a block whose replayed gas differs from the header's claim, on every real block and every mainnet fixture, so the debug assertion, its argument and the third full-fixture replay test were lower-altitude restatements of it. Drop all three and document what the existing fixture test pins. `verify_block_integrity` spelled out the trait's `encoded_2718()` by hand; call it. Its old `trie_hash() == *hash()` check was a tautology — `Signed` and `Sealed` return the cached hash the deserializer seeded from the RPC `hash` field — so the keccak over the envelope's own encoding is the first real check of that field. Say so in the comment and add a forged-hash test that the transactions-root check alone cannot catch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
❓ Review complete — 1 open question(s)
Reviewed 015aa8af..c0ac4b31.
Incremental review of the verify_block_integrity single-encode rewrite (with a new forged-hash regression test) and the gas_used sourcing change in replay_block.
Open questions — answer them in a reply on this PR. Each one is marked answered here once a later review round confirms the answer, so this list stays current:
✅ **Answered** — Head 5c39a74 reverts the executor.rs hunk in full: gas_used is again `receipts.last().map(...).cumulative_gas_used()` at executor.rs:496, so the receipt-chain equivalence is inherent and no debug_assert pin is needed — the `drop the hunk` resolution vincent-k2026 recommended on 2026-09-12.
- The PR description's "Pinned two ways" section still promises a
debug_assert_eq!at thegas_usedderivation site and a dedicatedreplayed_gas_used_matches_the_mainnet_headerfixture test, but both were removed in the current head — the surviving pin isverify_replay_outputs(output.gas_used == header.gas_used) exercised byvalidate_block_mainnet_fixtures. Was dropping the receipt-chain-equivalence pin intentional, or should either the description or the pin be restored? - Why it matters: The removed
debug_assert_eq!was the only check that would catch a mega-evm change whereexecution_result.gas_usedstops equalingreceipts.last().cumulative_gas_used()while still matching the header — a signal of upstream derivation drift, even when it does not surface as a consensus failure. If the description reflects the intended safety net, either it or the code should be aligned before landing. - How to verify: Look at
crates/stateless-core/src/executor.rs:497— onlylet gas_used = execution_result.gas_used;remains — and search the repo forreplayed_gas_used_matches_the_mainnet_header(no hits outside.pr-review/). Confirm whether the author still considersverify_replay_outputsthe single pin, and update the PR description accordingly, or re-add the internaldebug_assert_eq!pin.
|
Checked both claims against the upstream sources (alloy-consensus / alloy-eips 1.1.2, alloy-evm's trait defaults, mega-evm Part 1: the tautology is realVerified each link:
So the old check compared the provider's claim with itself, and the transactions-root check can't cover for it since the root comes from the bytes.
Part 2: equivalent, but the change buys nothingOne link the description skips: the call site is With that, the equivalence holds — My problem is the ROI:
On dropping the Suggestion: either drop this hunk (the old line is the spec written out) or keep it and restore the assert. I'd lean to dropping it — part 1 is a clean fix and this is the only hunk in the PR that needs an argument at all. Small stuff
|
…sert the forged-hash variants - crates/stateless-core/src/executor.rs returns to the base version: the header check reads the last receipt's cumulative gas, the consensus definition, instead of the executor's self-reported field - verify_block_integrity binds the Recovered<_> under the two RPC wrappers by name and calls the signer recovered_signer - verify_block_integrity_rejects_a_forged_transaction_hash asserts index 0 is a deposit and the last index is not, so both trie_hash overrides stay covered Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Dropped the Small items, same commit:
|
|
Answering the open question: the |
There was a problem hiding this comment.
0 blocking · 0 should-fix · 1 suggestion(s) · 0 open question(s)
Reviewed head 5c39a74a.
Findings without inline anchors:
crates/stateless-core/src/executor.rs:496— [Minor] PR description and title advertise a revertedgas_usedchange Anyone reading the PR (and the squash-merge commit that inherits body+title) is told a second consensus-critical derivation moved; reviewers scanning the description look for a safety story for a change that no longer ships, and the audit trail misstates what actually landed. Suggested fix: Drop §2 and the, read gas_used from the executorclause from the title (shorten tofix: check each RPC transaction hash against its own encoding in verify_block_integrity). If the executor hunk is instead intended to ship here, restore it and re-anchor the receipt-chain equivalence pin.
Resolves what main's squash-merges of #194, #195 and #196 left conflicting against this branch's own copies of that work. Every file outside this PR's two commits is taken from main verbatim, so the branch keeps only the reviewed form of those refactors and the PR diff narrows to the perf work. - verify_block_integrity: main's #196 form wins outright. It is this branch's "encode each transaction once" rewrite plus the per-transaction keccak check and its forged-hash test, so the PR no longer carries that hunk at all. The blocking-pool wrapper around it is unchanged. - executor.rs: main's inlined replay body, with this branch's `execution_result.gas_used` + `debug_assert_eq!` re-applied on top; the `execute_transactions` extraction is dropped, as it was during #194's review. - rpc_client.rs test imports: union of main's `TestFixtures` and this branch's `consistent_header`. The merged tree is origin/main plus exactly the deltas of e62e3e1 and 432e550. Cargo.lock is untouched; check, clippy, fmt, sort and the full test suite are green.
Summary
PR 3/6 of the #170 split, stacked on #195. One rewrite of a security-critical check, separated from the cleanup so it gets argued on its own. It turned out to be a fix rather than a perf change: the check it replaces was a tautology.
verify_block_integritychecks the claimed transaction hash against the envelope's own encodingBefore:
tx.inner.clone().into_inner().trie_hash() == *hash()per transaction, then a second clone insideordered_trie_root_with_encoderfor the transactions root.That hash check never checked anything.
Encodable2718::trie_hash()defaults tokeccak256(encoded_2718()), but every variant in play overrides it:Signed<T>::trie_hashreturns*self.hash()(alloy-consensus 1.1.2src/signed.rs:515),Sealed<T>::trie_hashreturnsself.hash()(alloy-eips 1.1.2src/eip2718.rs:327), andOpTxEnvelope'sTransactionEnvelopederive forwards per variant. The deserializer seeds that cached hash from the RPChashfield throughnew_unchecked(src/signed.rs:583), so the old predicate compared the provider's claim with itself. A provider returning the right transaction bytes under a wronghashpassed verification, and the transactions-root check cannot catch that because the root is computed from the bytes.After: each envelope is encoded once via
encoded_2718();keccak256of those bytes is checked against the claimed hash, and the same bytes feed the ordered trie (crates/stateless-common/src/rpc_client.rs:1587,:1609). The two clones per transaction are gone and the keccak is new work, the first real check of the field. The other half of the trade is memory:encoded_txskeeps every transaction's encoding resident until the trie root finishes, where the old encoder closure reused one scratch buffer per item — one wire-size copy per transaction, on top of the parsedBlock<Transaction>that is resident anyway.Pinned:
verify_block_integrity_rejects_a_forged_transaction_hash(rpc_client.rs:3001) forges thehashof the first and last transaction of a mainnet fixture block through a JSON round trip and asserts the block is rejected, with the transactions root left intact so only the hash check can reject. It asserts index 0 is the deposit (Sealedoverride) and the last index a signed envelope (Signedoverride), so a fixture change cannot silently drop either path.Testing
cargo fmt --all --check,cargo clippy --workspace --all-targets --all-features(0 warnings),cargo sort --check, full workspace suite 481 passed / 0 failed,cargo test -p stateless-core --no-default-features --lib --no-runclean.The positive path is covered by the validator's mock-RPC integration test, which fetches every fixture block through the verifying path.