From c548b767e6746da2565b46f92a0fbae40dbe25a7 Mon Sep 17 00:00:00 2001 From: Gregory Edison Date: Fri, 8 Aug 2025 10:50:29 +0200 Subject: [PATCH 1/8] feat: block validation Signed-off-by: Gregory Edison --- Cargo.lock | 1 + crates/scroll/alloy/consensus/src/lib.rs | 4 +- .../consensus/src/transaction/envelope.rs | 20 ++++- .../consensus/src/transaction/l1_message.rs | 8 ++ .../alloy/consensus/src/transaction/mod.rs | 4 +- crates/scroll/consensus/Cargo.toml | 1 + crates/scroll/consensus/src/constants.rs | 6 ++ crates/scroll/consensus/src/error.rs | 40 +++++++++ crates/scroll/consensus/src/lib.rs | 4 +- crates/scroll/consensus/src/validation.rs | 84 +++++++++++++++++-- crates/scroll/node/src/builder/consensus.rs | 3 +- crates/scroll/node/src/builder/engine.rs | 7 +- 12 files changed, 163 insertions(+), 19 deletions(-) create mode 100644 crates/scroll/consensus/src/constants.rs diff --git a/Cargo.lock b/Cargo.lock index 7081f4ec606..601c6b0d5a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10384,6 +10384,7 @@ dependencies = [ "reth-execution-types", "reth-primitives-traits", "reth-scroll-primitives", + "scroll-alloy-consensus", "scroll-alloy-hardforks", "thiserror 2.0.12", "tracing", diff --git a/crates/scroll/alloy/consensus/src/lib.rs b/crates/scroll/alloy/consensus/src/lib.rs index c6eb0dad6ea..061a6d6969f 100644 --- a/crates/scroll/alloy/consensus/src/lib.rs +++ b/crates/scroll/alloy/consensus/src/lib.rs @@ -13,8 +13,8 @@ extern crate alloc as std; mod transaction; pub use transaction::{ ScrollAdditionalInfo, ScrollL1MessageTransactionFields, ScrollPooledTransaction, - ScrollTransactionInfo, ScrollTxEnvelope, ScrollTxType, ScrollTypedTransaction, TxL1Message, - L1_MESSAGE_TRANSACTION_TYPE, L1_MESSAGE_TX_TYPE_ID, + ScrollTransaction, ScrollTransactionInfo, ScrollTxEnvelope, ScrollTxType, + ScrollTypedTransaction, TxL1Message, L1_MESSAGE_TRANSACTION_TYPE, L1_MESSAGE_TX_TYPE_ID, }; mod receipt; diff --git a/crates/scroll/alloy/consensus/src/transaction/envelope.rs b/crates/scroll/alloy/consensus/src/transaction/envelope.rs index 4fd4b8d1e2b..0fcc67f625f 100644 --- a/crates/scroll/alloy/consensus/src/transaction/envelope.rs +++ b/crates/scroll/alloy/consensus/src/transaction/envelope.rs @@ -1,4 +1,6 @@ -use crate::{ScrollPooledTransaction, ScrollTxType, ScrollTypedTransaction, TxL1Message}; +use crate::{ + ScrollPooledTransaction, ScrollTransaction, ScrollTxType, ScrollTypedTransaction, TxL1Message, +}; use core::hash::Hash; use alloy_consensus::{ @@ -424,6 +426,22 @@ impl ScrollTxEnvelope { } } +impl ScrollTransaction for ScrollTxEnvelope { + fn is_l1_message(&self) -> bool { + match self { + Self::Legacy(_) | Self::Eip2930(_) | Self::Eip1559(_) | Self::Eip7702(_) => false, + Self::L1Message(_) => true, + } + } + + fn queue_index(&self) -> Option { + match self { + Self::Legacy(_) | Self::Eip2930(_) | Self::Eip1559(_) | Self::Eip7702(_) => None, + Self::L1Message(tx) => Some(tx.queue_index), + } + } +} + #[cfg(feature = "reth-codec")] impl ToTxCompact for ScrollTxEnvelope { fn to_tx_compact(&self, buf: &mut (impl BufMut + AsMut<[u8]>)) { diff --git a/crates/scroll/alloy/consensus/src/transaction/l1_message.rs b/crates/scroll/alloy/consensus/src/transaction/l1_message.rs index 0c200904fbc..af75863dea4 100644 --- a/crates/scroll/alloy/consensus/src/transaction/l1_message.rs +++ b/crates/scroll/alloy/consensus/src/transaction/l1_message.rs @@ -17,6 +17,14 @@ use {reth_codecs::Compact, reth_codecs_derive::add_arbitrary_tests}; /// L1 message transaction type id, 0x7e in hex. pub const L1_MESSAGE_TRANSACTION_TYPE: u8 = 126; +/// A Scroll chain transaction. +pub trait ScrollTransaction { + /// Returns true if the transaction is a L1 message. + fn is_l1_message(&self) -> bool; + /// Returns the queue index if the transaction is a L1 message, None otherwise. + fn queue_index(&self) -> Option; +} + /// A message transaction sent from the settlement layer to the L2 for execution. /// /// The signature of the L1 message is already verified on the L1 and as such doesn't contain diff --git a/crates/scroll/alloy/consensus/src/transaction/mod.rs b/crates/scroll/alloy/consensus/src/transaction/mod.rs index c43aab42897..f57681bb962 100644 --- a/crates/scroll/alloy/consensus/src/transaction/mod.rs +++ b/crates/scroll/alloy/consensus/src/transaction/mod.rs @@ -7,7 +7,9 @@ mod envelope; pub use envelope::ScrollTxEnvelope; mod l1_message; -pub use l1_message::{ScrollL1MessageTransactionFields, TxL1Message, L1_MESSAGE_TRANSACTION_TYPE}; +pub use l1_message::{ + ScrollL1MessageTransactionFields, ScrollTransaction, TxL1Message, L1_MESSAGE_TRANSACTION_TYPE, +}; mod meta; pub use meta::{ScrollAdditionalInfo, ScrollTransactionInfo}; diff --git a/crates/scroll/consensus/Cargo.toml b/crates/scroll/consensus/Cargo.toml index f9c6c1319f2..a4cce400b52 100644 --- a/crates/scroll/consensus/Cargo.toml +++ b/crates/scroll/consensus/Cargo.toml @@ -26,6 +26,7 @@ reth-primitives-traits.workspace = true # scroll reth-scroll-primitives = { workspace = true, default-features = false } +scroll-alloy-consensus.workspace = true scroll-alloy-hardforks.workspace = true # misc diff --git a/crates/scroll/consensus/src/constants.rs b/crates/scroll/consensus/src/constants.rs new file mode 100644 index 00000000000..dc018bf1369 --- /dev/null +++ b/crates/scroll/consensus/src/constants.rs @@ -0,0 +1,6 @@ +use alloy_primitives::U256; + +/// The block difficulty for in turn signing in the Clique consensus. +pub const CLIQUE_IN_TURN_DIFFICULTY: U256 = U256::from_limbs([2, 0, 0, 0]); +/// The block difficulty for out of turn signing in the Clique consensus. +pub const CLIQUE_NO_TURN_DIFFICULTY: U256 = U256::from_limbs([1, 0, 0, 0]); diff --git a/crates/scroll/consensus/src/error.rs b/crates/scroll/consensus/src/error.rs index 39bae3f2eaf..ca81567210a 100644 --- a/crates/scroll/consensus/src/error.rs +++ b/crates/scroll/consensus/src/error.rs @@ -1,3 +1,4 @@ +use alloy_primitives::{Address, B256, B64, U256}; use reth_consensus::ConsensusError; /// Scroll consensus error. @@ -6,6 +7,36 @@ pub enum ScrollConsensusError { /// L1 [`ConsensusError`], that also occurs on L2. #[error(transparent)] Eth(#[from] ConsensusError), + /// Invalid L1 messages order. + #[error("invalid L1 message order")] + InvalidL1MessageOrder, + /// Block has non zero coinbase. + #[error("block coinbase not zero: {0}")] + CoinbaseNotZero(Address), + /// Block has non zero nonce. + #[error("block nonce not zero: {0:?}")] + NonceNotZero(Option), + /// Block has invalid clique nonce. + #[error("block nonce should be 0x0 or 0xffffffffffffffff: {0:?}")] + InvalidCliqueNonce(Option), + /// Block has non zero mix hash. + #[error("block mix hash not zero: {0:?}")] + MixHashNotZero(Option), + /// Block difficulty is not one. + #[error("block difficulty not one: {0}")] + DifficultyNotOne(U256), + /// Block has invalid clique difficulty. + #[error("block difficulty should be 1 or 2: {0}")] + InvalidCliqueDifficulty(U256), + /// Block extra data missing vanity. + #[error("block extra data missing vanity")] + MissingVanity, + /// Block extra data missing signature. + #[error("block extra data missing signature")] + MissingSignature, + /// Block extra data with invalid checkpoint signers. + #[error("block extra data contains invalid checkpoint signers")] + InvalidCheckpointSigners, /// Block body has non-empty withdrawals list. #[error("non-empty block body withdrawals list")] WithdrawalsNonEmpty, @@ -13,3 +44,12 @@ pub enum ScrollConsensusError { #[error("unexpected blob params at timestamp")] UnexpectedBlobParams, } + +impl From for ConsensusError { + fn from(value: ScrollConsensusError) -> Self { + match value { + ScrollConsensusError::Eth(eth) => eth, + err => Self::Other(err.to_string()), + } + } +} diff --git a/crates/scroll/consensus/src/lib.rs b/crates/scroll/consensus/src/lib.rs index 2bf959bc63b..67c147fd9d8 100644 --- a/crates/scroll/consensus/src/lib.rs +++ b/crates/scroll/consensus/src/lib.rs @@ -2,9 +2,11 @@ extern crate alloc; +mod constants; +pub use constants::{CLIQUE_IN_TURN_DIFFICULTY, CLIQUE_NO_TURN_DIFFICULTY}; + mod error; pub use error::ScrollConsensusError; mod validation; - pub use validation::ScrollBeaconConsensus; diff --git a/crates/scroll/consensus/src/validation.rs b/crates/scroll/consensus/src/validation.rs index adcd2706f85..001bf7523cd 100644 --- a/crates/scroll/consensus/src/validation.rs +++ b/crates/scroll/consensus/src/validation.rs @@ -1,9 +1,10 @@ use crate::error::ScrollConsensusError; use alloc::sync::Arc; -use core::fmt::Debug; +use crate::{CLIQUE_IN_TURN_DIFFICULTY, CLIQUE_NO_TURN_DIFFICULTY}; use alloy_consensus::{BlockHeader as _, TxReceipt, EMPTY_OMMER_ROOT_HASH}; -use alloy_primitives::B256; +use alloy_primitives::{b64, Address, B256, B64, U256}; +use core::fmt::Debug; use reth_chainspec::{EthChainSpec, EthereumHardforks}; use reth_consensus::{ validate_state_root, Consensus, ConsensusError, FullConsensus, HeaderValidator, @@ -14,9 +15,10 @@ use reth_consensus_common::validation::{ use reth_execution_types::BlockExecutionResult; use reth_primitives_traits::{ receipt::gas_spent_by_transactions, Block, BlockBody, BlockHeader, GotExpected, NodePrimitives, - RecoveredBlock, SealedBlock, SealedHeader, + RecoveredBlock, SealedBlock, SealedHeader, SignedTransaction, }; use reth_scroll_primitives::ScrollReceipt; +use scroll_alloy_consensus::ScrollTransaction; use scroll_alloy_hardforks::{ScrollHardfork, ScrollHardforks}; /// Scroll consensus implementation. @@ -35,8 +37,10 @@ impl ScrollBeaconConsensus { } } -impl> - FullConsensus for ScrollBeaconConsensus +impl< + ChainSpec: EthChainSpec + ScrollHardforks, + N: NodePrimitives, + > FullConsensus for ScrollBeaconConsensus { fn validate_block_post_execution( &self, @@ -75,8 +79,16 @@ impl Consensus - for ScrollBeaconConsensus +/// Following fields should be checked on body: +/// - Verify no ommers are present and hash to the header ommer root. +/// - Verify transactions trie root is valid. +/// - Validate L1 messages: should be at the start of the list of transactions and been continuous +/// in regard to the queue index. +impl Consensus for ScrollBeaconConsensus +where + B: Block, + ::Transaction: ScrollTransaction, + ChainSpec: EthChainSpec + ScrollHardforks, { type Error = ConsensusError; @@ -89,6 +101,12 @@ impl Consensus } fn validate_block_pre_execution(&self, block: &SealedBlock) -> Result<(), ConsensusError> { + // Check no ommers. + let ommers_len = block.body().ommers().map(|o| o.len()).unwrap_or_default(); + if ommers_len > 0 { + return Err(ConsensusError::Other("uncles not allowed".to_string())) + } + // Check ommers hash let ommers_hash = block.body().calculate_ommers_root(); if Some(block.ommers_hash()) != ommers_hash { @@ -111,6 +129,9 @@ impl Consensus return Err(ConsensusError::Other(ScrollConsensusError::WithdrawalsNonEmpty.to_string())) } + // Check L1 messages. + validate_l1_messages(block.body().transactions())?; + Ok(()) } } @@ -158,6 +179,20 @@ impl HeaderValidator< } } +/// Validates the timestamp of the header, which should not be in the future. +#[inline] +fn validate_header_timestamp(header: &H) -> Result<(), ConsensusError> { + let now = std::time::SystemTime::now(); + let since_unix_epoch = now.duration_since(std::time::SystemTime::UNIX_EPOCH).unwrap().as_secs(); + if header.timestamp() > since_unix_epoch { + return Err(ConsensusError::TimestampIsInPast { + parent_timestamp: since_unix_epoch, + timestamp: header.timestamp(), + }) + } + Ok(()) +} + /// Ensure the EIP-1559 base fee is set if the Curie hardfork is active. #[inline] fn validate_header_base_fee( @@ -189,3 +224,38 @@ fn validate_against_parent_timestamp( } Ok(()) } + +/// Validate the L1 messages by checking they are only present that the start of the block and only +/// have increasing queue index. +#[inline] +fn validate_l1_messages( + txs: &[Tx], +) -> Result<(), ScrollConsensusError> { + // Check if the block contains L1 messages. + if !txs.iter().any(ScrollTransaction::is_l1_message) { + return Ok(()) + } + + // Check L1 messages are only at the start of the block and correctly ordered. + let mut saw_l2_transaction = false; + let mut queue_index = 0; + + for tx in txs { + // Check index is strictly increasing. + if tx.is_l1_message() { + let tx_queue_index = tx.queue_index().expect("is_l1_message"); + if tx_queue_index < queue_index { + return Err(ScrollConsensusError::InvalidL1MessageOrder); + } + queue_index = tx_queue_index + 1; + } + + // Check correct ordering. + if tx.is_l1_message() && saw_l2_transaction { + return Err(ScrollConsensusError::InvalidL1MessageOrder); + } + saw_l2_transaction = !tx.is_l1_message(); + } + + Ok(()) +} diff --git a/crates/scroll/node/src/builder/consensus.rs b/crates/scroll/node/src/builder/consensus.rs index fcb25235ad1..05e6601827d 100644 --- a/crates/scroll/node/src/builder/consensus.rs +++ b/crates/scroll/node/src/builder/consensus.rs @@ -4,6 +4,7 @@ use reth_node_types::NodeTypes; use reth_primitives_traits::NodePrimitives; use reth_scroll_consensus::ScrollBeaconConsensus; use reth_scroll_primitives::ScrollReceipt; +use scroll_alloy_consensus::ScrollTransaction; use scroll_alloy_hardforks::ScrollHardforks; use std::sync::Arc; @@ -16,7 +17,7 @@ where Node: FullNodeTypes< Types: NodeTypes< ChainSpec: EthChainSpec + ScrollHardforks, - Primitives: NodePrimitives, + Primitives: NodePrimitives, >, >, { diff --git a/crates/scroll/node/src/builder/engine.rs b/crates/scroll/node/src/builder/engine.rs index 97085fdecfb..fcfa83e8946 100644 --- a/crates/scroll/node/src/builder/engine.rs +++ b/crates/scroll/node/src/builder/engine.rs @@ -1,5 +1,4 @@ use alloy_consensus::BlockHeader; -use alloy_primitives::U256; use alloy_rpc_types_engine::{ExecutionData, PayloadError}; use reth_node_api::{ InvalidPayloadAttributesError, MessageValidationKind, NewPayloadError, PayloadAttributes, @@ -13,17 +12,13 @@ use reth_node_builder::{ use reth_node_types::NodeTypes; use reth_primitives_traits::{Block, RecoveredBlock}; use reth_scroll_chainspec::ScrollChainSpec; +use reth_scroll_consensus::{CLIQUE_IN_TURN_DIFFICULTY, CLIQUE_NO_TURN_DIFFICULTY}; use reth_scroll_engine_primitives::{try_into_block, ScrollEngineTypes}; use reth_scroll_primitives::{ScrollBlock, ScrollPrimitives}; use scroll_alloy_hardforks::ScrollHardforks; use scroll_alloy_rpc_types_engine::ScrollPayloadAttributes; use std::sync::Arc; -/// The block difficulty for in turn signing in the Clique consensus. -const CLIQUE_IN_TURN_DIFFICULTY: U256 = U256::from_limbs([2, 0, 0, 0]); -/// The block difficulty for out of turn signing in the Clique consensus. -const CLIQUE_NO_TURN_DIFFICULTY: U256 = U256::from_limbs([1, 0, 0, 0]); - /// Builder for [`ScrollEngineValidator`]. #[derive(Debug, Default, Clone, Copy)] pub struct ScrollEngineValidatorBuilder; From e89184f5cb0868792761e60c750437d33f6b12e0 Mon Sep 17 00:00:00 2001 From: Gregory Edison Date: Fri, 8 Aug 2025 10:50:48 +0200 Subject: [PATCH 2/8] feat: header validation Signed-off-by: Gregory Edison --- crates/scroll/consensus/src/validation.rs | 86 +++++++++++++++++++++-- 1 file changed, 82 insertions(+), 4 deletions(-) diff --git a/crates/scroll/consensus/src/validation.rs b/crates/scroll/consensus/src/validation.rs index 001bf7523cd..9d1c6e8f213 100644 --- a/crates/scroll/consensus/src/validation.rs +++ b/crates/scroll/consensus/src/validation.rs @@ -140,10 +140,8 @@ impl HeaderValidator< for ScrollBeaconConsensus { fn validate_header(&self, header: &SealedHeader) -> Result<(), ConsensusError> { - if header.ommers_hash() != EMPTY_OMMER_ROOT_HASH { - return Err(ConsensusError::TheMergeOmmerRootIsNotEmpty) - } - + validate_header_timestamp(header.header())?; + validate_header_fields(header.header(), &self.chain_spec)?; validate_header_gas(header.header())?; validate_header_base_fee(header.header(), &self.chain_spec) } @@ -179,6 +177,86 @@ impl HeaderValidator< } } +#[inline] +fn validate_header_fields( + header: &H, + chain_spec: ChainSpec, +) -> Result<(), ScrollConsensusError> { + // Common checks to pre and post Euclid v2. + if header.ommers_hash() != EMPTY_OMMER_ROOT_HASH { + return Err(ConsensusError::TheMergeOmmerRootIsNotEmpty.into()) + } + if header.mix_hash() != Some(B256::ZERO) { + return Err(ScrollConsensusError::MixHashNotZero(header.mix_hash())) + } + + if chain_spec.is_euclid_v2_active_at_timestamp(header.timestamp()) { + verify_header_fields_post_euclid_v2(header)?; + } else { + let clique_config = + chain_spec.genesis().config.clique.expect("clique config required pre euclid v2"); + let epoch = clique_config.epoch.expect("epoch required pre euclid v2"); + verify_header_fields_pre_euclid_v2(header, epoch)?; + } + + Ok(()) +} + +/// Verify the header's field for post Euclid v2 blocks. +#[inline] +fn verify_header_fields_post_euclid_v2( + header: &H, +) -> Result<(), ScrollConsensusError> { + if header.beneficiary() != Address::ZERO { + return Err(ScrollConsensusError::CoinbaseNotZero(header.beneficiary())) + } + if header.nonce() != Some(B64::ZERO) { + return Err(ScrollConsensusError::NonceNotZero(header.nonce())) + } + if header.difficulty() != U256::ONE { + return Err(ScrollConsensusError::DifficultyNotOne(header.difficulty())) + } + if !header.extra_data().is_empty() { + return Err(ConsensusError::ExtraDataExceedsMax { len: header.extra_data().len() }.into()) + } + + Ok(()) +} + +/// Verify the header's field for pre Euclid v2 blocks. +#[inline] +fn verify_header_fields_pre_euclid_v2( + header: &H, + epoch: u64, +) -> Result<(), ScrollConsensusError> { + let is_checkpoint = (header.number() % epoch) == 0; + if is_checkpoint && header.beneficiary() != Address::ZERO { + return Err(ScrollConsensusError::CoinbaseNotZero(header.beneficiary())) + } + if header.nonce() != Some(B64::ZERO) || header.nonce() != Some(b64!("ffffffffffffffff")) { + return Err(ScrollConsensusError::InvalidCliqueNonce(header.nonce())) + } + if is_checkpoint && header.nonce() != Some(B64::ZERO) { + return Err(ScrollConsensusError::NonceNotZero(header.nonce())) + } + if header.extra_data().len() < 32 { + return Err(ScrollConsensusError::MissingVanity) + } + if header.extra_data().len() < 32 + 65 { + return Err(ScrollConsensusError::MissingSignature) + } + let signer_bytes = header.extra_data().len() - 32 - 65; + if !is_checkpoint && signer_bytes > 0 { + return Err(ScrollConsensusError::InvalidCheckpointSigners) + } + let difficulty = header.difficulty(); + if difficulty != CLIQUE_IN_TURN_DIFFICULTY || difficulty != CLIQUE_NO_TURN_DIFFICULTY { + return Err(ScrollConsensusError::InvalidCliqueDifficulty(difficulty)) + } + + Ok(()) +} + /// Validates the timestamp of the header, which should not be in the future. #[inline] fn validate_header_timestamp(header: &H) -> Result<(), ConsensusError> { From e71adb56a466f1e8d047defa12e8b7b0caff19de Mon Sep 17 00:00:00 2001 From: Gregory Edison Date: Fri, 8 Aug 2025 11:43:28 +0200 Subject: [PATCH 3/8] feat: body validation Signed-off-by: Gregory Edison --- crates/scroll/consensus/src/constants.rs | 3 + crates/scroll/consensus/src/error.rs | 8 +++ crates/scroll/consensus/src/lib.rs | 4 +- crates/scroll/consensus/src/validation.rs | 72 ++++++++++++++++++----- 4 files changed, 70 insertions(+), 17 deletions(-) diff --git a/crates/scroll/consensus/src/constants.rs b/crates/scroll/consensus/src/constants.rs index dc018bf1369..101b3f9064a 100644 --- a/crates/scroll/consensus/src/constants.rs +++ b/crates/scroll/consensus/src/constants.rs @@ -4,3 +4,6 @@ use alloy_primitives::U256; pub const CLIQUE_IN_TURN_DIFFICULTY: U256 = U256::from_limbs([2, 0, 0, 0]); /// The block difficulty for out of turn signing in the Clique consensus. pub const CLIQUE_NO_TURN_DIFFICULTY: U256 = U256::from_limbs([1, 0, 0, 0]); + +/// Maximum allowed base fee. We would only go above this if L1 base fee hits 2931 Gwei. +pub const SCROLL_MAXIMUM_BASE_FEE: u64 = 10000000000; diff --git a/crates/scroll/consensus/src/error.rs b/crates/scroll/consensus/src/error.rs index ca81567210a..09eca6d9b71 100644 --- a/crates/scroll/consensus/src/error.rs +++ b/crates/scroll/consensus/src/error.rs @@ -1,3 +1,5 @@ +use crate::constants::SCROLL_MAXIMUM_BASE_FEE; + use alloy_primitives::{Address, B256, B64, U256}; use reth_consensus::ConsensusError; @@ -37,6 +39,12 @@ pub enum ScrollConsensusError { /// Block extra data with invalid checkpoint signers. #[error("block extra data contains invalid checkpoint signers")] InvalidCheckpointSigners, + /// Block base fee present before Curie. + #[error("block base fee is set before Curie fork activation")] + UnexpectedBaseFee, + /// Block base fee over limit. + #[error("block base fee is over limit of {SCROLL_MAXIMUM_BASE_FEE}")] + BaseFeeOverLimit, /// Block body has non-empty withdrawals list. #[error("non-empty block body withdrawals list")] WithdrawalsNonEmpty, diff --git a/crates/scroll/consensus/src/lib.rs b/crates/scroll/consensus/src/lib.rs index 67c147fd9d8..8f8c4a8fb4a 100644 --- a/crates/scroll/consensus/src/lib.rs +++ b/crates/scroll/consensus/src/lib.rs @@ -3,7 +3,9 @@ extern crate alloc; mod constants; -pub use constants::{CLIQUE_IN_TURN_DIFFICULTY, CLIQUE_NO_TURN_DIFFICULTY}; +pub use constants::{ + CLIQUE_IN_TURN_DIFFICULTY, CLIQUE_NO_TURN_DIFFICULTY, SCROLL_MAXIMUM_BASE_FEE, +}; mod error; pub use error::ScrollConsensusError; diff --git a/crates/scroll/consensus/src/validation.rs b/crates/scroll/consensus/src/validation.rs index 9d1c6e8f213..b52e9804fe1 100644 --- a/crates/scroll/consensus/src/validation.rs +++ b/crates/scroll/consensus/src/validation.rs @@ -1,7 +1,9 @@ -use crate::error::ScrollConsensusError; +use crate::{ + constants::SCROLL_MAXIMUM_BASE_FEE, error::ScrollConsensusError, CLIQUE_IN_TURN_DIFFICULTY, + CLIQUE_NO_TURN_DIFFICULTY, +}; use alloc::sync::Arc; -use crate::{CLIQUE_IN_TURN_DIFFICULTY, CLIQUE_NO_TURN_DIFFICULTY}; use alloy_consensus::{BlockHeader as _, TxReceipt, EMPTY_OMMER_ROOT_HASH}; use alloy_primitives::{b64, Address, B256, B64, U256}; use core::fmt::Debug; @@ -14,12 +16,14 @@ use reth_consensus_common::validation::{ }; use reth_execution_types::BlockExecutionResult; use reth_primitives_traits::{ - receipt::gas_spent_by_transactions, Block, BlockBody, BlockHeader, GotExpected, NodePrimitives, - RecoveredBlock, SealedBlock, SealedHeader, SignedTransaction, + constants::{GAS_LIMIT_BOUND_DIVISOR, MINIMUM_GAS_LIMIT}, + receipt::gas_spent_by_transactions, + Block, BlockBody, BlockHeader, GotExpected, NodePrimitives, RecoveredBlock, SealedBlock, + SealedHeader, SignedTransaction, }; use reth_scroll_primitives::ScrollReceipt; use scroll_alloy_consensus::ScrollTransaction; -use scroll_alloy_hardforks::{ScrollHardfork, ScrollHardforks}; +use scroll_alloy_hardforks::ScrollHardforks; /// Scroll consensus implementation. /// @@ -143,7 +147,8 @@ impl HeaderValidator< validate_header_timestamp(header.header())?; validate_header_fields(header.header(), &self.chain_spec)?; validate_header_gas(header.header())?; - validate_header_base_fee(header.header(), &self.chain_spec) + validate_header_base_fee(header.header(), &self.chain_spec)?; + Ok(()) } fn validate_header_against_parent( @@ -153,10 +158,7 @@ impl HeaderValidator< ) -> Result<(), ConsensusError> { validate_against_parent_hash_number(header.header(), parent)?; validate_against_parent_timestamp(header.header(), parent.header())?; - - // TODO(scroll): we should have a way to validate the base fee from the header - // against the parent header using - // + validate_against_parent_gas_limit(header.header(), parent.header())?; // ensure that the blob gas fields for this block if self.chain_spec.blob_params_at_timestamp(header.timestamp()).is_some() { @@ -233,7 +235,7 @@ fn verify_header_fields_pre_euclid_v2( if is_checkpoint && header.beneficiary() != Address::ZERO { return Err(ScrollConsensusError::CoinbaseNotZero(header.beneficiary())) } - if header.nonce() != Some(B64::ZERO) || header.nonce() != Some(b64!("ffffffffffffffff")) { + if header.nonce() != Some(B64::ZERO) && header.nonce() != Some(b64!("ffffffffffffffff")) { return Err(ScrollConsensusError::InvalidCliqueNonce(header.nonce())) } if is_checkpoint && header.nonce() != Some(B64::ZERO) { @@ -250,7 +252,7 @@ fn verify_header_fields_pre_euclid_v2( return Err(ScrollConsensusError::InvalidCheckpointSigners) } let difficulty = header.difficulty(); - if difficulty != CLIQUE_IN_TURN_DIFFICULTY || difficulty != CLIQUE_NO_TURN_DIFFICULTY { + if difficulty != CLIQUE_IN_TURN_DIFFICULTY && difficulty != CLIQUE_NO_TURN_DIFFICULTY { return Err(ScrollConsensusError::InvalidCliqueDifficulty(difficulty)) } @@ -276,11 +278,21 @@ fn validate_header_timestamp(header: &H) -> Result<(), Consensus fn validate_header_base_fee( header: &H, chain_spec: &ChainSpec, -) -> Result<(), ConsensusError> { - if chain_spec.scroll_fork_activation(ScrollHardfork::Curie).active_at_block(header.number()) && - header.base_fee_per_gas().is_none() +) -> Result<(), ScrollConsensusError> { + if chain_spec.is_curie_active_at_block(header.number()) { + if header.base_fee_per_gas().is_none() { + return Err(ConsensusError::BaseFeeMissing.into()) + } + // note: we do not verify L2 base fee, the sequencer has the + // right to set any base fee below the maximum. L2 base fee + // is not subject to L2 consensus or zk verification. + if header.base_fee_per_gas().expect("checked") > SCROLL_MAXIMUM_BASE_FEE { + return Err(ScrollConsensusError::BaseFeeOverLimit) + } + } + if !chain_spec.is_curie_active_at_block(header.number()) && header.base_fee_per_gas().is_some() { - return Err(ConsensusError::BaseFeeMissing) + return Err(ScrollConsensusError::UnexpectedBaseFee) } Ok(()) } @@ -303,6 +315,34 @@ fn validate_against_parent_timestamp( Ok(()) } +/// Validates the gas limit of the block against the parent. +#[inline] +fn validate_against_parent_gas_limit( + header: &H, + parent: &H, +) -> Result<(), ConsensusError> { + let diff = header.gas_limit().abs_diff(parent.gas_limit()); + let limit = parent.gas_limit().saturating_div(GAS_LIMIT_BOUND_DIVISOR); + if diff > limit { + return if header.gas_limit() > parent.gas_limit() { + Err(ConsensusError::GasLimitInvalidIncrease { + parent_gas_limit: parent.gas_limit(), + child_gas_limit: parent.gas_limit(), + }) + } else { + Err(ConsensusError::GasLimitInvalidDecrease { + parent_gas_limit: parent.gas_limit(), + child_gas_limit: parent.gas_limit(), + }) + } + } + // Check that the gas limit is above the minimum allowed gas limit. + if header.gas_limit() < MINIMUM_GAS_LIMIT { + return Err(ConsensusError::GasLimitInvalidMinimum { child_gas_limit: header.gas_limit() }) + } + Ok(()) +} + /// Validate the L1 messages by checking they are only present that the start of the block and only /// have increasing queue index. #[inline] From 172efd32cfc7762aa707834881c559271e1e3da5 Mon Sep 17 00:00:00 2001 From: greg <82421016+greged93@users.noreply.github.com> Date: Fri, 8 Aug 2025 12:05:18 +0200 Subject: [PATCH 4/8] feat: withdraw trie root slot (#312) * feat: update execute_with_state_closure closure Signed-off-by: Gregory Edison * feat: LoadWithdrawRoot Signed-off-by: Gregory Edison * chore: fix clippy docs (#17726) Co-authored-by: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com> * fix: clippy Signed-off-by: Gregory Edison --------- Signed-off-by: Gregory Edison Co-authored-by: Matthias Seitz Co-authored-by: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com> --- Cargo.lock | 1 + crates/evm/evm/src/either.rs | 2 +- crates/evm/evm/src/execute.rs | 4 +- crates/ress/provider/src/lib.rs | 2 +- crates/rpc/rpc/Cargo.toml | 4 + crates/rpc/rpc/src/debug.rs | 9 +- crates/scroll/evm/Cargo.toml | 2 +- crates/scroll/evm/src/lib.rs | 3 + crates/scroll/evm/src/withdraw_root.rs | 99 +++++++++++++++++++ crates/scroll/rpc/Cargo.toml | 2 +- testing/ef-tests/src/cases/blockchain_test.rs | 2 +- 11 files changed, 122 insertions(+), 8 deletions(-) create mode 100644 crates/scroll/evm/src/withdraw_root.rs diff --git a/Cargo.lock b/Cargo.lock index 601c6b0d5a1..dd12a4675d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9996,6 +9996,7 @@ dependencies = [ "reth-rpc-eth-api", "reth-rpc-eth-types", "reth-rpc-server-types", + "reth-scroll-evm", "reth-storage-api", "reth-tasks", "reth-testing-utils", diff --git a/crates/evm/evm/src/either.rs b/crates/evm/evm/src/either.rs index 904ce7ebbd6..d497e142d7a 100644 --- a/crates/evm/evm/src/either.rs +++ b/crates/evm/evm/src/either.rs @@ -58,7 +58,7 @@ where state: F, ) -> Result::Receipt>, Self::Error> where - F: FnMut(&revm::database::State), + F: FnMut(&mut revm::database::State), { match self { Self::Left(a) => a.execute_with_state_closure(block, state), diff --git a/crates/evm/evm/src/execute.rs b/crates/evm/evm/src/execute.rs index 40c680d3868..45e929d23dd 100644 --- a/crates/evm/evm/src/execute.rs +++ b/crates/evm/evm/src/execute.rs @@ -100,11 +100,11 @@ pub trait Executor: Sized { mut f: F, ) -> Result::Receipt>, Self::Error> where - F: FnMut(&State), + F: FnMut(&mut State), { let result = self.execute_one(block)?; let mut state = self.into_state(); - f(&state); + f(&mut state); Ok(BlockExecutionOutput { state: state.take_bundle(), result }) } diff --git a/crates/ress/provider/src/lib.rs b/crates/ress/provider/src/lib.rs index 41318ebaaf1..cef54540b73 100644 --- a/crates/ress/provider/src/lib.rs +++ b/crates/ress/provider/src/lib.rs @@ -154,7 +154,7 @@ where // invalid blocks. if let Err(error) = self.evm_config.batch_executor(&mut db).execute_with_state_closure( &block, - |state: &State<_>| { + |state: &mut State<_>| { record.record_executed_state(state); }, ) { diff --git a/crates/rpc/rpc/Cargo.toml b/crates/rpc/rpc/Cargo.toml index e0d1fcb601f..304849a9a17 100644 --- a/crates/rpc/rpc/Cargo.toml +++ b/crates/rpc/rpc/Cargo.toml @@ -64,6 +64,9 @@ alloy-serde.workspace = true revm = { workspace = true, features = ["optional_block_gas_limit", "optional_eip3607", "optional_no_base_fee"] } revm-primitives = { workspace = true, features = ["serde"] } +# scroll +reth-scroll-evm = { workspace = true, optional = true } + # rpc jsonrpsee.workspace = true http.workspace = true @@ -104,3 +107,4 @@ jsonrpsee = { workspace = true, features = ["client"] } [features] js-tracer = ["revm-inspectors/js-tracer", "reth-rpc-eth-types/js-tracer"] +scroll = ["reth-scroll-evm"] diff --git a/crates/rpc/rpc/src/debug.rs b/crates/rpc/rpc/src/debug.rs index e5fa07e0e51..3e88bf8a82d 100644 --- a/crates/rpc/rpc/src/debug.rs +++ b/crates/rpc/rpc/src/debug.rs @@ -640,11 +640,18 @@ where let mut witness_record = ExecutionWitnessRecord::default(); + let mut withdraw_root_res: Result<_, reth_errors::ProviderError> = Ok(()); let _ = block_executor - .execute_with_state_closure(&(*block).clone(), |statedb: &State<_>| { + .execute_with_state_closure(&(*block).clone(), |statedb: &mut State<_>| { + #[cfg(feature = "scroll")] + { + use reth_scroll_evm::LoadWithdrawRoot; + withdraw_root_res = statedb.load_withdraw_root(); + } witness_record.record_executed_state(statedb); }) .map_err(|err| EthApiError::Internal(err.into()))?; + withdraw_root_res?; let ExecutionWitnessRecord { hashed_state, codes, keys, lowest_block_number } = witness_record; diff --git a/crates/scroll/evm/Cargo.toml b/crates/scroll/evm/Cargo.toml index 1874d574578..c1601ffb685 100644 --- a/crates/scroll/evm/Cargo.toml +++ b/crates/scroll/evm/Cargo.toml @@ -47,7 +47,7 @@ thiserror.workspace = true tracing.workspace = true [dev-dependencies] -alloy-primitives = { workspace = true, features = ["getrandom"] } +alloy-primitives = { workspace = true, features = ["getrandom", "rand"] } eyre.workspace = true [features] diff --git a/crates/scroll/evm/src/lib.rs b/crates/scroll/evm/src/lib.rs index 2268a1d7a03..d8135610e1f 100644 --- a/crates/scroll/evm/src/lib.rs +++ b/crates/scroll/evm/src/lib.rs @@ -23,6 +23,9 @@ pub use base_fee::{ mod receipt; pub use receipt::ScrollRethReceiptBuilder; +mod withdraw_root; +pub use withdraw_root::LoadWithdrawRoot; + use crate::build::ScrollBlockAssembler; use alloc::sync::Arc; diff --git a/crates/scroll/evm/src/withdraw_root.rs b/crates/scroll/evm/src/withdraw_root.rs new file mode 100644 index 00000000000..4775b889b55 --- /dev/null +++ b/crates/scroll/evm/src/withdraw_root.rs @@ -0,0 +1,99 @@ +use alloy_primitives::{address, Address, U256}; +use revm::{database::State, Database}; + +const L2_MESSAGE_QUEUE_ADDRESS: Address = address!("0x5300000000000000000000000000000000000000"); +const WITHDRAW_TRIE_ROOT_SLOT: U256 = U256::ZERO; + +/// Instance that implements the trait can load the `L2MessageQueue` withdraw root in state. +pub trait LoadWithdrawRoot { + /// Load the withdrawal root. + fn load_withdraw_root(&mut self) -> Result<(), DB::Error>; +} + +impl LoadWithdrawRoot for State { + fn load_withdraw_root(&mut self) -> Result<(), DB::Error> { + // we load the account in cache and query the storage slot. The storage slot will only be + // loaded from database if it is not already know. + self.load_cache_account(L2_MESSAGE_QUEUE_ADDRESS)?; + let _ = revm::Database::storage(self, L2_MESSAGE_QUEUE_ADDRESS, WITHDRAW_TRIE_ROOT_SLOT); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{collections::HashMap, convert::Infallible}; + + use alloy_primitives::B256; + use revm::{bytecode::Bytecode, state::AccountInfo}; + use revm_primitives::{StorageKey, StorageValue}; + + #[derive(Default)] + struct InMemoryDb { + pub accounts: HashMap, + pub storage: HashMap<(Address, U256), U256>, + } + + impl Database for InMemoryDb { + type Error = Infallible; + + fn basic(&mut self, address: Address) -> Result, Self::Error> { + Ok(self.accounts.get(&address).cloned()) + } + + fn code_by_hash(&mut self, _code_hash: B256) -> Result { + Ok(Default::default()) + } + + fn storage( + &mut self, + address: Address, + index: StorageKey, + ) -> Result { + Ok(self.storage.get(&(address, index)).copied().unwrap_or_default()) + } + + fn block_hash(&mut self, _number: u64) -> Result { + Ok(Default::default()) + } + } + + #[test] + fn test_should_load_withdraw_root() -> eyre::Result<()> { + // init db + let mut db = InMemoryDb::default(); + + // load L2 message queue contract + let withdraw_root = U256::random(); + db.accounts.insert(L2_MESSAGE_QUEUE_ADDRESS, Default::default()); + db.storage.insert((L2_MESSAGE_QUEUE_ADDRESS, U256::ZERO), withdraw_root); + + let mut state = + State::builder().with_database(db).with_bundle_update().without_state_clear().build(); + + assert!(state + .cache + .accounts + .get(&L2_MESSAGE_QUEUE_ADDRESS) + .map(|acc| acc.storage_slot(WITHDRAW_TRIE_ROOT_SLOT)) + .is_none()); + + // load root + state.load_withdraw_root()?; + + assert_eq!( + state + .cache + .accounts + .get(&L2_MESSAGE_QUEUE_ADDRESS) + .unwrap() + .storage_slot(WITHDRAW_TRIE_ROOT_SLOT) + .unwrap(), + withdraw_root + ); + + Ok(()) + } +} diff --git a/crates/scroll/rpc/Cargo.toml b/crates/scroll/rpc/Cargo.toml index 5bf30ccaa36..91850423b94 100644 --- a/crates/scroll/rpc/Cargo.toml +++ b/crates/scroll/rpc/Cargo.toml @@ -21,7 +21,7 @@ reth-rpc-eth-api = { workspace = true, features = ["scroll"] } reth-rpc-eth-types.workspace = true reth-tasks = { workspace = true, features = ["rayon"] } reth-transaction-pool.workspace = true -reth-rpc.workspace = true +reth-rpc = { workspace = true, features = ["scroll"] } reth-rpc-convert = { workspace = true, features = ["scroll"] } reth-node-api.workspace = true reth-node-builder.workspace = true diff --git a/testing/ef-tests/src/cases/blockchain_test.rs b/testing/ef-tests/src/cases/blockchain_test.rs index 4c463c612a6..b4cbbf89fce 100644 --- a/testing/ef-tests/src/cases/blockchain_test.rs +++ b/testing/ef-tests/src/cases/blockchain_test.rs @@ -237,7 +237,7 @@ fn run_case(case: &BlockchainTest) -> Result<(), Error> { let executor = executor_provider.batch_executor(state_db); let output = executor - .execute_with_state_closure(&(*block).clone(), |statedb: &State<_>| { + .execute_with_state_closure(&(*block).clone(), |statedb: &mut State<_>| { witness_record.record_executed_state(statedb); }) .map_err(|err| Error::block_failed(block_number, err))?; From f5e810fb6bcaf13d1d496fa16e5a2e667dbc3a89 Mon Sep 17 00:00:00 2001 From: Gregory Edison Date: Fri, 8 Aug 2025 14:27:08 +0200 Subject: [PATCH 5/8] chore: reorg Signed-off-by: Gregory Edison --- .../alloy/consensus/src/transaction/envelope.rs | 12 +++++++++--- .../alloy/consensus/src/transaction/l1_message.rs | 8 -------- crates/scroll/alloy/consensus/src/transaction/mod.rs | 6 ++---- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/crates/scroll/alloy/consensus/src/transaction/envelope.rs b/crates/scroll/alloy/consensus/src/transaction/envelope.rs index 0fcc67f625f..2905667012f 100644 --- a/crates/scroll/alloy/consensus/src/transaction/envelope.rs +++ b/crates/scroll/alloy/consensus/src/transaction/envelope.rs @@ -1,6 +1,4 @@ -use crate::{ - ScrollPooledTransaction, ScrollTransaction, ScrollTxType, ScrollTypedTransaction, TxL1Message, -}; +use crate::{ScrollPooledTransaction, ScrollTxType, ScrollTypedTransaction, TxL1Message}; use core::hash::Hash; use alloy_consensus::{ @@ -426,6 +424,14 @@ impl ScrollTxEnvelope { } } +/// A Scroll chain transaction. +pub trait ScrollTransaction { + /// Returns true if the transaction is a L1 message. + fn is_l1_message(&self) -> bool; + /// Returns the queue index if the transaction is a L1 message, None otherwise. + fn queue_index(&self) -> Option; +} + impl ScrollTransaction for ScrollTxEnvelope { fn is_l1_message(&self) -> bool { match self { diff --git a/crates/scroll/alloy/consensus/src/transaction/l1_message.rs b/crates/scroll/alloy/consensus/src/transaction/l1_message.rs index af75863dea4..0c200904fbc 100644 --- a/crates/scroll/alloy/consensus/src/transaction/l1_message.rs +++ b/crates/scroll/alloy/consensus/src/transaction/l1_message.rs @@ -17,14 +17,6 @@ use {reth_codecs::Compact, reth_codecs_derive::add_arbitrary_tests}; /// L1 message transaction type id, 0x7e in hex. pub const L1_MESSAGE_TRANSACTION_TYPE: u8 = 126; -/// A Scroll chain transaction. -pub trait ScrollTransaction { - /// Returns true if the transaction is a L1 message. - fn is_l1_message(&self) -> bool; - /// Returns the queue index if the transaction is a L1 message, None otherwise. - fn queue_index(&self) -> Option; -} - /// A message transaction sent from the settlement layer to the L2 for execution. /// /// The signature of the L1 message is already verified on the L1 and as such doesn't contain diff --git a/crates/scroll/alloy/consensus/src/transaction/mod.rs b/crates/scroll/alloy/consensus/src/transaction/mod.rs index f57681bb962..b2f08383ce6 100644 --- a/crates/scroll/alloy/consensus/src/transaction/mod.rs +++ b/crates/scroll/alloy/consensus/src/transaction/mod.rs @@ -4,12 +4,10 @@ mod tx_type; pub use tx_type::{ScrollTxType, L1_MESSAGE_TX_TYPE_ID}; mod envelope; -pub use envelope::ScrollTxEnvelope; +pub use envelope::{ScrollTransaction, ScrollTxEnvelope}; mod l1_message; -pub use l1_message::{ - ScrollL1MessageTransactionFields, ScrollTransaction, TxL1Message, L1_MESSAGE_TRANSACTION_TYPE, -}; +pub use l1_message::{ScrollL1MessageTransactionFields, TxL1Message, L1_MESSAGE_TRANSACTION_TYPE}; mod meta; pub use meta::{ScrollAdditionalInfo, ScrollTransactionInfo}; From 4d55a25db91883bd1a028c5d0a52633fbe48bc30 Mon Sep 17 00:00:00 2001 From: Gregory Edison Date: Mon, 11 Aug 2025 13:14:27 +0200 Subject: [PATCH 6/8] feat: answer comment + add unit test Signed-off-by: Gregory Edison --- Cargo.lock | 2 + crates/scroll/consensus/Cargo.toml | 6 +- crates/scroll/consensus/src/validation.rs | 445 +++++++++++++++++++++- 3 files changed, 447 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dd12a4675d6..429ca640281 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10378,12 +10378,14 @@ version = "1.6.0" dependencies = [ "alloy-consensus", "alloy-primitives", + "critical-section", "reth-chainspec", "reth-consensus", "reth-consensus-common", "reth-ethereum-consensus", "reth-execution-types", "reth-primitives-traits", + "reth-scroll-chainspec", "reth-scroll-primitives", "scroll-alloy-consensus", "scroll-alloy-hardforks", diff --git a/crates/scroll/consensus/Cargo.toml b/crates/scroll/consensus/Cargo.toml index a4cce400b52..6e12a4ae2cb 100644 --- a/crates/scroll/consensus/Cargo.toml +++ b/crates/scroll/consensus/Cargo.toml @@ -14,7 +14,7 @@ workspace = true [dependencies] # alloy alloy-consensus.workspace = true -alloy-primitives.workspace = true +alloy-primitives = { workspace = true, features = ["getrandom"] } # reth reth-chainspec.workspace = true @@ -35,3 +35,7 @@ tracing.workspace = true [package.metadata.cargo-udeps.ignore] normal = ["reth-primitives"] + +[dev-dependencies] +reth-scroll-chainspec.workspace = true +critical-section = { version = "1.0", features = ["std"] } diff --git a/crates/scroll/consensus/src/validation.rs b/crates/scroll/consensus/src/validation.rs index b52e9804fe1..4ba0239ea81 100644 --- a/crates/scroll/consensus/src/validation.rs +++ b/crates/scroll/consensus/src/validation.rs @@ -349,11 +349,6 @@ fn validate_against_parent_gas_limit( fn validate_l1_messages( txs: &[Tx], ) -> Result<(), ScrollConsensusError> { - // Check if the block contains L1 messages. - if !txs.iter().any(ScrollTransaction::is_l1_message) { - return Ok(()) - } - // Check L1 messages are only at the start of the block and correctly ordered. let mut saw_l2_transaction = false; let mut queue_index = 0; @@ -377,3 +372,443 @@ fn validate_l1_messages( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::ScrollConsensusError; + use alloy_consensus::{Header, Signed, TxEip1559}; + use alloy_primitives::{b64, Address, Bloom, Bytes, Signature, B256, U256}; + use reth_consensus::ConsensusError; + use reth_primitives_traits::constants::{GAS_LIMIT_BOUND_DIVISOR, MINIMUM_GAS_LIMIT}; + use reth_scroll_chainspec::SCROLL_MAINNET; + use scroll_alloy_consensus::{ScrollTxEnvelope, TxL1Message}; + + fn create_test_header() -> Header { + Header { + parent_hash: B256::random(), + ommers_hash: EMPTY_OMMER_ROOT_HASH, + beneficiary: Address::ZERO, + state_root: B256::random(), + transactions_root: B256::random(), + receipts_root: B256::random(), + logs_bloom: Bloom::default(), + difficulty: U256::ONE, + number: 1, + gas_limit: 30000000, + gas_used: 0, + timestamp: 1000, + extra_data: Bytes::new(), + mix_hash: B256::ZERO, + nonce: B64::ZERO, + base_fee_per_gas: None, + withdrawals_root: None, + blob_gas_used: None, + excess_blob_gas: None, + parent_beacon_block_root: None, + requests_hash: None, + } + } + + #[test] + fn test_validate_header_timestamp_success() { + let header = create_test_header(); + assert!(validate_header_timestamp(&header).is_ok()); + } + + #[test] + fn test_validate_header_timestamp_future() { + let mut header = create_test_header(); + // timestamp in the future. + header.timestamp = u64::MAX; + + let result = validate_header_timestamp(&header); + assert!(matches!(result, Err(ConsensusError::TimestampIsInPast { .. }))); + } + + #[test] + fn test_verify_header_fields_post_euclid_v2_success() { + let header = create_test_header(); + assert!(verify_header_fields_post_euclid_v2(&header).is_ok()); + } + + #[test] + fn test_verify_header_fields_post_euclid_v2_coinbase_not_zero() { + let mut header = create_test_header(); + header.beneficiary = Address::random(); + + let result = verify_header_fields_post_euclid_v2(&header); + assert!(matches!(result, Err(ScrollConsensusError::CoinbaseNotZero(_)))); + } + + #[test] + fn test_verify_header_fields_post_euclid_v2_nonce_not_zero() { + let mut header = create_test_header(); + header.nonce = b64!("0123456789abcdef"); + + let result = verify_header_fields_post_euclid_v2(&header); + assert!(matches!(result, Err(ScrollConsensusError::NonceNotZero(_)))); + } + + #[test] + fn test_verify_header_fields_post_euclid_v2_difficulty_not_one() { + let mut header = create_test_header(); + header.difficulty = U256::from(2); + + let result = verify_header_fields_post_euclid_v2(&header); + assert!(matches!(result, Err(ScrollConsensusError::DifficultyNotOne(_)))); + } + + #[test] + fn test_verify_header_fields_post_euclid_v2_extra_data_not_empty() { + let mut header = create_test_header(); + header.extra_data = Bytes::from(vec![1, 2, 3]); + + let result = verify_header_fields_post_euclid_v2(&header); + assert!(matches!( + result, + Err(ScrollConsensusError::Eth(ConsensusError::ExtraDataExceedsMax { .. })) + )); + } + + #[test] + fn test_verify_header_fields_pre_euclid_v2_success() { + let mut header = create_test_header(); + // valid extra data (32 bytes vanity + 65 bytes signature). + let mut extra_data = vec![0u8; 32]; + extra_data.extend_from_slice(&[0u8; 65]); + header.extra_data = Bytes::from(extra_data); + + assert!(verify_header_fields_pre_euclid_v2(&header, 30000).is_ok()); + } + + #[test] + fn test_verify_header_fields_pre_euclid_v2_checkpoint_coinbase_not_zero() { + let mut header = create_test_header(); + // checkpoint block. + header.number = 0; + header.beneficiary = Address::random(); + let mut extra_data = vec![0u8; 32]; + extra_data.extend_from_slice(&[0u8; 65]); + header.extra_data = Bytes::from(extra_data); + + let result = verify_header_fields_pre_euclid_v2(&header, 30000); + assert!(matches!(result, Err(ScrollConsensusError::CoinbaseNotZero(_)))); + } + + #[test] + fn test_verify_header_fields_pre_euclid_v2_invalid_nonce() { + let mut header = create_test_header(); + // invalid nonce. + header.nonce = b64!("1234567890abcdef"); + let mut extra_data = vec![0u8; 32]; + extra_data.extend_from_slice(&[0u8; 65]); + header.extra_data = Bytes::from(extra_data); + + let result = verify_header_fields_pre_euclid_v2(&header, 30000); + assert!(matches!(result, Err(ScrollConsensusError::InvalidCliqueNonce(_)))); + } + + #[test] + fn test_verify_header_fields_pre_euclid_v2_missing_vanity() { + let mut header = create_test_header(); + // vanity too short. + header.extra_data = Bytes::from(vec![0u8; 31]); + + let result = verify_header_fields_pre_euclid_v2(&header, 30000); + assert!(matches!(result, Err(ScrollConsensusError::MissingVanity))); + } + + #[test] + fn test_verify_header_fields_pre_euclid_v2_missing_signature() { + let mut header = create_test_header(); + // signature too short. + header.extra_data = Bytes::from(vec![0u8; 32]); + + let result = verify_header_fields_pre_euclid_v2(&header, 30000); + assert!(matches!(result, Err(ScrollConsensusError::MissingSignature))); + } + + #[test] + fn test_verify_header_fields_pre_euclid_v2_invalid_difficulty() { + let mut header = create_test_header(); + // invalid difficulty. + header.difficulty = U256::from(3); + let mut extra_data = vec![0u8; 32]; + extra_data.extend_from_slice(&[0u8; 65]); + header.extra_data = Bytes::from(extra_data); + + let result = verify_header_fields_pre_euclid_v2(&header, 30000); + assert!(matches!(result, Err(ScrollConsensusError::InvalidCliqueDifficulty(_)))); + } + + #[test] + fn test_validate_against_parent_timestamp_success() { + let parent = create_test_header(); + let mut header = create_test_header(); + header.timestamp = parent.timestamp + 1; + + assert!(validate_against_parent_timestamp(&header, &parent).is_ok()); + } + + #[test] + fn test_validate_against_parent_timestamp_same_time() { + let parent = create_test_header(); + let header = create_test_header(); + + assert!(validate_against_parent_timestamp(&header, &parent).is_ok()); + } + + #[test] + fn test_validate_against_parent_timestamp_in_past() { + let parent = create_test_header(); + let mut header = create_test_header(); + header.timestamp = parent.timestamp - 1; + + let result = validate_against_parent_timestamp(&header, &parent); + assert!(matches!(result, Err(ConsensusError::TimestampIsInPast { .. }))); + } + + #[test] + fn test_validate_against_parent_gas_limit_success() { + let parent = create_test_header(); + let mut header = create_test_header(); + // small gas increase. + header.gas_limit = parent.gas_limit + 100; + + assert!(validate_against_parent_gas_limit(&header, &parent).is_ok()); + } + + #[test] + fn test_validate_against_parent_gas_limit_too_high_increase() { + let parent = create_test_header(); + let mut header = create_test_header(); + header.gas_limit = parent.gas_limit + parent.gas_limit / GAS_LIMIT_BOUND_DIVISOR + 1; + + let result = validate_against_parent_gas_limit(&header, &parent); + assert!(matches!(result, Err(ConsensusError::GasLimitInvalidIncrease { .. }))); + } + + #[test] + fn test_validate_against_parent_gas_limit_too_high_decrease() { + let parent = create_test_header(); + let mut header = create_test_header(); + header.gas_limit = parent.gas_limit - parent.gas_limit / GAS_LIMIT_BOUND_DIVISOR - 1; + + let result = validate_against_parent_gas_limit(&header, &parent); + assert!(matches!(result, Err(ConsensusError::GasLimitInvalidDecrease { .. }))); + } + + #[test] + fn test_validate_against_parent_gas_limit_below_minimum() { + let mut parent = create_test_header(); + let mut header = create_test_header(); + parent.gas_limit = MINIMUM_GAS_LIMIT + 1; + header.gas_limit = MINIMUM_GAS_LIMIT - 1; + + let result = validate_against_parent_gas_limit(&header, &parent); + dbg!(&result); + assert!(matches!(result, Err(ConsensusError::GasLimitInvalidMinimum { .. }))); + } + + #[test] + fn test_validate_l1_messages_success() { + let txs: Vec = vec![ + TxL1Message { queue_index: 0, ..Default::default() }.into(), + TxL1Message { queue_index: 1, ..Default::default() }.into(), + Signed::new_unchecked( + TxEip1559::default(), + Signature::new(U256::ZERO, U256::ZERO, false), + B256::random(), + ) + .into(), + Signed::new_unchecked( + TxEip1559::default(), + Signature::new(U256::ZERO, U256::ZERO, false), + B256::random(), + ) + .into(), + ]; + + assert!(validate_l1_messages(&txs).is_ok()); + } + + #[test] + fn test_validate_l1_messages_empty() { + let txs: Vec = vec![]; + assert!(validate_l1_messages(&txs).is_ok()); + } + + #[test] + fn test_validate_l1_messages_only_l2() { + let txs: Vec = vec![ + Signed::new_unchecked( + TxEip1559::default(), + Signature::new(U256::ZERO, U256::ZERO, false), + B256::random(), + ) + .into(), + Signed::new_unchecked( + TxEip1559::default(), + Signature::new(U256::ZERO, U256::ZERO, false), + B256::random(), + ) + .into(), + Signed::new_unchecked( + TxEip1559::default(), + Signature::new(U256::ZERO, U256::ZERO, false), + B256::random(), + ) + .into(), + Signed::new_unchecked( + TxEip1559::default(), + Signature::new(U256::ZERO, U256::ZERO, false), + B256::random(), + ) + .into(), + ]; + + assert!(validate_l1_messages(&txs).is_ok()); + } + + #[test] + fn test_validate_l1_messages_invalid_order() { + let txs: Vec = vec![ + Signed::new_unchecked( + TxEip1559::default(), + Signature::new(U256::ZERO, U256::ZERO, false), + B256::random(), + ) + .into(), + TxL1Message { queue_index: 0, ..Default::default() }.into(), + ]; + + let result = validate_l1_messages(&txs); + assert!(matches!(result, Err(ScrollConsensusError::InvalidL1MessageOrder))); + } + + #[test] + fn test_validate_l1_messages_non_sequential_queue_index() { + let txs: Vec = vec![ + TxL1Message { queue_index: 0, ..Default::default() }.into(), + TxL1Message { queue_index: 2, ..Default::default() }.into(), + ]; + + // ok as it's not decreasing. + assert!(validate_l1_messages(&txs).is_ok()); + } + + #[test] + fn test_validate_l1_messages_decreasing_queue_index() { + let txs: Vec = vec![ + TxL1Message { queue_index: 1, ..Default::default() }.into(), + TxL1Message { queue_index: 0, ..Default::default() }.into(), + ]; + + let result = validate_l1_messages(&txs); + assert!(matches!(result, Err(ScrollConsensusError::InvalidL1MessageOrder))); + } + + #[test] + fn test_validate_header_base_fee_before_curie() { + let chain_spec = SCROLL_MAINNET.clone(); + + let mut header = create_test_header(); + // pre Curie. + header.number = 500; + header.base_fee_per_gas = Some(1000000000); + + let result = validate_header_base_fee(&header, &chain_spec); + assert!(matches!(result, Err(ScrollConsensusError::UnexpectedBaseFee))); + } + + #[test] + fn test_validate_header_base_fee_after_curie_missing() { + let chain_spec = SCROLL_MAINNET.clone(); + + let mut header = create_test_header(); + // post Curie. + header.number = 7096837; + header.base_fee_per_gas = None; + + let result = validate_header_base_fee(&header, &chain_spec); + assert!(matches!(result, Err(ScrollConsensusError::Eth(ConsensusError::BaseFeeMissing)))); + } + + #[test] + fn test_validate_header_base_fee_after_curie_over_limit() { + let chain_spec = SCROLL_MAINNET.clone(); + + let mut header = create_test_header(); + // post Curie. + header.number = 7096837; + header.base_fee_per_gas = Some(SCROLL_MAXIMUM_BASE_FEE + 1); + + let result = validate_header_base_fee(&header, &chain_spec); + assert!(matches!(result, Err(ScrollConsensusError::BaseFeeOverLimit))); + } + + #[test] + fn test_validate_header_base_fee_after_curie_valid() { + let chain_spec = SCROLL_MAINNET.clone(); + + let mut header = create_test_header(); + // post Curie. + header.number = 7096837; + header.base_fee_per_gas = Some(1000000000); + + let result = validate_header_base_fee(&header, &chain_spec); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_header_fields_pre_euclid_v2() { + let mut header = create_test_header(); + // pre Euclid v2. + header.timestamp = 1745305199; + // valid extra data for pre-euclid v2. + let mut extra_data = vec![0u8; 32]; + extra_data.extend_from_slice(&[0u8; 65]); + header.extra_data = Bytes::from(extra_data); + + assert!(verify_header_fields_pre_euclid_v2(&header, 30000).is_ok()); + } + + #[test] + fn test_validate_header_fields_post_euclid_v2() { + let chain_spec = SCROLL_MAINNET.clone(); + + let mut header = create_test_header(); + // post Euclid v2. + header.timestamp = 1745305201; + + assert!(validate_header_fields(&header, &chain_spec).is_ok()); + } + + #[test] + fn test_validate_header_fields_mix_hash_not_zero() { + let chain_spec = SCROLL_MAINNET.clone(); + + let mut header = create_test_header(); + // invalid mix hash. + header.mix_hash = B256::random(); + + let result = validate_header_fields(&header, &chain_spec); + assert!(matches!(result, Err(ScrollConsensusError::MixHashNotZero(_)))); + } + + #[test] + fn test_validate_header_fields_ommers_hash_not_empty() { + let chain_spec = SCROLL_MAINNET.clone(); + + let mut header = create_test_header(); + // invalid ommer hash. + header.ommers_hash = B256::random(); + + let result = validate_header_fields(&header, &chain_spec); + assert!(matches!( + result, + Err(ScrollConsensusError::Eth(ConsensusError::TheMergeOmmerRootIsNotEmpty)) + )); + } +} From fb3a5add144dc2ca8e9f3f2e5ac4d4f2cefde590 Mon Sep 17 00:00:00 2001 From: Gregory Edison Date: Mon, 11 Aug 2025 14:08:24 +0200 Subject: [PATCH 7/8] feat: answer comment Signed-off-by: Gregory Edison --- Cargo.lock | 1 - crates/scroll/consensus/Cargo.toml | 1 - crates/scroll/consensus/src/validation.rs | 3 ++- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 429ca640281..5d6f344f4ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10378,7 +10378,6 @@ version = "1.6.0" dependencies = [ "alloy-consensus", "alloy-primitives", - "critical-section", "reth-chainspec", "reth-consensus", "reth-consensus-common", diff --git a/crates/scroll/consensus/Cargo.toml b/crates/scroll/consensus/Cargo.toml index 6e12a4ae2cb..0456665bafa 100644 --- a/crates/scroll/consensus/Cargo.toml +++ b/crates/scroll/consensus/Cargo.toml @@ -38,4 +38,3 @@ normal = ["reth-primitives"] [dev-dependencies] reth-scroll-chainspec.workspace = true -critical-section = { version = "1.0", features = ["std"] } diff --git a/crates/scroll/consensus/src/validation.rs b/crates/scroll/consensus/src/validation.rs index 4ba0239ea81..5cc68082082 100644 --- a/crates/scroll/consensus/src/validation.rs +++ b/crates/scroll/consensus/src/validation.rs @@ -322,7 +322,7 @@ fn validate_against_parent_gas_limit( parent: &H, ) -> Result<(), ConsensusError> { let diff = header.gas_limit().abs_diff(parent.gas_limit()); - let limit = parent.gas_limit().saturating_div(GAS_LIMIT_BOUND_DIVISOR); + let limit = parent.gas_limit() / GAS_LIMIT_BOUND_DIVISOR; if diff > limit { return if header.gas_limit() > parent.gas_limit() { Err(ConsensusError::GasLimitInvalidIncrease { @@ -377,6 +377,7 @@ fn validate_l1_messages( mod tests { use super::*; use crate::ScrollConsensusError; + use alloy_consensus::{Header, Signed, TxEip1559}; use alloy_primitives::{b64, Address, Bloom, Bytes, Signature, B256, U256}; use reth_consensus::ConsensusError; From 436b8cc51bf24b7c7fd0c611341338bc40bee345 Mon Sep 17 00:00:00 2001 From: Gregory Edison Date: Wed, 13 Aug 2025 17:54:39 +0200 Subject: [PATCH 8/8] feat: check sequential L1 message post EuclidV2 Signed-off-by: Gregory Edison --- crates/scroll/consensus/src/validation.rs | 46 ++++++++++++++++++----- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/crates/scroll/consensus/src/validation.rs b/crates/scroll/consensus/src/validation.rs index 5cc68082082..86a6aa537de 100644 --- a/crates/scroll/consensus/src/validation.rs +++ b/crates/scroll/consensus/src/validation.rs @@ -134,7 +134,11 @@ where } // Check L1 messages. - validate_l1_messages(block.body().transactions())?; + let ts = block.header().timestamp(); + validate_l1_messages( + block.body().transactions(), + self.chain_spec.is_euclid_v2_active_at_timestamp(ts), + )?; Ok(()) } @@ -348,16 +352,28 @@ fn validate_against_parent_gas_limit( #[inline] fn validate_l1_messages( txs: &[Tx], + is_euclid_v2: bool, ) -> Result<(), ScrollConsensusError> { // Check L1 messages are only at the start of the block and correctly ordered. let mut saw_l2_transaction = false; - let mut queue_index = 0; + let mut queue_index = txs + .iter() + .find(|tx| tx.is_l1_message()) + .and_then(|tx| tx.queue_index()) + .unwrap_or_default(); + + // starting at EuclidV2, we don't skip L1 messages. + let l1_message_index_check: fn(u64, u64) -> bool = if is_euclid_v2 { + |tx_queue_index, queue_index| tx_queue_index != queue_index + } else { + |tx_queue_index, queue_index| tx_queue_index < queue_index + }; for tx in txs { - // Check index is strictly increasing. + // Check index is strictly increasing pre EuclidV2 and sequential post EuclidV2. if tx.is_l1_message() { let tx_queue_index = tx.queue_index().expect("is_l1_message"); - if tx_queue_index < queue_index { + if l1_message_index_check(tx_queue_index, queue_index) { return Err(ScrollConsensusError::InvalidL1MessageOrder); } queue_index = tx_queue_index + 1; @@ -631,13 +647,15 @@ mod tests { .into(), ]; - assert!(validate_l1_messages(&txs).is_ok()); + assert!(validate_l1_messages(&txs, true).is_ok()); + assert!(validate_l1_messages(&txs, false).is_ok()); } #[test] fn test_validate_l1_messages_empty() { let txs: Vec = vec![]; - assert!(validate_l1_messages(&txs).is_ok()); + assert!(validate_l1_messages(&txs, true).is_ok()); + assert!(validate_l1_messages(&txs, false).is_ok()); } #[test] @@ -669,7 +687,8 @@ mod tests { .into(), ]; - assert!(validate_l1_messages(&txs).is_ok()); + assert!(validate_l1_messages(&txs, true).is_ok()); + assert!(validate_l1_messages(&txs, false).is_ok()); } #[test] @@ -684,7 +703,9 @@ mod tests { TxL1Message { queue_index: 0, ..Default::default() }.into(), ]; - let result = validate_l1_messages(&txs); + let result = validate_l1_messages(&txs, true); + assert!(matches!(result, Err(ScrollConsensusError::InvalidL1MessageOrder))); + let result = validate_l1_messages(&txs, false); assert!(matches!(result, Err(ScrollConsensusError::InvalidL1MessageOrder))); } @@ -696,7 +717,10 @@ mod tests { ]; // ok as it's not decreasing. - assert!(validate_l1_messages(&txs).is_ok()); + assert!(validate_l1_messages(&txs, false).is_ok()); + // not ok as it's not sequential. + let result = validate_l1_messages(&txs, true); + assert!(matches!(result, Err(ScrollConsensusError::InvalidL1MessageOrder))); } #[test] @@ -706,7 +730,9 @@ mod tests { TxL1Message { queue_index: 0, ..Default::default() }.into(), ]; - let result = validate_l1_messages(&txs); + let result = validate_l1_messages(&txs, true); + assert!(matches!(result, Err(ScrollConsensusError::InvalidL1MessageOrder))); + let result = validate_l1_messages(&txs, false); assert!(matches!(result, Err(ScrollConsensusError::InvalidL1MessageOrder))); }