Skip to content
Merged
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
804 changes: 416 additions & 388 deletions Cargo.lock

Large diffs are not rendered by default.

164 changes: 73 additions & 91 deletions Cargo.toml

Large diffs are not rendered by default.

83 changes: 71 additions & 12 deletions crates/evm/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use alloy_evm::{
},
eth::{EthBlockExecutor, EthTxResult},
};
use alloy_sol_types::SolEvent as _;
use alloy_sol_types::{SolCall as _, SolEvent as _};
use reth_evm::block::StateDB;
use reth_revm::{Inspector, context::result::ResultAndState};
use tempo_evm::{TempoBlockExecutionCtx, TempoReceiptBuilder};
Expand All @@ -22,9 +22,7 @@ use tempo_revm::evm::TempoContext;
use tempo_zone_contracts::IZoneOutbox;
use zone_chainspec::ZoneChainSpec;
use zone_l1::state::L1StateProvider;
use zone_precompiles::{
ADVANCE_TEMPO_SELECTOR, L1StorageReader, is_finalize_withdrawal_batch_calldata,
};
use zone_precompiles::{ADVANCE_TEMPO_SELECTOR, L1StorageReader};
use zone_primitives::constants::{ZONE_INBOX_ADDRESS, ZONE_OUTBOX_ADDRESS};

use crate::{L1OverlayDB, ZoneEvm};
Expand Down Expand Up @@ -106,7 +104,8 @@ impl ZoneTransactionKind {
}

if tx.calls().any(|(kind, input)| {
kind.to() == Some(&ZONE_OUTBOX_ADDRESS) && is_finalize_withdrawal_batch_calldata(input)
kind.to() == Some(&ZONE_OUTBOX_ADDRESS)
&& input.starts_with(&IZoneOutbox::finalizeWithdrawalBatchCall::SELECTOR)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [ISSUE] finalizeWithdrawalBatch classification now accepts any selector-prefixed calldata

The classifier now only checks the 4-byte selector, whereas the removed helper decoded and re-encoded calldata to require canonical finalizeWithdrawalBatch bytes. Malformed or trailing-byte calldata can now advance the block phase on lenient forks, loosening a consensus-critical rule and letting non-canonical bytes reach proving/batch surfaces.

Recommended Fix:
Restore canonical calldata validation for classification, or deliberately move the relaxed rule into the executor/precompile consensus path with matching admission/prover behavior and tests.

}) {
return Self::FinalizeWithdrawalBatch;
}
Expand Down Expand Up @@ -285,7 +284,7 @@ mod tests {
use reth_chainspec::EthChainSpec as _;
use reth_primitives_traits::Recovered;
use revm::database::{CacheDB, EmptyDB};
use tempo_chainspec::spec::DEV;
use tempo_chainspec::{hardfork::TempoHardfork, spec::DEV};
use tempo_evm::TempoBlockExecutionCtx;
use tempo_precompiles::{
DEFAULT_FEE_TOKEN, TIP_FEE_MANAGER_ADDRESS,
Expand Down Expand Up @@ -461,14 +460,74 @@ mod tests {
ZONE_OUTBOX_ADDRESS,
Bytes::copy_from_slice(&IZoneOutbox::finalizeWithdrawalBatchCall::SELECTOR),
);
let error = ZoneBlockPhase::Executing
.validate_transaction(&malformed_finalize)
.unwrap_err();
assert_eq!(
error.to_string(),
"system transactions after advanceTempo must call \
ZoneOutbox.finalizeWithdrawalBatch"
ZoneBlockPhase::Executing
.validate_transaction(&malformed_finalize)
.unwrap(),
ZoneBlockPhase::WithdrawalsFinalized
);

let mut trailing_calldata = IZoneOutbox::finalizeWithdrawalBatchCall {
count: U256::ZERO,
blockNumber: 1,
encryptedSenders: vec![],
}
.abi_encode();
trailing_calldata.extend_from_slice(&[0; 32]);
let trailing_finalize = system_tx(ZONE_OUTBOX_ADDRESS, trailing_calldata.into());
assert_eq!(
ZoneBlockPhase::Executing
.validate_transaction(&trailing_finalize)
.unwrap(),
ZoneBlockPhase::WithdrawalsFinalized
);
}

#[test]
fn malformed_t11_finalization_does_not_advance_block_phase() {
let mut zone_genesis = DEV.genesis().clone();
zone_genesis.config.chain_id = zone_chain_id(DEV.chain().id(), 2).unwrap();
let chain_spec = std::sync::Arc::new(ZoneChainSpec::from_genesis(zone_genesis).unwrap());
let factory =
ZoneEvmFactory::new(chain_spec.clone(), MockL1Reader::default(), Address::ZERO);
let mut env = EvmEnv::default();
env.cfg_env.spec = TempoHardfork::T11;
let evm = factory.create_evm(CacheDB::new(EmptyDB::default()), env);
let ctx = TempoBlockExecutionCtx {
inner: EthBlockExecutionCtx {
parent_hash: B256::ZERO,
parent_beacon_block_root: None,
ommers: &[],
withdrawals: None,
extra_data: Bytes::new(),
tx_count_hint: Some(1),
slot_number: None,
},
general_gas_limit: 0,
shared_gas_limit: 0,
validator_set: None,
consensus_context: None,
subblock_fee_recipients: Default::default(),
};
let mut executor = ZoneBlockExecutor::new(evm, ctx, &chain_spec);
executor.phase = ZoneBlockPhase::Executing;

let tx = Recovered::new_unchecked(
system_tx(
ZONE_OUTBOX_ADDRESS,
Bytes::copy_from_slice(&IZoneOutbox::finalizeWithdrawalBatchCall::SELECTOR),
),
TEMPO_SYSTEM_TX_SENDER,
);
let error = executor.execute_transaction_without_commit(tx).unwrap_err();

assert!(
error
.to_string()
.contains("system transaction execution failed"),
"unexpected error: {error}"
);
assert_eq!(executor.phase, ZoneBlockPhase::Executing);
}

#[test]
Expand Down
9 changes: 7 additions & 2 deletions crates/node/src/replication.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ use std::{
time::Duration,
};
use tempo_alloy::TempoNetwork;
use tempo_chainspec::hardfork::TempoHardfork;
use tempo_precompiles::dispatch::abi_decoder_config_for_spec;
use tempo_primitives::{Block, TempoHeader, TempoTxEnvelope};
use tokio::sync::{mpsc, oneshot};
use tokio_util::sync;
Expand Down Expand Up @@ -1375,8 +1377,11 @@ fn decode_advance_tempo(
if signed.tx().to != ZONE_INBOX_ADDRESS.into() {
eyre::bail!("first Tempo system transaction is not sent to IZoneInbox")
}
let call = IZoneInbox::advanceTempoCall::abi_decode(signed.tx().input.as_ref())
.map_err(|err| eyre::eyre!("first transaction does not decode as advanceTempo: {err}"))?;
let call = IZoneInbox::advanceTempoCall::abi_decode_with_config(
signed.tx().input.as_ref(),
abi_decoder_config_for_spec(TempoHardfork::latest()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [POTENTIAL-VULNERABILITY] Peer-block admission decodes advanceTempo with latest() while execution uses the active fork

TempoHardfork::latest() selects strict decoding, but the executor decodes the same advanceTempo calldata through the active StorageCtx.spec() and remains lenient on T10 zones. A Byzantine leader can produce an executably valid block with lenient-only calldata, such as trailing bytes, that followers reject in peer-block import before passing it to the engine.

Recommended Fix:
Resolve the block's active TempoHardfork from the zone chain spec/timestamp and pass that to abi_decoder_config_for_spec. If canonical encoding is intended, enforce it in execution as a consensus rule, not only in gossip admission.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the leader encodes without trailing bytes in our current setup.

)
.map_err(|err| eyre::eyre!("first transaction does not decode as advanceTempo: {err}"))?;

// 3. the system tx is valid.
let mut header_rlp = call.header.as_ref();
Expand Down
81 changes: 54 additions & 27 deletions crates/node/src/rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use std::{
time::Duration,
};

use alloy_consensus::BlockHeader;
use alloy_consensus::{BlockHeader, transaction::TxHashRef};
use alloy_eips::eip2935::{HISTORY_SERVE_WINDOW, HISTORY_STORAGE_ADDRESS};
use alloy_network::{ReceiptResponse, TransactionBuilder, TransactionResponse};
use alloy_primitives::{Address, B256, Bloom, Bytes, U64, U256, keccak256};
Expand All @@ -37,7 +37,7 @@ use reth_rpc_eth_api::{
};
use reth_rpc_eth_types::{EthApiError, logs_utils};
use reth_storage_api::{BlockNumReader, StateProviderFactory};
use reth_trie_common::{ExecutionWitnessMode, HashedStorage};
use reth_trie_common::{ExecutionWitnessMode, HashedPostState};
use tempo_alloy::{
TempoNetwork,
provider::ext::TempoProviderExt as _,
Expand Down Expand Up @@ -286,17 +286,27 @@ where
let (evm_config, recorder) = eth_api.evm_config().with_l1_storage_recorder();
let block_executor = evm_config.executor(&mut db);
let mode = ExecutionWitnessMode::default();
let mut witness_record = ExecutionWitnessRecord::default();
let mut witness = None;

let _ = block_executor
.execute_with_state_closure(&block, |statedb: &State<_>| {
witness_record.record_executed_state(statedb, mode);
record_block_hash_storage_proofs(&mut witness_record, statedb);
let mut additional_state = HashedPostState::default();
record_block_hash_storage_proofs(&mut additional_state, statedb);
witness = Some(
ExecutionWitnessRecord::new(statedb)
.with_additional_state(additional_state)
.into_execution_witness(
&statedb.database.database.0,
eth_api.provider(),
block_number,
mode,
),
);
})
.map_err(|error| EthApiError::Internal(error.into()))?;

let witness = witness_record
.into_execution_witness(&db.database.0, eth_api.provider(), block_number, mode)
let witness = witness
.expect("state closure is called after successful execution")
.map_err(EthApiError::from)?;
Ok(ZoneExecutionWitness {
execution_witness: witness,
Expand All @@ -320,17 +330,16 @@ where
/// Reth records these reads in REVM's block-hash cache and normally proves them with ancestor
/// headers. Zones already commit the EIP-2935 history contract in state, so adding the matching
/// storage targets lets the SPF authenticate the same values against the parent state root.
fn record_block_hash_storage_proofs<DB>(witness: &mut ExecutionWitnessRecord, state: &State<DB>) {
fn record_block_hash_storage_proofs<DB>(additional_state: &mut HashedPostState, state: &State<DB>) {
let block_hashes = state.block_hashes.iter().collect::<Vec<_>>();
if block_hashes.is_empty() {
return;
}

let history_storage = witness
.hashed_state
let history_storage = additional_state
.storages
.entry(keccak256(HISTORY_STORAGE_ADDRESS))
.or_insert_with(|| HashedStorage::new(false));
.or_default();
for (number, hash) in block_hashes {
let slot = U256::from(number % HISTORY_SERVE_WINDOW as u64);
history_storage.storage.insert(
Expand Down Expand Up @@ -1187,6 +1196,7 @@ where
fn ws_subscribe_logs(&self, mut filter: Filter, auth: AuthContext) -> BoxWsSubscriptionFut<'_> {
Box::pin(async move {
let provider = self.eth.api.provider().clone();
let api = self.eth.api.clone();
let caller = auth.caller;

let zone_tokens = self.zone_tokens();
Expand All @@ -1195,18 +1205,36 @@ where

let stream = provider
.canonical_state_stream()
.flat_map(|canon_state| futures::stream::iter(canon_state.block_receipts()))
.flat_map(move |(block_receipts, removed)| {
let all_logs = logs_utils::matching_block_logs_with_tx_hashes(
&filter,
block_receipts.block,
block_receipts.timestamp,
block_receipts
.tx_receipts
.iter()
.map(|(tx, receipt)| (*tx, receipt)),
removed,
);
.flat_map(move |canon_state| {
let reverted_chains = canon_state.reverted();
let committed_chain = canon_state.committed();
let reverted = reverted_chains.iter().flat_map(|chain| {
chain
.blocks_and_receipts()
.map(|(block, receipts)| (block, receipts, true))
});
let committed = committed_chain
.blocks_and_receipts()
.map(|(block, receipts)| (block, receipts, false));
let mut all_logs = Vec::new();

for (block, receipts, removed) in reverted.chain(committed) {
match logs_utils::matching_block_logs_with_tx_hashes(
api.converter(),
&filter,
block.sealed_header(),
block
.transactions_recovered()
.zip(receipts.iter())
.map(|(tx, receipt)| (*tx.tx_hash(), receipt)),
removed,
) {
Ok(logs) => all_logs.extend(logs),
Err(error) => {
tracing::error!(target: "rpc", %error, "Failed to convert logs");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [ISSUE] Log subscriptions silently drop a block's logs on conversion errors

If matching_block_logs_with_tx_hashes fails for a block, this branch only logs the error and continues. That drops all matching logs from that block for the subscription and can desynchronize the redacted (transactionHash, logIndex) sequence from eth_getLogs.

Recommended Fix:
Propagate the error and fail/restart the subscription, or handle failures at per-log granularity so one conversion issue cannot silently suppress an entire block's logs.

}
}
}
futures::stream::iter(all_logs)
});

Expand Down Expand Up @@ -1484,12 +1512,11 @@ mod tests {
.with_database(revm::database::EmptyDB::default())
.build();
state.block_hashes.insert(number, hash);
let mut witness = ExecutionWitnessRecord::default();
let mut additional_state = HashedPostState::default();

record_block_hash_storage_proofs(&mut witness, &state);
record_block_hash_storage_proofs(&mut additional_state, &state);

let storage = witness
.hashed_state
let storage = additional_state
.storages
.get(&keccak256(HISTORY_STORAGE_ADDRESS))
.unwrap();
Expand Down
Loading
Loading