From 9c1988b5cc68b5633f4b3ca0dc2475cbace25901 Mon Sep 17 00:00:00 2001 From: Emilia Hane Date: Sat, 15 Feb 2025 09:13:35 +0100 Subject: [PATCH] feat(l2-withdrawals): consensus rules (#14308) --- Cargo.lock | 24 +++- crates/consensus/consensus/src/lib.rs | 5 +- crates/optimism/consensus/Cargo.toml | 24 ++++ crates/optimism/consensus/src/error.rs | 30 +++++ crates/optimism/consensus/src/lib.rs | 38 ++++-- .../consensus/src/validation/canyon.rs | 24 ++++ .../consensus/src/validation/isthmus.rs | 127 ++++++++++++++++++ .../src/{validation.rs => validation/mod.rs} | 6 + .../consensus/src/validation/shanghai.rs | 20 +++ crates/optimism/reth/src/lib.rs | 11 +- 10 files changed, 289 insertions(+), 20 deletions(-) create mode 100644 crates/optimism/consensus/src/error.rs create mode 100644 crates/optimism/consensus/src/validation/canyon.rs create mode 100644 crates/optimism/consensus/src/validation/isthmus.rs rename crates/optimism/consensus/src/{validation.rs => validation/mod.rs} (98%) create mode 100644 crates/optimism/consensus/src/validation/shanghai.rs diff --git a/Cargo.lock b/Cargo.lock index 457760f31..d02f756a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2460,15 +2460,15 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.7.0" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e60eed09d8c01d3cee5b7d30acb059b76614c918fa0f992e0dd6eeb10daad6f" +checksum = "575f75dfd25738df5b91b8e43e14d44bda14637a58fae779fd2b064f8bf3e010" [[package]] name = "data-encoding-macro" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b16d9d0d88a5273d830dac8b78ceb217ffc9b1d5404e5597a3542515329405b" +checksum = "9f9724adfcf41f45bf652b3995837669d73c4d49a1b5ac1ff82905ac7d9b5558" dependencies = [ "data-encoding", "data-encoding-macro-internal", @@ -2476,9 +2476,9 @@ dependencies = [ [[package]] name = "data-encoding-macro-internal" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1145d32e826a7748b69ee8fc62d3e6355ff7f1051df53141e7048162fc90481b" +checksum = "18e4fdb82bd54a12e42fb58a800dcae6b9e13982238ce2296dc3570b92148e1f" dependencies = [ "data-encoding", "syn 2.0.98", @@ -8457,6 +8457,7 @@ dependencies = [ name = "reth-optimism-consensus" version = "1.2.0" dependencies = [ + "alloy-chains", "alloy-consensus", "alloy-eips", "alloy-primitives", @@ -8465,12 +8466,23 @@ dependencies = [ "reth-chainspec", "reth-consensus", "reth-consensus-common", + "reth-db-common", "reth-execution-types", "reth-optimism-chainspec", "reth-optimism-forks", + "reth-optimism-node", "reth-optimism-primitives", "reth-primitives", "reth-primitives-traits", + "reth-provider", + "reth-revm", + "reth-storage-api", + "reth-storage-errors", + "reth-trie", + "reth-trie-common", + "reth-trie-db", + "revm", + "thiserror 2.0.11", "tracing", ] diff --git a/crates/consensus/consensus/src/lib.rs b/crates/consensus/consensus/src/lib.rs index f7d668feb..bb092ad86 100644 --- a/crates/consensus/consensus/src/lib.rs +++ b/crates/consensus/consensus/src/lib.rs @@ -11,7 +11,7 @@ extern crate alloc; -use alloc::{fmt::Debug, sync::Arc, vec::Vec}; +use alloc::{fmt::Debug, string::String, sync::Arc, vec::Vec}; use alloy_consensus::Header; use alloy_primitives::{BlockHash, BlockNumber, Bloom, B256, U256}; use reth_execution_types::BlockExecutionResult; @@ -433,6 +433,9 @@ pub enum ConsensusError { /// The block's timestamp. timestamp: u64, }, + /// Other, likely an injected L2 error. + #[error("{0}")] + Other(String), } impl ConsensusError { diff --git a/crates/optimism/consensus/Cargo.toml b/crates/optimism/consensus/Cargo.toml index d0d01a3b3..fde656273 100644 --- a/crates/optimism/consensus/Cargo.toml +++ b/crates/optimism/consensus/Cargo.toml @@ -19,6 +19,9 @@ reth-consensus-common.workspace = true reth-consensus.workspace = true reth-primitives.workspace = true reth-primitives-traits.workspace = true +reth-storage-api.workspace = true +reth-storage-errors.workspace = true +reth-trie-common.workspace = true # op-reth reth-optimism-forks.workspace = true @@ -31,13 +34,24 @@ alloy-eips.workspace = true alloy-primitives.workspace = true alloy-consensus.workspace = true alloy-trie.workspace = true +revm.workspace = true op-alloy-consensus.workspace = true +# misc tracing.workspace = true +thiserror.workspace = true [dev-dependencies] +reth-provider = { workspace = true, features = ["test-utils"] } +reth-trie-db.workspace = true +reth-db-common.workspace = true +reth-optimism-node.workspace = true +reth-revm.workspace = true +op-alloy-consensus.workspace = true +alloy-chains.workspace = true alloy-primitives.workspace = true reth-optimism-chainspec.workspace = true +reth-trie.workspace = true [features] default = ["std"] @@ -50,14 +64,24 @@ std = [ "reth-optimism-forks/std", "reth-optimism-chainspec/std", "reth-optimism-primitives/std", + "reth-storage-api/std", + "reth-storage-errors/std", + "reth-trie-common/std", + "alloy-chains/std", "alloy-eips/std", "alloy-primitives/std", "alloy-consensus/std", "alloy-trie/std", "op-alloy-consensus/std", + "reth-revm/std", + "revm/std", + "tracing/std", + "thiserror/std", "reth-execution-types/std", ] optimism = [ "reth-optimism-primitives/optimism", + "revm/optimism", "reth-execution-types/optimism", + "reth-optimism-node/optimism", ] diff --git a/crates/optimism/consensus/src/error.rs b/crates/optimism/consensus/src/error.rs new file mode 100644 index 000000000..73480a236 --- /dev/null +++ b/crates/optimism/consensus/src/error.rs @@ -0,0 +1,30 @@ +//! Optimism consensus errors + +use alloy_primitives::B256; +use reth_consensus::ConsensusError; +use reth_storage_errors::provider::ProviderError; + +/// Optimism consensus error. +#[derive(Debug, Clone, thiserror::Error)] +pub enum OpConsensusError { + /// Block body has non-empty withdrawals list (l1 withdrawals). + #[error("non-empty block body withdrawals list")] + WithdrawalsNonEmpty, + /// Failed to compute L2 withdrawals storage root. + #[error("compute L2 withdrawals root failed: {_0}")] + L2WithdrawalsRootCalculationFail(#[from] ProviderError), + /// L2 withdrawals root missing in block header. + #[error("L2 withdrawals root missing from block header")] + L2WithdrawalsRootMissing, + /// L2 withdrawals root in block header, doesn't match local storage root of predeploy. + #[error("L2 withdrawals root mismatch, header: {header}, exec_res: {exec_res}")] + L2WithdrawalsRootMismatch { + /// Storage root of pre-deploy in block. + header: B256, + /// Storage root of pre-deploy loaded from local state. + exec_res: B256, + }, + /// L1 [`ConsensusError`], that also occurs on L2. + #[error(transparent)] + Eth(#[from] ConsensusError), +} diff --git a/crates/optimism/consensus/src/lib.rs b/crates/optimism/consensus/src/lib.rs index 7c84d69c3..59d33cf8e 100644 --- a/crates/optimism/consensus/src/lib.rs +++ b/crates/optimism/consensus/src/lib.rs @@ -12,33 +12,36 @@ extern crate alloc; -use core::fmt::Debug; - -use alloc::sync::Arc; +use alloc::{format, sync::Arc}; use alloy_consensus::{BlockHeader as _, EMPTY_OMMER_ROOT_HASH}; use alloy_primitives::{B64, U256}; +use core::fmt::Debug; use reth_chainspec::{EthChainSpec, EthereumHardforks}; use reth_consensus::{Consensus, ConsensusError, FullConsensus, HeaderValidator}; use reth_consensus_common::validation::{ validate_against_parent_4844, validate_against_parent_eip1559_base_fee, validate_against_parent_hash_number, validate_against_parent_timestamp, validate_body_against_header, validate_cancun_gas, validate_header_base_fee, - validate_header_extra_data, validate_header_gas, validate_shanghai_withdrawals, + validate_header_extra_data, validate_header_gas, }; use reth_execution_types::BlockExecutionResult; use reth_optimism_forks::OpHardforks; use reth_optimism_primitives::DepositReceipt; use reth_primitives::{GotExpected, NodePrimitives, RecoveredBlock, SealedHeader}; +use reth_primitives_traits::{Block, BlockBody, BlockHeader, SealedBlock}; mod proof; pub use proof::calculate_receipt_root_no_memo_optimism; -use reth_primitives_traits::{Block, BlockBody, BlockHeader, SealedBlock}; -mod validation; +pub mod validation; pub use validation::{ - decode_holocene_base_fee, next_block_base_fee, validate_block_post_execution, + canyon, decode_holocene_base_fee, isthmus, next_block_base_fee, shanghai, + validate_block_post_execution, }; +pub mod error; +pub use error::OpConsensusError; + /// Optimism consensus implementation. /// /// Provides basic checks as outlined in the execution specs. @@ -98,13 +101,30 @@ impl Consensus return Err(ConsensusError::BodyTransactionRootDiff(error.into())) } - // EIP-4895: Beacon chain push withdrawals as operations + // Check empty shanghai-withdrawals if self.chain_spec.is_shanghai_active_at_timestamp(block.timestamp()) { - validate_shanghai_withdrawals(block)?; + shanghai::ensure_empty_shanghai_withdrawals(block.body()).map_err(|err| { + ConsensusError::Other(format!("failed to verify block {}: {err}", block.number())) + })? + } else { + return Ok(()) } if self.chain_spec.is_cancun_active_at_timestamp(block.timestamp()) { validate_cancun_gas(block)?; + } else { + return Ok(()) + } + + // Check withdrawals root field in header + if self.chain_spec.is_isthmus_active_at_timestamp(block.timestamp()) { + // storage root of withdrawals pre-deploy is verified post-execution + isthmus::ensure_withdrawals_storage_root_is_some(block.header()).map_err(|err| { + ConsensusError::Other(format!("failed to verify block {}: {err}", block.number())) + })? + } else { + // canyon is active, else would have returned already + canyon::ensure_empty_withdrawals_root(block.header())? } Ok(()) diff --git a/crates/optimism/consensus/src/validation/canyon.rs b/crates/optimism/consensus/src/validation/canyon.rs new file mode 100644 index 000000000..9055e6663 --- /dev/null +++ b/crates/optimism/consensus/src/validation/canyon.rs @@ -0,0 +1,24 @@ +//! Canyon consensus rule checks. + +use alloy_consensus::BlockHeader; +use alloy_trie::EMPTY_ROOT_HASH; +use reth_consensus::ConsensusError; +use reth_primitives::GotExpected; + +/// Verifies that withdrawals root in block header (Shanghai) is always [`EMPTY_ROOT_HASH`] in +/// Canyon. +#[inline] +pub fn ensure_empty_withdrawals_root(header: &H) -> Result<(), ConsensusError> { + // Shanghai rule + let header_withdrawals_root = + &header.withdrawals_root().ok_or(ConsensusError::WithdrawalsRootMissing)?; + + // Canyon rules + if *header_withdrawals_root != EMPTY_ROOT_HASH { + return Err(ConsensusError::BodyWithdrawalsRootDiff( + GotExpected { got: *header_withdrawals_root, expected: EMPTY_ROOT_HASH }.into(), + )); + } + + Ok(()) +} diff --git a/crates/optimism/consensus/src/validation/isthmus.rs b/crates/optimism/consensus/src/validation/isthmus.rs new file mode 100644 index 000000000..91d197b44 --- /dev/null +++ b/crates/optimism/consensus/src/validation/isthmus.rs @@ -0,0 +1,127 @@ +//! Block verification w.r.t. consensus rules new in Isthmus hardfork. + +use crate::OpConsensusError; +use alloy_consensus::BlockHeader; +use core::fmt; +use reth_optimism_primitives::predeploys::ADDRESS_L2_TO_L1_MESSAGE_PASSER; +use reth_storage_api::StorageRootProvider; +use reth_trie_common::HashedStorage; +use revm::db::BundleAccount; + +/// Verifies that `withdrawals_root` (i.e. `l2tol1-msg-passer` storage root since Isthmus) field is +/// set in block header. +pub fn ensure_withdrawals_storage_root_is_some( + header: H, +) -> Result<(), OpConsensusError> { + header.withdrawals_root().ok_or(OpConsensusError::L2WithdrawalsRootMissing)?; + + Ok(()) +} + +/// Verifies block header field `withdrawals_root` against storage root of +/// `L2ToL1MessagePasser.sol` predeploy post block execution. +/// +/// See . +pub fn verify_withdrawals_storage_root( + predeploy_account_updates: Option<&BundleAccount>, + state: DB, + header: H, +) -> Result<(), OpConsensusError> +where + DB: StorageRootProvider, + H: BlockHeader + fmt::Debug, +{ + let header_storage_root = + header.withdrawals_root().ok_or(OpConsensusError::L2WithdrawalsRootMissing)?; + + // if block contained l2 withdrawals transactions, use predeploy storage updates from + // execution + let hashed_storage_updates = predeploy_account_updates.map(|acc| { + HashedStorage::from_plain_storage( + acc.status, + acc.storage.iter().map(|(slot, value)| (slot, &value.present_value)), + ) + }); + + let storage_root = state + .storage_root(ADDRESS_L2_TO_L1_MESSAGE_PASSER, hashed_storage_updates.unwrap_or_default()) + .map_err(OpConsensusError::L2WithdrawalsRootCalculationFail)?; + + if header_storage_root != storage_root { + return Err(OpConsensusError::L2WithdrawalsRootMismatch { + header: header_storage_root, + exec_res: storage_root, + }) + } + + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use alloc::sync::Arc; + use alloy_chains::Chain; + use alloy_consensus::Header; + use alloy_primitives::{keccak256, B256, U256}; + use core::str::FromStr; + use reth_db_common::init::init_genesis; + use reth_optimism_chainspec::OpChainSpecBuilder; + use reth_optimism_node::OpNode; + use reth_provider::{ + providers::BlockchainProvider, test_utils::create_test_provider_factory_with_node_types, + StateWriter, + }; + use reth_storage_api::StateProviderFactory; + use reth_trie::test_utils::storage_root_prehashed; + use reth_trie_common::HashedPostState; + + #[test] + fn l2tol1_message_passer_no_withdrawals() { + let hashed_address = keccak256(ADDRESS_L2_TO_L1_MESSAGE_PASSER); + + // create account storage + let init_storage = HashedStorage::from_iter( + false, + [ + "50000000000000000000000000000004253371b55351a08cb3267d4d265530b6", + "512428ed685fff57294d1a9cbb147b18ae5db9cf6ae4b312fa1946ba0561882e", + "51e6784c736ef8548f856909870b38e49ef7a4e3e77e5e945e0d5e6fcaa3037f", + ] + .into_iter() + .map(|str| (B256::from_str(str).unwrap(), U256::from(1))), + ); + let mut state = HashedPostState::default(); + state.storages.insert(hashed_address, init_storage.clone()); + + // init test db + // note: must be empty (default) chain spec to ensure storage is empty after init genesis, + // otherwise can't use `storage_root_prehashed` to determine storage root later + let provider_factory = create_test_provider_factory_with_node_types::(Arc::new( + OpChainSpecBuilder::default().chain(Chain::dev()).genesis(Default::default()).build(), + )); + let _ = init_genesis(&provider_factory).unwrap(); + + // write account storage to database + let provider_rw = provider_factory.provider_rw().unwrap(); + provider_rw.write_hashed_state(&state.clone().into_sorted()).unwrap(); + provider_rw.commit().unwrap(); + + // create block header with withdrawals root set to storage root of l2tol1-msg-passer + let header = Header { + withdrawals_root: Some(storage_root_prehashed(init_storage.storage)), + ..Default::default() + }; + + // create state provider factory + let state_provider_factory = BlockchainProvider::new(provider_factory).unwrap(); + + // validate block against existing state by passing empty state updates + verify_withdrawals_storage_root( + None, + state_provider_factory.latest().expect("load state"), + &header, + ) + .unwrap(); + } +} diff --git a/crates/optimism/consensus/src/validation.rs b/crates/optimism/consensus/src/validation/mod.rs similarity index 98% rename from crates/optimism/consensus/src/validation.rs rename to crates/optimism/consensus/src/validation/mod.rs index 4343a1b51..ccbe5ebc0 100644 --- a/crates/optimism/consensus/src/validation.rs +++ b/crates/optimism/consensus/src/validation/mod.rs @@ -1,3 +1,9 @@ +//! Verification of blocks w.r.t. Optimism hardforks. + +pub mod canyon; +pub mod isthmus; +pub mod shanghai; + use crate::proof::calculate_receipt_root_optimism; use alloc::vec::Vec; use alloy_consensus::{BlockHeader, TxReceipt}; diff --git a/crates/optimism/consensus/src/validation/shanghai.rs b/crates/optimism/consensus/src/validation/shanghai.rs new file mode 100644 index 000000000..f05870c38 --- /dev/null +++ b/crates/optimism/consensus/src/validation/shanghai.rs @@ -0,0 +1,20 @@ +//! L2 Shanghai consensus rule checks. + +use crate::OpConsensusError; +use reth_consensus::ConsensusError; +use reth_primitives_traits::BlockBody; + +/// Verifies that withdrawals in block body (Shanghai) is always empty in Canyon. +/// +#[inline] +pub fn ensure_empty_shanghai_withdrawals(body: &T) -> Result<(), OpConsensusError> { + // Shanghai rule + let withdrawals = body.withdrawals().ok_or(ConsensusError::BodyWithdrawalsMissing)?; + + // Canyon rule + if !withdrawals.as_ref().is_empty() { + return Err(OpConsensusError::WithdrawalsNonEmpty) + } + + Ok(()) +} diff --git a/crates/optimism/reth/src/lib.rs b/crates/optimism/reth/src/lib.rs index 98921b087..4f5cfb3f3 100644 --- a/crates/optimism/reth/src/lib.rs +++ b/crates/optimism/reth/src/lib.rs @@ -27,10 +27,13 @@ pub mod primitives { pub mod consensus { #[doc(inline)] pub use reth_consensus::*; - #[doc(inline)] - pub use reth_consensus_common::*; - #[doc(inline)] - pub use reth_optimism_consensus::*; + /// Consensus rule checks. + pub mod validation { + #[doc(inline)] + pub use reth_consensus_common::validation::*; + #[doc(inline)] + pub use reth_optimism_consensus::validation::*; + } } /// Re-exported from `reth_chainspec`