feat(l2-withdrawals): consensus rules (#14308)

This commit is contained in:
Emilia Hane
2025-02-15 09:13:35 +01:00
committed by GitHub
parent 0e087ae1c3
commit 9c1988b5cc
10 changed files with 289 additions and 20 deletions

View File

@ -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",
]

View File

@ -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),
}

View File

@ -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<ChainSpec: EthChainSpec + OpHardforks, B: Block> Consensus<B>
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(())

View File

@ -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<H: BlockHeader>(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(())
}

View File

@ -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<H: BlockHeader>(
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 <https://specs.optimism.io/protocol/isthmus/exec-engine.html#l2tol1messagepasser-storage-root-in-header>.
pub fn verify_withdrawals_storage_root<DB, H>(
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::<OpNode>(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();
}
}

View File

@ -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};

View File

@ -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.
/// <https://specs.optimism.io/protocol/rollup-node-p2p.html#block-validation>
#[inline]
pub fn ensure_empty_shanghai_withdrawals<T: BlockBody>(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(())
}

View File

@ -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`