fix: check each RPC transaction hash against its own encoding in verify_block_integrity, read gas_used from the executor - #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
❓ 1 open question(s) Last reviewed: New this round: 0 finding(s), 1 question(s) · Resolved this round: 0 · Open questions: 1 Open questions awaiting an answer:
|
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:
❓ **Open question · Low confidence**
- 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.
Summary
PR 3/6 of the #170 split, stacked on #195. Two rewrites of security-critical derivations, separated from the cleanup so each gets argued on its own. The first turned out to be a fix rather than a perf change: the check it replaces was a tautology.
vincent's review of #170 on the second one: "This is the one I would most like to see argued explicitly… If they can disagree, this is a consensus divergence. Please state why they are equivalent and add an assertion test pinning it."
1.
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:1578). The two clones per transaction are gone; the keccak is new work and the first real check of the field.Pinned:
verify_block_integrity_rejects_a_forged_transaction_hash(rpc_client.rs:2997) forges thehashof the first (deposit,Sealed) and last (Signed) transaction of a mainnet fixture block through a JSON round trip and asserts the block is rejected. Under the old predicate the forged block verifies — checked by reverting the comparison locally and watching the test fail.2.
gas_usedcomes fromBlockExecutionResult::gas_usedBefore:
execution_result.receipts.last().map(|r| r.cumulative_gas_used()).unwrap_or(0).After:
execution_result.gas_used(crates/stateless-core/src/executor.rs:497).Equivalence, against the pinned mega-evm (
v1.7.0,30ce038,crates/mega-evm/src/block/executor.rs,MegaBlockExecutor::finish):Character for character the expression the header check read before, and
finish()computes it aftercommit_system_call_outcomes, so MegaETH's system transactions are accounted identically on both sides. There is only one derivation.Pinned where the contract lives.
verify_replay_outputs(executor.rs:622) fails any block whose replayedgas_useddiffers from the header's claim, in release, on every real block, andvalidate_block_mainnet_fixturesruns it over every paired mainnet fixture; a mega-evm that starts accounting gas outside the receipt chain fails there. An earlier revision of this PR also carried adebug_assert_eq!at the derivation site and a third full-fixture replay test asserting the same field; both were dropped as lower-altitude restatements of that check (the assertion could only ever fire on a correct upstream redefinition, which the header check is what would then adjudicate).Testing
cargo fmt --all --check,cargo clippy --workspace --all-targets --all-features(0 warnings),cargo sort --check, full workspace suite 481 passed / 0 failed (new:verify_block_integrity_rejects_a_forged_transaction_hash),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.