Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/stateless-common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
50 changes: 43 additions & 7 deletions crates/stateless-common/src/rpc_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -1575,13 +1575,19 @@ fn verify_block_integrity(block: &Block<Transaction>) -> 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
Expand All @@ -1594,10 +1600,11 @@ fn verify_block_integrity(block: &Block<Transaction>) -> 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,
Expand Down Expand Up @@ -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::*;
Expand Down Expand Up @@ -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<Transaction> = 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:?}");
}
}
}
8 changes: 6 additions & 2 deletions crates/stateless-core/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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");
Expand Down
Loading