diff --git a/Cargo.lock b/Cargo.lock index 57ad742b..4fc88aee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5691,6 +5691,7 @@ dependencies = [ "rolling-file", "salt", "serde", + "serde_json", "stateless-core", "stateless-r2", "stateless-test-utils", diff --git a/crates/stateless-common/Cargo.toml b/crates/stateless-common/Cargo.toml index bb2ea816..0d27b893 100644 --- a/crates/stateless-common/Cargo.toml +++ b/crates/stateless-common/Cargo.toml @@ -51,6 +51,7 @@ zstd.workspace = true [dev-dependencies] jsonrpsee.workspace = true kanal.workspace = true +serde_json.workspace = true stateless-test-utils = { path = "../stateless-test-utils", features = ["mock-rpc"] } tokio-util.workspace = true diff --git a/crates/stateless-common/src/rpc_client.rs b/crates/stateless-common/src/rpc_client.rs index 6a480bc5..6aa2d875 100644 --- a/crates/stateless-common/src/rpc_client.rs +++ b/crates/stateless-common/src/rpc_client.rs @@ -40,7 +40,7 @@ use std::{ time::{Duration, Instant}, }; -use alloy_primitives::{B256, Bytes, U64}; +use alloy_primitives::{B256, Bytes, U64, keccak256}; use alloy_provider::{Provider, RootProvider}; use alloy_rpc_client::ClientBuilder; use alloy_rpc_types_eth::{Block, BlockId, BlockNumberOrTag, Header}; @@ -1575,13 +1575,19 @@ fn verify_block_integrity(block: &Block) -> Result<()> { // Verify transaction hashes and transactions root if let BlockTransactions::Full(ref transactions) = block.transactions { + // The RPC `hash` field seeds the envelope's cached hash, so it is only trusted once keccak + // of the envelope's own encoding reproduces it; the same bytes then feed the ordered trie + // for the transactions-root check. + let mut encoded_txs = Vec::with_capacity(transactions.len()); for tx in transactions { - let tx_envelope = tx.inner.clone().into_inner(); + let tx_envelope = tx.inner.inner.inner(); + let encoded = tx_envelope.encoded_2718(); + let computed_hash = keccak256(&encoded); ensure!( - tx_envelope.trie_hash() == *tx_envelope.hash(), + computed_hash == *tx_envelope.hash(), "Transaction hash mismatch: expected {:?}, computed {:?}", tx_envelope.hash(), - tx_envelope.trie_hash() + computed_hash ); let recovered = tx_envelope @@ -1594,10 +1600,11 @@ fn verify_block_integrity(block: &Block) -> Result<()> { tx.from(), recovered ); + encoded_txs.push(encoded); } - let computed_tx_root = ordered_trie_root_with_encoder(transactions, |tx, buf| { - tx.inner.clone().into_inner().encode_2718(buf) + let computed_tx_root = ordered_trie_root_with_encoder(&encoded_txs, |tx_bytes, buf| { + buf.extend_from_slice(tx_bytes) }); ensure!( computed_tx_root == block.header.transactions_root, @@ -1629,7 +1636,10 @@ mod tests { find_divergence_point, pipeline::{BlockFetcher, DivergenceLookups}, }; - use stateless_test_utils::mock_rpc::{header_stub, parse_hex_u64, serve}; + use stateless_test_utils::{ + fixtures::TestFixtures, + mock_rpc::{header_stub, parse_hex_u64, serve}, + }; use tokio_util::sync::CancellationToken; use super::*; @@ -2977,4 +2987,30 @@ mod tests { } assert!(found, "witness failure log must carry the block_number span field, got:\n{logs}"); } + + /// The deserializer seeds each envelope's cached hash from the RPC `hash` field, so + /// `trie_hash()` reproduces the claim by construction; only keccak over the envelope's own + /// encoding can tell a hash that does not belong to the bytes. Forging one hash leaves the + /// transactions root intact, so the hash check is the only thing that rejects the block. + /// The first transaction is the deposit (`Sealed`) and the last a signed envelope. + #[test] + fn verify_block_integrity_rejects_a_forged_transaction_hash() { + let fx = TestFixtures::mainnet_shared(); + let block = fx + .paired_blocks() + .iter() + .map(|(_, hash)| &fx.blocks[hash]) + .find(|block| !block.transactions.is_empty()) + .expect("a paired mainnet fixture with transactions"); + verify_block_integrity(block).expect("untampered fixture block verifies"); + + let last = block.transactions.len() - 1; + for index in [0, last] { + let mut json = serde_json::to_value(block).unwrap(); + json["transactions"][index]["hash"] = serde_json::to_value(B256::ZERO).unwrap(); + let forged: Block = serde_json::from_value(json).unwrap(); + let err = verify_block_integrity(&forged).unwrap_err(); + assert!(err.to_string().contains("Transaction hash mismatch"), "tx {index}: {err:?}"); + } + } } diff --git a/crates/stateless-core/src/executor.rs b/crates/stateless-core/src/executor.rs index 0b1d15c8..58194078 100644 --- a/crates/stateless-core/src/executor.rs +++ b/crates/stateless-core/src/executor.rs @@ -492,8 +492,9 @@ where let logs_bloom = execution_result.receipts.iter().fold(Bloom::ZERO, |acc, receipt| acc | receipt.bloom()); - // Gas used is the cumulative gas used of the last receipt - let gas_used = execution_result.receipts.last().map(|r| r.cumulative_gas_used()).unwrap_or(0); + // mega-evm's `finish()` sets this to the last receipt's cumulative gas; + // `verify_replay_outputs` pins it against the header's claim. + let gas_used = execution_result.gas_used; let receipts_root = calculate_receipt_root(&execution_result.receipts); @@ -1062,6 +1063,9 @@ mod tests { assert!(matches!(err, ValidationError::BlockIncomplete), "{err:?}"); } + /// Every paired mainnet fixture must validate end to end, which pins the replayed withdrawals + /// root, receipts root, logs bloom and gas used against the header's claims through + /// `verify_replay_outputs`. #[test] fn validate_block_mainnet_fixtures() { let _logging = init_test_logging("stateless_core");