From fba7179a139a9166cd4f6814fee0490658f2fcb1 Mon Sep 17 00:00:00 2001 From: panos Date: Thu, 8 Jan 2026 21:20:41 +0800 Subject: [PATCH 01/13] feat: implement morph-consensus with l1 message validation --- Cargo.lock | 34 ++ Cargo.toml | 4 +- crates/chainspec/Cargo.toml | 3 + crates/chainspec/res/genesis/hoodi.json | 38 ++ crates/chainspec/res/genesis/mainnet.json | 38 ++ crates/chainspec/src/constants.rs | 27 + crates/chainspec/src/genesis.rs | 221 +++++++ crates/chainspec/src/hardfork.rs | 47 +- crates/chainspec/src/lib.rs | 16 + crates/chainspec/src/morph.rs | 44 ++ crates/chainspec/src/morph_hoodi.rs | 42 ++ crates/chainspec/src/spec.rs | 500 +++++++--------- crates/consensus/Cargo.toml | 37 ++ crates/consensus/src/error.rs | 69 +++ crates/consensus/src/lib.rs | 37 ++ crates/consensus/src/validation.rs | 554 ++++++++++++++++++ crates/evm/src/lib.rs | 4 +- crates/payload/types/Cargo.toml | 1 + crates/payload/types/src/attributes.rs | 16 +- .../payload/types/src/executable_l2_data.rs | 3 - crates/payload/types/src/lib.rs | 87 ++- crates/primitives/src/transaction/envelope.rs | 11 + .../src/transaction/l1_transaction.rs | 25 +- 23 files changed, 1545 insertions(+), 313 deletions(-) create mode 100644 crates/chainspec/res/genesis/hoodi.json create mode 100644 crates/chainspec/res/genesis/mainnet.json create mode 100644 crates/chainspec/src/constants.rs create mode 100644 crates/chainspec/src/genesis.rs create mode 100644 crates/chainspec/src/morph.rs create mode 100644 crates/chainspec/src/morph_hoodi.rs create mode 100644 crates/consensus/Cargo.toml create mode 100644 crates/consensus/src/error.rs create mode 100644 crates/consensus/src/lib.rs create mode 100644 crates/consensus/src/validation.rs diff --git a/Cargo.lock b/Cargo.lock index d930672..f2908aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3642,12 +3642,15 @@ checksum = "1fafa6961cabd9c63bcd77a45d7e3b7f3b552b70417831fb0f56db717e72407e" name = "morph-chainspec" version = "0.7.5" dependencies = [ + "alloy-chains", "alloy-consensus", "alloy-eips", "alloy-evm", "alloy-genesis", "alloy-hardforks", "alloy-primitives", + "alloy-serde", + "auto_impl", "eyre", "reth-chainspec", "reth-cli", @@ -3656,6 +3659,24 @@ dependencies = [ "serde_json", ] +[[package]] +name = "morph-consensus" +version = "0.7.5" +dependencies = [ + "alloy-consensus", + "alloy-evm", + "alloy-genesis", + "alloy-primitives", + "alloy-rlp", + "morph-chainspec", + "morph-primitives", + "reth-consensus", + "reth-consensus-common", + "reth-primitives-traits", + "serde_json", + "thiserror 2.0.17", +] + [[package]] name = "morph-evm" version = "0.7.5" @@ -3695,6 +3716,7 @@ dependencies = [ "alloy-serde", "morph-primitives", "rand 0.8.5", + "reth-engine-primitives", "reth-payload-primitives", "reth-primitives-traits", "serde", @@ -5029,6 +5051,18 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "reth-consensus-common" +version = "1.9.3" +source = "git+https://github.com/paradigmxyz/reth?rev=64909d3#64909d33e6b7ab60774e37f5508fb5ad17f41897" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "reth-chainspec", + "reth-consensus", + "reth-primitives-traits", +] + [[package]] name = "reth-db" version = "1.9.3" diff --git a/Cargo.toml b/Cargo.toml index 883cb0a..6a71166 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ publish = false resolver = "3" members = [ "crates/chainspec", + "crates/consensus", "crates/evm", "crates/payload/builder", "crates/payload/types", @@ -37,6 +38,7 @@ all = "warn" [workspace.dependencies] morph-chainspec = { path = "crates/chainspec", default-features = false } +morph-consensus = { path = "crates/consensus", default-features = false } morph-evm = { path = "crates/evm", default-features = false } morph-payload-builder = { path = "crates/payload/builder", default-features = false } morph-payload-types = { path = "crates/payload/types", default-features = false } @@ -115,7 +117,7 @@ alloy-signer = "1.1.3" alloy-signer-local = "1.1.3" alloy-sol-types = "1.4.1" alloy-transport = "1.1.3" - +alloy-chains = { version = "0.2.5", default-features = false } arbitrary = { version = "1.3", features = ["derive"] } async-lock = "3.4.1" async-trait = "0.1" diff --git a/crates/chainspec/Cargo.toml b/crates/chainspec/Cargo.toml index 7868036..64cf406 100644 --- a/crates/chainspec/Cargo.toml +++ b/crates/chainspec/Cargo.toml @@ -16,13 +16,16 @@ reth-cli = { workspace = true, optional = true } reth-chainspec.workspace = true reth-network-peers.workspace = true +alloy-chains.workspace = true alloy-consensus.workspace = true alloy-evm.workspace = true alloy-genesis.workspace = true alloy-primitives.workspace = true alloy-eips.workspace = true alloy-hardforks.workspace = true +alloy-serde.workspace = true +auto_impl.workspace = true eyre = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true diff --git a/crates/chainspec/res/genesis/hoodi.json b/crates/chainspec/res/genesis/hoodi.json new file mode 100644 index 0000000..3a48dff --- /dev/null +++ b/crates/chainspec/res/genesis/hoodi.json @@ -0,0 +1,38 @@ +{ + "config": { + "chainId": 2910, + "homesteadBlock": 0, + "eip150Block": 0, + "eip155Block": 0, + "eip158Block": 0, + "byzantiumBlock": 0, + "constantinopleBlock": 0, + "petersburgBlock": 0, + "istanbulBlock": 0, + "berlinBlock": 0, + "londonBlock": 0, + "mergeNetsplitBlock": 0, + "terminalTotalDifficulty": 0, + "terminalTotalDifficultyPassed": true, + "shanghaiTime": 0, + "cancunTime": 0, + "bernoulliTime": 0, + "curieTime": 0, + "morph203Time": 0, + "viridianTime": 0, + "emeraldTime": 0, + "morph": { + "feeVaultAddress": "0x29107CB79Ef8f69fE1587F77e283d47E84c5202f", + "maxTxPayloadBytesPerBlock": 122880 + } + }, + "nonce": "0x0", + "timestamp": "0x0", + "extraData": "0x", + "gasLimit": "0x1c9c380", + "difficulty": "0x0", + "mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "coinbase": "0x0000000000000000000000000000000000000000", + "alloc": {} +} + diff --git a/crates/chainspec/res/genesis/mainnet.json b/crates/chainspec/res/genesis/mainnet.json new file mode 100644 index 0000000..58eb269 --- /dev/null +++ b/crates/chainspec/res/genesis/mainnet.json @@ -0,0 +1,38 @@ +{ + "config": { + "chainId": 2818, + "homesteadBlock": 0, + "eip150Block": 0, + "eip155Block": 0, + "eip158Block": 0, + "byzantiumBlock": 0, + "constantinopleBlock": 0, + "petersburgBlock": 0, + "istanbulBlock": 0, + "berlinBlock": 0, + "londonBlock": 0, + "mergeNetsplitBlock": 0, + "terminalTotalDifficulty": 0, + "terminalTotalDifficultyPassed": true, + "shanghaiTime": 0, + "cancunTime": 0, + "bernoulliTime": 0, + "curieTime": 0, + "morph203Time": 0, + "viridianTime": 0, + "emeraldTime": 0, + "morph": { + "feeVaultAddress": "0x530000000000000000000000000000000000000a", + "maxTxPayloadBytesPerBlock": 122880 + } + }, + "nonce": "0x0", + "timestamp": "0x0", + "extraData": "0x", + "gasLimit": "0x1c9c380", + "difficulty": "0x0", + "mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "coinbase": "0x0000000000000000000000000000000000000000", + "alloc": {} +} + diff --git a/crates/chainspec/src/constants.rs b/crates/chainspec/src/constants.rs new file mode 100644 index 0000000..1bb17d2 --- /dev/null +++ b/crates/chainspec/src/constants.rs @@ -0,0 +1,27 @@ +//! Morph chainspec constants. + +use alloy_primitives::{Address, address}; + +/// The transaction fee recipient on the L2. +pub const MORPH_FEE_VAULT_ADDRESS_HOODI: Address = + address!("29107CB79Ef8f69fE1587F77e283d47E84c5202f"); + +/// The transaction fee recipient on the L2. +pub const MORPH_FEE_VAULT_ADDRESS_MAINNET: Address = + address!("530000000000000000000000000000000000000a"); + +/// The maximum size in bytes of the tx payload for a block. +pub const MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK: usize = 120 * 1024; + +/// The Morph Mainnet chain ID. +pub const MORPH_MAINNET_CHAIN_ID: u64 = 2818; + +/// The Morph Hoodi (testnet) chain ID. +pub const MORPH_HOODI_CHAIN_ID: u64 = 2910; + +/// The default L2 sequencer fee (0.001 Gwei = 1_000_000 wei). +/// The sequencer has the right to set any base fee below `MORPH_MAX_BASE_FEE`. +pub const MORPH_BASE_FEE: u64 = 1_000_000; + +/// The maximum allowed L2 base fee (10 Gwei = 10_000_000_000 wei). +pub const MORPH_MAX_BASE_FEE: u64 = 10_000_000_000; diff --git a/crates/chainspec/src/genesis.rs b/crates/chainspec/src/genesis.rs new file mode 100644 index 0000000..287fa7f --- /dev/null +++ b/crates/chainspec/src/genesis.rs @@ -0,0 +1,221 @@ +//! Morph types for genesis data. + +use crate::{ + MORPH_FEE_VAULT_ADDRESS_HOODI, MORPH_FEE_VAULT_ADDRESS_MAINNET, + constants::MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK, +}; +use alloy_primitives::Address; +use alloy_serde::OtherFields; +use serde::{Deserialize, Serialize, de::Error as _}; + +/// Container type for all Morph-specific fields in a genesis file. +#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MorphGenesisInfo { + /// Information about hard forks specific to the Morph chain. + #[serde(skip_serializing_if = "Option::is_none")] + pub hard_fork_info: Option, + /// Morph chain-specific configuration details. + pub morph_chain_info: MorphChainConfig, +} + +impl MorphGenesisInfo { + /// Extracts the Morph specific fields from a genesis file. + pub fn extract_from(others: &OtherFields) -> Option { + Self::try_from(others).ok() + } +} + +impl TryFrom<&OtherFields> for MorphGenesisInfo { + type Error = serde_json::Error; + + fn try_from(others: &OtherFields) -> Result { + let hard_fork_info = MorphHardforkInfo::try_from(others).ok(); + let morph_chain_info = + MorphChainConfig::try_from(others).unwrap_or_else(|_| MorphChainConfig::mainnet()); + + Ok(Self { + hard_fork_info, + morph_chain_info, + }) + } +} + +/// Morph hardfork info specifies the block numbers and timestamps at which +/// the Morph hardforks were activated. +#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MorphHardforkInfo { + /// Bernoulli hardfork timestamp. + #[serde(skip_serializing_if = "Option::is_none")] + pub bernoulli_time: Option, + /// Curie hardfork timestamp. + #[serde(skip_serializing_if = "Option::is_none")] + pub curie_time: Option, + /// Morph203 hardfork timestamp. + #[serde(skip_serializing_if = "Option::is_none")] + pub morph203_time: Option, + /// Viridian hardfork timestamp. + #[serde(skip_serializing_if = "Option::is_none")] + pub viridian_time: Option, + /// Emerald hardfork timestamp. + #[serde(skip_serializing_if = "Option::is_none")] + pub emerald_time: Option, +} + +impl MorphHardforkInfo { + /// Extract the Morph-specific genesis info from a genesis file. + pub fn extract_from(others: &OtherFields) -> Option { + Self::try_from(others).ok() + } +} + +impl TryFrom<&OtherFields> for MorphHardforkInfo { + type Error = serde_json::Error; + + fn try_from(others: &OtherFields) -> Result { + others.deserialize_as() + } +} + +/// The configuration for the Morph chain. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MorphChainConfig { + /// The address of the L2 transaction fee vault. + #[serde(skip_serializing_if = "Option::is_none")] + pub fee_vault_address: Option
, + /// The maximum tx payload size per block in bytes. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tx_payload_bytes_per_block: Option, +} + +impl Default for MorphChainConfig { + fn default() -> Self { + Self::mainnet() + } +} + +impl MorphChainConfig { + /// Extracts the morph config by looking for the `morph` key in genesis. + pub fn extract_from(others: &OtherFields) -> Option { + Self::try_from(others).ok() + } + + /// Returns the MorphChainConfig for Morph Mainnet. + pub const fn mainnet() -> Self { + Self { + fee_vault_address: Some(MORPH_FEE_VAULT_ADDRESS_MAINNET), + max_tx_payload_bytes_per_block: Some(MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK), + } + } + + /// Returns the MorphChainConfig for Morph Hoodi (testnet). + pub const fn hoodi() -> Self { + Self { + fee_vault_address: Some(MORPH_FEE_VAULT_ADDRESS_HOODI), + max_tx_payload_bytes_per_block: Some(MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK), + } + } + + /// Returns whether the fee vault is enabled. + pub const fn is_fee_vault_enabled(&self) -> bool { + self.fee_vault_address.is_some() + } + + /// Checks if the given block size (in bytes) is valid for this chain. + pub fn is_valid_block_size(&self, size: usize) -> bool { + self.max_tx_payload_bytes_per_block + .map(|max| size <= max) + .unwrap_or(true) + } +} + +impl TryFrom<&OtherFields> for MorphChainConfig { + type Error = serde_json::Error; + + fn try_from(others: &OtherFields) -> Result { + if let Some(Ok(morph_config)) = others.get_deserialized::("morph") { + Ok(morph_config) + } else { + Err(serde_json::Error::missing_field("morph")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::address; + + #[test] + fn test_extract_morph_hardfork_info() { + let genesis_info = r#" + { + "bernoulliTime": 1000, + "curieTime": 2000, + "morph203Time": 3000, + "viridianTime": 4000, + "emeraldTime": 5000 + } + "#; + + let others: OtherFields = serde_json::from_str(genesis_info).unwrap(); + let hardfork_info = MorphHardforkInfo::extract_from(&others).unwrap(); + + assert_eq!( + hardfork_info, + MorphHardforkInfo { + bernoulli_time: Some(1000), + curie_time: Some(2000), + morph203_time: Some(3000), + viridian_time: Some(4000), + emerald_time: Some(5000), + } + ); + } + + #[test] + fn test_extract_morph_chain_config() { + let config_str = r#" + { + "morph": { + "feeVaultAddress": "0x530000000000000000000000000000000000000a", + "maxTxPayloadBytesPerBlock": 122880 + } + } + "#; + + let others: OtherFields = serde_json::from_str(config_str).unwrap(); + let config = MorphChainConfig::extract_from(&others).unwrap(); + + assert_eq!( + config.fee_vault_address, + Some(address!("530000000000000000000000000000000000000a")) + ); + assert_eq!(config.max_tx_payload_bytes_per_block, Some(122880)); + assert!(config.is_fee_vault_enabled()); + assert!(config.is_valid_block_size(100000)); + assert!(!config.is_valid_block_size(200000)); + } + + #[test] + fn test_mainnet_config() { + let config = MorphChainConfig::mainnet(); + assert!(config.is_fee_vault_enabled()); + assert_eq!( + config.max_tx_payload_bytes_per_block, + Some(MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK) + ); + } + + #[test] + fn test_hoodi_config() { + let config = MorphChainConfig::hoodi(); + assert!(config.is_fee_vault_enabled()); + assert_eq!( + config.max_tx_payload_bytes_per_block, + Some(MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK) + ); + } +} diff --git a/crates/chainspec/src/hardfork.rs b/crates/chainspec/src/hardfork.rs index 3df5e58..927c964 100644 --- a/crates/chainspec/src/hardfork.rs +++ b/crates/chainspec/src/hardfork.rs @@ -17,7 +17,7 @@ //! //! ### In `spec.rs`: //! 8. Add `vivace_time: Option` field to `MorphGenesisInfo` -//! 9. Extract `vivace_time` in `MorphChainSpec::from_genesis` +//! 9. Extract `vivace_time` in `From for MorphChainSpec` //! 10. Add `(MorphHardfork::Vivace, vivace_time)` to `morph_forks` vec //! 11. Update tests to include `"vivaceTime": ` in genesis JSON //! @@ -46,8 +46,10 @@ hardfork!( /// Morph203 hardfork. Morph203, /// Viridian hardfork. - #[default] Viridian, + /// Emerald hardfork. + #[default] + Emerald, } ); @@ -63,10 +65,15 @@ impl MorphHardfork { self >= Self::Morph203 } - /// Returns `true` if this hardfork is viridian or later. + /// Returns `true` if this hardfork is Viridian or later. pub fn is_viridian(self) -> bool { self >= Self::Viridian } + + /// Returns `true` if this hardfork is Emerald or later. + pub fn is_emerald(self) -> bool { + self >= Self::Emerald + } } /// Trait for querying Morph-specific hardfork activations. @@ -80,7 +87,7 @@ pub trait MorphHardforks: EthereumHardforks { .active_at_timestamp(timestamp) } - /// Convenience method to check if Andantino hardfork is active at a given timestamp + /// Convenience method to check if Curie hardfork is active at a given timestamp fn is_curie_active_at_timestamp(&self, timestamp: u64) -> bool { self.morph_fork_activation(MorphHardfork::Curie) .active_at_timestamp(timestamp) @@ -92,15 +99,23 @@ pub trait MorphHardforks: EthereumHardforks { .active_at_timestamp(timestamp) } - /// Convenience method to check if viridian hardfork is active at a given timestamp + /// Convenience method to check if Viridian hardfork is active at a given timestamp fn is_viridian_active_at_timestamp(&self, timestamp: u64) -> bool { self.morph_fork_activation(MorphHardfork::Viridian) .active_at_timestamp(timestamp) } + /// Convenience method to check if Emerald hardfork is active at a given timestamp + fn is_emerald_active_at_timestamp(&self, timestamp: u64) -> bool { + self.morph_fork_activation(MorphHardfork::Emerald) + .active_at_timestamp(timestamp) + } + /// Retrieves the latest Morph hardfork active at a given timestamp. fn morph_hardfork_at(&self, timestamp: u64) -> MorphHardfork { - if self.is_viridian_active_at_timestamp(timestamp) { + if self.is_emerald_active_at_timestamp(timestamp) { + MorphHardfork::Emerald + } else if self.is_viridian_active_at_timestamp(timestamp) { MorphHardfork::Viridian } else if self.is_morph203_active_at_timestamp(timestamp) { MorphHardfork::Morph203 @@ -119,6 +134,7 @@ impl From for SpecId { MorphHardfork::Curie => Self::OSAKA, MorphHardfork::Morph203 => Self::OSAKA, MorphHardfork::Viridian => Self::OSAKA, + MorphHardfork::Emerald => Self::OSAKA, } } } @@ -130,7 +146,9 @@ impl From for MorphHardfork { /// `From for SpecId`, because multiple Morph /// hardforks may share the same underlying EVM spec. fn from(spec: SpecId) -> Self { - if spec.is_enabled_in(SpecId::from(Self::Viridian)) { + if spec.is_enabled_in(SpecId::from(Self::Emerald)) { + Self::Emerald + } else if spec.is_enabled_in(SpecId::from(Self::Viridian)) { Self::Viridian } else if spec.is_enabled_in(SpecId::from(Self::Morph203)) { Self::Morph203 @@ -180,6 +198,7 @@ mod tests { assert!(MorphHardfork::Curie.is_curie()); assert!(MorphHardfork::Morph203.is_curie()); assert!(MorphHardfork::Viridian.is_curie()); + assert!(MorphHardfork::Emerald.is_curie()); } #[test] @@ -189,6 +208,7 @@ mod tests { assert!(MorphHardfork::Morph203.is_morph203()); assert!(MorphHardfork::Viridian.is_morph203()); + assert!(MorphHardfork::Emerald.is_morph203()); assert!(MorphHardfork::Morph203.is_curie()); } @@ -201,5 +221,18 @@ mod tests { assert!(MorphHardfork::Viridian.is_viridian()); assert!(MorphHardfork::Viridian.is_morph203()); assert!(MorphHardfork::Viridian.is_curie()); + assert!(MorphHardfork::Emerald.is_viridian()); + } + + #[test] + fn test_is_emerald() { + assert!(!MorphHardfork::Bernoulli.is_emerald()); + assert!(!MorphHardfork::Curie.is_emerald()); + assert!(!MorphHardfork::Morph203.is_emerald()); + assert!(!MorphHardfork::Viridian.is_emerald()); + assert!(MorphHardfork::Emerald.is_emerald()); + assert!(MorphHardfork::Emerald.is_viridian()); + assert!(MorphHardfork::Emerald.is_morph203()); + assert!(MorphHardfork::Emerald.is_curie()); } } diff --git a/crates/chainspec/src/lib.rs b/crates/chainspec/src/lib.rs index a398fed..565bb35 100644 --- a/crates/chainspec/src/lib.rs +++ b/crates/chainspec/src/lib.rs @@ -6,6 +6,22 @@ // Used only in tests, but declared here to silence unused_crate_dependencies warning use serde_json as _; +pub mod constants; +pub mod genesis; pub mod hardfork; +pub mod morph; +pub mod morph_hoodi; pub mod spec; + +// Re-export constants +pub use constants::*; + +// Re-export genesis types +pub use genesis::{MorphChainConfig, MorphGenesisInfo, MorphHardforkInfo}; + +pub use morph::MORPH_MAINNET; +pub use morph_hoodi::MORPH_HOODI; pub use spec::MorphChainSpec; + +// Convenience re-export of the chain spec provider. +pub use reth_chainspec::ChainSpecProvider; diff --git a/crates/chainspec/src/morph.rs b/crates/chainspec/src/morph.rs new file mode 100644 index 0000000..973469c --- /dev/null +++ b/crates/chainspec/src/morph.rs @@ -0,0 +1,44 @@ +//! Morph Mainnet chain specification. + +use crate::MorphChainSpec; +use alloy_genesis::Genesis; +use std::sync::{Arc, LazyLock}; + +/// Morph Mainnet chain specification. +pub static MORPH_MAINNET: LazyLock> = LazyLock::new(|| { + let genesis: Genesis = serde_json::from_str(include_str!("../res/genesis/mainnet.json")) + .expect("Failed to parse Morph Mainnet genesis"); + MorphChainSpec::from(genesis).into() +}); + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + MORPH_FEE_VAULT_ADDRESS_MAINNET, MORPH_MAINNET_CHAIN_ID, hardfork::MorphHardforks, + }; + + #[test] + fn test_morph_mainnet_chain_id() { + assert_eq!(MORPH_MAINNET.inner.chain.id(), MORPH_MAINNET_CHAIN_ID); + } + + #[test] + fn test_morph_mainnet_fee_vault() { + assert!(MORPH_MAINNET.is_fee_vault_enabled()); + assert_eq!( + MORPH_MAINNET.fee_vault_address(), + Some(MORPH_FEE_VAULT_ADDRESS_MAINNET) + ); + } + + #[test] + fn test_morph_mainnet_hardforks() { + // All hardforks should be active at genesis + assert!(MORPH_MAINNET.is_bernoulli_active_at_timestamp(0)); + assert!(MORPH_MAINNET.is_curie_active_at_timestamp(0)); + assert!(MORPH_MAINNET.is_morph203_active_at_timestamp(0)); + assert!(MORPH_MAINNET.is_viridian_active_at_timestamp(0)); + assert!(MORPH_MAINNET.is_emerald_active_at_timestamp(0)); + } +} diff --git a/crates/chainspec/src/morph_hoodi.rs b/crates/chainspec/src/morph_hoodi.rs new file mode 100644 index 0000000..3f2bbcf --- /dev/null +++ b/crates/chainspec/src/morph_hoodi.rs @@ -0,0 +1,42 @@ +//! Morph Hoodi (testnet) chain specification. + +use crate::MorphChainSpec; +use alloy_genesis::Genesis; +use std::sync::{Arc, LazyLock}; + +/// Morph Hoodi (testnet) chain specification. +pub static MORPH_HOODI: LazyLock> = LazyLock::new(|| { + let genesis: Genesis = serde_json::from_str(include_str!("../res/genesis/hoodi.json")) + .expect("Failed to parse Morph Hoodi genesis"); + MorphChainSpec::from(genesis).into() +}); + +#[cfg(test)] +mod tests { + use super::*; + use crate::{MORPH_FEE_VAULT_ADDRESS_HOODI, MORPH_HOODI_CHAIN_ID, hardfork::MorphHardforks}; + + #[test] + fn test_morph_hoodi_chain_id() { + assert_eq!(MORPH_HOODI.inner.chain.id(), MORPH_HOODI_CHAIN_ID); + } + + #[test] + fn test_morph_hoodi_fee_vault() { + assert!(MORPH_HOODI.is_fee_vault_enabled()); + assert_eq!( + MORPH_HOODI.fee_vault_address(), + Some(MORPH_FEE_VAULT_ADDRESS_HOODI) + ); + } + + #[test] + fn test_morph_hoodi_hardforks() { + // All hardforks should be active at genesis + assert!(MORPH_HOODI.is_bernoulli_active_at_timestamp(0)); + assert!(MORPH_HOODI.is_curie_active_at_timestamp(0)); + assert!(MORPH_HOODI.is_morph203_active_at_timestamp(0)); + assert!(MORPH_HOODI.is_viridian_active_at_timestamp(0)); + assert!(MORPH_HOODI.is_emerald_active_at_timestamp(0)); + } +} diff --git a/crates/chainspec/src/spec.rs b/crates/chainspec/src/spec.rs index 4e5acc8..414d340 100644 --- a/crates/chainspec/src/spec.rs +++ b/crates/chainspec/src/spec.rs @@ -1,73 +1,49 @@ -use crate::hardfork::{MorphHardfork, MorphHardforks}; +//! Morph chain specification. + +use crate::{ + MORPH_BASE_FEE, + genesis::{MorphChainConfig, MorphGenesisInfo, MorphHardforkInfo}, + hardfork::{MorphHardfork, MorphHardforks}, +}; +use alloy_chains::Chain; use alloy_consensus::Header; use alloy_eips::eip7840::BlobParams; use alloy_evm::eth::spec::EthExecutorSpec; use alloy_genesis::Genesis; use alloy_primitives::{Address, B256, U256}; use reth_chainspec::{ - BaseFeeParams, Chain, ChainSpec, DepositContract, DisplayHardforks, EthChainSpec, - EthereumHardfork, EthereumHardforks, ForkCondition, ForkFilter, ForkId, Hardfork, Hardforks, - Head, + BaseFeeParams, ChainSpec, DepositContract, DisplayHardforks, EthChainSpec, EthereumHardfork, + EthereumHardforks, ForkCondition, ForkFilter, ForkId, Hardfork, Hardforks, Head, }; use reth_network_peers::NodeRecord; -use std::sync::Arc; - -pub const MORPH_BASE_FEE: u64 = 10_000_000_000; - -/// Morph genesis info extracted from genesis extra_fields -#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MorphGenesisInfo { - /// Timestamp of Bernoulli hardfork activation - #[serde(skip_serializing_if = "Option::is_none")] - bernoulli_time: Option, - /// Timestamp of Andantino hardfork activation - #[serde(skip_serializing_if = "Option::is_none")] - curie_time: Option, - - /// Timestamp of Morph203 hardfork activation - #[serde(skip_serializing_if = "Option::is_none")] - morph203_time: Option, - - /// Timestamp of viridian hardfork activation - #[serde(skip_serializing_if = "Option::is_none")] - viridian_time: Option, - - /// The epoch length used by consensus. - #[serde(skip_serializing_if = "Option::is_none")] - epoch_length: Option, -} +#[cfg(feature = "cli")] +use crate::{morph::MORPH_MAINNET, morph_hoodi::MORPH_HOODI}; +#[cfg(feature = "cli")] +use std::sync::Arc; -impl MorphGenesisInfo { - /// Extract Morph genesis info from genesis extra_fields - fn extract_from(genesis: &Genesis) -> Self { - genesis - .config - .extra_fields - .deserialize_as::() - .unwrap_or_default() - } +/// Chains supported by Morph. First value should be used as the default. +pub const SUPPORTED_CHAINS: &[&str] = &["mainnet", "hoodi"]; - pub fn epoch_length(&self) -> Option { - self.epoch_length - } -} +// ============================================================================= +// Chain Specification Parser (CLI) +// ============================================================================= /// Morph chain specification parser. #[derive(Debug, Clone, Default)] pub struct MorphChainSpecParser; -/// Chains supported by Morph. First value should be used as the default. -pub const SUPPORTED_CHAINS: &[&str] = &["testnet"]; - -/// Clap value parser for [`ChainSpec`]s. +/// Clap value parser for [`MorphChainSpec`]s. /// /// The value parser matches either a known chain, the path -/// to a json file, or a json formatted string in-memory. The json needs to be a Genesis struct. +/// to a json file, or a json formatted string in-memory. #[cfg(feature = "cli")] pub fn chain_value_parser(s: &str) -> eyre::Result> { - Ok(MorphChainSpec::from_genesis(reth_cli::chainspec::parse_genesis(s)?).into()) + Ok(match s { + "mainnet" => MORPH_MAINNET.clone(), + "hoodi" => MORPH_HOODI.clone(), + _ => Arc::new(MorphChainSpec::from(reth_cli::chainspec::parse_genesis(s)?)), + }) } #[cfg(feature = "cli")] @@ -81,6 +57,32 @@ impl reth_cli::chainspec::ChainSpecParser for MorphChainSpecParser { } } +// ============================================================================= +// ChainConfig Trait +// ============================================================================= + +/// Returns the chain configuration. +#[auto_impl::auto_impl(Arc)] +pub trait ChainConfig { + /// The configuration type. + type Config; + + /// Returns the chain configuration. + fn chain_config(&self) -> &Self::Config; +} + +impl ChainConfig for MorphChainSpec { + type Config = MorphChainConfig; + + fn chain_config(&self) -> &Self::Config { + &self.info.morph_chain_info + } +} + +// ============================================================================= +// MorphChainSpec +// ============================================================================= + /// Morph chain spec type. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MorphChainSpec { @@ -90,25 +92,60 @@ pub struct MorphChainSpec { } impl MorphChainSpec { + /// Create a new [`MorphChainSpec`] with the given inner spec and config. + pub fn new(inner: ChainSpec
, info: MorphGenesisInfo) -> Self { + Self { inner, info } + } + /// Converts the given [`Genesis`] into a [`MorphChainSpec`]. - pub fn from_genesis(genesis: Genesis) -> Self { - // Extract Morph genesis info from extra_fields - let info @ MorphGenesisInfo { - bernoulli_time, - curie_time, - morph203_time, - viridian_time, - .. - } = MorphGenesisInfo::extract_from(&genesis); + /// Returns whether the fee vault is enabled. + pub fn is_fee_vault_enabled(&self) -> bool { + self.info.morph_chain_info.is_fee_vault_enabled() + } + + /// Returns the fee vault address. + pub fn fee_vault_address(&self) -> Option
{ + self.info.morph_chain_info.fee_vault_address + } + + /// Returns the maximum tx payload size per block in bytes. + pub fn max_tx_payload_bytes_per_block(&self) -> Option { + self.info.morph_chain_info.max_tx_payload_bytes_per_block + } + + /// Checks if the given block size (in bytes) is valid for this chain. + pub fn is_valid_block_size(&self, size: usize) -> bool { + self.info.morph_chain_info.is_valid_block_size(size) + } +} + +impl From for MorphChainSpec { + fn from(value: ChainSpec) -> Self { + let genesis = value.genesis; + genesis.into() + } +} + +impl From for MorphChainSpec { + fn from(genesis: Genesis) -> Self { + let chain_info = MorphGenesisInfo::extract_from(&genesis.config.extra_fields) + .unwrap_or_else(|| MorphGenesisInfo { + hard_fork_info: MorphHardforkInfo::extract_from(&genesis.config.extra_fields), + morph_chain_info: MorphChainConfig::mainnet(), + }); + + let hardfork_info = chain_info.hard_fork_info.clone().unwrap_or_default(); // Create base chainspec from genesis (already has ordered Ethereum hardforks) let mut base_spec = ChainSpec::from_genesis(genesis); + // Add Morph hardforks let morph_forks = vec![ - (MorphHardfork::Bernoulli, bernoulli_time), - (MorphHardfork::Curie, curie_time), - (MorphHardfork::Morph203, morph203_time), - (MorphHardfork::Viridian, viridian_time), + (MorphHardfork::Bernoulli, hardfork_info.bernoulli_time), + (MorphHardfork::Curie, hardfork_info.curie_time), + (MorphHardfork::Morph203, hardfork_info.morph203_time), + (MorphHardfork::Viridian, hardfork_info.viridian_time), + (MorphHardfork::Emerald, hardfork_info.emerald_time), ] .into_iter() .filter_map(|(fork, time)| time.map(|time| (fork, ForkCondition::Timestamp(time)))); @@ -117,21 +154,14 @@ impl MorphChainSpec { Self { inner: base_spec, - info, + info: chain_info, } } } -// Required by reth's e2e-test-utils for integration tests. -// The test utilities need to convert from standard ChainSpec to custom chain specs. -impl From> for MorphChainSpec { - fn from(spec: ChainSpec
) -> Self { - Self { - inner: spec, - info: MorphGenesisInfo::default(), - } - } -} +// ============================================================================= +// Trait Implementations +// ============================================================================= impl Hardforks for MorphChainSpec { fn fork(&self, fork: H) -> ForkCondition { @@ -156,7 +186,7 @@ impl Hardforks for MorphChainSpec { } impl EthChainSpec for MorphChainSpec { - type Header = alloy_consensus::Header; + type Header = Header; fn chain(&self) -> Chain { self.inner.chain() @@ -234,12 +264,12 @@ impl MorphHardforks for MorphChainSpec { #[cfg(test)] mod tests { - use crate::hardfork::{MorphHardfork, MorphHardforks}; - use reth_chainspec::{EthereumHardfork, ForkCondition, Hardforks}; + use super::*; + use crate::hardfork::MorphHardforks; use serde_json::json; /// Helper function to create a test genesis with Morph hardforks at timestamp 0 - fn create_test_genesis() -> alloy_genesis::Genesis { + fn create_test_genesis() -> Genesis { let genesis_json = json!({ "config": { "chainId": 1337, @@ -261,7 +291,8 @@ mod tests { "bernoulliTime": 0, "curieTime": 0, "morph203Time": 0, - "viridianTime": 0 + "viridianTime": 0, + "emeraldTime": 0 }, "alloc": {} }); @@ -270,15 +301,16 @@ mod tests { #[test] fn test_morph_chainspec_has_morph_hardforks() { - let chainspec = super::MorphChainSpec::from_genesis(create_test_genesis()); + let chainspec = MorphChainSpec::from(create_test_genesis()); // Bernoulli should be active at genesis (timestamp 0) assert!(chainspec.is_bernoulli_active_at_timestamp(0)); + assert!(chainspec.is_emerald_active_at_timestamp(0)); } #[test] fn test_morph_chainspec_implements_morph_hardforks_trait() { - let chainspec = super::MorphChainSpec::from_genesis(create_test_genesis()); + let chainspec = MorphChainSpec::from(create_test_genesis()); // Should be able to query Morph hardfork activation through trait let activation = chainspec.morph_fork_activation(MorphHardfork::Bernoulli); @@ -291,7 +323,7 @@ mod tests { #[test] fn test_morph_hardforks_in_inner_hardforks() { - let chainspec = super::MorphChainSpec::from_genesis(create_test_genesis()); + let chainspec = MorphChainSpec::from(create_test_genesis()); // Morph hardforks should be queryable from inner.hardforks via Hardforks trait let activation = chainspec.fork(MorphHardfork::Bernoulli); @@ -309,8 +341,6 @@ mod tests { #[test] fn test_parse_morph_hardforks_from_genesis_extra_fields() { - // Create a genesis with Morph hardfork timestamps as extra fields in config - // (non-standard fields automatically go into extra_fields) let genesis_json = json!({ "config": { "chainId": 1337, @@ -333,127 +363,41 @@ mod tests { "curieTime": 2000, "morph203Time": 3000, "viridianTime": 4000, + "emeraldTime": 5000 }, "alloc": {} }); - let genesis: alloy_genesis::Genesis = + let genesis: Genesis = serde_json::from_value(genesis_json).expect("genesis should be valid"); - - let chainspec = super::MorphChainSpec::from_genesis(genesis); + let chainspec = MorphChainSpec::from(genesis); // Test Bernoulli activation let activation = chainspec.fork(MorphHardfork::Bernoulli); - assert_eq!( - activation, - ForkCondition::Timestamp(1000), - "Bernoulli should be activated at the parsed timestamp from extra_fields" - ); + assert_eq!(activation, ForkCondition::Timestamp(1000)); - assert!( - !chainspec.is_bernoulli_active_at_timestamp(0), - "Bernoulli should not be active before its activation timestamp" - ); - assert!( - chainspec.is_bernoulli_active_at_timestamp(1000), - "Bernoulli should be active at its activation timestamp" - ); - assert!( - chainspec.is_bernoulli_active_at_timestamp(2000), - "Bernoulli should be active after its activation timestamp" - ); + assert!(!chainspec.is_bernoulli_active_at_timestamp(0)); + assert!(chainspec.is_bernoulli_active_at_timestamp(1000)); + assert!(chainspec.is_bernoulli_active_at_timestamp(2000)); // Test Curie activation let activation = chainspec.fork(MorphHardfork::Curie); - assert_eq!( - activation, - ForkCondition::Timestamp(2000), - "Curie should be activated at the parsed timestamp from extra_fields" - ); + assert_eq!(activation, ForkCondition::Timestamp(2000)); - assert!( - !chainspec.is_curie_active_at_timestamp(0), - "Curie should not be active before its activation timestamp" - ); - assert!( - !chainspec.is_curie_active_at_timestamp(1000), - "Curie should not be active at Bernoulli's activation timestamp" - ); - assert!( - chainspec.is_curie_active_at_timestamp(2000), - "Curie should be active at its activation timestamp" - ); - assert!( - chainspec.is_curie_active_at_timestamp(3000), - "Curie should be active after its activation timestamp" - ); + assert!(!chainspec.is_curie_active_at_timestamp(0)); + assert!(!chainspec.is_curie_active_at_timestamp(1000)); + assert!(chainspec.is_curie_active_at_timestamp(2000)); - // Test Morph203 activation - let activation = chainspec.fork(MorphHardfork::Morph203); - assert_eq!( - activation, - ForkCondition::Timestamp(3000), - "Morph203 should be activated at the parsed timestamp from extra_fields" - ); - - assert!( - !chainspec.is_morph203_active_at_timestamp(0), - "Morph203 should not be active before its activation timestamp" - ); - assert!( - !chainspec.is_morph203_active_at_timestamp(1000), - "Morph203 should not be active at Bernoulli's activation timestamp" - ); - assert!( - !chainspec.is_morph203_active_at_timestamp(2000), - "Morph203 should not be active at Curie's activation timestamp" - ); - assert!( - chainspec.is_morph203_active_at_timestamp(3000), - "Morph203 should be active at its activation timestamp" - ); - assert!( - chainspec.is_morph203_active_at_timestamp(4000), - "Morph203 should be active after its activation timestamp" - ); - - // Test Viridian activation - let activation = chainspec.fork(MorphHardfork::Viridian); - assert_eq!( - activation, - ForkCondition::Timestamp(4000), - "Viridian should be activated at the parsed timestamp from extra_fields" - ); + // Test Emerald activation + let activation = chainspec.fork(MorphHardfork::Emerald); + assert_eq!(activation, ForkCondition::Timestamp(5000)); - assert!( - !chainspec.is_viridian_active_at_timestamp(0), - "Viridian should not be active before its activation timestamp" - ); - assert!( - !chainspec.is_viridian_active_at_timestamp(1000), - "Viridian should not be active at Bernoulli's activation timestamp" - ); - assert!( - !chainspec.is_viridian_active_at_timestamp(2000), - "Viridian should not be active at Curie's activation timestamp" - ); - assert!( - !chainspec.is_viridian_active_at_timestamp(3000), - "Viridian should not be active at Morph203's activation timestamp" - ); - assert!( - chainspec.is_viridian_active_at_timestamp(4000), - "Viridian should be active at its activation timestamp" - ); - assert!( - chainspec.is_viridian_active_at_timestamp(5000), - "Viridian should be active after its activation timestamp" - ); + assert!(!chainspec.is_emerald_active_at_timestamp(4000)); + assert!(chainspec.is_emerald_active_at_timestamp(5000)); } #[test] - fn test_morph_hardforks_are_ordered_correctly() { - // Create a genesis where Bernoulli should appear between Shanghai (time 0) and Cancun (time 2000) + fn test_morph_hardfork_at() { let genesis_json = json!({ "config": { "chainId": 1337, @@ -471,53 +415,76 @@ mod tests { "terminalTotalDifficulty": 0, "terminalTotalDifficultyPassed": true, "shanghaiTime": 0, - "cancunTime": 2000, + "cancunTime": 0, "bernoulliTime": 1000, + "curieTime": 2000, + "morph203Time": 3000, + "viridianTime": 4000, + "emeraldTime": 5000 }, "alloc": {} }); - let genesis: alloy_genesis::Genesis = + let genesis: Genesis = serde_json::from_value(genesis_json).expect("genesis should be valid"); + let chainspec = MorphChainSpec::from(genesis); - let chainspec = super::MorphChainSpec::from_genesis(genesis); + // Before Bernoulli activation - should return Bernoulli (baseline) + assert_eq!(chainspec.morph_hardfork_at(0), MorphHardfork::Bernoulli); - // Collect forks in order - let forks: Vec<_> = chainspec.inner.hardforks.forks_iter().collect(); + // At Bernoulli time + assert_eq!(chainspec.morph_hardfork_at(1000), MorphHardfork::Bernoulli); - // Find positions of Shanghai, Bernoulli, and Cancun - let shanghai_pos = forks - .iter() - .position(|(f, _)| f.name() == EthereumHardfork::Shanghai.name()); - let bernoulli_pos = forks - .iter() - .position(|(f, _)| f.name() == MorphHardfork::Bernoulli.name()); - let cancun_pos = forks - .iter() - .position(|(f, _)| f.name() == EthereumHardfork::Cancun.name()); + // At Curie time + assert_eq!(chainspec.morph_hardfork_at(2000), MorphHardfork::Curie); - assert!(shanghai_pos.is_some(), "Shanghai should be present"); - assert!(bernoulli_pos.is_some(), "Bernoulli should be present"); - assert!(cancun_pos.is_some(), "Cancun should be present"); + // At Morph203 time + assert_eq!(chainspec.morph_hardfork_at(3000), MorphHardfork::Morph203); - // Verify ordering: Shanghai (0) < Bernoulli (1000) < Cancun (2000) - assert!( - shanghai_pos.unwrap() < bernoulli_pos.unwrap(), - "Shanghai (time 0) should come before Bernoulli (time 1000), but got positions {} and {}", - shanghai_pos.unwrap(), - bernoulli_pos.unwrap() - ); - assert!( - bernoulli_pos.unwrap() < cancun_pos.unwrap(), - "Bernoulli (time 1000) should come before Cancun (time 2000), but got positions {} and {}", - bernoulli_pos.unwrap(), - cancun_pos.unwrap() - ); + // At Viridian time + assert_eq!(chainspec.morph_hardfork_at(4000), MorphHardfork::Viridian); + + // At Emerald time + assert_eq!(chainspec.morph_hardfork_at(5000), MorphHardfork::Emerald); + + // After Emerald + assert_eq!(chainspec.morph_hardfork_at(6000), MorphHardfork::Emerald); } #[test] - fn test_morph_hardfork_at() { - // Create a genesis with specific timestamps for each hardfork + fn test_chainspec_from_genesis() { + let genesis_json = json!({ + "config": { + "chainId": 1337, + "bernoulliTime": 0, + "curieTime": 0, + "morph203Time": 0, + "viridianTime": 0, + "emeraldTime": 0, + "morph": { + "feeVaultAddress": "0x530000000000000000000000000000000000000a", + "maxTxPayloadBytesPerBlock": 122880 + } + }, + "alloc": {} + }); + let genesis: Genesis = serde_json::from_value(genesis_json).unwrap(); + + let chainspec = MorphChainSpec::from(genesis); + + // All hardforks should be active at genesis + assert!(chainspec.is_bernoulli_active_at_timestamp(0)); + assert!(chainspec.is_curie_active_at_timestamp(0)); + assert!(chainspec.is_morph203_active_at_timestamp(0)); + assert!(chainspec.is_viridian_active_at_timestamp(0)); + assert!(chainspec.is_emerald_active_at_timestamp(0)); + + // Config should be extracted from genesis + assert!(chainspec.is_fee_vault_enabled()); + } + + #[test] + fn test_parse_morph_chain_info() { let genesis_json = json!({ "config": { "chainId": 1337, @@ -531,85 +498,32 @@ mod tests { "istanbulBlock": 0, "berlinBlock": 0, "londonBlock": 0, - "mergeNetsplitBlock": 0, - "terminalTotalDifficulty": 0, - "terminalTotalDifficultyPassed": true, - "shanghaiTime": 0, - "cancunTime": 0, - "bernoulliTime": 1000, - "curieTime": 2000, - "morph203Time": 3000, - "viridianTime": 4000 + "bernoulliTime": 0, + "curieTime": 0, + "morph": { + "feeVaultAddress": "0x530000000000000000000000000000000000000a", + "maxTxPayloadBytesPerBlock": 122880 + } }, "alloc": {} }); - let genesis: alloy_genesis::Genesis = - serde_json::from_value(genesis_json).expect("genesis should be valid"); - - let chainspec = super::MorphChainSpec::from_genesis(genesis); - - // Before Bernoulli activation - should return Bernoulli (it's the baseline) - assert_eq!( - chainspec.morph_hardfork_at(0), - MorphHardfork::Bernoulli, - "Should return Bernoulli at timestamp 0" - ); - - // At Bernoulli time - assert_eq!( - chainspec.morph_hardfork_at(1000), - MorphHardfork::Bernoulli, - "Should return Bernoulli at its activation time" - ); - - // Between Bernoulli and Curie - assert_eq!( - chainspec.morph_hardfork_at(1500), - MorphHardfork::Bernoulli, - "Should return Bernoulli between Bernoulli and Curie activation" - ); - - // At Curie time - assert_eq!( - chainspec.morph_hardfork_at(2000), - MorphHardfork::Curie, - "Should return Curie at its activation time" - ); - - // Between Curie and Morph203 - assert_eq!( - chainspec.morph_hardfork_at(2500), - MorphHardfork::Curie, - "Should return Curie between Curie and Morph203 activation" - ); + let genesis: Genesis = serde_json::from_value(genesis_json).unwrap(); + let chainspec = MorphChainSpec::from(genesis); - // At Morph203 time - assert_eq!( - chainspec.morph_hardfork_at(3000), - MorphHardfork::Morph203, - "Should return Morph203 at its activation time" - ); - - // Between Morph203 and Viridian - assert_eq!( - chainspec.morph_hardfork_at(3500), - MorphHardfork::Morph203, - "Should return Morph203 between Morph203 and Viridian activation" - ); + assert!(chainspec.is_fee_vault_enabled()); + assert_eq!(chainspec.max_tx_payload_bytes_per_block(), Some(122880)); + assert!(chainspec.is_valid_block_size(100000)); + assert!(!chainspec.is_valid_block_size(200000)); + } - // At Viridian time - assert_eq!( - chainspec.morph_hardfork_at(4000), - MorphHardfork::Viridian, - "Should return Viridian at its activation time" - ); + #[test] + fn test_chain_config_trait() { + let genesis = create_test_genesis(); + let chainspec = MorphChainSpec::from(genesis); - // After Viridian - assert_eq!( - chainspec.morph_hardfork_at(5000), - MorphHardfork::Viridian, - "Should return Viridian after its activation time" - ); + let config = chainspec.chain_config(); + // Default config is mainnet (has fee vault) + assert!(config.is_fee_vault_enabled()); } } diff --git a/crates/consensus/Cargo.toml b/crates/consensus/Cargo.toml new file mode 100644 index 0000000..c27e6ae --- /dev/null +++ b/crates/consensus/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "morph-consensus" +description = "Morph L2 consensus validation" + +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[dependencies] +# Morph +morph-chainspec.workspace = true +morph-primitives.workspace = true + +# Reth +reth-consensus.workspace = true +reth-consensus-common.workspace = true +reth-primitives-traits.workspace = true + +# Alloy +alloy-consensus.workspace = true +alloy-evm.workspace = true +alloy-primitives.workspace = true +alloy-rlp.workspace = true + +# Utils +thiserror.workspace = true + +[dev-dependencies] +alloy-genesis.workspace = true +alloy-primitives = { workspace = true, features = ["rand"] } +serde_json.workspace = true + diff --git a/crates/consensus/src/error.rs b/crates/consensus/src/error.rs new file mode 100644 index 0000000..87c67df --- /dev/null +++ b/crates/consensus/src/error.rs @@ -0,0 +1,69 @@ +//! Morph consensus error types. +//! +//! This module defines Morph-specific consensus errors that don't have +//! equivalents in reth's `ConsensusError`. +//! +//! For common errors (difficulty, nonce, ommers, gas, timestamp, base fee), +//! use the standard `reth_consensus::ConsensusError` variants directly. + +use alloy_primitives::Address; + +/// Morph consensus validation error. +/// +/// These are Morph L2-specific errors that have no direct equivalent +/// in the standard reth `ConsensusError`. +#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)] +pub enum MorphConsensusError { + /// Invalid L1 message order - either L1 messages are not at the start of the block + /// or queue indices are not strictly sequential. + #[error("Invalid L1 message order")] + InvalidL1MessageOrder, + + /// L1 messages queue indices are not sequential. + #[error("L1 messages are not in queue order: expected {expected}, got {actual}")] + L1MessagesNotInOrder { + /// Expected queue index. + expected: u64, + /// Actual queue index. + actual: u64, + }, + + /// Block base fee over limit. + #[error("Block base fee is over limit: {0}")] + BaseFeeOverLimit(u64), + + /// Invalid next L1 message index in header. + #[error("Invalid next L1 message index: expected {expected}, got {actual}")] + InvalidNextL1MessageIndex { + /// Expected next L1 message index. + expected: u64, + /// Actual next L1 message index. + actual: u64, + }, + + /// Invalid coinbase (must be empty when FeeVault is enabled). + #[error("Invalid coinbase: expected zero address, got {0}")] + InvalidCoinbase(Address), + + /// Invalid header field. + #[error("Invalid header: {0}")] + InvalidHeader(String), + + /// Invalid block body. + #[error("Invalid body: {0}")] + InvalidBody(String), + + /// Transaction decode error. + #[error("Failed to decode transaction: {0}")] + TransactionDecodeError(String), + + /// Withdrawals are not empty. + #[error("Withdrawals are not empty")] + WithdrawalsNonEmpty, +} + +impl From for MorphConsensusError { + fn from(err: alloy_rlp::Error) -> Self { + Self::TransactionDecodeError(err.to_string()) + } +} diff --git a/crates/consensus/src/lib.rs b/crates/consensus/src/lib.rs new file mode 100644 index 0000000..4b608bd --- /dev/null +++ b/crates/consensus/src/lib.rs @@ -0,0 +1,37 @@ +//! Morph L2 consensus validation. +//! +//! This crate provides consensus validation for Morph L2 blocks. +//! +//! # Main Components +//! +//! - [`MorphConsensus`]: The main consensus engine implementing reth's `Consensus`, +//! `HeaderValidator`, and `FullConsensus` traits. +//! - [`MorphConsensusError`]: Error types for consensus validation failures. +//! +//! # L1 Message Rules +//! +//! Morph L2 blocks must follow these rules for L1 messages: +//! +//! 1. All L1 messages must be at the beginning of the block +//! 2. L1 messages must be in ascending `queue_index` order +//! 3. No gaps in the `queue_index` sequence +//! +//! # Example +//! +//! ```ignore +//! use morph_consensus::MorphConsensus; +//! use std::sync::Arc; +//! +//! let chain_spec = Arc::new(MorphChainSpec::from(genesis)); +//! let consensus = MorphConsensus::new(chain_spec); +//! ``` + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] + +mod error; +mod validation; + +// Re-export main types +pub use error::MorphConsensusError; +pub use validation::MorphConsensus; diff --git a/crates/consensus/src/validation.rs b/crates/consensus/src/validation.rs new file mode 100644 index 0000000..df842b0 --- /dev/null +++ b/crates/consensus/src/validation.rs @@ -0,0 +1,554 @@ +//! Morph L2 consensus validation. +//! +//! This module provides consensus validation for Morph L2 blocks, including: +//! - Header validation +//! - Body validation (L1 messages ordering) +//! - Block pre/post execution validation +//! +use crate::MorphConsensusError; +use alloy_consensus::{BlockHeader as _, EMPTY_OMMER_ROOT_HASH, TxReceipt}; +use alloy_evm::block::BlockExecutionResult; +use morph_chainspec::{MorphChainSpec, hardfork::MorphHardforks}; +use morph_primitives::{Block, BlockBody, MorphReceipt, MorphTxEnvelope}; +use reth_consensus::{Consensus, ConsensusError, FullConsensus, HeaderValidator}; +use reth_consensus_common::validation::{ + validate_against_parent_hash_number, validate_body_against_header, +}; +use reth_primitives_traits::{ + BlockBody as BlockBodyTrait, BlockHeader, GotExpected, RecoveredBlock, SealedBlock, + SealedHeader, +}; +use std::sync::Arc; + +// ============================================================================ +// Constants +// ============================================================================ + +/// Maximum allowed base fee (10 Gwei) +const MORPH_MAXIMUM_BASE_FEE: u64 = 10_000_000_000; + +/// Maximum gas limit (2^63 - 1) +const MAX_GAS_LIMIT: u64 = 0x7fffffffffffffff; + +/// Minimum gas limit allowed for transactions. +const MINIMUM_GAS_LIMIT: u64 = 5000; + +/// The bound divisor of the gas limit, used in update calculations. +const GAS_LIMIT_BOUND_DIVISOR: u64 = 1024; + +// ============================================================================ +// MorphConsensus +// ============================================================================ + +/// Morph L2 consensus engine. +/// +/// Validates Morph L2 blocks according to the L2 consensus rules. +/// See module-level documentation for detailed validation rules. +#[derive(Debug, Clone)] +pub struct MorphConsensus { + /// Chain specification containing hardfork information and chain config. + chain_spec: Arc, +} + +impl MorphConsensus { + /// Creates a new [`MorphConsensus`] instance. + pub const fn new(chain_spec: Arc) -> Self { + Self { chain_spec } + } + + /// Returns a reference to the chain specification. + pub fn chain_spec(&self) -> &MorphChainSpec { + &self.chain_spec + } +} + +// ============================================================================ +// HeaderValidator Implementation +// ============================================================================ + +impl HeaderValidator for MorphConsensus { + fn validate_header( + &self, + header: &SealedHeader, + ) -> Result<(), ConsensusError> { + // Extra data must be empty (Morph L2 specific - stricter than max length) + if !header.extra_data().is_empty() { + return Err(ConsensusError::ExtraDataExceedsMax { + len: header.extra_data().len(), + }); + } + + // Nonce must be 0 (same as post-merge Ethereum) + if !header.nonce().is_some_and(|nonce| nonce.is_zero()) { + return Err(ConsensusError::TheMergeNonceIsNotZero); + } + + // Ommers hash must be empty (same as post-merge Ethereum) + if header.ommers_hash() != EMPTY_OMMER_ROOT_HASH { + return Err(ConsensusError::TheMergeOmmerRootIsNotEmpty); + } + + // Difficulty must be 0 (same as post-merge Ethereum) + if !header.difficulty().is_zero() { + return Err(ConsensusError::TheMergeDifficultyIsNotZero); + } + + // Coinbase must be zero if FeeVault is enabled (Morph L2 specific) + if self.chain_spec.is_fee_vault_enabled() + && header.beneficiary() != alloy_primitives::Address::ZERO + { + return Err(ConsensusError::Other( + MorphConsensusError::InvalidCoinbase(header.beneficiary()).to_string(), + )); + } + + // Check timestamp is not in the future + let now = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .expect("system time should never be before UNIX EPOCH") + .as_secs(); + + if header.timestamp() > now { + return Err(ConsensusError::TimestampIsInFuture { + timestamp: header.timestamp(), + present_timestamp: now, + }); + } + + // Gas limit must be <= MAX_GAS_LIMIT + if header.gas_limit() > MAX_GAS_LIMIT { + return Err(ConsensusError::HeaderGasLimitExceedsMax { + gas_limit: header.gas_limit(), + }); + } + + // Gas used must be <= gas limit + if header.gas_used() > header.gas_limit() { + return Err(ConsensusError::HeaderGasUsedExceedsGasLimit { + gas_used: header.gas_used(), + gas_limit: header.gas_limit(), + }); + } + + // Validate the EIP1559 fee is set if the header is after Curie + if self + .chain_spec + .is_curie_active_at_timestamp(header.timestamp()) + { + let base_fee = header + .base_fee_per_gas() + .ok_or(ConsensusError::BaseFeeMissing)?; + if base_fee > MORPH_MAXIMUM_BASE_FEE { + return Err(ConsensusError::Other( + MorphConsensusError::BaseFeeOverLimit(base_fee).to_string(), + )); + } + } + Ok(()) + } + + fn validate_header_against_parent( + &self, + header: &SealedHeader, + parent: &SealedHeader, + ) -> Result<(), ConsensusError> { + // Validate parent hash and block number + validate_against_parent_hash_number(header.header(), parent)?; + + // Validate timestamp against parent + validate_against_parent_timestamp(header.header(), parent.header())?; + + // Validate gas limit change (before Curie only) + validate_against_parent_gas_limit(header.header(), parent.header())?; + + Ok(()) + } +} + +// ============================================================================ +// Consensus Implementation +// ============================================================================ + +impl Consensus for MorphConsensus { + type Error = ConsensusError; + + fn validate_body_against_header( + &self, + body: &BlockBody, + header: &SealedHeader, + ) -> Result<(), Self::Error> { + validate_body_against_header(body, header.header()) + } + + fn validate_block_pre_execution(&self, block: &SealedBlock) -> Result<(), Self::Error> { + // Check no uncles allowed (Morph L2 has no uncle blocks) + 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 must be empty root hash + if block.ommers_hash() != EMPTY_OMMER_ROOT_HASH { + return Err(ConsensusError::BodyOmmersHashDiff( + GotExpected { + got: block.ommers_hash(), + expected: EMPTY_OMMER_ROOT_HASH, + } + .into(), + )); + } + + // Check transaction root + if let Err(error) = block.ensure_transaction_root_valid() { + return Err(ConsensusError::BodyTransactionRootDiff(error.into())); + } + + // Check withdrawals are empty + if block.body().withdrawals().is_some() { + return Err(ConsensusError::Other( + MorphConsensusError::WithdrawalsNonEmpty.to_string(), + )); + } + + // Validate L1 messages ordering + let txs: Vec<_> = block.body().transactions().collect(); + validate_l1_messages(&txs)?; + + Ok(()) + } +} + +// ============================================================================ +// FullConsensus Implementation +// ============================================================================ + +impl FullConsensus for MorphConsensus { + fn validate_block_post_execution( + &self, + block: &RecoveredBlock, + result: &BlockExecutionResult, + ) -> Result<(), ConsensusError> { + // Verify the block gas used + let cumulative_gas_used = result + .receipts + .last() + .map(|r| r.cumulative_gas_used()) + .unwrap_or(0); + + if block.gas_used() != cumulative_gas_used { + return Err(ConsensusError::BlockGasUsed { + gas: GotExpected { + got: cumulative_gas_used, + expected: block.gas_used(), + }, + gas_spent_by_tx: reth_primitives_traits::receipt::gas_spent_by_transactions( + &result.receipts, + ), + }); + } + + Ok(()) + } +} + +// +#[inline] +fn validate_against_parent_timestamp( + header: &H, + parent: &H, +) -> Result<(), ConsensusError> { + if header.timestamp() < parent.timestamp() { + return Err(ConsensusError::TimestampIsInPast { + parent_timestamp: parent.timestamp(), + timestamp: header.timestamp(), + }); + } + Ok(()) +} + +/// Validates gas limit change against parent. +/// +/// - Gas limit change must be within bounds (parent / GAS_LIMIT_BOUND_DIVISOR) +/// - Only checked before Curie hardfork +/// +/// Note: After Curie, gas limit verification is part of EIP-1559 header validation +/// which Morph doesn't strictly enforce (sequencer can set values). +#[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() / 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: header.gas_limit(), + }) + } else { + Err(ConsensusError::GasLimitInvalidDecrease { + parent_gas_limit: parent.gas_limit(), + child_gas_limit: header.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(()) +} + +// ============================================================================ +// L1 Message Validation +// ============================================================================ + +/// Validates L1 message ordering in a block's transactions. +/// +/// - All L1 messages must be at the beginning of the block +/// - L1 messages must have strictly sequential queue indices +#[inline] +fn validate_l1_messages(txs: &[&MorphTxEnvelope]) -> Result<(), ConsensusError> { + // Find the starting queue index from the first L1 message + let mut queue_index = txs + .iter() + .find(|tx| tx.is_l1_msg()) + .and_then(|tx| tx.queue_index()) + .unwrap_or_default(); + + let mut saw_l2_transaction = false; + + for tx in txs { + // Check queue index is strictly sequential + if tx.is_l1_msg() { + let tx_queue_index = tx.queue_index().expect("is_l1_msg"); + if tx_queue_index != queue_index { + return Err(ConsensusError::Other( + MorphConsensusError::L1MessagesNotInOrder { + expected: queue_index, + actual: tx_queue_index, + } + .to_string(), + )); + } + queue_index = tx_queue_index + 1; + } + + // Check L1 messages are only at the start of the block + if tx.is_l1_msg() && saw_l2_transaction { + return Err(ConsensusError::Other( + MorphConsensusError::InvalidL1MessageOrder.to_string(), + )); + } + saw_l2_transaction = !tx.is_l1_msg(); + } + + Ok(()) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use alloy_consensus::{Header, Signed}; + use alloy_genesis::Genesis; + use alloy_primitives::{Address, B64, B256, Bytes, Signature, TxKind, U256}; + use morph_primitives::transaction::TxL1Msg; + + fn create_test_chainspec() -> Arc { + let genesis_json = serde_json::json!({ + "config": { + "chainId": 1337, + "homesteadBlock": 0, + "eip150Block": 0, + "eip155Block": 0, + "eip158Block": 0, + "byzantiumBlock": 0, + "constantinopleBlock": 0, + "petersburgBlock": 0, + "istanbulBlock": 0, + "berlinBlock": 0, + "londonBlock": 0, + "bernoulliTime": 0, + "curieTime": 0, + "morph203Time": 0, + "viridianTime": 0, + "emeraldTime": 0 + }, + "alloc": {} + }); + + let genesis: Genesis = serde_json::from_value(genesis_json).unwrap(); + Arc::new(MorphChainSpec::from(genesis)) + } + + fn create_l1_msg_tx(queue_index: u64) -> MorphTxEnvelope { + let tx = TxL1Msg { + queue_index, + tx_hash: B256::ZERO, + from: Address::ZERO, + nonce: queue_index, // nonce is used as queue index for L1 messages + gas_limit: 21000, + to: TxKind::Call(Address::ZERO), + value: U256::ZERO, + input: Bytes::default(), + }; + let sig = Signature::new(U256::ZERO, U256::ZERO, false); + MorphTxEnvelope::L1Msg(Signed::new_unchecked(tx, sig, B256::ZERO)) + } + + fn create_regular_tx() -> MorphTxEnvelope { + use alloy_consensus::TxLegacy; + let tx = TxLegacy::default(); + let sig = Signature::new(U256::ZERO, U256::ZERO, false); + MorphTxEnvelope::Legacy(Signed::new_unchecked(tx, sig, B256::ZERO)) + } + + #[test] + fn test_morph_consensus_creation() { + let chain_spec = create_test_chainspec(); + let consensus = MorphConsensus::new(chain_spec); + assert_eq!(consensus.chain_spec().inner.chain.id(), 1337); + } + + #[test] + fn test_validate_l1_messages_valid() { + let txs = vec![ + create_l1_msg_tx(0), + create_l1_msg_tx(1), + create_regular_tx(), + ]; + let txs_refs: Vec<_> = txs.iter().collect(); + assert!(validate_l1_messages(&txs_refs).is_ok()); + } + + #[test] + fn test_validate_l1_messages_after_regular() { + let txs = vec![ + create_l1_msg_tx(0), + create_regular_tx(), + create_l1_msg_tx(1), + ]; + let txs_refs: Vec<_> = txs.iter().collect(); + assert!(validate_l1_messages(&txs_refs).is_err()); + } + + #[test] + fn test_validate_header_extra_data_not_empty() { + let chain_spec = create_test_chainspec(); + let consensus = MorphConsensus::new(chain_spec); + let header = Header { + extra_data: Bytes::from(vec![1, 2, 3]), + nonce: B64::ZERO, + ommers_hash: EMPTY_OMMER_ROOT_HASH, + ..Default::default() + }; + let sealed = SealedHeader::seal_slow(header); + let result = consensus.validate_header(&sealed); + assert!(matches!( + result, + Err(ConsensusError::ExtraDataExceedsMax { .. }) + )); + } + + #[test] + fn test_validate_header_invalid_difficulty() { + let chain_spec = create_test_chainspec(); + let consensus = MorphConsensus::new(chain_spec); + let header = Header { + difficulty: U256::from(1), + ommers_hash: EMPTY_OMMER_ROOT_HASH, + nonce: B64::ZERO, + ..Default::default() + }; + let sealed = SealedHeader::seal_slow(header); + let result = consensus.validate_header(&sealed); + assert!(matches!( + result, + Err(ConsensusError::TheMergeDifficultyIsNotZero) + )); + } + + #[test] + fn test_validate_header_invalid_nonce() { + let chain_spec = create_test_chainspec(); + let consensus = MorphConsensus::new(chain_spec); + let header = Header { + nonce: B64::from(1u64), + ommers_hash: EMPTY_OMMER_ROOT_HASH, + ..Default::default() + }; + let sealed = SealedHeader::seal_slow(header); + let result = consensus.validate_header(&sealed); + assert!(matches!( + result, + Err(ConsensusError::TheMergeNonceIsNotZero) + )); + } + + #[test] + fn test_validate_header_invalid_ommers() { + let chain_spec = create_test_chainspec(); + let consensus = MorphConsensus::new(chain_spec); + let header = Header { + nonce: B64::ZERO, + ommers_hash: B256::ZERO, // not EMPTY_OMMER_ROOT_HASH + ..Default::default() + }; + let sealed = SealedHeader::seal_slow(header); + let result = consensus.validate_header(&sealed); + assert!(matches!( + result, + Err(ConsensusError::TheMergeOmmerRootIsNotEmpty) + )); + } + + #[test] + fn test_validate_header_gas_used_exceeds_limit() { + let chain_spec = create_test_chainspec(); + let consensus = MorphConsensus::new(chain_spec); + let header = Header { + nonce: B64::ZERO, + ommers_hash: EMPTY_OMMER_ROOT_HASH, + gas_limit: 1000, + gas_used: 2000, // exceeds gas_limit + ..Default::default() + }; + let sealed = SealedHeader::seal_slow(header); + let result = consensus.validate_header(&sealed); + assert!(matches!( + result, + Err(ConsensusError::HeaderGasUsedExceedsGasLimit { .. }) + )); + } + + #[test] + fn test_validate_header_valid() { + let chain_spec = create_test_chainspec(); + let consensus = MorphConsensus::new(chain_spec); + // Create a valid header with timestamp not in the future + let now = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs(); + let header = Header { + nonce: B64::ZERO, + ommers_hash: EMPTY_OMMER_ROOT_HASH, + gas_limit: 30_000_000, + gas_used: 21_000, + timestamp: now - 10, // 10 seconds ago + base_fee_per_gas: Some(1_000_000), // 0.001 Gwei (after Curie) + ..Default::default() + }; + let sealed = SealedHeader::seal_slow(header); + let result = consensus.validate_header(&sealed); + assert!(result.is_ok()); + } +} diff --git a/crates/evm/src/lib.rs b/crates/evm/src/lib.rs index e23a693..c920346 100644 --- a/crates/evm/src/lib.rs +++ b/crates/evm/src/lib.rs @@ -228,9 +228,7 @@ mod tests { #[test] fn test_evm_config_can_query_morph_hardforks() { // Create a test chainspec with Bernoulli at genesis - let chainspec = Arc::new(morph_chainspec::MorphChainSpec::from_genesis( - create_test_genesis(), - )); + let chainspec = Arc::new(morph_chainspec::MorphChainSpec::from(create_test_genesis())); let evm_config = MorphEvmConfig::new_with_default_factory(chainspec); diff --git a/crates/payload/types/Cargo.toml b/crates/payload/types/Cargo.toml index 846e475..32767a0 100644 --- a/crates/payload/types/Cargo.toml +++ b/crates/payload/types/Cargo.toml @@ -16,6 +16,7 @@ workspace = true morph-primitives = { workspace = true, features = ["serde"] } # Reth +reth-engine-primitives.workspace = true reth-payload-primitives.workspace = true reth-primitives-traits.workspace = true diff --git a/crates/payload/types/src/attributes.rs b/crates/payload/types/src/attributes.rs index 39fa8a3..1216bdd 100644 --- a/crates/payload/types/src/attributes.rs +++ b/crates/payload/types/src/attributes.rs @@ -1,6 +1,6 @@ //! Morph payload attributes types. -use alloy_eips::eip4895::Withdrawals; +use alloy_eips::eip4895::{Withdrawal, Withdrawals}; use alloy_primitives::{Address, B256, Bytes}; use alloy_rpc_types_engine::PayloadAttributes; use reth_payload_primitives::PayloadBuilderAttributes; @@ -89,6 +89,20 @@ impl From for MorphPayloadAttributes { } } +impl reth_payload_primitives::PayloadAttributes for MorphPayloadAttributes { + fn timestamp(&self) -> u64 { + self.inner.timestamp + } + + fn withdrawals(&self) -> Option<&Vec> { + self.inner.withdrawals.as_ref() + } + + fn parent_beacon_block_root(&self) -> Option { + self.inner.parent_beacon_block_root + } +} + /// Internal payload builder attributes. /// /// This is the internal representation used by the payload builder, diff --git a/crates/payload/types/src/executable_l2_data.rs b/crates/payload/types/src/executable_l2_data.rs index 83db2d9..9530df1 100644 --- a/crates/payload/types/src/executable_l2_data.rs +++ b/crates/payload/types/src/executable_l2_data.rs @@ -1,6 +1,4 @@ //! ExecutableL2Data type definition. -//! -//! This type is compatible with go-ethereum's ExecutableL2Data struct. use alloy_primitives::{Address, B256, Bytes}; use serde::{Deserialize, Serialize}; @@ -8,7 +6,6 @@ use serde::{Deserialize, Serialize}; /// L2 block data used for AssembleL2Block/ValidateL2Block/NewL2Block. /// /// This struct contains all the data needed to construct and validate an L2 block. -/// It is designed to be compatible with go-ethereum's ExecutableL2Data type. /// /// # Fields /// diff --git a/crates/payload/types/src/lib.rs b/crates/payload/types/src/lib.rs index 9adef1f..57c1075 100644 --- a/crates/payload/types/src/lib.rs +++ b/crates/payload/types/src/lib.rs @@ -5,9 +5,10 @@ //! - [`SafeL2Data`]: Safe block data for NewSafeL2Block (derivation) //! - [`MorphPayloadAttributes`]: Extended payload attributes for block building //! - [`MorphBuiltPayload`]: Built payload result +//! - [`MorphEngineTypes`]: Engine types for the Morph Engine API +//! - [`MorphPayloadTypes`]: Default payload types implementation //! -//! These types are designed to be compatible with the go-ethereum L2 Engine API -//! while also supporting the standard Ethereum Engine API. +//! These types are designed to be compatible with the standard Ethereum Engine API. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] @@ -18,9 +19,91 @@ mod executable_l2_data; mod params; mod safe_l2_data; +use core::marker::PhantomData; + +use alloy_rpc_types_engine::{ + ExecutionData, ExecutionPayload, ExecutionPayloadEnvelopeV2, ExecutionPayloadEnvelopeV3, + ExecutionPayloadEnvelopeV4, ExecutionPayloadV1, +}; +use morph_primitives::Block; +use reth_engine_primitives::EngineTypes; +use reth_payload_primitives::{BuiltPayload, PayloadTypes}; +use reth_primitives_traits::{NodePrimitives, SealedBlock}; +use serde::{Deserialize, Serialize}; + // Re-export main types pub use attributes::{MorphPayloadAttributes, MorphPayloadBuilderAttributes}; pub use built::MorphBuiltPayload; pub use executable_l2_data::ExecutableL2Data; pub use params::{AssembleL2BlockParams, GenericResponse}; pub use safe_l2_data::SafeL2Data; + +/// The types used in the Morph beacon consensus engine. +/// +/// This is a generic wrapper that allows customizing the payload types. +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub struct MorphEngineTypes { + _marker: PhantomData, +} + +impl PayloadTypes for MorphEngineTypes +where + T: PayloadTypes, + T::BuiltPayload: BuiltPayload>, +{ + type ExecutionData = T::ExecutionData; + type BuiltPayload = T::BuiltPayload; + type PayloadAttributes = T::PayloadAttributes; + type PayloadBuilderAttributes = T::PayloadBuilderAttributes; + + fn block_to_payload( + block: SealedBlock< + <::Primitives as NodePrimitives>::Block, + >, + ) -> ExecutionData { + let (payload, sidecar) = + ExecutionPayload::from_block_unchecked(block.hash(), &block.into_block()); + ExecutionData { payload, sidecar } + } +} + +impl EngineTypes for MorphEngineTypes +where + T: PayloadTypes, + T::BuiltPayload: BuiltPayload> + + TryInto + + TryInto + + TryInto + + TryInto, +{ + type ExecutionPayloadEnvelopeV1 = ExecutionPayloadV1; + type ExecutionPayloadEnvelopeV2 = ExecutionPayloadEnvelopeV2; + type ExecutionPayloadEnvelopeV3 = ExecutionPayloadEnvelopeV3; + type ExecutionPayloadEnvelopeV4 = ExecutionPayloadEnvelopeV4; + type ExecutionPayloadEnvelopeV5 = ExecutionPayloadEnvelopeV4; +} + +/// A default payload type for [`MorphEngineTypes`]. +/// +/// This type provides the concrete implementations for Morph's payload handling. +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub struct MorphPayloadTypes; + +impl PayloadTypes for MorphPayloadTypes { + type ExecutionData = ExecutionData; + type BuiltPayload = MorphBuiltPayload; + type PayloadAttributes = MorphPayloadAttributes; + type PayloadBuilderAttributes = MorphPayloadBuilderAttributes; + + fn block_to_payload( + block: SealedBlock< + <::Primitives as NodePrimitives>::Block, + >, + ) -> Self::ExecutionData { + let (payload, sidecar) = + ExecutionPayload::from_block_unchecked(block.hash(), &block.into_block()); + ExecutionData { payload, sidecar } + } +} diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index 2e367f0..77cc9e3 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -56,6 +56,17 @@ impl MorphTxEnvelope { self.tx_type() == MorphTxType::L1Msg } + pub fn queue_index(&self) -> Option { + match self { + Self::Legacy(_) + | Self::Eip2930(_) + | Self::Eip1559(_) + | Self::Eip7702(_) + | Self::AltFee(_) => None, + Self::L1Msg(tx) => Some(tx.tx().queue_index), + } + } + /// Encode the transaction according to [EIP-2718] rules. First a 1-byte /// type flag in the range 0x0-0x7f, then the body of the transaction. pub fn rlp(&self) -> Bytes { diff --git a/crates/primitives/src/transaction/l1_transaction.rs b/crates/primitives/src/transaction/l1_transaction.rs index c1c4641..d4e0c4d 100644 --- a/crates/primitives/src/transaction/l1_transaction.rs +++ b/crates/primitives/src/transaction/l1_transaction.rs @@ -28,6 +28,9 @@ pub const L1_TX_TYPE_ID: u8 = 0x7E; #[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] #[cfg_attr(feature = "reth-codec", derive(reth_codecs::Compact))] pub struct TxL1Msg { + /// The queue index of the message in the L1 contract queue. + pub queue_index: u64, + /// The 32-byte hash of the transaction. pub tx_hash: B256, @@ -92,6 +95,7 @@ impl TxL1Msg { /// /// This accounts for all fields in the struct. pub fn size(&self) -> usize { + mem::size_of::() + // queue_index mem::size_of::() + // tx_hash mem::size_of::
() + // from mem::size_of::() + // nonce @@ -105,6 +109,7 @@ impl TxL1Msg { #[doc(hidden)] pub fn fields_len(&self) -> usize { let mut len = 0; + len += self.queue_index.length(); len += self.nonce.length(); len += self.gas_limit.length(); len += self.to.length(); @@ -116,6 +121,7 @@ impl TxL1Msg { /// Encode the transaction fields (without the RLP header). pub fn encode_fields(&self, out: &mut dyn BufMut) { + self.queue_index.encode(out); self.nonce.encode(out); self.gas_limit.encode(out); self.to.encode(out); @@ -126,6 +132,7 @@ impl TxL1Msg { pub fn decode_fields(buf: &mut &[u8]) -> alloy_rlp::Result { Ok(Self { + queue_index: Decodable::decode(buf)?, tx_hash: Decodable::decode(buf)?, nonce: Decodable::decode(buf)?, gas_limit: Decodable::decode(buf)?, @@ -276,6 +283,7 @@ impl Decodable for TxL1Msg { return Err(alloy_rlp::Error::InputTooShort); } + let queue_index = Decodable::decode(buf)?; let nonce = Decodable::decode(buf)?; let gas_limit = Decodable::decode(buf)?; let to = Decodable::decode(buf)?; @@ -291,6 +299,7 @@ impl Decodable for TxL1Msg { let tx_hash = B256::ZERO; Ok(Self { + queue_index, tx_hash, from, nonce, @@ -363,6 +372,7 @@ mod tests { #[test] fn test_l1_transaction_trait_methods() { let tx = TxL1Msg { + queue_index: 0, tx_hash: B256::ZERO, from: address!("0000000000000000000000000000000000000001"), nonce: 0, @@ -373,9 +383,8 @@ mod tests { }; // Test Transaction trait methods - // Note: L1 transactions always return nonce 0 from Transaction trait assert_eq!(tx.chain_id(), None); - assert_eq!(Transaction::nonce(&tx), 0); + assert_eq!(Transaction::nonce(&tx), 0); // nonce is set to 0 in this test case assert_eq!(Transaction::gas_limit(&tx), 21_000); assert_eq!(tx.gas_price(), Some(0)); assert_eq!(tx.max_fee_per_gas(), 0); @@ -427,6 +436,7 @@ mod tests { #[test] fn test_l1_transaction_signature_hash() { let tx = TxL1Msg { + queue_index: 0, tx_hash: B256::ZERO, from: address!("0000000000000000000000000000000000000001"), nonce: 1, @@ -443,6 +453,7 @@ mod tests { #[test] fn test_l1_transaction_rlp_roundtrip() { let tx = TxL1Msg { + queue_index: 5, tx_hash: B256::ZERO, from: address!("0000000000000000000000000000000000000001"), nonce: 42, @@ -459,6 +470,7 @@ mod tests { // Decode let decoded = TxL1Msg::decode(&mut buf.as_slice()).expect("Should decode"); + assert_eq!(tx.queue_index, decoded.queue_index); assert_eq!(tx.from, decoded.from); assert_eq!(tx.nonce, decoded.nonce); assert_eq!(tx.gas_limit, decoded.gas_limit); @@ -470,6 +482,7 @@ mod tests { #[test] fn test_l1_transaction_create() { let tx = TxL1Msg { + queue_index: 0, tx_hash: B256::ZERO, from: address!("0000000000000000000000000000000000000001"), nonce: 0, @@ -492,6 +505,7 @@ mod tests { #[test] fn test_l1_transaction_encode_2718() { let tx = TxL1Msg { + queue_index: 0, tx_hash: B256::ZERO, from: address!("0000000000000000000000000000000000000001"), nonce: 1, @@ -517,6 +531,7 @@ mod tests { #[test] fn test_l1_transaction_decode_rejects_malformed_rlp() { let tx = TxL1Msg { + queue_index: 0, tx_hash: B256::ZERO, from: address!("0000000000000000000000000000000000000001"), nonce: 42, @@ -548,6 +563,7 @@ mod tests { #[test] fn test_l1_transaction_size() { let tx = TxL1Msg { + queue_index: 0, tx_hash: B256::ZERO, from: Address::ZERO, nonce: 0, @@ -558,7 +574,8 @@ mod tests { }; // Calculate expected size manually - let expected_size = mem::size_of::() + // tx_hash + let expected_size = mem::size_of::() + // queue_index + mem::size_of::() + // tx_hash mem::size_of::
() + // from mem::size_of::() + // nonce mem::size_of::() + // gas_limit @@ -571,6 +588,7 @@ mod tests { #[test] fn test_l1_transaction_fields_len() { let tx = TxL1Msg { + queue_index: 0, tx_hash: B256::ZERO, from: address!("0000000000000000000000000000000000000001"), nonce: 1, @@ -591,6 +609,7 @@ mod tests { #[test] fn test_l1_transaction_encode_fields() { let tx = TxL1Msg { + queue_index: 0, tx_hash: B256::ZERO, from: address!("0000000000000000000000000000000000000001"), nonce: 1, From 535e1d59ae62c1e7c732c4e625bb29d6fc0ebb77 Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 14 Jan 2026 22:27:33 +0800 Subject: [PATCH 02/13] add morph tx receipt primitives --- Cargo.lock | 2 + Cargo.toml | 1 + crates/consensus/src/validation.rs | 543 +++++++++++ crates/evm/src/block.rs | 22 +- crates/primitives/Cargo.toml | 7 +- crates/primitives/src/lib.rs | 15 +- crates/primitives/src/receipt/mod.rs | 916 ++++++++++++++++++ crates/primitives/src/receipt/receipt.rs | 326 +++++++ crates/primitives/src/transaction/envelope.rs | 19 +- crates/primitives/src/transaction/mod.rs | 2 +- 10 files changed, 1837 insertions(+), 16 deletions(-) create mode 100644 crates/primitives/src/receipt/mod.rs create mode 100644 crates/primitives/src/receipt/receipt.rs diff --git a/Cargo.lock b/Cargo.lock index f2908aa..3ab63de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3733,11 +3733,13 @@ dependencies = [ "alloy-primitives", "alloy-rlp", "alloy-serde", + "bytes", "modular-bitfield", "reth-codecs", "reth-db-api", "reth-ethereum-primitives", "reth-primitives-traits", + "reth-zstd-compressors", "serde", ] diff --git a/Cargo.toml b/Cargo.toml index 6a71166..aeee60a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,6 +92,7 @@ reth-rpc-server-types = { git = "https://github.com/paradigmxyz/reth", rev = "64 reth-storage-api = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3" } reth-tracing = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3" } reth-transaction-pool = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3" } +reth-zstd-compressors = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3", default-features = false } reth-revm = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3", features = [ "std", diff --git a/crates/consensus/src/validation.rs b/crates/consensus/src/validation.rs index df842b0..c92314a 100644 --- a/crates/consensus/src/validation.rs +++ b/crates/consensus/src/validation.rs @@ -8,6 +8,7 @@ use crate::MorphConsensusError; use alloy_consensus::{BlockHeader as _, EMPTY_OMMER_ROOT_HASH, TxReceipt}; use alloy_evm::block::BlockExecutionResult; +use alloy_primitives::{B256, Bloom}; use morph_chainspec::{MorphChainSpec, hardfork::MorphHardforks}; use morph_primitives::{Block, BlockBody, MorphReceipt, MorphTxEnvelope}; use reth_consensus::{Consensus, ConsensusError, FullConsensus, HeaderValidator}; @@ -247,6 +248,9 @@ impl FullConsensus for MorphConsensus { }); } + // Verify the receipts logs bloom and root + verify_receipts(block.receipts_root(), block.logs_bloom(), &result.receipts)?; + Ok(()) } } @@ -350,6 +354,56 @@ fn validate_l1_messages(txs: &[&MorphTxEnvelope]) -> Result<(), ConsensusError> Ok(()) } +// ============================================================================ +// Receipts Validation +// ============================================================================ + +/// Verifies the receipts root and logs bloom against the expected values. +/// +/// This function: +/// 1. Calculates the receipts root from the provided receipts +/// 2. Calculates the logs bloom by combining all receipt blooms +/// 3. Compares both against the expected values from the block header +#[inline] +fn verify_receipts( + expected_receipts_root: B256, + expected_logs_bloom: Bloom, + receipts: &[MorphReceipt], +) -> Result<(), ConsensusError> { + // Calculate receipts root + let receipts_with_bloom: Vec<_> = receipts.iter().map(TxReceipt::with_bloom_ref).collect(); + let receipts_root = alloy_consensus::proofs::calculate_receipt_root(&receipts_with_bloom); + + // Calculate logs bloom by combining all receipt blooms + let logs_bloom = receipts_with_bloom + .iter() + .fold(Bloom::ZERO, |bloom, r| bloom | r.bloom_ref()); + + // Compare receipts root + if receipts_root != expected_receipts_root { + return Err(ConsensusError::BodyReceiptRootDiff( + GotExpected { + got: receipts_root, + expected: expected_receipts_root, + } + .into(), + )); + } + + // Compare logs bloom + if logs_bloom != expected_logs_bloom { + return Err(ConsensusError::BodyBloomLogDiff( + GotExpected { + got: logs_bloom, + expected: expected_logs_bloom, + } + .into(), + )); + } + + Ok(()) +} + // ============================================================================ // Tests // ============================================================================ @@ -551,4 +605,493 @@ mod tests { let result = consensus.validate_header(&sealed); assert!(result.is_ok()); } + + // ======================================================================== + // L1 Message Validation Tests + // ======================================================================== + + #[test] + fn test_validate_l1_messages_empty_block() { + let txs: Vec = vec![]; + let txs_refs: Vec<_> = txs.iter().collect(); + assert!(validate_l1_messages(&txs_refs).is_ok()); + } + + #[test] + fn test_validate_l1_messages_only_l1_messages() { + let txs = vec![ + create_l1_msg_tx(0), + create_l1_msg_tx(1), + create_l1_msg_tx(2), + ]; + let txs_refs: Vec<_> = txs.iter().collect(); + assert!(validate_l1_messages(&txs_refs).is_ok()); + } + + #[test] + fn test_validate_l1_messages_only_regular_txs() { + let txs = vec![ + create_regular_tx(), + create_regular_tx(), + create_regular_tx(), + ]; + let txs_refs: Vec<_> = txs.iter().collect(); + assert!(validate_l1_messages(&txs_refs).is_ok()); + } + + #[test] + fn test_validate_l1_messages_skipped_index() { + // Skip index 1: 0, 2 + let txs = vec![create_l1_msg_tx(0), create_l1_msg_tx(2)]; + let txs_refs: Vec<_> = txs.iter().collect(); + let result = validate_l1_messages(&txs_refs); + assert!(result.is_err()); + let err_str = result.unwrap_err().to_string(); + assert!(err_str.contains("expected 1")); + assert!(err_str.contains("got 2")); + } + + #[test] + fn test_validate_l1_messages_non_zero_start_index() { + // Starting from index 100 is valid + let txs = vec![ + create_l1_msg_tx(100), + create_l1_msg_tx(101), + create_regular_tx(), + ]; + let txs_refs: Vec<_> = txs.iter().collect(); + assert!(validate_l1_messages(&txs_refs).is_ok()); + } + + #[test] + fn test_validate_l1_messages_duplicate_index() { + // Duplicate index: 0, 0 + let txs = vec![create_l1_msg_tx(0), create_l1_msg_tx(0)]; + let txs_refs: Vec<_> = txs.iter().collect(); + let result = validate_l1_messages(&txs_refs); + assert!(result.is_err()); + let err_str = result.unwrap_err().to_string(); + assert!(err_str.contains("expected 1")); + assert!(err_str.contains("got 0")); + } + + #[test] + fn test_validate_l1_messages_out_of_order() { + // Reversed order: 1, 0 + let txs = vec![create_l1_msg_tx(1), create_l1_msg_tx(0)]; + let txs_refs: Vec<_> = txs.iter().collect(); + let result = validate_l1_messages(&txs_refs); + assert!(result.is_err()); + } + + #[test] + fn test_validate_l1_messages_multiple_l1_after_regular() { + // Multiple L1 messages after regular tx + let txs = vec![ + create_l1_msg_tx(0), + create_regular_tx(), + create_l1_msg_tx(1), + create_l1_msg_tx(2), + ]; + let txs_refs: Vec<_> = txs.iter().collect(); + assert!(validate_l1_messages(&txs_refs).is_err()); + } + + // ======================================================================== + // Header Validation Tests (Additional) + // ======================================================================== + + #[test] + fn test_validate_header_timestamp_in_future() { + let chain_spec = create_test_chainspec(); + let consensus = MorphConsensus::new(chain_spec); + let future_ts = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs() + + 3600; // 1 hour in the future + + let header = Header { + nonce: B64::ZERO, + ommers_hash: EMPTY_OMMER_ROOT_HASH, + gas_limit: 30_000_000, + timestamp: future_ts, + base_fee_per_gas: Some(1_000_000), + ..Default::default() + }; + let sealed = SealedHeader::seal_slow(header); + let result = consensus.validate_header(&sealed); + assert!(matches!( + result, + Err(ConsensusError::TimestampIsInFuture { .. }) + )); + } + + #[test] + fn test_validate_header_gas_limit_exceeds_max() { + let chain_spec = create_test_chainspec(); + let consensus = MorphConsensus::new(chain_spec); + let now = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let header = Header { + nonce: B64::ZERO, + ommers_hash: EMPTY_OMMER_ROOT_HASH, + gas_limit: MAX_GAS_LIMIT + 1, // Exceeds max + timestamp: now - 10, + base_fee_per_gas: Some(1_000_000), + ..Default::default() + }; + let sealed = SealedHeader::seal_slow(header); + let result = consensus.validate_header(&sealed); + assert!(matches!( + result, + Err(ConsensusError::HeaderGasLimitExceedsMax { .. }) + )); + } + + #[test] + fn test_validate_header_base_fee_over_limit() { + let chain_spec = create_test_chainspec(); + let consensus = MorphConsensus::new(chain_spec); + let now = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let header = Header { + nonce: B64::ZERO, + ommers_hash: EMPTY_OMMER_ROOT_HASH, + gas_limit: 30_000_000, + timestamp: now - 10, + base_fee_per_gas: Some(MORPH_MAXIMUM_BASE_FEE + 1), // Over limit + ..Default::default() + }; + let sealed = SealedHeader::seal_slow(header); + let result = consensus.validate_header(&sealed); + assert!(result.is_err()); + let err_str = result.unwrap_err().to_string(); + assert!(err_str.contains("over limit")); + } + + #[test] + fn test_validate_header_base_fee_missing_after_curie() { + let chain_spec = create_test_chainspec(); + let consensus = MorphConsensus::new(chain_spec); + let now = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let header = Header { + nonce: B64::ZERO, + ommers_hash: EMPTY_OMMER_ROOT_HASH, + gas_limit: 30_000_000, + timestamp: now - 10, + base_fee_per_gas: None, // Missing after Curie + ..Default::default() + }; + let sealed = SealedHeader::seal_slow(header); + let result = consensus.validate_header(&sealed); + assert!(matches!(result, Err(ConsensusError::BaseFeeMissing))); + } + + #[test] + fn test_validate_header_base_fee_at_max() { + let chain_spec = create_test_chainspec(); + let consensus = MorphConsensus::new(chain_spec); + let now = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let header = Header { + nonce: B64::ZERO, + ommers_hash: EMPTY_OMMER_ROOT_HASH, + gas_limit: 30_000_000, + timestamp: now - 10, + base_fee_per_gas: Some(MORPH_MAXIMUM_BASE_FEE), // Exactly at max (valid) + ..Default::default() + }; + let sealed = SealedHeader::seal_slow(header); + let result = consensus.validate_header(&sealed); + assert!(result.is_ok()); + } + + // ======================================================================== + // Header Against Parent Validation Tests + // ======================================================================== + + fn create_valid_header(timestamp: u64, gas_limit: u64, number: u64) -> Header { + Header { + nonce: B64::ZERO, + ommers_hash: EMPTY_OMMER_ROOT_HASH, + gas_limit, + timestamp, + number, + base_fee_per_gas: Some(1_000_000), + ..Default::default() + } + } + + #[test] + fn test_validate_header_against_parent_valid() { + let chain_spec = create_test_chainspec(); + let consensus = MorphConsensus::new(chain_spec); + + let parent = create_valid_header(1000, 30_000_000, 100); + let parent_sealed = SealedHeader::seal_slow(parent); + + let mut child = create_valid_header(1001, 30_000_000, 101); + child.parent_hash = parent_sealed.hash(); + let child_sealed = SealedHeader::seal_slow(child); + + let result = consensus.validate_header_against_parent(&child_sealed, &parent_sealed); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_header_against_parent_timestamp_less_than_parent() { + let chain_spec = create_test_chainspec(); + let consensus = MorphConsensus::new(chain_spec); + + let parent = create_valid_header(1000, 30_000_000, 100); + let parent_sealed = SealedHeader::seal_slow(parent); + + let mut child = create_valid_header(999, 30_000_000, 101); // timestamp < parent + child.parent_hash = parent_sealed.hash(); + let child_sealed = SealedHeader::seal_slow(child); + + let result = consensus.validate_header_against_parent(&child_sealed, &parent_sealed); + assert!(matches!( + result, + Err(ConsensusError::TimestampIsInPast { .. }) + )); + } + + #[test] + fn test_validate_header_against_parent_timestamp_equal_to_parent() { + let chain_spec = create_test_chainspec(); + let consensus = MorphConsensus::new(chain_spec); + + let parent = create_valid_header(1000, 30_000_000, 100); + let parent_sealed = SealedHeader::seal_slow(parent); + + let mut child = create_valid_header(1000, 30_000_000, 101); // timestamp == parent (valid) + child.parent_hash = parent_sealed.hash(); + let child_sealed = SealedHeader::seal_slow(child); + + let result = consensus.validate_header_against_parent(&child_sealed, &parent_sealed); + // timestamp >= parent is valid + assert!(result.is_ok()); + } + + #[test] + fn test_validate_header_against_parent_gas_limit_increase_too_much() { + let chain_spec = create_test_chainspec(); + let consensus = MorphConsensus::new(chain_spec); + + let parent_gas_limit = 30_000_000u64; + let max_increase = parent_gas_limit / GAS_LIMIT_BOUND_DIVISOR; + + let parent = create_valid_header(1000, parent_gas_limit, 100); + let parent_sealed = SealedHeader::seal_slow(parent); + + // Increase by more than allowed + let mut child = create_valid_header(1001, parent_gas_limit + max_increase + 1, 101); + child.parent_hash = parent_sealed.hash(); + let child_sealed = SealedHeader::seal_slow(child); + + let result = consensus.validate_header_against_parent(&child_sealed, &parent_sealed); + assert!(matches!( + result, + Err(ConsensusError::GasLimitInvalidIncrease { .. }) + )); + } + + #[test] + fn test_validate_header_against_parent_gas_limit_decrease_too_much() { + let chain_spec = create_test_chainspec(); + let consensus = MorphConsensus::new(chain_spec); + + let parent_gas_limit = 30_000_000u64; + let max_decrease = parent_gas_limit / GAS_LIMIT_BOUND_DIVISOR; + + let parent = create_valid_header(1000, parent_gas_limit, 100); + let parent_sealed = SealedHeader::seal_slow(parent); + + // Decrease by more than allowed + let mut child = create_valid_header(1001, parent_gas_limit - max_decrease - 1, 101); + child.parent_hash = parent_sealed.hash(); + let child_sealed = SealedHeader::seal_slow(child); + + let result = consensus.validate_header_against_parent(&child_sealed, &parent_sealed); + assert!(matches!( + result, + Err(ConsensusError::GasLimitInvalidDecrease { .. }) + )); + } + + #[test] + fn test_validate_header_against_parent_gas_limit_at_boundary() { + let chain_spec = create_test_chainspec(); + let consensus = MorphConsensus::new(chain_spec); + + let parent_gas_limit = 30_000_000u64; + let max_change = parent_gas_limit / GAS_LIMIT_BOUND_DIVISOR; + + let parent = create_valid_header(1000, parent_gas_limit, 100); + let parent_sealed = SealedHeader::seal_slow(parent); + + // Increase by exactly the allowed amount (valid) + let mut child = create_valid_header(1001, parent_gas_limit + max_change, 101); + child.parent_hash = parent_sealed.hash(); + let child_sealed = SealedHeader::seal_slow(child); + + let result = consensus.validate_header_against_parent(&child_sealed, &parent_sealed); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_header_against_parent_gas_limit_below_minimum() { + let chain_spec = create_test_chainspec(); + let consensus = MorphConsensus::new(chain_spec); + + // Use a parent gas limit that allows decreasing to below minimum within bounds + // Parent = MINIMUM_GAS_LIMIT, so max decrease = MINIMUM_GAS_LIMIT / 1024 = 4 + // Child = MINIMUM_GAS_LIMIT - 1 = 4999, change = 1 which is < 4 (within bounds) + let parent = create_valid_header(1000, MINIMUM_GAS_LIMIT, 100); + let parent_sealed = SealedHeader::seal_slow(parent); + + let mut child = create_valid_header(1001, MINIMUM_GAS_LIMIT - 1, 101); + child.parent_hash = parent_sealed.hash(); + let child_sealed = SealedHeader::seal_slow(child); + + let result = consensus.validate_header_against_parent(&child_sealed, &parent_sealed); + assert!(matches!( + result, + Err(ConsensusError::GasLimitInvalidMinimum { .. }) + )); + } + + #[test] + fn test_validate_header_against_parent_wrong_parent_hash() { + let chain_spec = create_test_chainspec(); + let consensus = MorphConsensus::new(chain_spec); + + let parent = create_valid_header(1000, 30_000_000, 100); + let parent_sealed = SealedHeader::seal_slow(parent); + + let mut child = create_valid_header(1001, 30_000_000, 101); + child.parent_hash = B256::random(); // Wrong parent hash + let child_sealed = SealedHeader::seal_slow(child); + + let result = consensus.validate_header_against_parent(&child_sealed, &parent_sealed); + assert!(matches!(result, Err(ConsensusError::ParentHashMismatch(_)))); + } + + #[test] + fn test_validate_header_against_parent_wrong_block_number() { + let chain_spec = create_test_chainspec(); + let consensus = MorphConsensus::new(chain_spec); + + let parent = create_valid_header(1000, 30_000_000, 100); + let parent_sealed = SealedHeader::seal_slow(parent); + + let mut child = create_valid_header(1001, 30_000_000, 102); // Should be 101 + child.parent_hash = parent_sealed.hash(); + let child_sealed = SealedHeader::seal_slow(child); + + let result = consensus.validate_header_against_parent(&child_sealed, &parent_sealed); + assert!(matches!( + result, + Err(ConsensusError::ParentBlockNumberMismatch { .. }) + )); + } + + // ======================================================================== + // Receipts Validation Tests + // ======================================================================== + + #[test] + fn test_verify_receipts_empty() { + let receipts: Vec = vec![]; + let expected_root = alloy_consensus::proofs::calculate_receipt_root::< + alloy_consensus::ReceiptWithBloom<&MorphReceipt>, + >(&[]); + let expected_bloom = Bloom::ZERO; + + let result = verify_receipts(expected_root, expected_bloom, &receipts); + assert!(result.is_ok()); + } + + #[test] + fn test_verify_receipts_root_mismatch() { + let receipts: Vec = vec![]; + let wrong_root = B256::random(); // Wrong root + let expected_bloom = Bloom::ZERO; + + let result = verify_receipts(wrong_root, expected_bloom, &receipts); + assert!(matches!( + result, + Err(ConsensusError::BodyReceiptRootDiff(_)) + )); + } + + #[test] + fn test_verify_receipts_bloom_mismatch() { + let receipts: Vec = vec![]; + let expected_root = alloy_consensus::proofs::calculate_receipt_root::< + alloy_consensus::ReceiptWithBloom<&MorphReceipt>, + >(&[]); + let wrong_bloom = Bloom::repeat_byte(0xff); // Wrong bloom + + let result = verify_receipts(expected_root, wrong_bloom, &receipts); + assert!(matches!(result, Err(ConsensusError::BodyBloomLogDiff(_)))); + } + + // ======================================================================== + // Gas Limit Validation Helper Tests + // ======================================================================== + + #[test] + fn test_validate_against_parent_gas_limit_no_change() { + let parent = create_valid_header(1000, 30_000_000, 100); + let child = create_valid_header(1001, 30_000_000, 101); + + let result = validate_against_parent_gas_limit(&child, &parent); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_against_parent_timestamp_valid() { + let parent = create_valid_header(1000, 30_000_000, 100); + let child = create_valid_header(1001, 30_000_000, 101); + + let result = validate_against_parent_timestamp(&child, &parent); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_against_parent_timestamp_equal() { + let parent = create_valid_header(1000, 30_000_000, 100); + let child = create_valid_header(1000, 30_000_000, 101); // Same timestamp + + let result = validate_against_parent_timestamp(&child, &parent); + assert!(result.is_ok()); // Equal timestamp is allowed + } + + #[test] + fn test_validate_against_parent_timestamp_past() { + let parent = create_valid_header(1000, 30_000_000, 100); + let child = create_valid_header(999, 30_000_000, 101); // Earlier timestamp + + let result = validate_against_parent_timestamp(&child, &parent); + assert!(matches!( + result, + Err(ConsensusError::TimestampIsInPast { .. }) + )); + } } diff --git a/crates/evm/src/block.rs b/crates/evm/src/block.rs index a88f265..c16a857 100644 --- a/crates/evm/src/block.rs +++ b/crates/evm/src/block.rs @@ -1,4 +1,5 @@ use crate::{MorphBlockExecutionCtx, evm::MorphEvm}; +use alloy_consensus::Receipt; use alloy_evm::{ Database, Evm, block::{BlockExecutionError, BlockExecutionResult, BlockExecutor, ExecutableTx, OnStateHook}, @@ -8,7 +9,7 @@ use alloy_evm::{ }, }; use morph_chainspec::MorphChainSpec; -use morph_primitives::{MorphReceipt, MorphTxEnvelope}; +use morph_primitives::{MorphReceipt, MorphTransactionReceipt, MorphTxEnvelope, MorphTxType}; use morph_revm::{MorphHaltReason, evm::MorphContext}; use reth_revm::{Inspector, State, context::result::ResultAndState}; @@ -31,13 +32,22 @@ impl ReceiptBuilder for MorphReceiptBuilder { cumulative_gas_used, .. } = ctx; - MorphReceipt { - tx_type: tx.tx_type(), - // Success flag was added in `EIP-658: Embedding transaction status code in - // receipts`. - success: result.is_success(), + + let inner = Receipt { + status: result.is_success().into(), cumulative_gas_used, logs: result.into_logs(), + }; + + // Create the appropriate receipt variant based on transaction type + // TODO: Add L1 fee calculation from execution context + match tx.tx_type() { + MorphTxType::Legacy => MorphReceipt::Legacy(MorphTransactionReceipt::new(inner)), + MorphTxType::Eip2930 => MorphReceipt::Eip2930(MorphTransactionReceipt::new(inner)), + MorphTxType::Eip1559 => MorphReceipt::Eip1559(MorphTransactionReceipt::new(inner)), + MorphTxType::Eip7702 => MorphReceipt::Eip7702(MorphTransactionReceipt::new(inner)), + MorphTxType::L1Msg => MorphReceipt::L1Msg(inner), + MorphTxType::AltFee => MorphReceipt::AltFee(MorphTransactionReceipt::new(inner)), } } } diff --git a/crates/primitives/Cargo.toml b/crates/primitives/Cargo.toml index 4b36796..2d4bdf2 100644 --- a/crates/primitives/Cargo.toml +++ b/crates/primitives/Cargo.toml @@ -14,8 +14,9 @@ workspace = true # Reth reth-db-api = { workspace = true, optional = true } reth-ethereum-primitives = { workspace = true, optional = true } -reth-primitives-traits = { workspace = true, optional = true } +reth-primitives-traits.workspace = true reth-codecs = { workspace = true, optional = true } +reth-zstd-compressors = { workspace = true, optional = true } # Alloy alloy-consensus.workspace = true @@ -25,6 +26,7 @@ alloy-rlp.workspace = true alloy-serde = { workspace = true, optional = true } # Utils +bytes.workspace = true serde = { workspace = true, features = ["derive"], optional = true } modular-bitfield = { version = "0.11.2", optional = true } @@ -38,10 +40,10 @@ serde = [ "dep:alloy-serde", "alloy-primitives/serde", "alloy-eips/serde", + "alloy-consensus/serde", ] reth = [ "dep:reth-ethereum-primitives", - "dep:reth-primitives-traits", ] reth-codec = [ "reth", @@ -49,6 +51,7 @@ reth-codec = [ "dep:reth-codecs", "dep:reth-db-api", "dep:modular-bitfield", + "dep:reth-zstd-compressors", "reth-ethereum-primitives/reth-codec", "reth-codecs/alloy", "reth-primitives-traits/reth-codec", diff --git a/crates/primitives/src/lib.rs b/crates/primitives/src/lib.rs index d38fc37..03d30ee 100644 --- a/crates/primitives/src/lib.rs +++ b/crates/primitives/src/lib.rs @@ -10,17 +10,20 @@ use alloy_consensus as _; use alloy_eips as _; use alloy_primitives as _; use alloy_rlp as _; +use bytes as _; +#[cfg(feature = "reth")] +use reth_ethereum_primitives as _; +#[cfg(feature = "reth-codec")] +use reth_zstd_compressors as _; +pub mod receipt; pub mod transaction; -use crate::transaction::envelope::MorphTxType; -use alloy_primitives::Log; // Re-export standard Ethereum types pub use alloy_consensus::Header; /// Header alias for backwards compatibility. pub type MorphHeader = Header; -use reth_ethereum_primitives::EthereumReceipt; use reth_primitives_traits::NodePrimitives; /// Morph block. @@ -29,12 +32,12 @@ pub type Block = alloy_consensus::Block; /// Morph block body. pub type BlockBody = alloy_consensus::BlockBody; -/// Morph receipt. -pub type MorphReceipt = EthereumReceipt; +// Re-export receipt types +pub use receipt::{MorphReceipt, MorphReceiptWithBloom, MorphTransactionReceipt}; // Re-export transaction types pub use transaction::{ - ALT_FEE_TX_TYPE_ID, L1_TX_TYPE_ID, MorphTxEnvelope, TxAltFee, TxAltFeeExt, TxL1Msg, + ALT_FEE_TX_TYPE_ID, L1_TX_TYPE_ID, MorphTxEnvelope, MorphTxType, TxAltFee, TxAltFeeExt, TxL1Msg, }; /// A [`NodePrimitives`] implementation for Morph. diff --git a/crates/primitives/src/receipt/mod.rs b/crates/primitives/src/receipt/mod.rs new file mode 100644 index 0000000..ac3aab8 --- /dev/null +++ b/crates/primitives/src/receipt/mod.rs @@ -0,0 +1,916 @@ +//! Morph receipt types. +//! +//! This module provides: +//! - [`MorphTransactionReceipt`]: Receipt with L1 fee and AltFee fields +//! - [`MorphReceipt`]: Typed receipt enum for different transaction types + +#[allow(clippy::module_inception)] +mod receipt; +pub use receipt::{MorphReceiptWithBloom, MorphTransactionReceipt}; + +use crate::transaction::envelope::MorphTxType; +use alloy_consensus::{ + Eip2718EncodableReceipt, Receipt, ReceiptWithBloom, RlpDecodableReceipt, RlpEncodableReceipt, + TxReceipt, Typed2718, +}; +use alloy_eips::eip2718::{Decodable2718, Eip2718Result, Encodable2718}; +use alloy_primitives::{B256, Bloom, Log}; +use alloy_rlp::{BufMut, Decodable, Encodable, Header}; +use reth_primitives_traits::InMemorySize; + +/// Morph typed receipt. +/// +/// This enum wraps different receipt types based on the transaction type. +/// For L1 messages, it uses a standard receipt without L1 fee. +/// For other transactions, it uses [`MorphTransactionReceipt`] with L1 fee and optional AltFee fields. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum MorphReceipt { + /// Legacy receipt + Legacy(MorphTransactionReceipt), + /// EIP-2930 receipt + Eip2930(MorphTransactionReceipt), + /// EIP-1559 receipt + Eip1559(MorphTransactionReceipt), + /// EIP-7702 receipt + Eip7702(MorphTransactionReceipt), + /// L1 message receipt (no L1 fee since it's pre-paid on L1) + L1Msg(Receipt), + /// AltFee receipt + AltFee(MorphTransactionReceipt), +} + +impl Default for MorphReceipt { + fn default() -> Self { + Self::Legacy(MorphTransactionReceipt::default()) + } +} + +impl MorphReceipt { + /// Returns [`MorphTxType`] of the receipt. + pub const fn tx_type(&self) -> MorphTxType { + match self { + Self::Legacy(_) => MorphTxType::Legacy, + Self::Eip2930(_) => MorphTxType::Eip2930, + Self::Eip1559(_) => MorphTxType::Eip1559, + Self::Eip7702(_) => MorphTxType::Eip7702, + Self::L1Msg(_) => MorphTxType::L1Msg, + Self::AltFee(_) => MorphTxType::AltFee, + } + } + + /// Returns inner [`Receipt`]. + pub const fn as_receipt(&self) -> &Receipt { + match self { + Self::Legacy(receipt) + | Self::Eip2930(receipt) + | Self::Eip1559(receipt) + | Self::Eip7702(receipt) + | Self::AltFee(receipt) => &receipt.inner, + Self::L1Msg(receipt) => receipt, + } + } + + /// Returns the L1 fee if present. + pub fn l1_fee(&self) -> Option { + match self { + Self::Legacy(r) + | Self::Eip2930(r) + | Self::Eip1559(r) + | Self::Eip7702(r) + | Self::AltFee(r) => r.l1_fee, + Self::L1Msg(_) => None, + } + } + + /// Returns true if this is an L1 message receipt. + pub const fn is_l1_message(&self) -> bool { + matches!(self, Self::L1Msg(_)) + } + + /// Returns length of RLP-encoded receipt fields with the given [`Bloom`] without an RLP header. + pub fn rlp_encoded_fields_length(&self, bloom: &Bloom) -> usize { + match self { + Self::Legacy(r) + | Self::Eip2930(r) + | Self::Eip1559(r) + | Self::Eip7702(r) + | Self::AltFee(r) => r.rlp_encoded_fields_length_with_bloom(bloom), + Self::L1Msg(r) => r.rlp_encoded_fields_length_with_bloom(bloom), + } + } + + /// RLP-encodes receipt fields with the given [`Bloom`] without an RLP header. + pub fn rlp_encode_fields(&self, bloom: &Bloom, out: &mut dyn BufMut) { + match self { + Self::Legacy(r) + | Self::Eip2930(r) + | Self::Eip1559(r) + | Self::Eip7702(r) + | Self::AltFee(r) => r.rlp_encode_fields_with_bloom(bloom, out), + Self::L1Msg(r) => r.rlp_encode_fields_with_bloom(bloom, out), + } + } + + /// Returns RLP header for inner encoding. + pub fn rlp_header_inner(&self, bloom: &Bloom) -> Header { + Header { + list: true, + payload_length: self.rlp_encoded_fields_length(bloom), + } + } + + /// Returns RLP header for inner encoding without bloom. + /// + /// Used for DA (data availability) layer compression where bloom is omitted to save space. + pub fn rlp_header_inner_without_bloom(&self) -> Header { + Header { + list: true, + payload_length: self.rlp_encoded_fields_length_without_bloom(), + } + } + + /// Returns length of RLP-encoded receipt fields without bloom and without an RLP header. + /// + /// The fields are: `[status, cumulative_gas_used, logs]` (no bloom). + /// Used for DA layer compression. + pub fn rlp_encoded_fields_length_without_bloom(&self) -> usize { + match self { + Self::Legacy(r) + | Self::Eip2930(r) + | Self::Eip1559(r) + | Self::Eip7702(r) + | Self::AltFee(r) => { + r.inner.status.length() + + r.inner.cumulative_gas_used.length() + + r.inner.logs.length() + } + Self::L1Msg(r) => r.status.length() + r.cumulative_gas_used.length() + r.logs.length(), + } + } + + /// RLP-encodes receipt fields without bloom and without an RLP header. + /// + /// Encodes: `[status, cumulative_gas_used, logs]` (no bloom). + /// Used for DA layer compression. + pub fn rlp_encode_fields_without_bloom(&self, out: &mut dyn BufMut) { + match self { + Self::Legacy(r) + | Self::Eip2930(r) + | Self::Eip1559(r) + | Self::Eip7702(r) + | Self::AltFee(r) => { + r.inner.status.encode(out); + r.inner.cumulative_gas_used.encode(out); + r.inner.logs.encode(out); + } + Self::L1Msg(r) => { + r.status.encode(out); + r.cumulative_gas_used.encode(out); + r.logs.encode(out); + } + } + } + + /// RLP-decodes the receipt from the provided buffer without bloom. + /// + /// Expects format: `[status, cumulative_gas_used, logs]` (no bloom). + /// Used for DA layer decompression. + pub fn rlp_decode_inner_without_bloom( + buf: &mut &[u8], + tx_type: MorphTxType, + ) -> alloy_rlp::Result { + let header = Header::decode(buf)?; + if !header.list { + return Err(alloy_rlp::Error::UnexpectedString); + } + + let remaining = buf.len(); + let status = Decodable::decode(buf)?; + let cumulative_gas_used = Decodable::decode(buf)?; + let logs = Decodable::decode(buf)?; + + if buf.len() + header.payload_length != remaining { + return Err(alloy_rlp::Error::UnexpectedLength); + } + + let inner = Receipt { + status, + cumulative_gas_used, + logs, + }; + + match tx_type { + MorphTxType::Legacy => Ok(Self::Legacy(MorphTransactionReceipt::new(inner))), + MorphTxType::Eip2930 => Ok(Self::Eip2930(MorphTransactionReceipt::new(inner))), + MorphTxType::Eip1559 => Ok(Self::Eip1559(MorphTransactionReceipt::new(inner))), + MorphTxType::Eip7702 => Ok(Self::Eip7702(MorphTransactionReceipt::new(inner))), + MorphTxType::L1Msg => Ok(Self::L1Msg(inner)), + MorphTxType::AltFee => Ok(Self::AltFee(MorphTransactionReceipt::new(inner))), + } + } + + /// RLP-decodes the receipt from the provided buffer. This does not expect a type byte or + /// network header. + fn rlp_decode_inner( + buf: &mut &[u8], + tx_type: MorphTxType, + ) -> alloy_rlp::Result> { + // Decode using standard Receipt, then wrap in appropriate MorphReceipt variant + let ReceiptWithBloom { + receipt: inner, + logs_bloom, + } = Receipt::rlp_decode_with_bloom(buf)?; + + let receipt = match tx_type { + MorphTxType::Legacy => Self::Legacy(MorphTransactionReceipt::new(inner)), + MorphTxType::Eip2930 => Self::Eip2930(MorphTransactionReceipt::new(inner)), + MorphTxType::Eip1559 => Self::Eip1559(MorphTransactionReceipt::new(inner)), + MorphTxType::Eip7702 => Self::Eip7702(MorphTransactionReceipt::new(inner)), + MorphTxType::L1Msg => Self::L1Msg(inner), + MorphTxType::AltFee => Self::AltFee(MorphTransactionReceipt::new(inner)), + }; + + Ok(ReceiptWithBloom { + receipt, + logs_bloom, + }) + } +} + +impl TxReceipt for MorphReceipt { + type Log = Log; + + fn status_or_post_state(&self) -> alloy_consensus::Eip658Value { + self.as_receipt().status_or_post_state() + } + + fn status(&self) -> bool { + self.as_receipt().status() + } + + fn bloom(&self) -> Bloom { + self.as_receipt().bloom() + } + + fn cumulative_gas_used(&self) -> u64 { + self.as_receipt().cumulative_gas_used() + } + + fn logs(&self) -> &[Log] { + self.as_receipt().logs() + } +} + +impl Typed2718 for MorphReceipt { + fn ty(&self) -> u8 { + self.tx_type().into() + } +} + +impl Eip2718EncodableReceipt for MorphReceipt { + fn eip2718_encoded_length_with_bloom(&self, bloom: &Bloom) -> usize { + !self.tx_type().is_legacy() as usize + self.rlp_header_inner(bloom).length_with_payload() + } + + fn eip2718_encode_with_bloom(&self, bloom: &Bloom, out: &mut dyn BufMut) { + if !self.tx_type().is_legacy() { + out.put_u8(self.tx_type().into()); + } + self.rlp_header_inner(bloom).encode(out); + self.rlp_encode_fields(bloom, out); + } +} + +impl RlpEncodableReceipt for MorphReceipt { + fn rlp_encoded_length_with_bloom(&self, bloom: &Bloom) -> usize { + let mut len = self.eip2718_encoded_length_with_bloom(bloom); + if !self.tx_type().is_legacy() { + // For typed receipts, add string header length + len += Header { + list: false, + payload_length: self.eip2718_encoded_length_with_bloom(bloom), + } + .length(); + } + len + } + + fn rlp_encode_with_bloom(&self, bloom: &Bloom, out: &mut dyn BufMut) { + if !self.tx_type().is_legacy() { + // For typed receipts, write string header first + Header { + list: false, + payload_length: self.eip2718_encoded_length_with_bloom(bloom), + } + .encode(out); + } + self.eip2718_encode_with_bloom(bloom, out); + } +} + +impl RlpDecodableReceipt for MorphReceipt { + fn rlp_decode_with_bloom(buf: &mut &[u8]) -> alloy_rlp::Result> { + let header_buf = &mut &**buf; + let header = Header::decode(header_buf)?; + + // Legacy receipt: header.list = true (directly an RLP list) + if header.list { + return Self::rlp_decode_inner(buf, MorphTxType::Legacy); + } + + // Typed receipt: header.list = false (string containing type + RLP list) + // Advance the buffer past the header + *buf = *header_buf; + + let remaining = buf.len(); + let tx_type = MorphTxType::rlp_decode(buf)?; + let this = Self::rlp_decode_inner(buf, tx_type)?; + + if buf.len() + header.payload_length != remaining { + return Err(alloy_rlp::Error::UnexpectedLength); + } + + Ok(this) + } +} + +impl Encodable2718 for MorphReceipt { + /// Returns the length of the EIP-2718 encoded receipt without bloom. + /// + /// Format: `[type_byte] + RLP([status, cumulative_gas_used, logs])` + /// + /// Bloom is omitted for DA layer compression - it can be recalculated from logs. + fn encode_2718_len(&self) -> usize { + !self.tx_type().is_legacy() as usize + + self.rlp_header_inner_without_bloom().length_with_payload() + } + + /// EIP-2718 encodes the receipt without bloom. + /// + /// Format: `[type_byte] + RLP([status, cumulative_gas_used, logs])` + /// + /// Bloom is omitted for DA layer compression - it can be recalculated from logs. + fn encode_2718(&self, out: &mut dyn BufMut) { + if !self.tx_type().is_legacy() { + out.put_u8(self.tx_type().into()); + } + self.rlp_header_inner_without_bloom().encode(out); + self.rlp_encode_fields_without_bloom(out); + } +} + +impl Decodable2718 for MorphReceipt { + /// Decodes a typed receipt without bloom. + /// + /// Expects format: `RLP([status, cumulative_gas_used, logs])` (no bloom). + /// This matches the encoding from `encode_2718`. + fn typed_decode(ty: u8, buf: &mut &[u8]) -> Eip2718Result { + let tx_type = MorphTxType::try_from(ty) + .map_err(|_| alloy_eips::eip2718::Eip2718Error::UnexpectedType(ty))?; + + Ok(Self::rlp_decode_inner_without_bloom(buf, tx_type)?) + } + + /// Decodes a legacy receipt without bloom. + /// + /// Expects format: `RLP([status, cumulative_gas_used, logs])` (no bloom). + fn fallback_decode(buf: &mut &[u8]) -> Eip2718Result { + Ok(Self::rlp_decode_inner_without_bloom( + buf, + MorphTxType::Legacy, + )?) + } +} + +impl alloy_rlp::Encodable for MorphReceipt { + /// Encodes the receipt for P2P network transmission. + /// + /// Uses `network_encode` which wraps typed receipts in an additional RLP string header, + /// as required by the eth wire protocol (eth/66, eth/67). + fn encode(&self, out: &mut dyn BufMut) { + self.network_encode(out); + } + + fn length(&self) -> usize { + self.network_len() + } +} + +impl alloy_rlp::Decodable for MorphReceipt { + /// Decodes the receipt from P2P network format. + /// + /// Uses `network_decode` which expects typed receipts to be wrapped in an RLP string header. + fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { + Self::network_decode(buf).map_err(|_| alloy_rlp::Error::Custom("Failed to decode receipt")) + } +} + +impl InMemorySize for MorphReceipt { + fn size(&self) -> usize { + self.as_receipt().size() + } +} + +/// Calculates the root hash of a list of receipts. +pub fn calculate_receipt_root(receipts: &[MorphReceipt]) -> B256 { + alloy_consensus::proofs::ordered_trie_root_with_encoder(receipts, |r, buf| { + r.encode_2718(buf); + }) +} + +#[cfg(feature = "reth-codec")] +mod compact { + use super::*; + use alloy_primitives::U256; + use reth_codecs::Compact; + use std::borrow::Cow; + + /// Compact representation of [`MorphReceipt`] for database storage. + /// + /// Note: `tx_type` must be the last field because it's not a known fixed-size type + /// for the CompactZstd derive macro. + /// + /// Note: `fee_token_id` is stored as `u64` instead of `u16` because `u16` doesn't implement + /// `Compact` in reth_codecs. The conversion is lossless since `u16` fits in `u64`. + #[derive(reth_codecs::CompactZstd)] + #[reth_zstd( + compressor = reth_zstd_compressors::RECEIPT_COMPRESSOR, + decompressor = reth_zstd_compressors::RECEIPT_DECOMPRESSOR + )] + struct CompactMorphReceipt<'a> { + success: bool, + cumulative_gas_used: u64, + #[allow(clippy::owned_cow)] + logs: Cow<'a, Vec>, + l1_fee: Option, + /// Stored as u64 for Compact compatibility (u16 doesn't implement Compact) + fee_token_id: Option, + fee_rate: Option, + token_scale: Option, + fee_limit: Option, + /// Must be the last field - not a known fixed-size type + tx_type: MorphTxType, + } + + impl<'a> From<&'a MorphReceipt> for CompactMorphReceipt<'a> { + fn from(receipt: &'a MorphReceipt) -> Self { + let (l1_fee, fee_token_id, fee_rate, token_scale, fee_limit) = match receipt { + MorphReceipt::Legacy(r) + | MorphReceipt::Eip2930(r) + | MorphReceipt::Eip1559(r) + | MorphReceipt::Eip7702(r) + | MorphReceipt::AltFee(r) => ( + r.l1_fee, + r.fee_token_id.map(u64::from), + r.fee_rate, + r.token_scale, + r.fee_limit, + ), + MorphReceipt::L1Msg(_) => (None, None, None, None, None), + }; + + Self { + success: receipt.status(), + cumulative_gas_used: receipt.cumulative_gas_used(), + logs: Cow::Borrowed(&receipt.as_receipt().logs), + l1_fee, + fee_token_id, + fee_rate, + token_scale, + fee_limit, + tx_type: receipt.tx_type(), + } + } + } + + impl From> for MorphReceipt { + fn from(receipt: CompactMorphReceipt<'_>) -> Self { + let CompactMorphReceipt { + success, + cumulative_gas_used, + logs, + l1_fee, + fee_token_id, + fee_rate, + token_scale, + fee_limit, + tx_type, + } = receipt; + + let inner = Receipt { + status: success.into(), + cumulative_gas_used, + logs: logs.into_owned(), + }; + + let morph_receipt = MorphTransactionReceipt { + inner: inner.clone(), + l1_fee, + fee_token_id: fee_token_id.map(|id| id as u16), + fee_rate, + token_scale, + fee_limit, + }; + + match tx_type { + MorphTxType::Legacy => Self::Legacy(morph_receipt), + MorphTxType::Eip2930 => Self::Eip2930(morph_receipt), + MorphTxType::Eip1559 => Self::Eip1559(morph_receipt), + MorphTxType::Eip7702 => Self::Eip7702(morph_receipt), + MorphTxType::L1Msg => Self::L1Msg(inner), + MorphTxType::AltFee => Self::AltFee(morph_receipt), + } + } + } + + impl Compact for MorphReceipt { + fn to_compact(&self, buf: &mut B) -> usize + where + B: bytes::BufMut + AsMut<[u8]>, + { + CompactMorphReceipt::from(self).to_compact(buf) + } + + fn from_compact(buf: &[u8], len: usize) -> (Self, &[u8]) { + let (receipt, buf) = CompactMorphReceipt::from_compact(buf, len); + (receipt.into(), buf) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{LogData, U256, address, b256, bytes}; + + /// Creates a test receipt with logs for encoding/decoding tests. + fn create_test_receipt() -> MorphReceipt { + let inner = Receipt { + status: true.into(), + cumulative_gas_used: 21000, + logs: vec![Log { + address: address!("0000000000000000000000000000000000000011"), + data: LogData::new_unchecked( + vec![b256!( + "000000000000000000000000000000000000000000000000000000000000dead" + )], + bytes!("0100ff"), + ), + }], + }; + + MorphReceipt::Eip1559(MorphTransactionReceipt::with_l1_fee( + inner, + U256::from(1000), + )) + } + + /// Creates a legacy receipt for testing. + fn create_legacy_receipt() -> MorphReceipt { + let inner = Receipt { + status: true.into(), + cumulative_gas_used: 21000, + logs: vec![Log { + address: address!("0000000000000000000000000000000000000011"), + data: LogData::new_unchecked( + vec![b256!( + "000000000000000000000000000000000000000000000000000000000000beef" + )], + bytes!("deadbeef"), + ), + }], + }; + + MorphReceipt::Legacy(MorphTransactionReceipt::new(inner)) + } + + /// Creates an L1 message receipt for testing. + fn create_l1_msg_receipt() -> MorphReceipt { + MorphReceipt::L1Msg(Receipt { + status: true.into(), + cumulative_gas_used: 100000, + logs: vec![], + }) + } + + /// Creates an AltFee receipt for testing. + fn create_alt_fee_receipt() -> MorphReceipt { + let inner = Receipt { + status: false.into(), + cumulative_gas_used: 50000, + logs: vec![], + }; + + MorphReceipt::AltFee(MorphTransactionReceipt::with_alt_fee( + inner, + U256::from(2000), // l1_fee + 1, // fee_token_id + U256::from(100), // fee_rate + U256::from(18), // token_scale + U256::from(500000), // fee_limit + )) + } + + #[test] + fn test_receipt_tx_type() { + let legacy = MorphReceipt::Legacy(MorphTransactionReceipt::default()); + assert_eq!(legacy.tx_type(), MorphTxType::Legacy); + + let l1_msg = MorphReceipt::L1Msg(Receipt::default()); + assert_eq!(l1_msg.tx_type(), MorphTxType::L1Msg); + + let alt_fee = MorphReceipt::AltFee(MorphTransactionReceipt::default()); + assert_eq!(alt_fee.tx_type(), MorphTxType::AltFee); + } + + #[test] + fn test_receipt_l1_fee() { + let receipt = create_test_receipt(); + assert_eq!(receipt.l1_fee(), Some(U256::from(1000))); + + let l1_msg = MorphReceipt::L1Msg(Receipt::default()); + assert_eq!(l1_msg.l1_fee(), None); + } + + #[test] + fn test_receipt_is_l1_message() { + let receipt = create_test_receipt(); + assert!(!receipt.is_l1_message()); + + let l1_msg = MorphReceipt::L1Msg(Receipt::default()); + assert!(l1_msg.is_l1_message()); + } + + #[test] + fn test_receipt_status() { + let receipt = create_test_receipt(); + assert!(receipt.status()); + } + + #[test] + fn test_receipt_in_memory_size() { + let receipt = create_test_receipt(); + let size = receipt.size(); + assert!(size > 0); + } + + // ==================== Encode/Decode Tests ==================== + + /// Tests that EIP-2718 encoding and decoding roundtrips correctly for EIP-1559 receipt. + /// + /// This tests the without-bloom encoding used for DA compression: + /// - encode_2718: encodes [status, gas, logs] without bloom + /// - decode_2718: decodes the same format + #[test] + fn test_eip1559_receipt_encode_2718_roundtrip() { + let original = create_test_receipt(); + + // Encode using EIP-2718 (without bloom) + let mut encoded = Vec::new(); + original.encode_2718(&mut encoded); + + // Verify type byte is present for typed receipt + assert_eq!(encoded[0], MorphTxType::Eip1559 as u8); + + // Decode + let decoded = MorphReceipt::decode_2718(&mut encoded.as_slice()).unwrap(); + + // Verify core fields match (l1_fee is not encoded, so it won't roundtrip) + assert_eq!(decoded.tx_type(), original.tx_type()); + assert_eq!(decoded.status(), original.status()); + assert_eq!( + decoded.cumulative_gas_used(), + original.cumulative_gas_used() + ); + assert_eq!(decoded.logs().len(), original.logs().len()); + } + + /// Tests that EIP-2718 encoding and decoding roundtrips correctly for legacy receipt. + /// + /// Legacy receipts have no type byte prefix. + #[test] + fn test_legacy_receipt_encode_2718_roundtrip() { + let original = create_legacy_receipt(); + + // Encode using EIP-2718 (without bloom) + let mut encoded = Vec::new(); + original.encode_2718(&mut encoded); + + // Verify no type byte for legacy (first byte should be RLP list marker >= 0xc0) + assert!( + encoded[0] >= 0xc0, + "Legacy receipt should start with RLP list marker" + ); + + // Decode + let decoded = MorphReceipt::decode_2718(&mut encoded.as_slice()).unwrap(); + + // Verify fields match + assert_eq!(decoded.tx_type(), MorphTxType::Legacy); + assert_eq!(decoded.status(), original.status()); + assert_eq!( + decoded.cumulative_gas_used(), + original.cumulative_gas_used() + ); + assert_eq!(decoded.logs().len(), original.logs().len()); + } + + /// Tests that EIP-2718 encoding and decoding roundtrips correctly for L1 message receipt. + #[test] + fn test_l1_msg_receipt_encode_2718_roundtrip() { + let original = create_l1_msg_receipt(); + + // Encode + let mut encoded = Vec::new(); + original.encode_2718(&mut encoded); + + // Verify type byte + assert_eq!(encoded[0], MorphTxType::L1Msg as u8); + + // Decode + let decoded = MorphReceipt::decode_2718(&mut encoded.as_slice()).unwrap(); + + // Verify fields + assert_eq!(decoded.tx_type(), MorphTxType::L1Msg); + assert_eq!(decoded.status(), original.status()); + assert_eq!( + decoded.cumulative_gas_used(), + original.cumulative_gas_used() + ); + assert!(decoded.is_l1_message()); + } + + /// Tests that EIP-2718 encoding and decoding roundtrips correctly for AltFee receipt. + #[test] + fn test_alt_fee_receipt_encode_2718_roundtrip() { + let original = create_alt_fee_receipt(); + + // Encode + let mut encoded = Vec::new(); + original.encode_2718(&mut encoded); + + // Verify type byte + assert_eq!(encoded[0], MorphTxType::AltFee as u8); + + // Decode + let decoded = MorphReceipt::decode_2718(&mut encoded.as_slice()).unwrap(); + + // Verify fields + assert_eq!(decoded.tx_type(), MorphTxType::AltFee); + assert_eq!(decoded.status(), original.status()); + assert_eq!( + decoded.cumulative_gas_used(), + original.cumulative_gas_used() + ); + } + + /// Tests network encoding (P2P format) roundtrip. + /// + /// Network encoding wraps typed receipts in an additional RLP string header, + /// as required by the eth wire protocol. + #[test] + fn test_network_encode_decode_roundtrip() { + let original = create_test_receipt(); + + // Network encode (uses Encodable trait) + let mut encoded = Vec::new(); + alloy_rlp::Encodable::encode(&original, &mut encoded); + + // Network decode (uses Decodable trait) + let decoded: MorphReceipt = alloy_rlp::Decodable::decode(&mut encoded.as_slice()).unwrap(); + + // Verify + assert_eq!(decoded.tx_type(), original.tx_type()); + assert_eq!(decoded.status(), original.status()); + assert_eq!( + decoded.cumulative_gas_used(), + original.cumulative_gas_used() + ); + } + + /// Tests that network encoding length calculation is correct. + #[test] + fn test_network_encode_length() { + let receipt = create_test_receipt(); + + // Calculate expected length + let expected_len = alloy_rlp::Encodable::length(&receipt); + + // Actually encode + let mut encoded = Vec::new(); + alloy_rlp::Encodable::encode(&receipt, &mut encoded); + + assert_eq!(encoded.len(), expected_len); + } + + /// Tests RLP encoding with bloom (used for P2P and Merkle trie). + #[test] + fn test_rlp_encode_with_bloom_roundtrip() { + let original = create_test_receipt(); + let bloom = original.bloom(); + + // Encode with bloom + let mut encoded = Vec::new(); + RlpEncodableReceipt::rlp_encode_with_bloom(&original, &bloom, &mut encoded); + + // Decode with bloom + let ReceiptWithBloom { + receipt: decoded, + logs_bloom: decoded_bloom, + }: ReceiptWithBloom = + RlpDecodableReceipt::rlp_decode_with_bloom(&mut encoded.as_slice()).unwrap(); + + // Verify + assert_eq!(decoded.tx_type(), original.tx_type()); + assert_eq!(decoded.status(), original.status()); + assert_eq!( + decoded.cumulative_gas_used(), + original.cumulative_gas_used() + ); + assert_eq!(decoded_bloom, bloom); + } + + /// Tests that without-bloom encoding is smaller than with-bloom encoding. + /// + /// This verifies the DA compression benefit. + #[test] + fn test_without_bloom_is_smaller() { + let receipt = create_test_receipt(); + let bloom = receipt.bloom(); + + // EIP-2718 encoding (without bloom) + let len_without_bloom = receipt.encode_2718_len(); + + // EIP-2718 encoding with bloom + let len_with_bloom = receipt.eip2718_encoded_length_with_bloom(&bloom); + + // Without bloom should be smaller (bloom is 256 bytes) + assert!( + len_without_bloom < len_with_bloom, + "Without bloom ({len_without_bloom}) should be smaller than with bloom ({len_with_bloom})" + ); + + // The difference should be approximately 256 bytes (bloom size) + some RLP overhead + let difference = len_with_bloom - len_without_bloom; + assert!( + difference >= 256, + "Difference ({difference}) should be at least 256 bytes (bloom size)" + ); + } + + /// Tests all transaction types for encode/decode roundtrip. + #[test] + fn test_all_tx_types_encode_decode() { + let receipts = vec![ + (MorphTxType::Legacy, create_legacy_receipt()), + (MorphTxType::Eip1559, create_test_receipt()), + (MorphTxType::L1Msg, create_l1_msg_receipt()), + (MorphTxType::AltFee, create_alt_fee_receipt()), + ]; + + for (expected_type, original) in receipts { + // EIP-2718 roundtrip + let mut encoded = Vec::new(); + original.encode_2718(&mut encoded); + + let decoded = MorphReceipt::decode_2718(&mut encoded.as_slice()) + .unwrap_or_else(|e| panic!("Failed to decode {expected_type:?}: {e:?}")); + + assert_eq!( + decoded.tx_type(), + expected_type, + "Transaction type mismatch for {expected_type:?}" + ); + assert_eq!( + decoded.status(), + original.status(), + "Status mismatch for {expected_type:?}" + ); + assert_eq!( + decoded.cumulative_gas_used(), + original.cumulative_gas_used(), + "Gas mismatch for {expected_type:?}" + ); + } + } + + /// Tests that decoding invalid data returns an error. + #[test] + fn test_decode_invalid_data() { + // Empty buffer + let result = MorphReceipt::decode_2718(&mut [].as_slice()); + assert!(result.is_err()); + + // Invalid type byte + let result = MorphReceipt::decode_2718(&mut [0xff].as_slice()); + assert!(result.is_err()); + + // Truncated data + let mut encoded = Vec::new(); + create_test_receipt().encode_2718(&mut encoded); + let truncated = &encoded[..encoded.len() / 2]; + let result = MorphReceipt::decode_2718(&mut truncated.to_vec().as_slice()); + assert!(result.is_err()); + } +} diff --git a/crates/primitives/src/receipt/receipt.rs b/crates/primitives/src/receipt/receipt.rs new file mode 100644 index 0000000..9495877 --- /dev/null +++ b/crates/primitives/src/receipt/receipt.rs @@ -0,0 +1,326 @@ +//! Morph transaction receipt types. +//! +//! This module defines the Morph-specific receipt types that include: +//! - L1 data fee for rollup transactions +//! - AltFee fields for alternative fee token transactions + +use alloy_consensus::{Eip658Value, Receipt, ReceiptWithBloom, TxReceipt}; +use alloy_primitives::{Bloom, Log, U256}; +use alloy_rlp::{BufMut, Decodable, Encodable, Header}; + +/// Morph transaction receipt with L1 fee and AltFee fields. +/// +/// This receipt extends the standard Ethereum receipt with: +/// - `l1_fee`: The L1 data fee charged for posting transaction data to L1 +/// - `fee_token_id`: The ERC20 token ID used for fee payment (AltFee) +/// - `fee_rate`: The exchange rate for the fee token +/// - `token_scale`: The scale factor for the token +/// - `fee_limit`: The fee limit for AltFee transactions +#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +pub struct MorphTransactionReceipt { + /// The inner receipt type. + #[cfg_attr(feature = "serde", serde(flatten))] + pub inner: Receipt, + + /// L1 fee for Morph transactions. + /// This is the cost of posting the transaction data to L1. + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub l1_fee: Option, + + /// The ERC20 token ID used for fee payment (AltFee feature). + /// Only present for AltFee transactions. + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub fee_token_id: Option, + + /// The exchange rate for the fee token. + /// Only present for AltFee transactions. + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub fee_rate: Option, + + /// The scale factor for the token. + /// Only present for AltFee transactions. + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub token_scale: Option, + + /// The fee limit for the AltFee transaction. + /// Only present for AltFee transactions. + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub fee_limit: Option, +} + +impl MorphTransactionReceipt { + /// Creates a new receipt with the given inner receipt. + pub const fn new(inner: Receipt) -> Self { + Self { + inner, + l1_fee: None, + fee_token_id: None, + fee_rate: None, + token_scale: None, + fee_limit: None, + } + } + + /// Creates a new receipt with L1 fee. + pub const fn with_l1_fee(inner: Receipt, l1_fee: U256) -> Self { + Self { + inner, + l1_fee: Some(l1_fee), + fee_token_id: None, + fee_rate: None, + token_scale: None, + fee_limit: None, + } + } + + /// Creates a new receipt with AltFee fields. + pub const fn with_alt_fee( + inner: Receipt, + l1_fee: U256, + fee_token_id: u16, + fee_rate: U256, + token_scale: U256, + fee_limit: U256, + ) -> Self { + Self { + inner, + l1_fee: Some(l1_fee), + fee_token_id: Some(fee_token_id), + fee_rate: Some(fee_rate), + token_scale: Some(token_scale), + fee_limit: Some(fee_limit), + } + } + + /// Returns true if this receipt is for an AltFee transaction. + pub const fn is_alt_fee(&self) -> bool { + self.fee_token_id.is_some() + } + + /// Returns the L1 fee, defaulting to zero if not set. + pub fn l1_fee_or_zero(&self) -> U256 { + self.l1_fee.unwrap_or(U256::ZERO) + } +} + +impl MorphTransactionReceipt { + /// Calculates [`Log`]'s bloom filter. + pub fn bloom_slow(&self) -> Bloom { + self.inner.logs.iter().collect() + } + + /// Calculates the bloom filter for the receipt and returns the [`ReceiptWithBloom`] + /// container type. + pub fn with_bloom(self) -> MorphReceiptWithBloom { + self.into() + } +} + +impl MorphTransactionReceipt { + /// Returns length of RLP-encoded receipt fields with the given [`Bloom`] without an RLP header. + /// + /// Note: L1 fee and AltFee fields are NOT included in the RLP encoding for consensus, + /// matching go-ethereum's behavior. + pub fn rlp_encoded_fields_length_with_bloom(&self, bloom: &Bloom) -> usize { + self.inner.rlp_encoded_fields_length_with_bloom(bloom) + } + + /// RLP-encodes receipt fields with the given [`Bloom`] without an RLP header. + /// + /// Note: L1 fee and AltFee fields are NOT included in the RLP encoding for consensus, + /// matching go-ethereum's behavior. + pub fn rlp_encode_fields_with_bloom(&self, bloom: &Bloom, out: &mut dyn BufMut) { + self.inner.rlp_encode_fields_with_bloom(bloom, out); + } + + /// Returns RLP header for this receipt encoding with the given [`Bloom`]. + pub fn rlp_header_with_bloom(&self, bloom: &Bloom) -> Header { + Header { + list: true, + payload_length: self.rlp_encoded_fields_length_with_bloom(bloom), + } + } +} + +impl MorphTransactionReceipt { + /// RLP-decodes receipt's field with a [`Bloom`]. + /// + /// Does not expect an RLP header. + pub fn rlp_decode_fields_with_bloom( + buf: &mut &[u8], + ) -> alloy_rlp::Result> { + let ReceiptWithBloom { + receipt: inner, + logs_bloom, + } = Receipt::rlp_decode_fields_with_bloom(buf)?; + + Ok(ReceiptWithBloom { + logs_bloom, + receipt: Self::new(inner), + }) + } +} + +impl AsRef> for MorphTransactionReceipt { + fn as_ref(&self) -> &Receipt { + &self.inner + } +} + +impl TxReceipt for MorphTransactionReceipt +where + T: AsRef + Clone + core::fmt::Debug + PartialEq + Eq + Send + Sync, +{ + type Log = T; + + fn status_or_post_state(&self) -> Eip658Value { + self.inner.status_or_post_state() + } + + fn status(&self) -> bool { + self.inner.status() + } + + fn bloom(&self) -> Bloom { + self.inner.bloom_slow() + } + + fn cumulative_gas_used(&self) -> u64 { + self.inner.cumulative_gas_used() + } + + fn logs(&self) -> &[Self::Log] { + self.inner.logs() + } +} + +impl From> for MorphTransactionReceipt { + fn from(inner: Receipt) -> Self { + Self::new(inner) + } +} + +/// [`MorphTransactionReceipt`] with calculated bloom filter. +pub type MorphReceiptWithBloom = ReceiptWithBloom>; + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{LogData, address, b256, bytes}; + + fn create_test_log() -> Log { + Log { + address: address!("0000000000000000000000000000000000000011"), + data: LogData::new_unchecked( + vec![ + b256!("000000000000000000000000000000000000000000000000000000000000dead"), + b256!("000000000000000000000000000000000000000000000000000000000000beef"), + ], + bytes!("0100ff"), + ), + } + } + + #[test] + fn test_morph_receipt_new() { + let inner = Receipt { + status: true.into(), + cumulative_gas_used: 21000, + logs: vec![create_test_log()], + }; + let receipt = MorphTransactionReceipt::new(inner); + + assert!(receipt.status()); + assert_eq!(receipt.cumulative_gas_used(), 21000); + assert!(receipt.l1_fee.is_none()); + assert!(receipt.fee_token_id.is_none()); + assert!(!receipt.is_alt_fee()); + } + + #[test] + fn test_morph_receipt_with_l1_fee() { + let inner: Receipt = Receipt { + status: true.into(), + cumulative_gas_used: 21000, + logs: vec![], + }; + let l1_fee = U256::from(1000000); + let receipt = MorphTransactionReceipt::with_l1_fee(inner, l1_fee); + + assert_eq!(receipt.l1_fee, Some(l1_fee)); + assert_eq!(receipt.l1_fee_or_zero(), l1_fee); + assert!(!receipt.is_alt_fee()); + } + + #[test] + fn test_morph_receipt_with_alt_fee() { + let inner: Receipt = Receipt { + status: true.into(), + cumulative_gas_used: 21000, + logs: vec![], + }; + let l1_fee = U256::from(1000000); + let fee_token_id = 1u16; + let fee_rate = U256::from(100); + let token_scale = U256::from(18); + let fee_limit = U256::from(5000000); + + let receipt = MorphTransactionReceipt::with_alt_fee( + inner, + l1_fee, + fee_token_id, + fee_rate, + token_scale, + fee_limit, + ); + + assert!(receipt.is_alt_fee()); + assert_eq!(receipt.fee_token_id, Some(fee_token_id)); + assert_eq!(receipt.fee_rate, Some(fee_rate)); + assert_eq!(receipt.token_scale, Some(token_scale)); + assert_eq!(receipt.fee_limit, Some(fee_limit)); + } + + #[test] + fn test_morph_receipt_bloom() { + let inner = Receipt { + status: true.into(), + cumulative_gas_used: 21000, + logs: vec![create_test_log()], + }; + let receipt = MorphTransactionReceipt::new(inner); + + let bloom = receipt.bloom_slow(); + assert_ne!(bloom, Bloom::default()); + } + + #[test] + fn test_morph_receipt_with_bloom() { + let inner = Receipt { + status: true.into(), + cumulative_gas_used: 21000, + logs: vec![create_test_log()], + }; + let receipt = MorphTransactionReceipt::new(inner); + let receipt_with_bloom = receipt.with_bloom(); + + assert_ne!(receipt_with_bloom.logs_bloom, Bloom::default()); + } +} diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index 77cc9e3..3ca41e0 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -45,7 +45,10 @@ impl MorphTxEnvelope { } } - /// Same as [`Self::signer`], but skips signature validation checks. + /// Recovers the signer of the transaction without validating the signature. + /// + /// This is faster than validating the signature first, but should only be used + /// when the signature is already known to be valid. pub fn signer_unchecked( &self, ) -> Result { @@ -102,6 +105,20 @@ impl reth_primitives_traits::InMemorySize for MorphTxType { } } +impl MorphTxType { + /// Returns `true` if this is a legacy transaction. + pub const fn is_legacy(&self) -> bool { + matches!(self, Self::Legacy) + } + + /// Decodes the transaction type from the buffer. + pub fn rlp_decode(buf: &mut &[u8]) -> alloy_rlp::Result { + use alloy_rlp::Decodable; + let ty = u8::decode(buf)?; + Self::try_from(ty).map_err(|_| alloy_rlp::Error::Custom("unknown tx type")) + } +} + impl alloy_consensus::transaction::TxHashRef for MorphTxEnvelope { fn tx_hash(&self) -> &B256 { match self { diff --git a/crates/primitives/src/transaction/mod.rs b/crates/primitives/src/transaction/mod.rs index b27a8d1..99951df 100644 --- a/crates/primitives/src/transaction/mod.rs +++ b/crates/primitives/src/transaction/mod.rs @@ -5,5 +5,5 @@ pub mod envelope; pub mod l1_transaction; pub use alt_fee::{ALT_FEE_TX_TYPE_ID, TxAltFee, TxAltFeeExt}; -pub use envelope::MorphTxEnvelope; +pub use envelope::{MorphTxEnvelope, MorphTxType}; pub use l1_transaction::{L1_TX_TYPE_ID, TxL1Msg}; From 9ccd1b6b55d3778f5235f20a00c231119a5f30a9 Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 14 Jan 2026 22:48:09 +0800 Subject: [PATCH 03/13] style: clippy fix --- crates/consensus/src/validation.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/consensus/src/validation.rs b/crates/consensus/src/validation.rs index c92314a..f03c3d4 100644 --- a/crates/consensus/src/validation.rs +++ b/crates/consensus/src/validation.rs @@ -474,7 +474,7 @@ mod tests { #[test] fn test_validate_l1_messages_valid() { - let txs = vec![ + let txs = [ create_l1_msg_tx(0), create_l1_msg_tx(1), create_regular_tx(), @@ -485,7 +485,7 @@ mod tests { #[test] fn test_validate_l1_messages_after_regular() { - let txs = vec![ + let txs = [ create_l1_msg_tx(0), create_regular_tx(), create_l1_msg_tx(1), @@ -499,7 +499,7 @@ mod tests { let chain_spec = create_test_chainspec(); let consensus = MorphConsensus::new(chain_spec); let header = Header { - extra_data: Bytes::from(vec![1, 2, 3]), + extra_data: Bytes::from([1, 2, 3].as_slice()), nonce: B64::ZERO, ommers_hash: EMPTY_OMMER_ROOT_HASH, ..Default::default() @@ -612,14 +612,14 @@ mod tests { #[test] fn test_validate_l1_messages_empty_block() { - let txs: Vec = vec![]; + let txs: [MorphTxEnvelope; 0] = []; let txs_refs: Vec<_> = txs.iter().collect(); assert!(validate_l1_messages(&txs_refs).is_ok()); } #[test] fn test_validate_l1_messages_only_l1_messages() { - let txs = vec![ + let txs = [ create_l1_msg_tx(0), create_l1_msg_tx(1), create_l1_msg_tx(2), @@ -630,7 +630,7 @@ mod tests { #[test] fn test_validate_l1_messages_only_regular_txs() { - let txs = vec![ + let txs = [ create_regular_tx(), create_regular_tx(), create_regular_tx(), @@ -642,7 +642,7 @@ mod tests { #[test] fn test_validate_l1_messages_skipped_index() { // Skip index 1: 0, 2 - let txs = vec![create_l1_msg_tx(0), create_l1_msg_tx(2)]; + let txs = [create_l1_msg_tx(0), create_l1_msg_tx(2)]; let txs_refs: Vec<_> = txs.iter().collect(); let result = validate_l1_messages(&txs_refs); assert!(result.is_err()); @@ -654,7 +654,7 @@ mod tests { #[test] fn test_validate_l1_messages_non_zero_start_index() { // Starting from index 100 is valid - let txs = vec![ + let txs = [ create_l1_msg_tx(100), create_l1_msg_tx(101), create_regular_tx(), @@ -666,7 +666,7 @@ mod tests { #[test] fn test_validate_l1_messages_duplicate_index() { // Duplicate index: 0, 0 - let txs = vec![create_l1_msg_tx(0), create_l1_msg_tx(0)]; + let txs = [create_l1_msg_tx(0), create_l1_msg_tx(0)]; let txs_refs: Vec<_> = txs.iter().collect(); let result = validate_l1_messages(&txs_refs); assert!(result.is_err()); @@ -678,7 +678,7 @@ mod tests { #[test] fn test_validate_l1_messages_out_of_order() { // Reversed order: 1, 0 - let txs = vec![create_l1_msg_tx(1), create_l1_msg_tx(0)]; + let txs = [create_l1_msg_tx(1), create_l1_msg_tx(0)]; let txs_refs: Vec<_> = txs.iter().collect(); let result = validate_l1_messages(&txs_refs); assert!(result.is_err()); @@ -687,7 +687,7 @@ mod tests { #[test] fn test_validate_l1_messages_multiple_l1_after_regular() { // Multiple L1 messages after regular tx - let txs = vec![ + let txs = [ create_l1_msg_tx(0), create_regular_tx(), create_l1_msg_tx(1), @@ -1017,7 +1017,7 @@ mod tests { #[test] fn test_verify_receipts_empty() { - let receipts: Vec = vec![]; + let receipts: [MorphReceipt; 0] = []; let expected_root = alloy_consensus::proofs::calculate_receipt_root::< alloy_consensus::ReceiptWithBloom<&MorphReceipt>, >(&[]); @@ -1029,7 +1029,7 @@ mod tests { #[test] fn test_verify_receipts_root_mismatch() { - let receipts: Vec = vec![]; + let receipts: [MorphReceipt; 0] = []; let wrong_root = B256::random(); // Wrong root let expected_bloom = Bloom::ZERO; @@ -1042,7 +1042,7 @@ mod tests { #[test] fn test_verify_receipts_bloom_mismatch() { - let receipts: Vec = vec![]; + let receipts: [MorphReceipt; 0] = []; let expected_root = alloy_consensus::proofs::calculate_receipt_root::< alloy_consensus::ReceiptWithBloom<&MorphReceipt>, >(&[]); From 80e9f9ea2d80e91611bb534d76517607a5b5f79d Mon Sep 17 00:00:00 2001 From: panos Date: Thu, 15 Jan 2026 16:12:32 +0800 Subject: [PATCH 04/13] feat: add payload builder --- Cargo.lock | 34 ++ Cargo.toml | 3 + crates/payload/builder/Cargo.toml | 32 +- crates/payload/builder/src/builder.rs | 508 +++++++++++++++++++++++++ crates/payload/builder/src/error.rs | 50 +++ crates/payload/builder/src/lib.rs | 28 +- crates/payload/types/Cargo.toml | 1 + crates/payload/types/src/attributes.rs | 209 +++------- 8 files changed, 708 insertions(+), 157 deletions(-) create mode 100644 crates/payload/builder/src/builder.rs create mode 100644 crates/payload/builder/src/error.rs diff --git a/Cargo.lock b/Cargo.lock index 3ab63de..41793cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3703,6 +3703,29 @@ dependencies = [ [[package]] name = "morph-payload-builder" version = "0.7.5" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "morph-chainspec", + "morph-evm", + "morph-payload-types", + "morph-primitives", + "reth-basic-payload-builder", + "reth-chainspec", + "reth-evm", + "reth-payload-builder", + "reth-payload-primitives", + "reth-payload-util", + "reth-primitives-traits", + "reth-revm", + "reth-storage-api", + "reth-transaction-pool", + "revm", + "thiserror 2.0.17", + "tracing", +] [[package]] name = "morph-payload-types" @@ -3717,6 +3740,7 @@ dependencies = [ "morph-primitives", "rand 0.8.5", "reth-engine-primitives", + "reth-payload-builder", "reth-payload-primitives", "reth-primitives-traits", "serde", @@ -5827,6 +5851,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "reth-payload-util" +version = "1.9.3" +source = "git+https://github.com/paradigmxyz/reth?rev=64909d3#64909d33e6b7ab60774e37f5508fb5ad17f41897" +dependencies = [ + "alloy-consensus", + "alloy-primitives", + "reth-transaction-pool", +] + [[package]] name = "reth-primitives-traits" version = "1.9.3" diff --git a/Cargo.toml b/Cargo.toml index aeee60a..9cb50d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ morph-primitives = { path = "crates/primitives", default-features = false, featu morph-revm = { path = "crates/revm", default-features = false } reth-basic-payload-builder = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3" } +reth-chain-state = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3" } reth-chainspec = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3" } reth-cli = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3" } reth-cli-commands = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3" } @@ -72,6 +73,7 @@ reth-ethereum-engine-primitives = { git = "https://github.com/paradigmxyz/reth", reth-ethereum-primitives = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3", default-features = false } reth-evm = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3" } reth-evm-ethereum = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3" } +reth-execution-types = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3" } reth-metrics = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3" } reth-network-peers = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3" } reth-node-api = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3" } @@ -81,6 +83,7 @@ reth-node-ethereum = { git = "https://github.com/paradigmxyz/reth", rev = "64909 reth-node-metrics = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3" } reth-payload-builder = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3" } reth-payload-primitives = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3" } +reth-payload-util = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3" } reth-primitives-traits = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3", default-features = false } reth-provider = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3" } reth-rpc = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3" } diff --git a/crates/payload/builder/Cargo.toml b/crates/payload/builder/Cargo.toml index c6e5876..2345900 100644 --- a/crates/payload/builder/Cargo.toml +++ b/crates/payload/builder/Cargo.toml @@ -12,10 +12,38 @@ publish.workspace = true workspace = true [dependencies] -# Dependencies will be added when implementing the builder +# Morph +morph-chainspec.workspace = true +morph-evm.workspace = true +morph-payload-types.workspace = true +morph-primitives.workspace = true + +# Reth +reth-basic-payload-builder.workspace = true +reth-chainspec.workspace = true +reth-evm.workspace = true +reth-payload-builder.workspace = true +reth-payload-primitives.workspace = true +reth-payload-util.workspace = true +reth-primitives-traits.workspace = true +reth-revm.workspace = true +reth-storage-api.workspace = true +reth-transaction-pool.workspace = true + +# Alloy +alloy-consensus.workspace = true +alloy-eips.workspace = true +alloy-primitives.workspace = true +alloy-rlp.workspace = true + +# Revm +revm.workspace = true + +# Utils +tracing.workspace = true +thiserror.workspace = true [dev-dependencies] [features] default = [] - diff --git a/crates/payload/builder/src/builder.rs b/crates/payload/builder/src/builder.rs new file mode 100644 index 0000000..cb51cf0 --- /dev/null +++ b/crates/payload/builder/src/builder.rs @@ -0,0 +1,508 @@ +//! Morph payload builder implementation. + +use crate::MorphPayloadBuilderError; +use alloy_consensus::{BlockHeader, Transaction, Typed2718}; +use alloy_eips::eip2718::Encodable2718; +use alloy_primitives::{Bytes, U256}; +use alloy_rlp::Encodable; +use morph_chainspec::MorphChainSpec; +use morph_evm::{MorphEvmConfig, MorphNextBlockEnvAttributes}; +use morph_payload_types::{ExecutableL2Data, MorphBuiltPayload, MorphPayloadBuilderAttributes}; +use morph_primitives::{MorphHeader, MorphTxEnvelope}; +use reth_basic_payload_builder::{ + is_better_payload, BuildArguments, BuildOutcome, BuildOutcomeKind, MissingPayloadBehaviour, + PayloadBuilder, PayloadConfig, +}; +use reth_chainspec::ChainSpecProvider; +use reth_evm::{ + block::BlockExecutionError, + execute::{BlockBuilder, BlockBuilderOutcome}, + ConfigureEvm, Database, Evm, NextBlockEnvAttributes, +}; +use reth_payload_builder::PayloadId; +use reth_payload_primitives::{PayloadBuilderAttributes, PayloadBuilderError}; +use reth_payload_util::{BestPayloadTransactions, NoopPayloadTransactions, PayloadTransactions}; +use reth_primitives_traits::SealedHeader; +use reth_revm::{database::StateProviderDatabase, db::State}; +use reth_storage_api::{StateProvider, StateProviderFactory}; +use reth_transaction_pool::{BestTransactionsAttributes, PoolTransaction, TransactionPool}; +use revm::context_interface::Block as RevmBlock; +use std::sync::Arc; + +/// A type that returns the [`PayloadTransactions`] that should be included in the pool. +pub trait MorphPayloadTransactions: Clone + Send + Sync + Unpin + 'static { + /// Returns an iterator that yields the transactions in the order they should get included in + /// the new payload. + fn best_transactions>( + &self, + pool: Pool, + attr: BestTransactionsAttributes, + ) -> impl PayloadTransactions; +} + +impl MorphPayloadTransactions for () { + fn best_transactions>( + &self, + pool: Pool, + attr: BestTransactionsAttributes, + ) -> impl PayloadTransactions { + BestPayloadTransactions::new(pool.best_transactions_with_attributes(attr)) + } +} + +/// Morph's payload builder. +/// +/// Builds L2 blocks by executing: +/// 1. Forced transactions from payload attributes (L1 messages first) +/// 2. Pool transactions (if allowed) +#[derive(Clone, Debug)] +pub struct MorphPayloadBuilder { + /// The EVM configuration. + pub evm_config: MorphEvmConfig, + /// Transaction pool. + pub pool: Pool, + /// Node client for state access. + pub client: Client, + /// The type responsible for yielding the best transactions to include. + pub best_transactions: Txs, +} + +impl MorphPayloadBuilder { + /// Creates a new [`MorphPayloadBuilder`]. + pub const fn new(pool: Pool, evm_config: MorphEvmConfig, client: Client) -> Self { + Self { + evm_config, + pool, + client, + best_transactions: (), + } + } +} + +impl MorphPayloadBuilder { + /// Configures the type responsible for yielding the transactions that should be included in + /// the payload. + pub fn with_transactions(self, best_transactions: T) -> MorphPayloadBuilder + where + T: MorphPayloadTransactions, + Pool: TransactionPool, + { + let Self { + evm_config, + pool, + client, + .. + } = self; + MorphPayloadBuilder { + evm_config, + pool, + client, + best_transactions, + } + } +} + +impl MorphPayloadBuilder +where + Pool: TransactionPool>, + Client: StateProviderFactory + ChainSpecProvider, +{ + /// Constructs a Morph payload from the transactions sent via the payload attributes. + fn build_payload<'a, BestTxs>( + &self, + args: BuildArguments, + best: impl FnOnce(BestTransactionsAttributes) -> BestTxs + Send + Sync + 'a, + ) -> Result, PayloadBuilderError> + where + BestTxs: + PayloadTransactions> + 'a, + { + let BuildArguments { + mut cached_reads, + config, + cancel, + best_payload, + } = args; + + let ctx = MorphPayloadBuilderCtx { + evm_config: self.evm_config.clone(), + chain_spec: self.client.chain_spec(), + config, + cancel, + best_payload, + }; + + let state_provider = self.client.state_by_block_hash(ctx.parent().hash())?; + let state = StateProviderDatabase::new(&state_provider); + + // Reuse cached reads from previous runs for incremental payload building + build_payload_inner(cached_reads.as_db_mut(state), &state_provider, ctx, best) + .map(|out| out.with_cached_reads(cached_reads)) + } +} + +/// Implementation of the [`PayloadBuilder`] trait for [`MorphPayloadBuilder`]. +impl PayloadBuilder for MorphPayloadBuilder +where + Pool: TransactionPool> + Clone, + Client: StateProviderFactory + ChainSpecProvider + Clone, + Txs: MorphPayloadTransactions, +{ + type Attributes = MorphPayloadBuilderAttributes; + type BuiltPayload = MorphBuiltPayload; + + fn try_build( + &self, + args: BuildArguments, + ) -> Result, PayloadBuilderError> { + let pool = self.pool.clone(); + self.build_payload(args, |attrs| { + self.best_transactions.best_transactions(pool, attrs) + }) + } + + fn on_missing_payload( + &self, + _args: BuildArguments, + ) -> MissingPayloadBehaviour { + // Wait for the job that's already in progress + MissingPayloadBehaviour::AwaitInProgress + } + + fn build_empty_payload( + &self, + config: PayloadConfig, + ) -> Result { + let args = BuildArguments { + config, + cached_reads: Default::default(), + cancel: Default::default(), + best_payload: None, + }; + self.build_payload(args, |_| { + NoopPayloadTransactions::::default() + })? + .into_payload() + .ok_or(PayloadBuilderError::MissingPayload) + } +} + +/// Container type that holds all necessities to build a new payload. +#[derive(Debug)] +#[allow(dead_code)] +struct MorphPayloadBuilderCtx { + /// The EVM configuration. + evm_config: MorphEvmConfig, + /// The chain specification. + chain_spec: Arc, + /// Payload configuration. + config: PayloadConfig, + /// Marker to check whether the job has been cancelled. + cancel: reth_revm::cancelled::CancelOnDrop, + /// The currently best payload. + best_payload: Option, +} + +impl MorphPayloadBuilderCtx { + /// Returns the parent block the payload will be built on. + fn parent(&self) -> &SealedHeader { + &self.config.parent_header + } + + /// Returns the builder attributes. + fn attributes(&self) -> &MorphPayloadBuilderAttributes { + &self.config.attributes + } + + /// Returns the unique ID for this payload job. + fn payload_id(&self) -> PayloadId { + self.attributes().payload_id() + } + + /// Returns true if the fees are higher than the previous payload. + fn is_better_payload(&self, total_fees: U256) -> bool { + is_better_payload(self.best_payload.as_ref(), total_fees) + } + + /// Returns the current fee settings for transactions from the mempool. + #[allow(dead_code)] + fn best_transaction_attributes( + &self, + base_fee: u64, + blob_gas_price: Option, + ) -> BestTransactionsAttributes { + BestTransactionsAttributes::new(base_fee, blob_gas_price) + } +} + +/// Execution information collected during payload building. +#[derive(Debug, Default)] +struct ExecutionInfo { + /// Cumulative gas used by all executed transactions. + cumulative_gas_used: u64, + /// Cumulative DA bytes used (for L2 data availability). + cumulative_da_bytes_used: u64, + /// Total fees collected from executed transactions. + total_fees: U256, + /// Next L1 message queue index. + next_l1_message_index: u64, +} + +impl ExecutionInfo { + /// Creates a new [`ExecutionInfo`]. + const fn new() -> Self { + Self { + cumulative_gas_used: 0, + cumulative_da_bytes_used: 0, + total_fees: U256::ZERO, + next_l1_message_index: 0, + } + } + + /// Returns true if the transaction would exceed the block limits. + fn is_tx_over_limits( + &self, + tx_gas_limit: u64, + tx_size: u64, + block_gas_limit: u64, + block_da_limit: Option, + ) -> bool { + // Check DA limit if configured + if block_da_limit.is_some_and(|da_limit| self.cumulative_da_bytes_used + tx_size > da_limit) + { + return true; + } + + // Check gas limit + self.cumulative_gas_used + tx_gas_limit > block_gas_limit + } +} + +/// Builds the payload on top of the state. +fn build_payload_inner<'a, DB, BestTxs>( + db: DB, + state_provider: &impl StateProvider, + ctx: MorphPayloadBuilderCtx, + best: impl FnOnce(BestTransactionsAttributes) -> BestTxs + Send + Sync + 'a, +) -> Result, PayloadBuilderError> +where + DB: Database, + BestTxs: PayloadTransactions> + 'a, +{ + let attributes = ctx.attributes(); + + tracing::debug!( + target: "payload_builder", + id = %ctx.payload_id(), + parent_hash = ?ctx.parent().hash(), + parent_number = ctx.parent().number, + "building new payload" + ); + + let mut db = State::builder() + .with_database(db) + .with_bundle_update() + .build(); + + // Build next block env attributes using the inner EthPayloadBuilderAttributes + let next_block_attrs = MorphNextBlockEnvAttributes { + inner: NextBlockEnvAttributes { + timestamp: attributes.inner.timestamp, + suggested_fee_recipient: attributes.inner.suggested_fee_recipient, + prev_randao: attributes.inner.prev_randao, + gas_limit: ctx.parent().gas_limit, + withdrawals: Some(attributes.inner.withdrawals.clone()), + parent_beacon_block_root: attributes.inner.parent_beacon_block_root, + extra_data: Default::default(), + }, + }; + + // Create block builder + let mut builder = ctx + .evm_config + .builder_for_next_block(&mut db, ctx.parent(), next_block_attrs) + .map_err(PayloadBuilderError::other)?; + + // 1. Apply pre-execution changes (system contracts, etc.) + builder.apply_pre_execution_changes().map_err(|err| { + tracing::warn!(target: "payload_builder", %err, "failed to apply pre-execution changes"); + PayloadBuilderError::Internal(err.into()) + })?; + + let mut info = ExecutionInfo::new(); + let block_gas_limit = builder.evm().block().gas_limit(); + let base_fee = builder.evm().block().basefee(); + + // Collect executed transactions for ExecutableL2Data + let mut executed_txs: Vec = Vec::new(); + + // 2. Execute forced transactions from payload attributes (L1 messages first) + // Transactions are already decoded and recovered in MorphPayloadBuilderAttributes + for tx_with_encoded in &attributes.transactions { + let recovered_tx = tx_with_encoded.value(); + let tx_bytes = tx_with_encoded.encoded_bytes(); + + // Blob transactions are not supported + if recovered_tx.is_eip4844() { + return Err(PayloadBuilderError::other( + MorphPayloadBuilderError::BlobTransactionRejected, + )); + } + + let tx_gas = recovered_tx.gas_limit(); + + // Check gas limit + if info.cumulative_gas_used + tx_gas > block_gas_limit { + return Err(PayloadBuilderError::other( + MorphPayloadBuilderError::BlockGasLimitExceededBySequencerTransactions { + gas_spent_by_tx: vec![tx_gas], + gas: block_gas_limit, + }, + )); + } + + // Execute the transaction + let gas_used = match builder.execute_transaction(recovered_tx.clone()) { + Ok(gas_used) => gas_used, + Err(BlockExecutionError::Validation(err)) => { + tracing::trace!( + target: "payload_builder", + %err, + ?recovered_tx, + "Error in sequencer transaction, skipping." + ); + continue; + } + Err(err) => { + return Err(PayloadBuilderError::EvmExecutionError(Box::new(err))); + } + }; + + // For L1 messages, use full gas limit (no refund) and no fees + let gas_used = if recovered_tx.is_l1_msg() { + recovered_tx.gas_limit() + // L1 messages have zero gas price, so no fees are collected + } else { + // Calculate fees for L2 transactions: effective_tip * gas_used + let effective_tip = recovered_tx + .effective_tip_per_gas(base_fee) + .unwrap_or_default(); + info.total_fees += U256::from(effective_tip) * U256::from(gas_used); + gas_used + }; + + info.cumulative_gas_used += gas_used; + + // Store the original transaction bytes for ExecutableL2Data + executed_txs.push(tx_bytes.clone()); + } + + // 3. Execute pool transactions (best transactions from mempool) + let mut best_txs = best(ctx.best_transaction_attributes(base_fee, None)); + + while let Some(tx) = best_txs.next(()) { + // Check if the job was cancelled + if ctx.cancel.is_cancelled() { + return Ok(BuildOutcomeKind::Cancelled); + } + + let tx = tx.into_consensus(); + + // Skip blob transactions and L1 messages from pool + if tx.is_eip4844() || tx.is_l1_msg() { + best_txs.mark_invalid(tx.signer(), tx.nonce()); + continue; + } + + // Check if the transaction exceeds block limits + if info.is_tx_over_limits(tx.gas_limit(), tx.length() as u64, block_gas_limit, None) { + best_txs.mark_invalid(tx.signer(), tx.nonce()); + continue; + } + + // Execute the transaction + let gas_used = match builder.execute_transaction(tx.clone()) { + Ok(gas_used) => gas_used, + Err(BlockExecutionError::Validation(err)) => { + tracing::trace!( + target: "payload_builder", + %err, + ?tx, + "Error in pool transaction, skipping." + ); + best_txs.mark_invalid(tx.signer(), tx.nonce()); + continue; + } + Err(err) => { + return Err(PayloadBuilderError::EvmExecutionError(Box::new(err))); + } + }; + + // Update execution info + info.cumulative_gas_used += gas_used; + info.cumulative_da_bytes_used += tx.length() as u64; + + // Calculate fees: effective_tip * gas_used + let effective_tip = tx.effective_tip_per_gas(base_fee).unwrap_or_default(); + info.total_fees += U256::from(effective_tip) * U256::from(gas_used); + + // Store the transaction bytes for ExecutableL2Data + let mut tx_bytes = Vec::new(); + tx.encode_2718(&mut tx_bytes); + executed_txs.push(Bytes::from(tx_bytes)); + } + + // Check if this payload is better than the previous one + if !ctx.is_better_payload(info.total_fees) { + return Ok(BuildOutcomeKind::Aborted { + fees: info.total_fees, + }); + } + + // Finish building the block + let BlockBuilderOutcome { + execution_result, + block, + .. + } = builder.finish(state_provider)?; + + let sealed_block = Arc::new(block.sealed_block().clone()); + let header = sealed_block.header(); + + tracing::debug!( + target: "payload_builder", + id = %ctx.payload_id(), + sealed_block_header = ?header, + "sealed built block" + ); + + // Build ExecutableL2Data from the sealed block + let mut logs_bloom_bytes = Vec::new(); + header.logs_bloom().encode(&mut logs_bloom_bytes); + + let executable_data = ExecutableL2Data { + parent_hash: header.parent_hash(), + miner: header.beneficiary(), + number: header.number(), + gas_limit: header.gas_limit(), + base_fee_per_gas: header.base_fee_per_gas().map(|f| f as u128), + timestamp: header.timestamp(), + transactions: executed_txs, + state_root: header.state_root(), + gas_used: execution_result.gas_used, + receipts_root: header.receipts_root(), + logs_bloom: Bytes::from(logs_bloom_bytes), + withdraw_trie_root: Default::default(), // TODO: compute withdraw trie root + next_l1_message_index: info.next_l1_message_index, + hash: sealed_block.hash(), + }; + + let payload = MorphBuiltPayload::new( + ctx.payload_id(), + sealed_block, + info.total_fees, + executable_data, + ); + + Ok(BuildOutcomeKind::Better { payload }) +} diff --git a/crates/payload/builder/src/error.rs b/crates/payload/builder/src/error.rs new file mode 100644 index 0000000..95c571a --- /dev/null +++ b/crates/payload/builder/src/error.rs @@ -0,0 +1,50 @@ +//! Morph payload builder error types. + +use alloy_primitives::B256; + +/// Errors that can occur during Morph payload building. +#[derive(Debug, thiserror::Error)] +pub enum MorphPayloadBuilderError { + /// Blob transactions are not supported on Morph L2. + #[error("blob transactions are not supported")] + BlobTransactionRejected, + + /// Failed to recover transaction signer. + #[error("failed to recover transaction signer")] + TransactionEcRecoverFailed, + + /// Block gas limit exceeded by sequencer transactions. + #[error("block gas limit {gas} exceeded by sequencer transactions, gas spent by tx: {gas_spent_by_tx:?}")] + BlockGasLimitExceededBySequencerTransactions { + /// Gas spent by each transaction. + gas_spent_by_tx: Vec, + /// Block gas limit. + gas: u64, + }, + + /// Failed to decode transaction from payload attributes. + #[error("failed to decode transaction: {0}")] + TransactionDecodeError(#[from] alloy_rlp::Error), + + /// Invalid L1 message queue index. + #[error("invalid L1 message queue index: expected {expected}, got {actual}")] + InvalidL1MessageQueueIndex { + /// Expected queue index. + expected: u64, + /// Actual queue index. + actual: u64, + }, + + /// L1 message appears after regular transaction. + #[error("L1 message appears after regular transaction")] + L1MessageAfterRegularTx, + + /// Invalid transaction hash. + #[error("invalid transaction hash: expected {expected}, got {actual}")] + InvalidTransactionHash { + /// Expected hash. + expected: B256, + /// Actual hash. + actual: B256, + }, +} diff --git a/crates/payload/builder/src/lib.rs b/crates/payload/builder/src/lib.rs index a599cb3..c8548a3 100644 --- a/crates/payload/builder/src/lib.rs +++ b/crates/payload/builder/src/lib.rs @@ -2,14 +2,30 @@ //! //! This crate provides the payload building logic for Morph L2. //! -//! The `MorphPayloadBuilder` will implement reth's `PayloadBuilder` trait +//! The [`MorphPayloadBuilder`] implements reth's [`PayloadBuilder`] trait //! to construct L2 blocks with: -//! - L1 message transactions (prioritized) +//! - L1 message transactions (prioritized, must be at the beginning) //! - Sequencer forced transactions -//! - Pool transactions +//! - Pool transactions (optional, controlled by `no_tx_pool` flag) +//! +//! # Transaction Ordering +//! +//! Transactions are included in the following order: +//! 1. L1 messages from payload attributes (must have sequential queue indices) +//! 2. Other forced transactions from payload attributes +//! 3. Pool transactions (if `no_tx_pool` is false) +//! +//! # L1 Message Rules +//! +//! - L1 messages must appear at the beginning of the block +//! - Queue indices must be strictly sequential +//! - Gas is prepaid on L1, so no refunds for unused gas +#![cfg_attr(not(test), warn(unused_crate_dependencies))] #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] -#![allow(unused)] -// TODO: Implement MorphPayloadBuilder -// See tempo-payload-builder for reference implementation +mod builder; +mod error; + +pub use builder::{MorphPayloadBuilder, MorphPayloadTransactions}; +pub use error::MorphPayloadBuilderError; diff --git a/crates/payload/types/Cargo.toml b/crates/payload/types/Cargo.toml index 32767a0..74e4e3e 100644 --- a/crates/payload/types/Cargo.toml +++ b/crates/payload/types/Cargo.toml @@ -17,6 +17,7 @@ morph-primitives = { workspace = true, features = ["serde"] } # Reth reth-engine-primitives.workspace = true +reth-payload-builder.workspace = true reth-payload-primitives.workspace = true reth-primitives-traits.workspace = true diff --git a/crates/payload/types/src/attributes.rs b/crates/payload/types/src/attributes.rs index 1216bdd..42b8ec7 100644 --- a/crates/payload/types/src/attributes.rs +++ b/crates/payload/types/src/attributes.rs @@ -1,9 +1,12 @@ //! Morph payload attributes types. -use alloy_eips::eip4895::{Withdrawal, Withdrawals}; +use alloy_eips::{eip2718::Decodable2718, eip4895::Withdrawals}; use alloy_primitives::{Address, B256, Bytes}; -use alloy_rpc_types_engine::PayloadAttributes; +use alloy_rpc_types_engine::{PayloadAttributes, PayloadId}; +use morph_primitives::MorphTxEnvelope; +use reth_payload_builder::EthPayloadBuilderAttributes; use reth_payload_primitives::PayloadBuilderAttributes; +use reth_primitives_traits::{Recovered, SignerRecoverable, WithEncoded}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -11,12 +14,6 @@ use sha2::{Digest, Sha256}; /// /// This extends the standard Ethereum [`PayloadAttributes`] with L2-specific fields /// for forced transaction inclusion (L1 messages). -/// -/// # Compatibility -/// -/// This type is designed to be compatible with both: -/// - Standard Ethereum Engine API (via the inner [`PayloadAttributes`]) -/// - Morph L2 requirements (via the `transactions` field for L1 messages) #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MorphPayloadAttributes { @@ -32,106 +29,20 @@ pub struct MorphPayloadAttributes { pub transactions: Option>, } -impl MorphPayloadAttributes { - /// Create new [`MorphPayloadAttributes`] from standard attributes. - pub fn new(inner: PayloadAttributes) -> Self { - Self { - inner, - transactions: None, - } - } - - /// Returns the timestamp for the payload. - pub fn timestamp(&self) -> u64 { - self.inner.timestamp - } - - /// Returns the suggested fee recipient (coinbase). - pub fn suggested_fee_recipient(&self) -> Address { - self.inner.suggested_fee_recipient - } - - /// Returns the prev_randao value. - pub fn prev_randao(&self) -> B256 { - self.inner.prev_randao - } - - /// Returns the parent beacon block root if set. - pub fn parent_beacon_block_root(&self) -> Option { - self.inner.parent_beacon_block_root - } - - /// Returns the withdrawals if set. - pub fn withdrawals(&self) -> Option<&Vec> { - self.inner.withdrawals.as_ref() - } - - /// Returns the forced transactions (L1 messages) if any. - pub fn forced_transactions(&self) -> &[Bytes] { - self.transactions.as_deref().unwrap_or(&[]) - } - - /// Returns true if there are forced transactions. - pub fn has_forced_transactions(&self) -> bool { - self.transactions.as_ref().is_some_and(|t| !t.is_empty()) - } - - /// Builder method to set forced transactions. - pub fn with_transactions(mut self, transactions: Vec) -> Self { - self.transactions = Some(transactions); - self - } -} - -impl From for MorphPayloadAttributes { - fn from(inner: PayloadAttributes) -> Self { - Self::new(inner) - } -} - -impl reth_payload_primitives::PayloadAttributes for MorphPayloadAttributes { - fn timestamp(&self) -> u64 { - self.inner.timestamp - } - - fn withdrawals(&self) -> Option<&Vec> { - self.inner.withdrawals.as_ref() - } - - fn parent_beacon_block_root(&self) -> Option { - self.inner.parent_beacon_block_root - } -} - /// Internal payload builder attributes. /// /// This is the internal representation used by the payload builder, /// with decoded transactions and computed payload ID. #[derive(Debug, Clone)] pub struct MorphPayloadBuilderAttributes { - /// Payload ID. - pub id: alloy_rpc_types_engine::PayloadId, + /// Inner Ethereum payload builder attributes. + pub inner: EthPayloadBuilderAttributes, - /// Parent block hash. - pub parent: B256, - - /// Block timestamp. - pub timestamp: u64, - - /// Suggested fee recipient (coinbase). - pub suggested_fee_recipient: Address, - - /// Previous RANDAO value. - pub prev_randao: B256, - - /// Withdrawals (usually empty for L2). - pub withdrawals: Withdrawals, - - /// Parent beacon block root. - pub parent_beacon_block_root: Option, - - /// Forced transactions (L1 messages). - pub transactions: Vec, + /// Decoded sequencer transactions with original encoded bytes. + /// + /// Transactions are decoded and recovered during construction to avoid + /// repeated decoding in the payload builder. + pub transactions: Vec>>, } impl PayloadBuilderAttributes for MorphPayloadBuilderAttributes { @@ -145,7 +56,26 @@ impl PayloadBuilderAttributes for MorphPayloadBuilderAttributes { ) -> Result { let id = payload_id_morph(&parent, &attributes, version); - Ok(Self { + // Decode and recover transactions + let transactions = attributes + .transactions + .unwrap_or_default() + .into_iter() + .map(|data| { + let mut buf = data.as_ref(); + let tx = MorphTxEnvelope::decode_2718(&mut buf)?; + if !buf.is_empty() { + return Err(alloy_rlp::Error::UnexpectedLength); + } + let recovered = tx + .try_into_recovered() + .map_err(|_| alloy_rlp::Error::Custom("failed to recover signer"))?; + Ok(WithEncoded::new(data, recovered)) + }) + .collect::, alloy_rlp::Error>>()?; + + // Build inner Ethereum attributes + let inner = EthPayloadBuilderAttributes { id, parent, timestamp: attributes.inner.timestamp, @@ -153,36 +83,40 @@ impl PayloadBuilderAttributes for MorphPayloadBuilderAttributes { prev_randao: attributes.inner.prev_randao, withdrawals: attributes.inner.withdrawals.unwrap_or_default().into(), parent_beacon_block_root: attributes.inner.parent_beacon_block_root, - transactions: attributes.transactions.unwrap_or_default(), + }; + + Ok(Self { + inner, + transactions, }) } - fn payload_id(&self) -> alloy_rpc_types_engine::PayloadId { - self.id + fn payload_id(&self) -> PayloadId { + self.inner.id } fn parent(&self) -> B256 { - self.parent + self.inner.parent } fn timestamp(&self) -> u64 { - self.timestamp + self.inner.timestamp } fn parent_beacon_block_root(&self) -> Option { - self.parent_beacon_block_root + self.inner.parent_beacon_block_root } fn suggested_fee_recipient(&self) -> Address { - self.suggested_fee_recipient + self.inner.suggested_fee_recipient } fn prev_randao(&self) -> B256 { - self.prev_randao + self.inner.prev_randao } fn withdrawals(&self) -> &Withdrawals { - &self.withdrawals + &self.inner.withdrawals } } @@ -200,7 +134,7 @@ fn payload_id_morph( parent: &B256, attributes: &MorphPayloadAttributes, version: u8, -) -> alloy_rpc_types_engine::PayloadId { +) -> PayloadId { let mut hasher = Sha256::new(); // Hash parent @@ -240,7 +174,7 @@ fn payload_id_morph( let mut result = hasher.finalize(); result[0] = version; - alloy_rpc_types_engine::PayloadId::new( + PayloadId::new( result.as_slice()[..8] .try_into() .expect("sufficient length"), @@ -265,30 +199,17 @@ mod tests { } #[test] - fn test_new_from_payload_attributes() { - let inner = PayloadAttributes { - timestamp: 1234567890, - prev_randao: B256::random(), - suggested_fee_recipient: Address::random(), - withdrawals: None, - parent_beacon_block_root: None, - }; - - let attrs = MorphPayloadAttributes::new(inner.clone()); - assert_eq!(attrs.timestamp(), inner.timestamp); - assert_eq!( - attrs.suggested_fee_recipient(), - inner.suggested_fee_recipient - ); - assert!(!attrs.has_forced_transactions()); + fn test_default_attributes() { + let attrs = MorphPayloadAttributes::default(); + assert!(attrs.transactions.is_none()); } #[test] fn test_with_transactions() { - let attrs = create_test_attributes().with_transactions(vec![Bytes::from(vec![0x01])]); + let mut attrs = create_test_attributes(); + attrs.transactions = Some(vec![Bytes::from(vec![0x01])]); - assert_eq!(attrs.forced_transactions().len(), 1); - assert!(attrs.has_forced_transactions()); + assert_eq!(attrs.transactions.as_ref().unwrap().len(), 1); } #[test] @@ -318,7 +239,8 @@ mod tests { fn test_payload_id_different_with_transactions() { let parent = B256::random(); let attrs1 = create_test_attributes(); - let attrs2 = create_test_attributes().with_transactions(vec![Bytes::from(vec![0x01])]); + let mut attrs2 = create_test_attributes(); + attrs2.transactions = Some(vec![Bytes::from(vec![0x01])]); let id1 = payload_id_morph(&parent, &attrs1, 1); let id2 = payload_id_morph(&parent, &attrs2, 1); @@ -327,21 +249,10 @@ mod tests { assert_ne!(id1, id2); } - #[test] - fn test_payload_builder_attributes_try_new() { - let parent = B256::random(); - let attrs = create_test_attributes(); - - let builder_attrs = MorphPayloadBuilderAttributes::try_new(parent, attrs.clone(), 1) - .expect("should succeed"); - - assert_eq!(builder_attrs.parent(), parent); - assert_eq!(builder_attrs.timestamp(), attrs.timestamp()); - } - #[test] fn test_serde_roundtrip() { - let attrs = create_test_attributes().with_transactions(vec![Bytes::from(vec![0x01, 0x02])]); + let mut attrs = create_test_attributes(); + attrs.transactions = Some(vec![Bytes::from(vec![0x01, 0x02])]); let json = serde_json::to_string(&attrs).expect("serialize"); let decoded: MorphPayloadAttributes = serde_json::from_str(&json).expect("deserialize"); @@ -359,8 +270,8 @@ mod tests { }"#; let attrs: MorphPayloadAttributes = serde_json::from_str(json).expect("deserialize"); - assert_eq!(attrs.timestamp(), 1234567890); - assert!(!attrs.has_forced_transactions()); + assert_eq!(attrs.inner.timestamp, 1234567890); + assert!(attrs.transactions.is_none()); } #[test] @@ -373,6 +284,6 @@ mod tests { }"#; let attrs: MorphPayloadAttributes = serde_json::from_str(json).expect("deserialize"); - assert_eq!(attrs.forced_transactions().len(), 1); + assert_eq!(attrs.transactions.as_ref().unwrap().len(), 1); } } From 64463178709c70b02def4d7ab4312820e30647d8 Mon Sep 17 00:00:00 2001 From: panos Date: Fri, 16 Jan 2026 21:35:38 +0800 Subject: [PATCH 05/13] feat: add l1 and pool tx execution --- Cargo.lock | 1 + crates/chainspec/res/genesis/hoodi.json | 3 - crates/chainspec/res/genesis/mainnet.json | 2 - crates/chainspec/src/genesis.rs | 24 +- crates/chainspec/src/hardfork.rs | 125 +++--- crates/chainspec/src/morph.rs | 2 - crates/chainspec/src/morph_hoodi.rs | 2 - crates/chainspec/src/spec.rs | 104 ++--- crates/consensus/src/lib.rs | 10 - crates/consensus/src/validation.rs | 46 +- crates/evm/Cargo.toml | 3 +- crates/evm/src/block.rs | 91 +++- crates/evm/src/lib.rs | 12 +- crates/evm/src/system_contracts.rs | 191 ++++++++ crates/payload/builder/src/builder.rs | 420 ++++++++++++------ crates/payload/builder/src/config.rs | 283 ++++++++++++ crates/payload/builder/src/error.rs | 5 + crates/payload/builder/src/lib.rs | 2 + crates/payload/types/src/attributes.rs | 17 +- crates/primitives/src/transaction/envelope.rs | 31 +- .../src/transaction/l1_transaction.rs | 286 +++++------- crates/revm/src/handler.rs | 21 +- crates/revm/src/l1block.rs | 216 +++------ 23 files changed, 1230 insertions(+), 667 deletions(-) create mode 100644 crates/evm/src/system_contracts.rs create mode 100644 crates/payload/builder/src/config.rs diff --git a/Cargo.lock b/Cargo.lock index 41793cb..7249a73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3698,6 +3698,7 @@ dependencies = [ "revm", "serde_json", "thiserror 2.0.17", + "tracing", ] [[package]] diff --git a/crates/chainspec/res/genesis/hoodi.json b/crates/chainspec/res/genesis/hoodi.json index 3a48dff..e7cb05b 100644 --- a/crates/chainspec/res/genesis/hoodi.json +++ b/crates/chainspec/res/genesis/hoodi.json @@ -16,8 +16,6 @@ "terminalTotalDifficultyPassed": true, "shanghaiTime": 0, "cancunTime": 0, - "bernoulliTime": 0, - "curieTime": 0, "morph203Time": 0, "viridianTime": 0, "emeraldTime": 0, @@ -35,4 +33,3 @@ "coinbase": "0x0000000000000000000000000000000000000000", "alloc": {} } - diff --git a/crates/chainspec/res/genesis/mainnet.json b/crates/chainspec/res/genesis/mainnet.json index 58eb269..0c2d883 100644 --- a/crates/chainspec/res/genesis/mainnet.json +++ b/crates/chainspec/res/genesis/mainnet.json @@ -16,8 +16,6 @@ "terminalTotalDifficultyPassed": true, "shanghaiTime": 0, "cancunTime": 0, - "bernoulliTime": 0, - "curieTime": 0, "morph203Time": 0, "viridianTime": 0, "emeraldTime": 0, diff --git a/crates/chainspec/src/genesis.rs b/crates/chainspec/src/genesis.rs index 287fa7f..2f96e03 100644 --- a/crates/chainspec/src/genesis.rs +++ b/crates/chainspec/src/genesis.rs @@ -41,17 +41,16 @@ impl TryFrom<&OtherFields> for MorphGenesisInfo { } } -/// Morph hardfork info specifies the block numbers and timestamps at which +/// Morph hardfork info specifies the timestamps at which /// the Morph hardforks were activated. +/// +/// Note: Earlier hardforks (Archimedes, Shanghai, Bernoulli, Curie) are omitted as they +/// were already active at genesis for both mainnet and testnet. +/// +/// All hardforks are timestamp-based. #[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MorphHardforkInfo { - /// Bernoulli hardfork timestamp. - #[serde(skip_serializing_if = "Option::is_none")] - pub bernoulli_time: Option, - /// Curie hardfork timestamp. - #[serde(skip_serializing_if = "Option::is_none")] - pub curie_time: Option, /// Morph203 hardfork timestamp. #[serde(skip_serializing_if = "Option::is_none")] pub morph203_time: Option, @@ -61,6 +60,9 @@ pub struct MorphHardforkInfo { /// Emerald hardfork timestamp. #[serde(skip_serializing_if = "Option::is_none")] pub emerald_time: Option, + /// MPTFork hardfork timestamp. + #[serde(skip_serializing_if = "Option::is_none")] + pub mpt_fork_time: Option, } impl MorphHardforkInfo { @@ -152,11 +154,10 @@ mod tests { fn test_extract_morph_hardfork_info() { let genesis_info = r#" { - "bernoulliTime": 1000, - "curieTime": 2000, "morph203Time": 3000, "viridianTime": 4000, - "emeraldTime": 5000 + "emeraldTime": 5000, + "mptForkTime": 6000 } "#; @@ -166,11 +167,10 @@ mod tests { assert_eq!( hardfork_info, MorphHardforkInfo { - bernoulli_time: Some(1000), - curie_time: Some(2000), morph203_time: Some(3000), viridian_time: Some(4000), emerald_time: Some(5000), + mpt_fork_time: Some(6000), } ); } diff --git a/crates/chainspec/src/hardfork.rs b/crates/chainspec/src/hardfork.rs index 927c964..f563a05 100644 --- a/crates/chainspec/src/hardfork.rs +++ b/crates/chainspec/src/hardfork.rs @@ -36,44 +36,51 @@ use reth_chainspec::{EthereumHardforks, ForkCondition}; hardfork!( /// Morph-specific hardforks for network upgrades. + /// + /// Note: Earlier hardforks (Archimedes, Shanghai, Bernoulli, Curie) are omitted as they + /// were already active at genesis for both mainnet and testnet. The reth client assumes + /// these features are always enabled. + /// + /// All remaining hardforks are timestamp-based. #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Default)] MorphHardfork { - /// Bernoulli. - Bernoulli, - /// Curie hardfork. - Curie, - /// Morph203 hardfork. + /// Morph203 hardfork (timestamp-based). Morph203, - /// Viridian hardfork. + /// Viridian hardfork (timestamp-based). Viridian, - /// Emerald hardfork. - #[default] + /// Emerald hardfork (timestamp-based). Emerald, + /// MPTFork hardfork (timestamp-based). + #[default] + MPTFork, } ); impl MorphHardfork { - /// Returns `true` if this hardfork is Curie or later. - #[inline] - pub fn is_curie(self) -> bool { - self >= Self::Curie - } - /// Returns `true` if this hardfork is Morph203 or later. + #[inline] pub fn is_morph203(self) -> bool { self >= Self::Morph203 } /// Returns `true` if this hardfork is Viridian or later. + #[inline] pub fn is_viridian(self) -> bool { self >= Self::Viridian } /// Returns `true` if this hardfork is Emerald or later. + #[inline] pub fn is_emerald(self) -> bool { self >= Self::Emerald } + + /// Returns `true` if this hardfork is MPTFork or later. + #[inline] + pub fn is_mpt_fork(self) -> bool { + self >= Self::MPTFork + } } /// Trait for querying Morph-specific hardfork activations. @@ -81,18 +88,6 @@ pub trait MorphHardforks: EthereumHardforks { /// Retrieves activation condition for a Morph-specific hardfork fn morph_fork_activation(&self, fork: MorphHardfork) -> ForkCondition; - /// Convenience method to check if Bernoulli hardfork is active at a given timestamp - fn is_bernoulli_active_at_timestamp(&self, timestamp: u64) -> bool { - self.morph_fork_activation(MorphHardfork::Bernoulli) - .active_at_timestamp(timestamp) - } - - /// Convenience method to check if Curie hardfork is active at a given timestamp - fn is_curie_active_at_timestamp(&self, timestamp: u64) -> bool { - self.morph_fork_activation(MorphHardfork::Curie) - .active_at_timestamp(timestamp) - } - /// Convenience method to check if Morph203 hardfork is active at a given timestamp fn is_morph203_active_at_timestamp(&self, timestamp: u64) -> bool { self.morph_fork_activation(MorphHardfork::Morph203) @@ -111,18 +106,25 @@ pub trait MorphHardforks: EthereumHardforks { .active_at_timestamp(timestamp) } + /// Convenience method to check if MPTFork hardfork is active at a given timestamp + fn is_mpt_fork_active_at_timestamp(&self, timestamp: u64) -> bool { + self.morph_fork_activation(MorphHardfork::MPTFork) + .active_at_timestamp(timestamp) + } + /// Retrieves the latest Morph hardfork active at a given timestamp. + /// + /// Note: All Morph hardforks are timestamp-based. fn morph_hardfork_at(&self, timestamp: u64) -> MorphHardfork { - if self.is_emerald_active_at_timestamp(timestamp) { + if self.is_mpt_fork_active_at_timestamp(timestamp) { + MorphHardfork::MPTFork + } else if self.is_emerald_active_at_timestamp(timestamp) { MorphHardfork::Emerald } else if self.is_viridian_active_at_timestamp(timestamp) { MorphHardfork::Viridian - } else if self.is_morph203_active_at_timestamp(timestamp) { - MorphHardfork::Morph203 - } else if self.is_curie_active_at_timestamp(timestamp) { - MorphHardfork::Curie } else { - MorphHardfork::Bernoulli + // Default to Morph203 (baseline) + MorphHardfork::Morph203 } } } @@ -130,11 +132,10 @@ pub trait MorphHardforks: EthereumHardforks { impl From for SpecId { fn from(value: MorphHardfork) -> Self { match value { - MorphHardfork::Bernoulli => Self::OSAKA, - MorphHardfork::Curie => Self::OSAKA, MorphHardfork::Morph203 => Self::OSAKA, MorphHardfork::Viridian => Self::OSAKA, MorphHardfork::Emerald => Self::OSAKA, + MorphHardfork::MPTFork => Self::OSAKA, } } } @@ -146,16 +147,14 @@ impl From for MorphHardfork { /// `From for SpecId`, because multiple Morph /// hardforks may share the same underlying EVM spec. fn from(spec: SpecId) -> Self { - if spec.is_enabled_in(SpecId::from(Self::Emerald)) { + if spec.is_enabled_in(SpecId::from(Self::MPTFork)) { + Self::MPTFork + } else if spec.is_enabled_in(SpecId::from(Self::Emerald)) { Self::Emerald } else if spec.is_enabled_in(SpecId::from(Self::Viridian)) { Self::Viridian - } else if spec.is_enabled_in(SpecId::from(Self::Morph203)) { - Self::Morph203 - } else if spec.is_enabled_in(SpecId::from(Self::Curie)) { - Self::Curie } else { - Self::Bernoulli + Self::Morph203 } } } @@ -166,14 +165,14 @@ mod tests { use reth_chainspec::Hardfork; #[test] - fn test_bernoulli_hardfork_name() { - let fork = MorphHardfork::Bernoulli; - assert_eq!(fork.name(), "Bernoulli"); + fn test_morph203_hardfork_name() { + let fork = MorphHardfork::Morph203; + assert_eq!(fork.name(), "Morph203"); } #[test] fn test_hardfork_trait_implementation() { - let fork = MorphHardfork::Bernoulli; + let fork = MorphHardfork::Morph203; // Should implement Hardfork trait let _name: &str = Hardfork::name(&fork); } @@ -181,58 +180,46 @@ mod tests { #[test] #[cfg(feature = "serde")] fn test_morph_hardfork_serde() { - let fork = MorphHardfork::Bernoulli; + let fork = MorphHardfork::Morph203; // Serialize to JSON let json = serde_json::to_string(&fork).unwrap(); - assert_eq!(json, "\"Bernoulli\""); + assert_eq!(json, "\"Morph203\""); // Deserialize from JSON let deserialized: MorphHardfork = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized, fork); } - #[test] - fn test_is_curie() { - assert!(!MorphHardfork::Bernoulli.is_curie()); - assert!(MorphHardfork::Curie.is_curie()); - assert!(MorphHardfork::Morph203.is_curie()); - assert!(MorphHardfork::Viridian.is_curie()); - assert!(MorphHardfork::Emerald.is_curie()); - } - #[test] fn test_is_morph203() { - assert!(!MorphHardfork::Bernoulli.is_morph203()); - assert!(!MorphHardfork::Curie.is_morph203()); - assert!(MorphHardfork::Morph203.is_morph203()); assert!(MorphHardfork::Viridian.is_morph203()); assert!(MorphHardfork::Emerald.is_morph203()); - - assert!(MorphHardfork::Morph203.is_curie()); + assert!(MorphHardfork::MPTFork.is_morph203()); } #[test] fn test_is_viridian() { - assert!(!MorphHardfork::Bernoulli.is_viridian()); - assert!(!MorphHardfork::Curie.is_viridian()); assert!(!MorphHardfork::Morph203.is_viridian()); assert!(MorphHardfork::Viridian.is_viridian()); - assert!(MorphHardfork::Viridian.is_morph203()); - assert!(MorphHardfork::Viridian.is_curie()); assert!(MorphHardfork::Emerald.is_viridian()); + assert!(MorphHardfork::MPTFork.is_viridian()); } #[test] fn test_is_emerald() { - assert!(!MorphHardfork::Bernoulli.is_emerald()); - assert!(!MorphHardfork::Curie.is_emerald()); assert!(!MorphHardfork::Morph203.is_emerald()); assert!(!MorphHardfork::Viridian.is_emerald()); assert!(MorphHardfork::Emerald.is_emerald()); - assert!(MorphHardfork::Emerald.is_viridian()); - assert!(MorphHardfork::Emerald.is_morph203()); - assert!(MorphHardfork::Emerald.is_curie()); + assert!(MorphHardfork::MPTFork.is_emerald()); + } + + #[test] + fn test_is_mpt_fork() { + assert!(!MorphHardfork::Morph203.is_mpt_fork()); + assert!(!MorphHardfork::Viridian.is_mpt_fork()); + assert!(!MorphHardfork::Emerald.is_mpt_fork()); + assert!(MorphHardfork::MPTFork.is_mpt_fork()); } } diff --git a/crates/chainspec/src/morph.rs b/crates/chainspec/src/morph.rs index 973469c..196aed3 100644 --- a/crates/chainspec/src/morph.rs +++ b/crates/chainspec/src/morph.rs @@ -35,8 +35,6 @@ mod tests { #[test] fn test_morph_mainnet_hardforks() { // All hardforks should be active at genesis - assert!(MORPH_MAINNET.is_bernoulli_active_at_timestamp(0)); - assert!(MORPH_MAINNET.is_curie_active_at_timestamp(0)); assert!(MORPH_MAINNET.is_morph203_active_at_timestamp(0)); assert!(MORPH_MAINNET.is_viridian_active_at_timestamp(0)); assert!(MORPH_MAINNET.is_emerald_active_at_timestamp(0)); diff --git a/crates/chainspec/src/morph_hoodi.rs b/crates/chainspec/src/morph_hoodi.rs index 3f2bbcf..76e784e 100644 --- a/crates/chainspec/src/morph_hoodi.rs +++ b/crates/chainspec/src/morph_hoodi.rs @@ -33,8 +33,6 @@ mod tests { #[test] fn test_morph_hoodi_hardforks() { // All hardforks should be active at genesis - assert!(MORPH_HOODI.is_bernoulli_active_at_timestamp(0)); - assert!(MORPH_HOODI.is_curie_active_at_timestamp(0)); assert!(MORPH_HOODI.is_morph203_active_at_timestamp(0)); assert!(MORPH_HOODI.is_viridian_active_at_timestamp(0)); assert!(MORPH_HOODI.is_emerald_active_at_timestamp(0)); diff --git a/crates/chainspec/src/spec.rs b/crates/chainspec/src/spec.rs index 414d340..837f459 100644 --- a/crates/chainspec/src/spec.rs +++ b/crates/chainspec/src/spec.rs @@ -139,18 +139,17 @@ impl From for MorphChainSpec { // Create base chainspec from genesis (already has ordered Ethereum hardforks) let mut base_spec = ChainSpec::from_genesis(genesis); - // Add Morph hardforks - let morph_forks = vec![ - (MorphHardfork::Bernoulli, hardfork_info.bernoulli_time), - (MorphHardfork::Curie, hardfork_info.curie_time), + // Add Morph hardforks (all timestamp-based: Morph203, Viridian, Emerald, MPTFork) + let timestamp_forks = vec![ (MorphHardfork::Morph203, hardfork_info.morph203_time), (MorphHardfork::Viridian, hardfork_info.viridian_time), (MorphHardfork::Emerald, hardfork_info.emerald_time), + (MorphHardfork::MPTFork, hardfork_info.mpt_fork_time), ] .into_iter() .filter_map(|(fork, time)| time.map(|time| (fork, ForkCondition::Timestamp(time)))); - base_spec.hardforks.extend(morph_forks); + base_spec.hardforks.extend(timestamp_forks); Self { inner: base_spec, @@ -268,7 +267,7 @@ mod tests { use crate::hardfork::MorphHardforks; use serde_json::json; - /// Helper function to create a test genesis with Morph hardforks at timestamp 0 + /// Helper function to create a test genesis with Morph hardforks at genesis (timestamp 0) fn create_test_genesis() -> Genesis { let genesis_json = json!({ "config": { @@ -288,11 +287,10 @@ mod tests { "terminalTotalDifficultyPassed": true, "shanghaiTime": 0, "cancunTime": 0, - "bernoulliTime": 0, - "curieTime": 0, "morph203Time": 0, "viridianTime": 0, - "emeraldTime": 0 + "emeraldTime": 0, + "mptForkTime": 0 }, "alloc": {} }); @@ -303,9 +301,11 @@ mod tests { fn test_morph_chainspec_has_morph_hardforks() { let chainspec = MorphChainSpec::from(create_test_genesis()); - // Bernoulli should be active at genesis (timestamp 0) - assert!(chainspec.is_bernoulli_active_at_timestamp(0)); + // All timestamp-based forks should be active at genesis (timestamp 0) + assert!(chainspec.is_morph203_active_at_timestamp(0)); + assert!(chainspec.is_viridian_active_at_timestamp(0)); assert!(chainspec.is_emerald_active_at_timestamp(0)); + assert!(chainspec.is_mpt_fork_active_at_timestamp(0)); } #[test] @@ -313,12 +313,12 @@ mod tests { let chainspec = MorphChainSpec::from(create_test_genesis()); // Should be able to query Morph hardfork activation through trait - let activation = chainspec.morph_fork_activation(MorphHardfork::Bernoulli); + let activation = chainspec.morph_fork_activation(MorphHardfork::Morph203); assert_eq!(activation, ForkCondition::Timestamp(0)); // Should be able to use convenience method through trait - assert!(chainspec.is_bernoulli_active_at_timestamp(0)); - assert!(chainspec.is_bernoulli_active_at_timestamp(1000)); + assert!(chainspec.is_morph203_active_at_timestamp(0)); + assert!(chainspec.is_morph203_active_at_timestamp(1000)); } #[test] @@ -326,16 +326,16 @@ mod tests { let chainspec = MorphChainSpec::from(create_test_genesis()); // Morph hardforks should be queryable from inner.hardforks via Hardforks trait - let activation = chainspec.fork(MorphHardfork::Bernoulli); + let activation = chainspec.fork(MorphHardfork::Morph203); assert_eq!(activation, ForkCondition::Timestamp(0)); - // Verify Bernoulli appears in forks iterator - let has_bernoulli = chainspec + // Verify Morph203 appears in forks iterator + let has_morph203 = chainspec .forks_iter() - .any(|(fork, _)| fork.name() == "Bernoulli"); + .any(|(fork, _)| fork.name() == "Morph203"); assert!( - has_bernoulli, - "Bernoulli hardfork should be in inner.hardforks" + has_morph203, + "Morph203 hardfork should be in inner.hardforks" ); } @@ -359,11 +359,10 @@ mod tests { "terminalTotalDifficultyPassed": true, "shanghaiTime": 0, "cancunTime": 0, - "bernoulliTime": 1000, - "curieTime": 2000, "morph203Time": 3000, "viridianTime": 4000, - "emeraldTime": 5000 + "emeraldTime": 5000, + "mptForkTime": 6000 }, "alloc": {} }); @@ -372,28 +371,26 @@ mod tests { serde_json::from_value(genesis_json).expect("genesis should be valid"); let chainspec = MorphChainSpec::from(genesis); - // Test Bernoulli activation - let activation = chainspec.fork(MorphHardfork::Bernoulli); - assert_eq!(activation, ForkCondition::Timestamp(1000)); - - assert!(!chainspec.is_bernoulli_active_at_timestamp(0)); - assert!(chainspec.is_bernoulli_active_at_timestamp(1000)); - assert!(chainspec.is_bernoulli_active_at_timestamp(2000)); - - // Test Curie activation - let activation = chainspec.fork(MorphHardfork::Curie); - assert_eq!(activation, ForkCondition::Timestamp(2000)); + // Test Morph203 activation (timestamp-based) + let activation = chainspec.fork(MorphHardfork::Morph203); + assert_eq!(activation, ForkCondition::Timestamp(3000)); - assert!(!chainspec.is_curie_active_at_timestamp(0)); - assert!(!chainspec.is_curie_active_at_timestamp(1000)); - assert!(chainspec.is_curie_active_at_timestamp(2000)); + assert!(!chainspec.is_morph203_active_at_timestamp(2000)); + assert!(chainspec.is_morph203_active_at_timestamp(3000)); - // Test Emerald activation + // Test Emerald activation (timestamp-based) let activation = chainspec.fork(MorphHardfork::Emerald); assert_eq!(activation, ForkCondition::Timestamp(5000)); assert!(!chainspec.is_emerald_active_at_timestamp(4000)); assert!(chainspec.is_emerald_active_at_timestamp(5000)); + + // Test MPTFork activation (timestamp-based) + let activation = chainspec.fork(MorphHardfork::MPTFork); + assert_eq!(activation, ForkCondition::Timestamp(6000)); + + assert!(!chainspec.is_mpt_fork_active_at_timestamp(5000)); + assert!(chainspec.is_mpt_fork_active_at_timestamp(6000)); } #[test] @@ -416,11 +413,10 @@ mod tests { "terminalTotalDifficultyPassed": true, "shanghaiTime": 0, "cancunTime": 0, - "bernoulliTime": 1000, - "curieTime": 2000, "morph203Time": 3000, "viridianTime": 4000, - "emeraldTime": 5000 + "emeraldTime": 5000, + "mptForkTime": 6000 }, "alloc": {} }); @@ -429,14 +425,8 @@ mod tests { serde_json::from_value(genesis_json).expect("genesis should be valid"); let chainspec = MorphChainSpec::from(genesis); - // Before Bernoulli activation - should return Bernoulli (baseline) - assert_eq!(chainspec.morph_hardfork_at(0), MorphHardfork::Bernoulli); - - // At Bernoulli time - assert_eq!(chainspec.morph_hardfork_at(1000), MorphHardfork::Bernoulli); - - // At Curie time - assert_eq!(chainspec.morph_hardfork_at(2000), MorphHardfork::Curie); + // Before any Morph fork activation - should return Morph203 (baseline) + assert_eq!(chainspec.morph_hardfork_at(0), MorphHardfork::Morph203); // At Morph203 time assert_eq!(chainspec.morph_hardfork_at(3000), MorphHardfork::Morph203); @@ -447,8 +437,11 @@ mod tests { // At Emerald time assert_eq!(chainspec.morph_hardfork_at(5000), MorphHardfork::Emerald); - // After Emerald - assert_eq!(chainspec.morph_hardfork_at(6000), MorphHardfork::Emerald); + // At MPTFork time + assert_eq!(chainspec.morph_hardfork_at(6000), MorphHardfork::MPTFork); + + // After MPTFork + assert_eq!(chainspec.morph_hardfork_at(7000), MorphHardfork::MPTFork); } #[test] @@ -456,11 +449,10 @@ mod tests { let genesis_json = json!({ "config": { "chainId": 1337, - "bernoulliTime": 0, - "curieTime": 0, "morph203Time": 0, "viridianTime": 0, "emeraldTime": 0, + "mptForkTime": 0, "morph": { "feeVaultAddress": "0x530000000000000000000000000000000000000a", "maxTxPayloadBytesPerBlock": 122880 @@ -473,11 +465,10 @@ mod tests { let chainspec = MorphChainSpec::from(genesis); // All hardforks should be active at genesis - assert!(chainspec.is_bernoulli_active_at_timestamp(0)); - assert!(chainspec.is_curie_active_at_timestamp(0)); assert!(chainspec.is_morph203_active_at_timestamp(0)); assert!(chainspec.is_viridian_active_at_timestamp(0)); assert!(chainspec.is_emerald_active_at_timestamp(0)); + assert!(chainspec.is_mpt_fork_active_at_timestamp(0)); // Config should be extracted from genesis assert!(chainspec.is_fee_vault_enabled()); @@ -498,8 +489,7 @@ mod tests { "istanbulBlock": 0, "berlinBlock": 0, "londonBlock": 0, - "bernoulliTime": 0, - "curieTime": 0, + "morph203Time": 0, "morph": { "feeVaultAddress": "0x530000000000000000000000000000000000000a", "maxTxPayloadBytesPerBlock": 122880 diff --git a/crates/consensus/src/lib.rs b/crates/consensus/src/lib.rs index 4b608bd..5d1f8de 100644 --- a/crates/consensus/src/lib.rs +++ b/crates/consensus/src/lib.rs @@ -15,16 +15,6 @@ //! 1. All L1 messages must be at the beginning of the block //! 2. L1 messages must be in ascending `queue_index` order //! 3. No gaps in the `queue_index` sequence -//! -//! # Example -//! -//! ```ignore -//! use morph_consensus::MorphConsensus; -//! use std::sync::Arc; -//! -//! let chain_spec = Arc::new(MorphChainSpec::from(genesis)); -//! let consensus = MorphConsensus::new(chain_spec); -//! ``` #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] diff --git a/crates/consensus/src/validation.rs b/crates/consensus/src/validation.rs index f03c3d4..db1b6b0 100644 --- a/crates/consensus/src/validation.rs +++ b/crates/consensus/src/validation.rs @@ -9,7 +9,7 @@ use crate::MorphConsensusError; use alloy_consensus::{BlockHeader as _, EMPTY_OMMER_ROOT_HASH, TxReceipt}; use alloy_evm::block::BlockExecutionResult; use alloy_primitives::{B256, Bloom}; -use morph_chainspec::{MorphChainSpec, hardfork::MorphHardforks}; +use morph_chainspec::MorphChainSpec; use morph_primitives::{Block, BlockBody, MorphReceipt, MorphTxEnvelope}; use reth_consensus::{Consensus, ConsensusError, FullConsensus, HeaderValidator}; use reth_consensus_common::validation::{ @@ -131,19 +131,14 @@ impl HeaderValidator for MorphConsensus { }); } - // Validate the EIP1559 fee is set if the header is after Curie - if self - .chain_spec - .is_curie_active_at_timestamp(header.timestamp()) - { - let base_fee = header - .base_fee_per_gas() - .ok_or(ConsensusError::BaseFeeMissing)?; - if base_fee > MORPH_MAXIMUM_BASE_FEE { - return Err(ConsensusError::Other( - MorphConsensusError::BaseFeeOverLimit(base_fee).to_string(), - )); - } + // Validate base fee (always required, EIP-1559 is always active) + let base_fee = header + .base_fee_per_gas() + .ok_or(ConsensusError::BaseFeeMissing)?; + if base_fee > MORPH_MAXIMUM_BASE_FEE { + return Err(ConsensusError::Other( + MorphConsensusError::BaseFeeOverLimit(base_fee).to_string(), + )); } Ok(()) } @@ -273,10 +268,6 @@ fn validate_against_parent_timestamp( /// Validates gas limit change against parent. /// /// - Gas limit change must be within bounds (parent / GAS_LIMIT_BOUND_DIVISOR) -/// - Only checked before Curie hardfork -/// -/// Note: After Curie, gas limit verification is part of EIP-1559 header validation -/// which Morph doesn't strictly enforce (sequencer can set values). #[inline] fn validate_against_parent_gas_limit( header: &H, @@ -413,7 +404,7 @@ mod tests { use super::*; use alloy_consensus::{Header, Signed}; use alloy_genesis::Genesis; - use alloy_primitives::{Address, B64, B256, Bytes, Signature, TxKind, U256}; + use alloy_primitives::{Address, B64, B256, Bytes, Signature, U256}; use morph_primitives::transaction::TxL1Msg; fn create_test_chainspec() -> Arc { @@ -430,8 +421,6 @@ mod tests { "istanbulBlock": 0, "berlinBlock": 0, "londonBlock": 0, - "bernoulliTime": 0, - "curieTime": 0, "morph203Time": 0, "viridianTime": 0, "emeraldTime": 0 @@ -444,18 +433,17 @@ mod tests { } fn create_l1_msg_tx(queue_index: u64) -> MorphTxEnvelope { + use alloy_consensus::Sealed; let tx = TxL1Msg { queue_index, - tx_hash: B256::ZERO, - from: Address::ZERO, - nonce: queue_index, // nonce is used as queue index for L1 messages gas_limit: 21000, - to: TxKind::Call(Address::ZERO), + to: Address::ZERO, value: U256::ZERO, input: Bytes::default(), + sender: Address::ZERO, }; - let sig = Signature::new(U256::ZERO, U256::ZERO, false); - MorphTxEnvelope::L1Msg(Signed::new_unchecked(tx, sig, B256::ZERO)) + // L1 messages have no signature - use Sealed instead of Signed + MorphTxEnvelope::L1Msg(Sealed::new(tx)) } fn create_regular_tx() -> MorphTxEnvelope { @@ -777,7 +765,7 @@ mod tests { } #[test] - fn test_validate_header_base_fee_missing_after_curie() { + fn test_validate_header_base_fee_missing() { let chain_spec = create_test_chainspec(); let consensus = MorphConsensus::new(chain_spec); let now = std::time::SystemTime::now() @@ -790,7 +778,7 @@ mod tests { ommers_hash: EMPTY_OMMER_ROOT_HASH, gas_limit: 30_000_000, timestamp: now - 10, - base_fee_per_gas: None, // Missing after Curie + base_fee_per_gas: None, // Missing (required) ..Default::default() }; let sealed = SealedHeader::seal_slow(header); diff --git a/crates/evm/Cargo.toml b/crates/evm/Cargo.toml index 345186e..3026693 100644 --- a/crates/evm/Cargo.toml +++ b/crates/evm/Cargo.toml @@ -29,9 +29,10 @@ alloy-primitives.workspace = true derive_more.workspace = true thiserror.workspace = true +revm.workspace = true +tracing.workspace = true [dev-dependencies] -revm.workspace = true serde_json.workspace = true alloy-genesis.workspace = true diff --git a/crates/evm/src/block.rs b/crates/evm/src/block.rs index c16a857..3454111 100644 --- a/crates/evm/src/block.rs +++ b/crates/evm/src/block.rs @@ -1,4 +1,10 @@ -use crate::{MorphBlockExecutionCtx, evm::MorphEvm}; +use crate::{ + MorphBlockExecutionCtx, evm::MorphEvm, + system_contracts::{ + BLOB_ENABLED, GPO_IS_BLOB_ENABLED_SLOT, L1_GAS_PRICE_ORACLE_ADDRESS, + L1_GAS_PRICE_ORACLE_INIT_STORAGE, + }, +}; use alloy_consensus::Receipt; use alloy_evm::{ Database, Evm, @@ -12,6 +18,10 @@ use morph_chainspec::MorphChainSpec; use morph_primitives::{MorphReceipt, MorphTransactionReceipt, MorphTxEnvelope, MorphTxType}; use morph_revm::{MorphHaltReason, evm::MorphContext}; use reth_revm::{Inspector, State, context::result::ResultAndState}; +use revm::{ + Database as RevmDatabase, + database::states::StorageSlot, +}; /// Builder for [`MorphReceipt`]. #[derive(Debug, Clone, Copy, Default)] @@ -54,12 +64,16 @@ impl ReceiptBuilder for MorphReceiptBuilder { /// Block executor for Morph. Wraps an inner [`EthBlockExecutor`]. pub(crate) struct MorphBlockExecutor<'a, DB: Database, I> { + /// Inner Ethereum block executor. pub(crate) inner: EthBlockExecutor< 'a, MorphEvm<&'a mut State, I>, &'a MorphChainSpec, MorphReceiptBuilder, >, + /// Reference to the chain spec for hardfork checks. + #[allow(dead_code)] + chain_spec: &'a MorphChainSpec, } impl<'a, DB, I> MorphBlockExecutor<'a, DB, I> @@ -79,6 +93,7 @@ where chain_spec, MorphReceiptBuilder::default(), ), + chain_spec, } } } @@ -93,7 +108,25 @@ where type Evm = MorphEvm<&'a mut State, I>; fn apply_pre_execution_changes(&mut self) -> Result<(), BlockExecutionError> { - self.inner.apply_pre_execution_changes() + // 1. Apply base Ethereum pre-execution changes (state clear flag, EIP-2935, EIP-4788) + self.inner.apply_pre_execution_changes()?; + + // 2. Load L1 gas oracle contract into cache for L1 fee calculations + let _ = self + .inner + .evm_mut() + .db_mut() + .load_cache_account(L1_GAS_PRICE_ORACLE_ADDRESS) + .map_err(BlockExecutionError::other)?; + + // 3. Initialize L1 gas price oracle storage (idempotent - only applies if not already done) + if let Err(err) = init_l1_gas_price_oracle_storage(self.inner.evm_mut().db_mut()) { + return Err(BlockExecutionError::msg(format!( + "error occurred at L1 gas price oracle initialization: {err:?}" + ))); + } + + Ok(()) } fn execute_transaction_without_commit( @@ -129,3 +162,57 @@ where self.inner.evm() } } + +/// Initializes L1 gas price oracle storage slots for blob-based L1 fee calculations. +/// +/// Updates L1GasPriceOracle storage slots: +/// - Sets `isBlobEnabled` slot to 1 (true) +/// - Sets `l1BlobBaseFee` slot to 1 +/// - Sets `commitScalar` slot to initial value +/// - Sets `blobScalar` slot to initial value +/// +/// This function is idempotent - if already applied (isBlobEnabled == 1), it's a no-op. +fn init_l1_gas_price_oracle_storage( + state: &mut State, +) -> Result<(), DB::Error> { + // No-op if already applied (check isBlobEnabled slot). + // This makes the function idempotent so we can call it on every block. + if state + .database + .storage(L1_GAS_PRICE_ORACLE_ADDRESS, GPO_IS_BLOB_ENABLED_SLOT)? + == BLOB_ENABLED + { + return Ok(()); + } + + tracing::info!(target: "morph::evm", "Initializing L1 gas price oracle storage slots"); + + let oracle = state.load_cache_account(L1_GAS_PRICE_ORACLE_ADDRESS)?; + + // Create storage updates + let new_storage = L1_GAS_PRICE_ORACLE_INIT_STORAGE + .into_iter() + .map(|(slot, present_value)| { + ( + slot, + StorageSlot { + present_value, + previous_or_original_value: oracle.storage_slot(slot).unwrap_or_default(), + }, + ) + }) + .collect(); + + // Get existing account info or use default + let oracle_info = oracle.account_info().unwrap_or_default(); + + // Create transition for oracle storage update + let transition = oracle.change(oracle_info, new_storage); + + // Add transition to state + if let Some(s) = state.transition_state.as_mut() { + s.add_transitions(vec![(L1_GAS_PRICE_ORACLE_ADDRESS, transition)]); + } + + Ok(()) +} diff --git a/crates/evm/src/lib.rs b/crates/evm/src/lib.rs index c920346..4abb95c 100644 --- a/crates/evm/src/lib.rs +++ b/crates/evm/src/lib.rs @@ -15,6 +15,7 @@ pub use context::{MorphBlockExecutionCtx, MorphNextBlockEnvAttributes}; mod error; pub use error::MorphEvmError; pub mod evm; +pub mod system_contracts; use std::{borrow::Cow, sync::Arc}; use alloy_evm::{ @@ -149,6 +150,7 @@ impl ConfigureEvm for MorphEvmConfig { .blob_params_at_timestamp(attributes.timestamp), ); + // Next block number is parent + 1 let spec = self.chain_spec().morph_hardfork_at(attributes.timestamp); Ok(EvmEnv { @@ -215,8 +217,6 @@ mod tests { "terminalTotalDifficultyPassed": true, "shanghaiTime": 0, "cancunTime": 0, - "bernoulliTime": 0, - "curieTime": 0, "morph203Time": 0, "viridianTime": 0 }, @@ -227,23 +227,23 @@ mod tests { #[test] fn test_evm_config_can_query_morph_hardforks() { - // Create a test chainspec with Bernoulli at genesis + // Create a test chainspec with Morph203 at genesis let chainspec = Arc::new(morph_chainspec::MorphChainSpec::from(create_test_genesis())); let evm_config = MorphEvmConfig::new_with_default_factory(chainspec); // Should be able to query Morph hardforks through the chainspec - assert!(evm_config.chain_spec().is_bernoulli_active_at_timestamp(0)); + assert!(evm_config.chain_spec().is_morph203_active_at_timestamp(0)); assert!( evm_config .chain_spec() - .is_bernoulli_active_at_timestamp(1000) + .is_morph203_active_at_timestamp(1000) ); // Should be able to query activation condition let activation = evm_config .chain_spec() - .morph_fork_activation(MorphHardfork::Bernoulli); + .morph_fork_activation(MorphHardfork::Morph203); assert_eq!(activation, reth_chainspec::ForkCondition::Timestamp(0)); } } diff --git a/crates/evm/src/system_contracts.rs b/crates/evm/src/system_contracts.rs new file mode 100644 index 0000000..5b11b06 --- /dev/null +++ b/crates/evm/src/system_contracts.rs @@ -0,0 +1,191 @@ +//! Morph system contract addresses and storage slots. +//! +//! This module defines the pre-deployed system contract addresses and their storage layouts +//! for the Morph L2 network. These contracts are deployed at genesis and provide essential +//! L2 functionality like L1 fee calculation and message queuing. + +use alloy_primitives::{Address, B256, U256, address}; + +// ============================================================================= +// System Contract Addresses +// ============================================================================= + +/// L1 Gas Price Oracle contract address. +/// +/// This contract stores L1 gas prices used for calculating L1 data fees. +/// Reference: `rollup/rcfg/config.go` in go-ethereum +pub const L1_GAS_PRICE_ORACLE_ADDRESS: Address = + address!("530000000000000000000000000000000000000F"); + +/// L2 Message Queue contract address. +/// +/// Manages the L1-to-L2 message queue and stores the withdraw trie root. +/// See: +pub const L2_MESSAGE_QUEUE_ADDRESS: Address = + address!("5300000000000000000000000000000000000001"); + +// ============================================================================= +// L2 Message Queue Storage Slots +// ============================================================================= + +// forge inspect contracts/L2/predeploys/L2MessageQueue.sol:L2MessageQueue storageLayout +// The contract inherits from AppendOnlyMerkleTree which has: +// - slot 0-31: branches[32] array +// - slot 32: nextIndex +// - slot 33: messageRoot (the withdraw trie root) + +/// Storage slot for the withdraw trie root (`messageRoot`) in L2MessageQueue contract. +/// +/// This is slot 33, which stores the Merkle root for L2→L1 messages. +/// See: +pub const L2_MESSAGE_QUEUE_WITHDRAW_TRIE_ROOT_SLOT: U256 = U256::from_limbs([33, 0, 0, 0]); + +// ============================================================================= +// L2 Message Queue Functions +// ============================================================================= + +/// Reads the withdraw trie root from the L2MessageQueue contract storage. +/// +/// This function reads the `messageRoot` slot from the L2MessageQueue contract, +/// which stores the Merkle root for L2→L1 withdrawal messages. +/// +pub fn read_withdraw_trie_root(db: &mut DB) -> Result { + let value = db.storage(L2_MESSAGE_QUEUE_ADDRESS, L2_MESSAGE_QUEUE_WITHDRAW_TRIE_ROOT_SLOT)?; + Ok(B256::from(value)) +} + +/// Morph Fee Vault contract address. +/// +/// Collects transaction fees. +pub const MORPH_FEE_VAULT_ADDRESS: Address = + address!("530000000000000000000000000000000000000a"); + + +/// Morph Token Registry contract address. +pub const MORPH_TOKEN_REGISTRY_ADDRESS: Address = + address!("5300000000000000000000000000000000000021"); + +/// Sequencer contract address. +/// +/// Manages sequencer operations. +pub const SEQUENCER_ADDRESS: Address = address!("5300000000000000000000000000000000000017"); + +// ============================================================================= +// L1 Gas Price Oracle Storage Slots +// ============================================================================= + +// forge inspect contracts/l2/system/GasPriceOracle.sol:GasPriceOracle storageLayout +// +-----------------+---------------------+------+--------+-------+ +// | Name | Type | Slot | Offset | Bytes | +// +=================================================================+ +// | owner | address | 0 | 0 | 20 | +// +-----------------+---------------------+------+--------+-------+ +// | l1BaseFee | uint256 | 1 | 0 | 32 | +// +-----------------+---------------------+------+--------+-------+ +// | overhead | uint256 | 2 | 0 | 32 | +// +-----------------+---------------------+------+--------+-------+ +// | scalar | uint256 | 3 | 0 | 32 | +// +-----------------+---------------------+------+--------+-------+ +// | whitelist | contract IWhitelist | 4 | 0 | 20 | +// +-----------------+---------------------+------+--------+-------+ +// | __deprecated0 | uint256 | 5 | 0 | 32 | +// +-----------------+---------------------+------+--------+-------+ +// | l1BlobBaseFee | uint256 | 6 | 0 | 32 | +// +-----------------+---------------------+------+--------+-------+ +// | commitScalar | uint256 | 7 | 0 | 32 | +// +-----------------+---------------------+------+--------+-------+ +// | blobScalar | uint256 | 8 | 0 | 32 | +// +-----------------+---------------------+------+--------+-------+ +// | isBlobEnabled | bool | 9 | 0 | 1 | +// +-----------------+---------------------+------+--------+-------+ + +/// Storage slot for `l1BaseFee` in the `L1GasPriceOracle` contract. +pub const GPO_L1_BASE_FEE_SLOT: U256 = U256::from_limbs([1, 0, 0, 0]); + +/// Storage slot for `overhead` in the `L1GasPriceOracle` contract. +pub const GPO_OVERHEAD_SLOT: U256 = U256::from_limbs([2, 0, 0, 0]); + +/// Storage slot for `scalar` in the `L1GasPriceOracle` contract. +pub const GPO_SCALAR_SLOT: U256 = U256::from_limbs([3, 0, 0, 0]); + +/// Storage slot for `l1BlobBaseFee` in the `L1GasPriceOracle` contract. +pub const GPO_L1_BLOB_BASE_FEE_SLOT: U256 = U256::from_limbs([6, 0, 0, 0]); + +/// Storage slot for `commitScalar` in the `L1GasPriceOracle` contract. +pub const GPO_COMMIT_SCALAR_SLOT: U256 = U256::from_limbs([7, 0, 0, 0]); + +/// Storage slot for `blobScalar` in the `L1GasPriceOracle` contract. +pub const GPO_BLOB_SCALAR_SLOT: U256 = U256::from_limbs([8, 0, 0, 0]); + +/// Storage slot for `isBlobEnabled` in the `L1GasPriceOracle` contract. +pub const GPO_IS_BLOB_ENABLED_SLOT: U256 = U256::from_limbs([9, 0, 0, 0]); + +// ============================================================================= +// L1 Gas Price Oracle Initial Values +// ============================================================================= + +/// The initial blob base fee used by the oracle contract. +pub const INITIAL_L1_BLOB_BASE_FEE: U256 = U256::from_limbs([1, 0, 0, 0]); + +/// The initial commit scalar used by the oracle contract. +/// Reference: `rcfg.InitialCommitScalar` in go-ethereum (230759955285) +pub const INITIAL_COMMIT_SCALAR: U256 = U256::from_limbs([230759955285, 0, 0, 0]); + +/// The initial blob scalar used by the oracle contract. +/// Reference: `rcfg.InitialBlobScalar` in go-ethereum (417565260) +pub const INITIAL_BLOB_SCALAR: U256 = U256::from_limbs([417565260, 0, 0, 0]); + +/// Blob enabled flag is set to 1 (true). +pub const BLOB_ENABLED: U256 = U256::from_limbs([1, 0, 0, 0]); + +/// Storage updates for L1 gas price oracle initialization. +pub const L1_GAS_PRICE_ORACLE_INIT_STORAGE: [(U256, U256); 4] = [ + (GPO_L1_BLOB_BASE_FEE_SLOT, INITIAL_L1_BLOB_BASE_FEE), + (GPO_COMMIT_SCALAR_SLOT, INITIAL_COMMIT_SCALAR), + (GPO_BLOB_SCALAR_SLOT, INITIAL_BLOB_SCALAR), + (GPO_IS_BLOB_ENABLED_SLOT, BLOB_ENABLED), +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_system_contract_addresses() { + // Verify addresses match go-ethereum definitions + assert_eq!( + L1_GAS_PRICE_ORACLE_ADDRESS, + address!("530000000000000000000000000000000000000F") + ); + assert_eq!( + L2_MESSAGE_QUEUE_ADDRESS, + address!("5300000000000000000000000000000000000001") + ); + assert_eq!( + MORPH_FEE_VAULT_ADDRESS, + address!("530000000000000000000000000000000000000a") + ); + assert_eq!( + SEQUENCER_ADDRESS, + address!("5300000000000000000000000000000000000017") + ); + } + + #[test] + fn test_storage_slots() { + assert_eq!(GPO_L1_BASE_FEE_SLOT, U256::from(1)); + assert_eq!(GPO_OVERHEAD_SLOT, U256::from(2)); + assert_eq!(GPO_SCALAR_SLOT, U256::from(3)); + assert_eq!(GPO_L1_BLOB_BASE_FEE_SLOT, U256::from(6)); + assert_eq!(GPO_COMMIT_SCALAR_SLOT, U256::from(7)); + assert_eq!(GPO_BLOB_SCALAR_SLOT, U256::from(8)); + assert_eq!(GPO_IS_BLOB_ENABLED_SLOT, U256::from(9)); + } + + #[test] + fn test_initial_values() { + // Values from go-ethereum rcfg/config.go + assert_eq!(INITIAL_COMMIT_SCALAR, U256::from(230759955285u64)); + assert_eq!(INITIAL_BLOB_SCALAR, U256::from(417565260u64)); + } +} diff --git a/crates/payload/builder/src/builder.rs b/crates/payload/builder/src/builder.rs index cb51cf0..483d710 100644 --- a/crates/payload/builder/src/builder.rs +++ b/crates/payload/builder/src/builder.rs @@ -1,12 +1,14 @@ //! Morph payload builder implementation. -use crate::MorphPayloadBuilderError; +use crate::{config::PayloadBuildingBreaker, MorphBuilderConfig, MorphPayloadBuilderError}; use alloy_consensus::{BlockHeader, Transaction, Typed2718}; use alloy_eips::eip2718::Encodable2718; use alloy_primitives::{Bytes, U256}; use alloy_rlp::Encodable; use morph_chainspec::MorphChainSpec; -use morph_evm::{MorphEvmConfig, MorphNextBlockEnvAttributes}; +use morph_evm::{ + system_contracts::read_withdraw_trie_root, MorphEvmConfig, MorphNextBlockEnvAttributes, +}; use morph_payload_types::{ExecutableL2Data, MorphBuiltPayload, MorphPayloadBuilderAttributes}; use morph_primitives::{MorphHeader, MorphTxEnvelope}; use reth_basic_payload_builder::{ @@ -15,7 +17,7 @@ use reth_basic_payload_builder::{ }; use reth_chainspec::ChainSpecProvider; use reth_evm::{ - block::BlockExecutionError, + block::{BlockExecutionError, BlockValidationError}, execute::{BlockBuilder, BlockBuilderOutcome}, ConfigureEvm, Database, Evm, NextBlockEnvAttributes, }; @@ -65,16 +67,35 @@ pub struct MorphPayloadBuilder { pub client: Client, /// The type responsible for yielding the best transactions to include. pub best_transactions: Txs, + /// Builder configuration. + pub config: MorphBuilderConfig, } impl MorphPayloadBuilder { - /// Creates a new [`MorphPayloadBuilder`]. - pub const fn new(pool: Pool, evm_config: MorphEvmConfig, client: Client) -> Self { + /// Creates a new [`MorphPayloadBuilder`] with default configuration. + pub fn new(pool: Pool, evm_config: MorphEvmConfig, client: Client) -> Self { Self { evm_config, pool, client, best_transactions: (), + config: MorphBuilderConfig::default(), + } + } + + /// Creates a new [`MorphPayloadBuilder`] with the specified configuration. + pub const fn with_config( + pool: Pool, + evm_config: MorphEvmConfig, + client: Client, + config: MorphBuilderConfig, + ) -> Self { + Self { + evm_config, + pool, + client, + best_transactions: (), + config, } } } @@ -91,6 +112,7 @@ impl MorphPayloadBuilder { evm_config, pool, client, + config, .. } = self; MorphPayloadBuilder { @@ -98,8 +120,15 @@ impl MorphPayloadBuilder { pool, client, best_transactions, + config, } } + + /// Sets the builder configuration. + pub fn set_config(mut self, config: MorphBuilderConfig) -> Self { + self.config = config; + self + } } impl MorphPayloadBuilder @@ -126,10 +155,10 @@ where let ctx = MorphPayloadBuilderCtx { evm_config: self.evm_config.clone(), - chain_spec: self.client.chain_spec(), config, cancel, best_payload, + builder_config: self.config.clone(), }; let state_provider = self.client.state_by_block_hash(ctx.parent().hash())?; @@ -189,18 +218,17 @@ where /// Container type that holds all necessities to build a new payload. #[derive(Debug)] -#[allow(dead_code)] struct MorphPayloadBuilderCtx { /// The EVM configuration. evm_config: MorphEvmConfig, - /// The chain specification. - chain_spec: Arc, /// Payload configuration. config: PayloadConfig, /// Marker to check whether the job has been cancelled. cancel: reth_revm::cancelled::CancelOnDrop, /// The currently best payload. best_payload: Option, + /// Builder configuration with limits. + builder_config: MorphBuilderConfig, } impl MorphPayloadBuilderCtx { @@ -225,13 +253,228 @@ impl MorphPayloadBuilderCtx { } /// Returns the current fee settings for transactions from the mempool. - #[allow(dead_code)] - fn best_transaction_attributes( + fn best_transaction_attributes(&self, base_fee: u64) -> BestTransactionsAttributes { + BestTransactionsAttributes::new(base_fee, None) + } + + /// Executes all sequencer transactions that are included in the payload attributes. + /// + /// These transactions are forced and come from the sequencer (L1 messages first). + /// Returns the executed transaction bytes for inclusion in ExecutableL2Data. + fn execute_sequencer_transactions( + &self, + builder: &mut impl BlockBuilder, + info: &mut ExecutionInfo, + ) -> Result, PayloadBuilderError> { + let block_gas_limit = builder.evm().block().gas_limit(); + let base_fee = builder.evm().block().basefee(); + let mut executed_txs: Vec = Vec::new(); + // Track gas spent by each transaction for error reporting + let mut gas_spent_by_transactions: Vec = Vec::new(); + + for tx_with_encoded in &self.attributes().transactions { + // The transaction is already recovered in `try_new` via `try_into_recovered()`. + // For L1 message transactions (which have no signature), this extracts + // the `from` address directly from the transaction. + let recovered_tx = tx_with_encoded.value(); + let tx_bytes = tx_with_encoded.encoded_bytes(); + + // Blob transactions are not supported on L2 + if recovered_tx.is_eip4844() { + return Err(PayloadBuilderError::other( + MorphPayloadBuilderError::BlobTransactionRejected, + )); + } + + let tx_gas = recovered_tx.gas_limit(); + + // Check if adding this transaction would exceed block gas limit + if info.cumulative_gas_used + tx_gas > block_gas_limit { + gas_spent_by_transactions.push(tx_gas); + return Err(PayloadBuilderError::other( + MorphPayloadBuilderError::BlockGasLimitExceededBySequencerTransactions { + gas_spent_by_tx: gas_spent_by_transactions, + gas: block_gas_limit, + }, + )); + } + + // Execute the transaction + let gas_used = match builder.execute_transaction(recovered_tx.clone()) { + Ok(gas_used) => gas_used, + Err(BlockExecutionError::Validation(BlockValidationError::InvalidTx { + error, + .. + })) => { + // For sequencer transactions, we log and skip invalid transactions + // but don't fail the entire block build + tracing::trace!( + target: "payload_builder", + %error, + ?recovered_tx, + "Error in sequencer transaction, skipping." + ); + continue; + } + Err(BlockExecutionError::Validation(err)) => { + tracing::trace!( + target: "payload_builder", + %err, + ?recovered_tx, + "Validation error in sequencer transaction, skipping." + ); + continue; + } + Err(err) => { + // Fatal error - this is a bug or misconfiguration + return Err(PayloadBuilderError::EvmExecutionError(Box::new(err))); + } + }; + + // For L1 messages, use full gas limit (no refund) and no fees collected. + // L1 gas is prepaid on L1, so unused gas is not refunded. + // Also track the next L1 message index. + let gas_used = if recovered_tx.is_l1_msg() { + // Update next_l1_message_index to be queue_index + 1 + if let Some(queue_index) = recovered_tx.queue_index() { + info.next_l1_message_index = queue_index + 1; + } + recovered_tx.gas_limit() + } else { + // Calculate fees for L2 transactions: effective_tip * gas_used + let effective_tip = recovered_tx + .effective_tip_per_gas(base_fee) + .unwrap_or_default(); + info.total_fees += U256::from(effective_tip) * U256::from(gas_used); + gas_used + }; + + info.cumulative_gas_used += gas_used; + gas_spent_by_transactions.push(gas_used); + + // Store the original transaction bytes for ExecutableL2Data + executed_txs.push(tx_bytes.clone()); + } + + Ok(executed_txs) + } + + /// Executes the best transactions from the mempool. + /// + /// Returns `Ok(Some(()))` if the job was cancelled or breaker triggered, `Ok(None)` otherwise. + /// Executed transaction bytes are appended to the provided vector. + fn execute_pool_transactions( &self, - base_fee: u64, - blob_gas_price: Option, - ) -> BestTransactionsAttributes { - BestTransactionsAttributes::new(base_fee, blob_gas_price) + builder: &mut impl BlockBuilder, + info: &mut ExecutionInfo, + executed_txs: &mut Vec, + mut best_txs: BestTxs, + breaker: &PayloadBuildingBreaker, + ) -> Result, PayloadBuilderError> + where + BestTxs: PayloadTransactions>, + { + let block_gas_limit = builder.evm().block().gas_limit(); + let base_fee = builder.evm().block().basefee(); + + while let Some(tx) = best_txs.next(()) { + // Check if the job was cancelled + if self.cancel.is_cancelled() { + return Ok(Some(())); + } + + // Check if the breaker triggers (time/gas/DA limits) + if breaker.should_break(info.cumulative_gas_used, info.cumulative_da_bytes_used) { + tracing::debug!( + target: "payload_builder", + cumulative_gas_used = info.cumulative_gas_used, + cumulative_da_bytes_used = info.cumulative_da_bytes_used, + elapsed = ?breaker.elapsed(), + "breaker triggered, stopping pool transaction execution" + ); + return Ok(Some(())); + } + + let tx = tx.into_consensus(); + + // Skip blob transactions and L1 messages from pool + if tx.is_eip4844() || tx.is_l1_msg() { + best_txs.mark_invalid(tx.signer(), tx.nonce()); + continue; + } + + // Check if the transaction exceeds block limits + if info.is_tx_over_limits( + tx.gas_limit(), + tx.length() as u64, + block_gas_limit, + self.builder_config.max_da_block_size, + ) { + best_txs.mark_invalid(tx.signer(), tx.nonce()); + continue; + } + + // Execute the transaction + let gas_used = match builder.execute_transaction(tx.clone()) { + Ok(gas_used) => gas_used, + Err(BlockExecutionError::Validation(BlockValidationError::InvalidTx { + error, + .. + })) => { + if error.is_nonce_too_low() { + // If the nonce is too low, we can skip this transaction + // but don't mark as invalid - the sender may have other valid txs + tracing::trace!( + target: "payload_builder", + %error, + ?tx, + "skipping nonce too low transaction" + ); + } else { + // If the transaction is invalid for other reasons, + // skip it and all of its descendants from this sender + tracing::trace!( + target: "payload_builder", + %error, + ?tx, + "skipping invalid transaction and its descendants" + ); + best_txs.mark_invalid(tx.signer(), tx.nonce()); + } + continue; + } + Err(BlockExecutionError::Validation(err)) => { + // Other validation errors - skip transaction and descendants + tracing::trace!( + target: "payload_builder", + %err, + ?tx, + "validation error in pool transaction, skipping" + ); + best_txs.mark_invalid(tx.signer(), tx.nonce()); + continue; + } + Err(err) => { + // Fatal error - should not continue + return Err(PayloadBuilderError::EvmExecutionError(Box::new(err))); + } + }; + + // Update execution info + info.cumulative_gas_used += gas_used; + info.cumulative_da_bytes_used += tx.length() as u64; + + // Calculate fees: effective_tip * gas_used + let effective_tip = tx.effective_tip_per_gas(base_fee).unwrap_or_default(); + info.total_fees += U256::from(effective_tip) * U256::from(gas_used); + + // Store the transaction bytes for ExecutableL2Data + let mut tx_bytes = Vec::new(); + tx.encode_2718(&mut tx_bytes); + executed_txs.push(Bytes::from(tx_bytes)); + } + + Ok(None) } } @@ -304,7 +547,7 @@ where .with_bundle_update() .build(); - // Build next block env attributes using the inner EthPayloadBuilderAttributes + // Build next block env attributes let next_block_attrs = MorphNextBlockEnvAttributes { inner: NextBlockEnvAttributes { timestamp: attributes.inner.timestamp, @@ -330,136 +573,49 @@ where })?; let mut info = ExecutionInfo::new(); - let block_gas_limit = builder.evm().block().gas_limit(); let base_fee = builder.evm().block().basefee(); + let block_gas_limit = builder.evm().block().gas_limit(); - // Collect executed transactions for ExecutableL2Data - let mut executed_txs: Vec = Vec::new(); - - // 2. Execute forced transactions from payload attributes (L1 messages first) - // Transactions are already decoded and recovered in MorphPayloadBuilderAttributes - for tx_with_encoded in &attributes.transactions { - let recovered_tx = tx_with_encoded.value(); - let tx_bytes = tx_with_encoded.encoded_bytes(); - - // Blob transactions are not supported - if recovered_tx.is_eip4844() { - return Err(PayloadBuilderError::other( - MorphPayloadBuilderError::BlobTransactionRejected, - )); - } - - let tx_gas = recovered_tx.gas_limit(); - - // Check gas limit - if info.cumulative_gas_used + tx_gas > block_gas_limit { - return Err(PayloadBuilderError::other( - MorphPayloadBuilderError::BlockGasLimitExceededBySequencerTransactions { - gas_spent_by_tx: vec![tx_gas], - gas: block_gas_limit, - }, - )); - } - - // Execute the transaction - let gas_used = match builder.execute_transaction(recovered_tx.clone()) { - Ok(gas_used) => gas_used, - Err(BlockExecutionError::Validation(err)) => { - tracing::trace!( - target: "payload_builder", - %err, - ?recovered_tx, - "Error in sequencer transaction, skipping." - ); - continue; - } - Err(err) => { - return Err(PayloadBuilderError::EvmExecutionError(Box::new(err))); - } - }; - - // For L1 messages, use full gas limit (no refund) and no fees - let gas_used = if recovered_tx.is_l1_msg() { - recovered_tx.gas_limit() - // L1 messages have zero gas price, so no fees are collected - } else { - // Calculate fees for L2 transactions: effective_tip * gas_used - let effective_tip = recovered_tx - .effective_tip_per_gas(base_fee) - .unwrap_or_default(); - info.total_fees += U256::from(effective_tip) * U256::from(gas_used); - gas_used - }; - - info.cumulative_gas_used += gas_used; + // Create breaker for early exit from pool transaction execution + let breaker = ctx.builder_config.breaker(block_gas_limit); - // Store the original transaction bytes for ExecutableL2Data - executed_txs.push(tx_bytes.clone()); - } + // 2. Execute sequencer transactions (L1 messages and forced transactions) + let mut executed_txs = ctx.execute_sequencer_transactions(&mut builder, &mut info)?; // 3. Execute pool transactions (best transactions from mempool) - let mut best_txs = best(ctx.best_transaction_attributes(base_fee, None)); - - while let Some(tx) = best_txs.next(()) { - // Check if the job was cancelled + let best_txs = best(ctx.best_transaction_attributes(base_fee)); + if ctx + .execute_pool_transactions(&mut builder, &mut info, &mut executed_txs, best_txs, &breaker)? + .is_some() + { + // Check if it was a cancellation or just breaker triggered if ctx.cancel.is_cancelled() { return Ok(BuildOutcomeKind::Cancelled); } - - let tx = tx.into_consensus(); - - // Skip blob transactions and L1 messages from pool - if tx.is_eip4844() || tx.is_l1_msg() { - best_txs.mark_invalid(tx.signer(), tx.nonce()); - continue; - } - - // Check if the transaction exceeds block limits - if info.is_tx_over_limits(tx.gas_limit(), tx.length() as u64, block_gas_limit, None) { - best_txs.mark_invalid(tx.signer(), tx.nonce()); - continue; - } - - // Execute the transaction - let gas_used = match builder.execute_transaction(tx.clone()) { - Ok(gas_used) => gas_used, - Err(BlockExecutionError::Validation(err)) => { - tracing::trace!( - target: "payload_builder", - %err, - ?tx, - "Error in pool transaction, skipping." - ); - best_txs.mark_invalid(tx.signer(), tx.nonce()); - continue; - } - Err(err) => { - return Err(PayloadBuilderError::EvmExecutionError(Box::new(err))); - } - }; - - // Update execution info - info.cumulative_gas_used += gas_used; - info.cumulative_da_bytes_used += tx.length() as u64; - - // Calculate fees: effective_tip * gas_used - let effective_tip = tx.effective_tip_per_gas(base_fee).unwrap_or_default(); - info.total_fees += U256::from(effective_tip) * U256::from(gas_used); - - // Store the transaction bytes for ExecutableL2Data - let mut tx_bytes = Vec::new(); - tx.encode_2718(&mut tx_bytes); - executed_txs.push(Bytes::from(tx_bytes)); + // Breaker triggered - continue with current transactions + tracing::debug!( + target: "payload_builder", + elapsed = ?breaker.elapsed(), + cumulative_gas_used = info.cumulative_gas_used, + cumulative_da_bytes_used = info.cumulative_da_bytes_used, + tx_count = executed_txs.len(), + "breaker stopped pool execution, finalizing payload" + ); } - // Check if this payload is better than the previous one + // 4. Check if this payload is better than the previous one if !ctx.is_better_payload(info.total_fees) { return Ok(BuildOutcomeKind::Aborted { fees: info.total_fees, }); } - // Finish building the block + // 5. Read withdraw_trie_root from L2MessageQueue contract storage + // This must be done before finish() consumes the builder + let withdraw_trie_root = read_withdraw_trie_root(builder.evm_mut().db_mut()) + .map_err(|err| PayloadBuilderError::other(MorphPayloadBuilderError::Database(err)))?; + + // 6. Finish building the block let BlockBuilderOutcome { execution_result, block, @@ -476,7 +632,7 @@ where "sealed built block" ); - // Build ExecutableL2Data from the sealed block + // 6. Build ExecutableL2Data from the sealed block let mut logs_bloom_bytes = Vec::new(); header.logs_bloom().encode(&mut logs_bloom_bytes); @@ -492,7 +648,7 @@ where gas_used: execution_result.gas_used, receipts_root: header.receipts_root(), logs_bloom: Bytes::from(logs_bloom_bytes), - withdraw_trie_root: Default::default(), // TODO: compute withdraw trie root + withdraw_trie_root, next_l1_message_index: info.next_l1_message_index, hash: sealed_block.hash(), }; diff --git a/crates/payload/builder/src/config.rs b/crates/payload/builder/src/config.rs new file mode 100644 index 0000000..2e498fe --- /dev/null +++ b/crates/payload/builder/src/config.rs @@ -0,0 +1,283 @@ +//! Configuration for the Morph payload builder. + +use core::time::Duration; +use reth_chainspec::MIN_TRANSACTION_GAS; +use std::{fmt::Debug, time::Instant}; + +/// Minimal data bytes size per transaction. +/// This is a conservative estimate for the minimum encoded transaction size. +pub(crate) const MIN_TRANSACTION_DATA_SIZE: u64 = 115; + +/// Settings for the Morph payload builder. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MorphBuilderConfig { + /// Optional gas limit for payload building. + /// + /// This is a soft limit that can be lower than the block gas limit. + /// When set, the builder will stop adding transactions once this limit is approached. + /// This allows operators to: + /// - Reserve gas for specific transactions + /// - Reduce payload building time + /// - Control block utilization + /// + /// If `None`, the block gas limit from the EVM environment is used. + pub gas_limit: Option, + + /// Time limit for payload building. + /// + /// The builder will stop adding pool transactions and return the current payload + /// once this duration has elapsed since the start of building. + /// This ensures timely block production even with large mempools. + pub time_limit: Duration, + + /// Maximum total data availability size for a block. + /// + /// L2 transactions need to be published to L1 for data availability. + /// This limit controls the maximum size of transaction data in a single block. + /// If `None`, no DA limit is enforced. + pub max_da_block_size: Option, +} + +impl Default for MorphBuilderConfig { + fn default() -> Self { + Self { + gas_limit: None, + // Default to 1 second - leaves time for consensus + time_limit: Duration::from_secs(1), + // No DA limit by default + max_da_block_size: None, + } + } +} + +impl MorphBuilderConfig { + /// Creates a new [`MorphBuilderConfig`] with the specified parameters. + pub const fn new( + gas_limit: Option, + time_limit: Duration, + max_da_block_size: Option, + ) -> Self { + Self { gas_limit, time_limit, max_da_block_size } + } + + /// Sets the gas limit. + pub const fn with_gas_limit(mut self, gas_limit: u64) -> Self { + self.gas_limit = Some(gas_limit); + self + } + + /// Sets the time limit. + pub const fn with_time_limit(mut self, time_limit: Duration) -> Self { + self.time_limit = time_limit; + self + } + + /// Sets the maximum DA block size. + pub const fn with_max_da_block_size(mut self, max_da_block_size: u64) -> Self { + self.max_da_block_size = Some(max_da_block_size); + self + } + + /// Creates a [`PayloadBuildingBreaker`] for this configuration. + pub(crate) fn breaker(&self, block_gas_limit: u64) -> PayloadBuildingBreaker { + // Use configured gas limit or fall back to block gas limit + let effective_gas_limit = self.gas_limit.unwrap_or(block_gas_limit); + PayloadBuildingBreaker::new(self.time_limit, effective_gas_limit, self.max_da_block_size) + } +} + +/// Used in the payload builder to exit the transactions execution loop early. +/// +/// The breaker checks three conditions: +/// 1. Time limit - stop if building takes too long +/// 2. Gas limit - stop if remaining gas is insufficient for any transaction +/// 3. DA limit - stop if data availability size limit is reached +#[derive(Debug, Clone)] +pub struct PayloadBuildingBreaker { + /// When the payload building started. + start: Instant, + /// Maximum time allowed for building. + time_limit: Duration, + /// Gas limit for the payload. + gas_limit: u64, + /// Maximum DA block size. + max_da_block_size: Option, +} + +impl PayloadBuildingBreaker { + /// Creates a new [`PayloadBuildingBreaker`]. + fn new(time_limit: Duration, gas_limit: u64, max_da_block_size: Option) -> Self { + Self { + start: Instant::now(), + time_limit, + gas_limit, + max_da_block_size, + } + } + + /// Returns whether the payload building should stop. + /// + /// Returns `true` if any of the following conditions are met: + /// - Time limit has been exceeded + /// - Gas limit has been reached (leaving room for at least one minimal transaction) + /// - DA size limit has been reached (leaving room for at least one minimal transaction) + pub fn should_break(&self, cumulative_gas_used: u64, cumulative_da_size_used: u64) -> bool { + // Check time limit + if self.start.elapsed() >= self.time_limit { + tracing::trace!( + target: "payload_builder", + elapsed = ?self.start.elapsed(), + time_limit = ?self.time_limit, + "time limit reached" + ); + return true; + } + + // Check gas limit - stop if remaining gas can't fit even the smallest transaction + if cumulative_gas_used > self.gas_limit.saturating_sub(MIN_TRANSACTION_GAS) { + tracing::trace!( + target: "payload_builder", + cumulative_gas_used, + gas_limit = self.gas_limit, + "gas limit reached" + ); + return true; + } + + // Check DA size limit if configured + if let Some(max_size) = self.max_da_block_size + && cumulative_da_size_used > max_size.saturating_sub(MIN_TRANSACTION_DATA_SIZE) + { + tracing::trace!( + target: "payload_builder", + cumulative_da_size_used, + max_da_block_size = max_size, + "DA size limit reached" + ); + return true; + } + + false + } + + /// Returns the elapsed time since building started. + pub fn elapsed(&self) -> Duration { + self.start.elapsed() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_default() { + let config = MorphBuilderConfig::default(); + assert_eq!(config.gas_limit, None); + assert_eq!(config.time_limit, Duration::from_secs(1)); + assert_eq!(config.max_da_block_size, None); + } + + #[test] + fn test_config_builder_pattern() { + let config = MorphBuilderConfig::default() + .with_gas_limit(20_000_000) + .with_time_limit(Duration::from_millis(500)) + .with_max_da_block_size(128 * 1024); + + assert_eq!(config.gas_limit, Some(20_000_000)); + assert_eq!(config.time_limit, Duration::from_millis(500)); + assert_eq!(config.max_da_block_size, Some(128 * 1024)); + } + + #[test] + fn test_breaker_should_break_on_time_limit() { + let breaker = PayloadBuildingBreaker::new( + Duration::from_millis(100), + 30_000_000, + Some(128 * 1024), + ); + + // Should not break immediately + assert!(!breaker.should_break(0, 0)); + + // Wait for time limit + std::thread::sleep(Duration::from_millis(150)); + + // Should break now + assert!(breaker.should_break(0, 0)); + } + + #[test] + fn test_breaker_should_break_on_gas_limit() { + // Set gas_limit = 2 * MIN_TRANSACTION_GAS = 42000 + // Threshold = 42000 - 21000 = 21000 + // should_break returns true when cumulative_gas_used > threshold + let gas_limit = 2 * MIN_TRANSACTION_GAS; + let breaker = PayloadBuildingBreaker::new(Duration::from_secs(10), gas_limit, None); + + // At threshold (21000), should NOT break (21000 > 21000 is false) + assert!(!breaker.should_break(MIN_TRANSACTION_GAS, 0)); + + // Just over threshold, should break (21001 > 21000 is true) + assert!(breaker.should_break(MIN_TRANSACTION_GAS + 1, 0)); + } + + #[test] + fn test_breaker_should_break_on_da_limit() { + // Set max_da = 2 * MIN_TRANSACTION_DATA_SIZE = 230 + // Threshold = 230 - 115 = 115 + let max_da_size = 2 * MIN_TRANSACTION_DATA_SIZE; + let breaker = + PayloadBuildingBreaker::new(Duration::from_secs(10), 30_000_000, Some(max_da_size)); + + // At threshold (115), should NOT break (115 > 115 is false) + assert!(!breaker.should_break(0, MIN_TRANSACTION_DATA_SIZE)); + + // Just over threshold, should break (116 > 115 is true) + assert!(breaker.should_break(0, MIN_TRANSACTION_DATA_SIZE + 1)); + } + + #[test] + fn test_breaker_no_da_limit() { + let breaker = PayloadBuildingBreaker::new(Duration::from_secs(10), 30_000_000, None); + + // Should not break even with huge DA size when no limit is set + assert!(!breaker.should_break(0, u64::MAX)); + } + + #[test] + fn test_config_breaker_uses_block_gas_limit_when_not_configured() { + let config = MorphBuilderConfig::default(); + // When gas_limit is None, breaker uses block_gas_limit + let block_gas_limit = 2 * MIN_TRANSACTION_GAS; + + let breaker = config.breaker(block_gas_limit); + + // Threshold = 42000 - 21000 = 21000 + // At threshold, should NOT break + assert!(!breaker.should_break(MIN_TRANSACTION_GAS, 0)); + // Just over, should break + assert!(breaker.should_break(MIN_TRANSACTION_GAS + 1, 0)); + } + + #[test] + fn test_config_breaker_uses_configured_gas_limit() { + // Configure a gas limit lower than block gas limit + let configured_limit = 2 * MIN_TRANSACTION_GAS; // 42000 + let config = MorphBuilderConfig::default().with_gas_limit(configured_limit); + let block_gas_limit = 30_000_000; // Much higher + + let breaker = config.breaker(block_gas_limit); + + // Should use configured_limit, not block_gas_limit + // Threshold = 42000 - 21000 = 21000 + assert!(!breaker.should_break(MIN_TRANSACTION_GAS, 0)); + assert!(breaker.should_break(MIN_TRANSACTION_GAS + 1, 0)); + + // Verify it's not using block_gas_limit + // If using block_gas_limit, threshold would be ~29,979,000 + // and 21001 would NOT trigger the breaker + // Since it does trigger, we know it's using configured_limit + } +} diff --git a/crates/payload/builder/src/error.rs b/crates/payload/builder/src/error.rs index 95c571a..2516b4b 100644 --- a/crates/payload/builder/src/error.rs +++ b/crates/payload/builder/src/error.rs @@ -1,6 +1,7 @@ //! Morph payload builder error types. use alloy_primitives::B256; +use reth_evm::execute::ProviderError; /// Errors that can occur during Morph payload building. #[derive(Debug, thiserror::Error)] @@ -47,4 +48,8 @@ pub enum MorphPayloadBuilderError { /// Actual hash. actual: B256, }, + + /// Database error when reading contract storage. + #[error("database error: {0}")] + Database(#[from] ProviderError), } diff --git a/crates/payload/builder/src/lib.rs b/crates/payload/builder/src/lib.rs index c8548a3..1ce5228 100644 --- a/crates/payload/builder/src/lib.rs +++ b/crates/payload/builder/src/lib.rs @@ -25,7 +25,9 @@ #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] mod builder; +mod config; mod error; pub use builder::{MorphPayloadBuilder, MorphPayloadTransactions}; +pub use config::{MorphBuilderConfig, PayloadBuildingBreaker}; pub use error::MorphPayloadBuilderError; diff --git a/crates/payload/types/src/attributes.rs b/crates/payload/types/src/attributes.rs index 42b8ec7..e8dbd5b 100644 --- a/crates/payload/types/src/attributes.rs +++ b/crates/payload/types/src/attributes.rs @@ -1,6 +1,7 @@ //! Morph payload attributes types. -use alloy_eips::{eip2718::Decodable2718, eip4895::Withdrawals}; +use alloy_eips::eip4895::{Withdrawal, Withdrawals}; +use alloy_eips::eip2718::Decodable2718; use alloy_primitives::{Address, B256, Bytes}; use alloy_rpc_types_engine::{PayloadAttributes, PayloadId}; use morph_primitives::MorphTxEnvelope; @@ -29,6 +30,20 @@ pub struct MorphPayloadAttributes { pub transactions: Option>, } +impl reth_payload_primitives::PayloadAttributes for MorphPayloadAttributes { + fn timestamp(&self) -> u64 { + self.inner.timestamp + } + + fn withdrawals(&self) -> Option<&Vec> { + self.inner.withdrawals.as_ref() + } + + fn parent_beacon_block_root(&self) -> Option { + self.inner.parent_beacon_block_root + } +} + /// Internal payload builder attributes. /// /// This is the internal representation used by the payload builder, diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index 3ca41e0..9cfe59c 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -1,4 +1,5 @@ -use alloy_consensus::{Signed, TransactionEnvelope, TxEip1559, TxEip2930, TxEip7702, TxLegacy}; +use alloy_consensus::{Sealed, Signed, TransactionEnvelope, TxEip1559, TxEip2930, TxEip7702, TxLegacy}; +use alloy_eips::eip2718::Encodable2718; use alloy_primitives::{B256, Bytes}; use alloy_rlp::BytesMut; @@ -25,7 +26,7 @@ pub enum MorphTxEnvelope { /// Layer1 Message Transaction #[envelope(ty = 0x7e)] - L1Msg(Signed), + L1Msg(Sealed), /// Alt Fee Transaction #[envelope(ty = 0x7f)] @@ -66,7 +67,7 @@ impl MorphTxEnvelope { | Self::Eip1559(_) | Self::Eip7702(_) | Self::AltFee(_) => None, - Self::L1Msg(tx) => Some(tx.tx().queue_index), + Self::L1Msg(tx) => Some(tx.queue_index), } } @@ -126,7 +127,7 @@ impl alloy_consensus::transaction::TxHashRef for MorphTxEnvelope { Self::Eip2930(tx) => tx.hash(), Self::Eip1559(tx) => tx.hash(), Self::Eip7702(tx) => tx.hash(), - Self::L1Msg(tx) => tx.hash(), + Self::L1Msg(tx) => tx.hash_ref(), Self::AltFee(tx) => tx.hash(), } } @@ -147,7 +148,8 @@ impl alloy_consensus::transaction::SignerRecoverable for MorphTxEnvelope { Self::Eip7702(tx) => { alloy_consensus::transaction::SignerRecoverable::recover_signer(tx) } - Self::L1Msg(tx) => alloy_consensus::transaction::SignerRecoverable::recover_signer(tx), + // L1 messages have no signature - the sender is stored in the transaction itself + Self::L1Msg(tx) => Ok(tx.sender), Self::AltFee(tx) => alloy_consensus::transaction::SignerRecoverable::recover_signer(tx), } } @@ -168,9 +170,8 @@ impl alloy_consensus::transaction::SignerRecoverable for MorphTxEnvelope { Self::Eip7702(tx) => { alloy_consensus::transaction::SignerRecoverable::recover_signer_unchecked(tx) } - Self::L1Msg(tx) => { - alloy_consensus::transaction::SignerRecoverable::recover_signer_unchecked(tx) - } + // L1 messages have no signature - the sender is stored in the transaction itself + Self::L1Msg(tx) => Ok(tx.sender), Self::AltFee(tx) => { alloy_consensus::transaction::SignerRecoverable::recover_signer_unchecked(tx) } @@ -236,7 +237,8 @@ mod codec { } MorphTxType::L1Msg => { let (tx, buf) = TxL1Msg::from_compact(buf, buf.len()); - let tx = Signed::new_unhashed(tx, signature); + // L1 messages have no signature - use Sealed instead of Signed + let tx = Sealed::new(tx); (Self::L1Msg(tx), buf) } MorphTxType::AltFee => { @@ -255,12 +257,19 @@ mod codec { Self::Eip2930(tx) => tx.tx().to_compact(buf), Self::Eip1559(tx) => tx.tx().to_compact(buf), Self::Eip7702(tx) => tx.tx().to_compact(buf), - Self::L1Msg(tx) => tx.tx().to_compact(buf), + Self::L1Msg(tx) => tx.to_compact(buf), Self::AltFee(tx) => tx.tx().to_compact(buf), }; } } + /// Dummy signature for L1 messages (which have no signature). + const L1_MSG_SIGNATURE: Signature = Signature::new( + alloy_primitives::U256::ZERO, + alloy_primitives::U256::ZERO, + false, + ); + impl Envelope for MorphTxEnvelope { fn signature(&self) -> &Signature { match self { @@ -268,7 +277,7 @@ mod codec { Self::Eip2930(tx) => tx.signature(), Self::Eip1559(tx) => tx.signature(), Self::Eip7702(tx) => tx.signature(), - Self::L1Msg(tx) => tx.signature(), + Self::L1Msg(_) => &L1_MSG_SIGNATURE, Self::AltFee(tx) => tx.signature(), } } diff --git a/crates/primitives/src/transaction/l1_transaction.rs b/crates/primitives/src/transaction/l1_transaction.rs index d4e0c4d..dee23d7 100644 --- a/crates/primitives/src/transaction/l1_transaction.rs +++ b/crates/primitives/src/transaction/l1_transaction.rs @@ -9,7 +9,7 @@ use alloy_consensus::{ SignableTransaction, Transaction, transaction::{RlpEcdsaDecodableTx, RlpEcdsaEncodableTx}, }; -use alloy_eips::{Typed2718, eip2718::Encodable2718}; +use alloy_eips::{Typed2718, eip2718::{Decodable2718, Eip2718Error, Eip2718Result, Encodable2718}}; use alloy_primitives::{Address, B256, Bytes, ChainId, Signature, TxKind, U256, keccak256}; use alloy_rlp::{BufMut, Decodable, Encodable, Header}; use core::mem; @@ -22,6 +22,11 @@ pub const L1_TX_TYPE_ID: u8 = 0x7E; /// This transaction type represents L1 message transactions that are processed on L2, /// typically including deposit transactions or L1-originated messages. /// +/// The signature of the L1 message is already verified on the L1 and as such doesn't contain +/// a signature field. Gas for the transaction execution is already paid for on the L1. +/// +/// Note: Contract creation is NOT allowed via L1 message transactions. +/// /// Reference: #[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -31,37 +36,23 @@ pub struct TxL1Msg { /// The queue index of the message in the L1 contract queue. pub queue_index: u64, - /// The 32-byte hash of the transaction. - pub tx_hash: B256, - - /// The 160-bit address of the message call's sender. - pub from: Address, + /// The gas limit for the transaction. Gas is paid for when message is sent from the L1. + #[cfg_attr(feature = "serde", serde(with = "alloy_serde::quantity", rename = "gas"))] + pub gas_limit: u64, - /// A scalar value equal to the number of transactions sent by the sender. - #[cfg_attr(feature = "serde", serde(with = "alloy_serde::quantity"))] - pub nonce: u64, - - /// A scalar value equal to the maximum amount of gas that should be used - /// in executing this transaction. This is paid up-front, before any - /// computation is done and may not be increased later. - #[cfg_attr(feature = "serde", serde(with = "alloy_serde::quantity"))] - pub gas_limit: u128, - - /// The 160-bit address of the message call's recipient or, for a contract - /// creation transaction, empty. - pub to: TxKind, + /// The destination address for the transaction. + /// Contract creation is NOT allowed via L1 message transactions. + pub to: Address, /// A scalar value equal to the number of Wei to be transferred to the - /// message call's recipient or, in the case of contract creation, as an - /// endowment to the newly created account. + /// message call's recipient. pub value: U256, - /// Input has two uses depending if transaction is Create or Call (if `to` - /// field is None or Some). - /// - init: An unlimited size byte array specifying the EVM-code for the - /// account initialisation procedure CREATE. - /// - data: An unlimited size byte array specifying the input data of the - /// message call. + /// The L1 sender of the transaction. + pub sender: Address, + + /// The input data of the message call. + /// Note: This field must be last for reth-codec Compact derive. #[cfg_attr(feature = "serde", serde(default, alias = "data"))] pub input: Bytes, } @@ -73,14 +64,9 @@ impl TxL1Msg { L1_TX_TYPE_ID } - /// Returns the sender address. - pub const fn sender(&self) -> Address { - self.from - } - - /// Returns the transaction hash. - pub const fn hash(&self) -> B256 { - self.tx_hash + /// Returns an empty signature for the [`TxL1Msg`], which don't include a signature. + pub const fn signature() -> Signature { + Signature::new(U256::ZERO, U256::ZERO, false) } /// Validates the transaction according to the spec rules. @@ -92,61 +78,55 @@ impl TxL1Msg { } /// Calculate the in-memory size of this transaction. - /// - /// This accounts for all fields in the struct. pub fn size(&self) -> usize { mem::size_of::() + // queue_index - mem::size_of::() + // tx_hash - mem::size_of::
() + // from - mem::size_of::() + // nonce - mem::size_of::() + // gas_limit - mem::size_of::() + // to + mem::size_of::() + // gas_limit + mem::size_of::
() + // to mem::size_of::() + // value - self.input.len() // input (dynamic size) + self.input.len() + // input (dynamic size) + mem::size_of::
() // sender } /// Outputs the length of the transaction's fields. + /// Field order matches go-ethereum: queue_index, gas_limit, to, value, input, sender #[doc(hidden)] pub fn fields_len(&self) -> usize { - let mut len = 0; - len += self.queue_index.length(); - len += self.nonce.length(); - len += self.gas_limit.length(); - len += self.to.length(); - len += self.value.length(); - len += self.input.0.length(); - len += self.from.length(); - len + self.queue_index.length() + + self.gas_limit.length() + + self.to.length() + + self.value.length() + + self.input.0.length() + + self.sender.length() } /// Encode the transaction fields (without the RLP header). + /// Field order matches go-ethereum: queue_index, gas_limit, to, value, input, sender pub fn encode_fields(&self, out: &mut dyn BufMut) { self.queue_index.encode(out); - self.nonce.encode(out); self.gas_limit.encode(out); self.to.encode(out); self.value.encode(out); self.input.0.encode(out); - self.from.encode(out); + self.sender.encode(out); } + /// Decode the transaction fields from RLP bytes. + /// Field order matches go-ethereum: queue_index, gas_limit, to, value, input, sender pub fn decode_fields(buf: &mut &[u8]) -> alloy_rlp::Result { Ok(Self { queue_index: Decodable::decode(buf)?, - tx_hash: Decodable::decode(buf)?, - nonce: Decodable::decode(buf)?, gas_limit: Decodable::decode(buf)?, to: Decodable::decode(buf)?, value: Decodable::decode(buf)?, input: Decodable::decode(buf)?, - from: Decodable::decode(buf)?, + sender: Decodable::decode(buf)?, }) } - /// Computes the hash used for the transaction. + /// Computes the transaction hash. /// - /// For L1 messages, this computes the keccak256 hash of the RLP encoding. - pub fn signature_hash(&self) -> B256 { + /// For L1 messages, this computes the keccak256 hash of the EIP-2718 encoding. + pub fn tx_hash(&self) -> B256 { let mut buf = Vec::with_capacity(self.encode_2718_len()); self.encode_2718(&mut buf); keccak256(&buf) @@ -165,15 +145,17 @@ impl Transaction for TxL1Msg { } fn nonce(&self) -> u64 { + // L1 messages always have nonce 0 0 } fn gas_limit(&self) -> u64 { - self.gas_limit as u64 + self.gas_limit } fn gas_price(&self) -> Option { - Some(0) + // L1 messages have no gas price - gas is paid on L1 + None } fn max_fee_per_gas(&self) -> u128 { @@ -201,11 +183,13 @@ impl Transaction for TxL1Msg { } fn kind(&self) -> TxKind { - self.to + // L1 messages are always calls, never contract creations + TxKind::Call(self.to) } fn is_create(&self) -> bool { - self.to.is_create() + // Contract creation is NOT allowed via L1 message transactions + false } fn value(&self) -> U256 { @@ -283,31 +267,13 @@ impl Decodable for TxL1Msg { return Err(alloy_rlp::Error::InputTooShort); } - let queue_index = Decodable::decode(buf)?; - let nonce = Decodable::decode(buf)?; - let gas_limit = Decodable::decode(buf)?; - let to = Decodable::decode(buf)?; - let value = Decodable::decode(buf)?; - let input = Decodable::decode(buf)?; - let from = Decodable::decode(buf)?; + let this = Self::decode_fields(buf)?; if buf.len() + header.payload_length != remaining { return Err(alloy_rlp::Error::UnexpectedLength); } - // For L1 messages, the hash is typically computed externally - let tx_hash = B256::ZERO; - - Ok(Self { - queue_index, - tx_hash, - from, - nonce, - gas_limit, - to, - value, - input, - }) + Ok(this) } } @@ -337,12 +303,31 @@ impl Encodable2718 for TxL1Msg { } } +impl Decodable2718 for TxL1Msg { + fn typed_decode(ty: u8, buf: &mut &[u8]) -> Eip2718Result { + if ty != L1_TX_TYPE_ID { + return Err(Eip2718Error::UnexpectedType(ty)); + } + Self::decode(buf).map_err(Into::into) + } + + fn fallback_decode(buf: &mut &[u8]) -> Eip2718Result { + Self::decode(buf).map_err(Into::into) + } +} + impl reth_primitives_traits::InMemorySize for TxL1Msg { fn size(&self) -> usize { Self::size(self) } } +impl alloy_consensus::Sealable for TxL1Msg { + fn hash_slow(&self) -> B256 { + self.tx_hash() + } +} + #[cfg(test)] mod tests { use super::*; @@ -351,10 +336,11 @@ mod tests { #[test] fn test_l1_transaction_default() { let tx = TxL1Msg::default(); - assert_eq!(tx.nonce, 0); + assert_eq!(tx.queue_index, 0); assert_eq!(tx.gas_limit, 0); assert_eq!(tx.value, U256::ZERO); - assert_eq!(tx.from, Address::ZERO); + assert_eq!(tx.sender, Address::ZERO); + assert_eq!(tx.to, Address::ZERO); } #[test] @@ -373,27 +359,25 @@ mod tests { fn test_l1_transaction_trait_methods() { let tx = TxL1Msg { queue_index: 0, - tx_hash: B256::ZERO, - from: address!("0000000000000000000000000000000000000001"), - nonce: 0, gas_limit: 21_000, - to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + to: address!("0000000000000000000000000000000000000002"), value: U256::from(100u64), input: Bytes::from(vec![1, 2, 3, 4]), + sender: address!("0000000000000000000000000000000000000001"), }; // Test Transaction trait methods assert_eq!(tx.chain_id(), None); - assert_eq!(Transaction::nonce(&tx), 0); // nonce is set to 0 in this test case + assert_eq!(Transaction::nonce(&tx), 0); // L1 messages always have nonce 0 assert_eq!(Transaction::gas_limit(&tx), 21_000); - assert_eq!(tx.gas_price(), Some(0)); + assert_eq!(tx.gas_price(), None); // L1 messages have no gas price assert_eq!(tx.max_fee_per_gas(), 0); assert_eq!(tx.max_priority_fee_per_gas(), None); assert_eq!(tx.max_fee_per_blob_gas(), None); assert_eq!(tx.priority_fee_or_price(), 0); assert_eq!(tx.effective_gas_price(Some(100)), 0); assert!(!tx.is_dynamic_fee()); - assert!(!tx.is_create()); + assert!(!tx.is_create()); // L1 messages can never create contracts assert_eq!( tx.kind(), TxKind::Call(address!("0000000000000000000000000000000000000002")) @@ -407,46 +391,42 @@ mod tests { } #[test] - fn test_l1_transaction_is_create() { - let create_tx = TxL1Msg { - to: TxKind::Create, - ..Default::default() - }; - assert!(create_tx.is_create()); + fn test_l1_transaction_is_never_create() { + // L1 messages should never be contract creation + let tx = TxL1Msg::default(); + assert!(!tx.is_create()); - let call_tx = TxL1Msg { - to: TxKind::Call(address!("0000000000000000000000000000000000000001")), + let tx_with_address = TxL1Msg { + to: address!("0000000000000000000000000000000000000001"), ..Default::default() }; - assert!(!call_tx.is_create()); + assert!(!tx_with_address.is_create()); } #[test] fn test_l1_transaction_sender() { let tx = TxL1Msg { - from: address!("0000000000000000000000000000000000000001"), + sender: address!("0000000000000000000000000000000000000001"), ..Default::default() }; assert_eq!( - tx.sender(), + tx.sender, address!("0000000000000000000000000000000000000001") ); } #[test] - fn test_l1_transaction_signature_hash() { + fn test_l1_transaction_tx_hash() { let tx = TxL1Msg { queue_index: 0, - tx_hash: B256::ZERO, - from: address!("0000000000000000000000000000000000000001"), - nonce: 1, gas_limit: 21_000, - to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + to: address!("0000000000000000000000000000000000000002"), value: U256::from(100u64), input: Bytes::new(), + sender: address!("0000000000000000000000000000000000000001"), }; - let hash = tx.signature_hash(); + let hash = tx.tx_hash(); assert_ne!(hash, B256::ZERO); } @@ -454,13 +434,11 @@ mod tests { fn test_l1_transaction_rlp_roundtrip() { let tx = TxL1Msg { queue_index: 5, - tx_hash: B256::ZERO, - from: address!("0000000000000000000000000000000000000001"), - nonce: 42, gas_limit: 21_000, - to: TxKind::Call(address!("0000000000000000000000000000000000000002")), - value: U256::from(1_000_000_000_000_000_000u128), + to: address!("0000000000000000000000000000000000000002"), + value: U256::from(1_000_000_000_000_000_000u64), input: Bytes::from(vec![0x12, 0x34]), + sender: address!("0000000000000000000000000000000000000001"), }; // Encode @@ -471,48 +449,22 @@ mod tests { let decoded = TxL1Msg::decode(&mut buf.as_slice()).expect("Should decode"); assert_eq!(tx.queue_index, decoded.queue_index); - assert_eq!(tx.from, decoded.from); - assert_eq!(tx.nonce, decoded.nonce); assert_eq!(tx.gas_limit, decoded.gas_limit); assert_eq!(tx.to, decoded.to); assert_eq!(tx.value, decoded.value); assert_eq!(tx.input, decoded.input); - } - - #[test] - fn test_l1_transaction_create() { - let tx = TxL1Msg { - queue_index: 0, - tx_hash: B256::ZERO, - from: address!("0000000000000000000000000000000000000001"), - nonce: 0, - gas_limit: 100_000, - to: TxKind::Create, - value: U256::ZERO, - input: Bytes::from(vec![0x60, 0x80, 0x60, 0x40]), - }; - - // Encode - let mut buf = Vec::new(); - tx.encode(&mut buf); - - // Decode - let decoded = TxL1Msg::decode(&mut buf.as_slice()).expect("Should decode"); - - assert_eq!(decoded.to, TxKind::Create); + assert_eq!(tx.sender, decoded.sender); } #[test] fn test_l1_transaction_encode_2718() { let tx = TxL1Msg { queue_index: 0, - tx_hash: B256::ZERO, - from: address!("0000000000000000000000000000000000000001"), - nonce: 1, gas_limit: 21_000, - to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + to: address!("0000000000000000000000000000000000000002"), value: U256::from(100u64), input: Bytes::new(), + sender: address!("0000000000000000000000000000000000000001"), }; let mut buf = Vec::new(); @@ -532,13 +484,11 @@ mod tests { fn test_l1_transaction_decode_rejects_malformed_rlp() { let tx = TxL1Msg { queue_index: 0, - tx_hash: B256::ZERO, - from: address!("0000000000000000000000000000000000000001"), - nonce: 42, gas_limit: 21_000, - to: TxKind::Call(address!("0000000000000000000000000000000000000002")), - value: U256::from(1_000_000_000_000_000_000u128), + to: address!("0000000000000000000000000000000000000002"), + value: U256::from(1_000_000_000_000_000_000u64), input: Bytes::from(vec![0x12, 0x34]), + sender: address!("0000000000000000000000000000000000000001"), }; // Encode the transaction @@ -564,23 +514,20 @@ mod tests { fn test_l1_transaction_size() { let tx = TxL1Msg { queue_index: 0, - tx_hash: B256::ZERO, - from: Address::ZERO, - nonce: 0, gas_limit: 0, - to: TxKind::Create, + to: Address::ZERO, value: U256::ZERO, input: Bytes::new(), + sender: Address::ZERO, }; // Calculate expected size manually let expected_size = mem::size_of::() + // queue_index - mem::size_of::() + // tx_hash - mem::size_of::
() + // from - mem::size_of::() + // nonce - mem::size_of::() + // gas_limit - mem::size_of::() + // to - mem::size_of::(); // value (empty input) + mem::size_of::() + // gas_limit + mem::size_of::
() + // to + mem::size_of::() + // value + 0 + // input (empty) + mem::size_of::
(); // sender assert_eq!(tx.size(), expected_size); } @@ -589,13 +536,11 @@ mod tests { fn test_l1_transaction_fields_len() { let tx = TxL1Msg { queue_index: 0, - tx_hash: B256::ZERO, - from: address!("0000000000000000000000000000000000000001"), - nonce: 1, gas_limit: 21_000, - to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + to: address!("0000000000000000000000000000000000000002"), value: U256::from(100u64), input: Bytes::from(vec![1, 2, 3, 4]), + sender: address!("0000000000000000000000000000000000000001"), }; let fields_len = tx.fields_len(); @@ -610,13 +555,11 @@ mod tests { fn test_l1_transaction_encode_fields() { let tx = TxL1Msg { queue_index: 0, - tx_hash: B256::ZERO, - from: address!("0000000000000000000000000000000000000001"), - nonce: 1, gas_limit: 21_000, - to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + to: address!("0000000000000000000000000000000000000002"), value: U256::from(100u64), input: Bytes::new(), + sender: address!("0000000000000000000000000000000000000001"), }; let mut buf = Vec::new(); @@ -626,4 +569,13 @@ mod tests { assert!(!buf.is_empty()); assert_eq!(buf.len(), tx.fields_len()); } + + #[test] + fn test_l1_transaction_signature() { + // L1 messages should return an empty signature + let sig = TxL1Msg::signature(); + assert_eq!(sig.r(), U256::ZERO); + assert_eq!(sig.s(), U256::ZERO); + assert!(!sig.v()); + } } diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 17b9665..d6d2dc1 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -148,11 +148,8 @@ where let basefee = evm.ctx_ref().block().basefee() as u128; let effective_gas_price = evm.ctx_ref().tx().effective_gas_price(basefee); - // Get the current hardfork for L1 fee calculation - let hardfork = evm.ctx_ref().cfg().spec(); - // Fetch L1 block info from the L1 Gas Price Oracle contract - let l1_block_info = L1BlockInfo::try_fetch(evm.ctx_mut().db_mut(), hardfork)?; + let l1_block_info = L1BlockInfo::try_fetch(evm.ctx_mut().db_mut())?; // Get RLP-encoded transaction bytes for L1 fee calculation // This represents the full transaction data posted to L1 for data availability @@ -165,7 +162,7 @@ where .unwrap_or_default(); // Calculate L1 data fee based on full RLP-encoded transaction - let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes, hardfork); + let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes); // Get mutable access to context components let journal = evm.ctx().journal_mut(); @@ -246,11 +243,8 @@ where &self, evm: &mut MorphEvm, ) -> Result<(), EVMError> { - // Get the current hardfork for L1 fee calculation - let hardfork = evm.ctx_ref().cfg().spec(); - // Fetch L1 block info from the L1 Gas Price Oracle contract - let l1_block_info = L1BlockInfo::try_fetch(evm.ctx_mut().db_mut(), hardfork)?; + let l1_block_info = L1BlockInfo::try_fetch(evm.ctx_mut().db_mut())?; // Get RLP-encoded transaction bytes for L1 fee calculation // This represents the full transaction data posted to L1 for data availability @@ -263,7 +257,7 @@ where .unwrap_or_default(); // Calculate L1 data fee based on full RLP-encoded transaction - let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes, hardfork); + let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes); // Get mutable access to context components let (block, tx, cfg, journal, _, _) = evm.ctx().all_mut(); @@ -318,11 +312,8 @@ where return Err(MorphInvalidTransaction::TokenNotActive(token_id).into()); } - // Get the current hardfork for L1 fee calculation - let hardfork = evm.ctx_ref().cfg().spec(); - // Fetch L1 block info from the L1 Gas Price Oracle contract - let l1_block_info = L1BlockInfo::try_fetch(evm.ctx_mut().db_mut(), hardfork)?; + let l1_block_info = L1BlockInfo::try_fetch(evm.ctx_mut().db_mut())?; // Get RLP-encoded transaction bytes for L1 fee calculation // This represents the full transaction data posted to L1 for data availability @@ -335,7 +326,7 @@ where .unwrap_or_default(); // Calculate L1 data fee (in ETH) based on full RLP-encoded transaction - let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes, hardfork); + let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes); // Calculate L2 gas fee (in ETH) let gas_limit = evm.ctx_ref().tx().gas_limit(); diff --git a/crates/revm/src/l1block.rs b/crates/revm/src/l1block.rs index 64f7244..852a84e 100644 --- a/crates/revm/src/l1block.rs +++ b/crates/revm/src/l1block.rs @@ -2,18 +2,12 @@ //! //! This module provides the infrastructure for calculating L1 data fees on Morph L2. //! The fee parameters are read from the L1 Gas Price Oracle contract deployed on L2. +//! +//! L1 fees are calculated using the blob-based model with commit scalar and blob scalar. use alloy_primitives::{Address, U256, address}; -use morph_chainspec::hardfork::MorphHardfork; use revm::Database; -/// Gas cost for zero bytes in calldata. -const ZERO_BYTE_COST: u64 = 4; -/// Gas cost for non-zero bytes in calldata. -const NON_ZERO_BYTE_COST: u64 = 16; - -/// Extra cost added to L1 commit gas calculation. -const TX_L1_COMMIT_EXTRA_COST: U256 = U256::from_limbs([64u64, 0, 0, 0]); /// Precision factor for L1 fee calculation (1e9). const TX_L1_FEE_PRECISION: U256 = U256::from_limbs([1_000_000_000u64, 0, 0, 0]); @@ -23,137 +17,76 @@ pub const L1_GAS_PRICE_ORACLE_ADDRESS: Address = /// Storage slot for L1 base fee. const L1_BASE_FEE_SLOT: U256 = U256::from_limbs([1u64, 0, 0, 0]); -/// Storage slot for L1 overhead. -const L1_OVERHEAD_SLOT: U256 = U256::from_limbs([2u64, 0, 0, 0]); -/// Storage slot for L1 scalar. -const L1_SCALAR_SLOT: U256 = U256::from_limbs([3u64, 0, 0, 0]); -/// Storage slot for L1 blob base fee (Curie+). +/// Storage slot for L1 blob base fee. const L1_BLOB_BASE_FEE_SLOT: U256 = U256::from_limbs([6u64, 0, 0, 0]); -/// Storage slot for L1 commit scalar (Curie+). +/// Storage slot for L1 commit scalar. const L1_COMMIT_SCALAR_SLOT: U256 = U256::from_limbs([7u64, 0, 0, 0]); -/// Storage slot for L1 blob scalar (Curie+). +/// Storage slot for L1 blob scalar. const L1_BLOB_SCALAR_SLOT: U256 = U256::from_limbs([8u64, 0, 0, 0]); /// L1 block info for fee calculation. /// /// Contains the fee parameters fetched from the L1 Gas Price Oracle contract. -/// These parameters are used to calculate the L1 data fee for transactions. +/// These parameters are used to calculate the L1 data fee for transactions +/// using the blob-based model. #[derive(Clone, Debug, Default)] pub struct L1BlockInfo { /// The base fee of the L1 origin block. pub l1_base_fee: U256, - /// The current L1 fee overhead. - pub l1_fee_overhead: U256, - /// The current L1 fee scalar. - pub l1_base_fee_scalar: U256, - /// The current L1 blob base fee, None if before Curie. - pub l1_blob_base_fee: Option, - /// The current L1 commit scalar, None if before Curie. - pub l1_commit_scalar: Option, - /// The current L1 blob scalar, None if before Curie. - pub l1_blob_scalar: Option, - /// The current call data gas (l1_commit_scalar * l1_base_fee), None if before Curie. - pub calldata_gas: Option, + /// The current L1 blob base fee. + pub l1_blob_base_fee: U256, + /// The current L1 commit scalar. + pub l1_commit_scalar: U256, + /// The current L1 blob scalar. + pub l1_blob_scalar: U256, + /// Pre-computed calldata gas component (l1_commit_scalar * l1_base_fee). + pub calldata_gas: U256, } impl L1BlockInfo { /// Try to fetch the L1 block info from the database. /// - /// This reads the fee parameters from the L1 Gas Price Oracle contract storage. - /// Different parameters are fetched depending on whether the Curie hardfork is active. - pub fn try_fetch( - db: &mut DB, - hardfork: MorphHardfork, - ) -> Result { + /// This reads the fee parameters from the L1 Gas Price Oracle contract storage + /// for blob-based L1 fee calculation. + pub fn try_fetch(db: &mut DB) -> Result { let l1_base_fee = db.storage(L1_GAS_PRICE_ORACLE_ADDRESS, L1_BASE_FEE_SLOT)?; - let l1_fee_overhead = db.storage(L1_GAS_PRICE_ORACLE_ADDRESS, L1_OVERHEAD_SLOT)?; - let l1_base_fee_scalar = db.storage(L1_GAS_PRICE_ORACLE_ADDRESS, L1_SCALAR_SLOT)?; - - if !hardfork.is_curie() { - Ok(Self { - l1_base_fee, - l1_fee_overhead, - l1_base_fee_scalar, - ..Default::default() - }) - } else { - let l1_blob_base_fee = - db.storage(L1_GAS_PRICE_ORACLE_ADDRESS, L1_BLOB_BASE_FEE_SLOT)?; - let l1_commit_scalar = - db.storage(L1_GAS_PRICE_ORACLE_ADDRESS, L1_COMMIT_SCALAR_SLOT)?; - let l1_blob_scalar = db.storage(L1_GAS_PRICE_ORACLE_ADDRESS, L1_BLOB_SCALAR_SLOT)?; - - // calldata component of commit fees (calldata gas + execution) - let calldata_gas = l1_commit_scalar.saturating_mul(l1_base_fee); - - Ok(Self { - l1_base_fee, - l1_fee_overhead, - l1_base_fee_scalar, - l1_blob_base_fee: Some(l1_blob_base_fee), - l1_commit_scalar: Some(l1_commit_scalar), - l1_blob_scalar: Some(l1_blob_scalar), - calldata_gas: Some(calldata_gas), - }) - } + let l1_blob_base_fee = db.storage(L1_GAS_PRICE_ORACLE_ADDRESS, L1_BLOB_BASE_FEE_SLOT)?; + let l1_commit_scalar = db.storage(L1_GAS_PRICE_ORACLE_ADDRESS, L1_COMMIT_SCALAR_SLOT)?; + let l1_blob_scalar = db.storage(L1_GAS_PRICE_ORACLE_ADDRESS, L1_BLOB_SCALAR_SLOT)?; + + // Pre-compute calldata gas component: commitScalar * l1BaseFee + let calldata_gas = l1_commit_scalar.saturating_mul(l1_base_fee); + + Ok(Self { + l1_base_fee, + l1_blob_base_fee, + l1_commit_scalar, + l1_blob_scalar, + calldata_gas, + }) } - /// Calculate the data gas for posting the transaction on L1. + /// Calculate the blob gas cost for transaction data. /// - /// Before Curie: Calldata costs 16 gas per non-zero byte and 4 gas per zero byte, - /// plus overhead and extra commit cost. - /// - /// After Curie: Uses blob-based calculation with blob base fee and blob scalar. - pub fn data_gas(&self, input: &[u8], hardfork: MorphHardfork) -> U256 { - if !hardfork.is_curie() { - U256::from(input.iter().fold(0, |acc, byte| { - acc + if *byte == 0x00 { - ZERO_BYTE_COST - } else { - NON_ZERO_BYTE_COST - } - })) - .saturating_add(self.l1_fee_overhead) - .saturating_add(TX_L1_COMMIT_EXTRA_COST) - } else { - U256::from(input.len()) - .saturating_mul(self.l1_blob_base_fee.unwrap_or_default()) - .saturating_mul(self.l1_blob_scalar.unwrap_or_default()) - } + /// Formula: `data.length * l1BlobBaseFee * blobScalar` + pub fn blob_gas(&self, input: &[u8]) -> U256 { + U256::from(input.len()) + .saturating_mul(self.l1_blob_base_fee) + .saturating_mul(self.l1_blob_scalar) } - /// Calculate L1 cost for a transaction before Curie hardfork. - fn calculate_tx_l1_cost_pre_curie(&self, input: &[u8], hardfork: MorphHardfork) -> U256 { - let tx_l1_gas = self.data_gas(input, hardfork); - tx_l1_gas - .saturating_mul(self.l1_base_fee) - .saturating_mul(self.l1_base_fee_scalar) - .wrapping_div(TX_L1_FEE_PRECISION) - } - - /// Calculate L1 cost for a transaction after Curie hardfork. + /// Calculate the L1 data fee for a transaction. /// - /// Formula: `commitScalar * l1BaseFee + blobScalar * _data.length * l1BlobBaseFee` - fn calculate_tx_l1_cost_curie(&self, input: &[u8], hardfork: MorphHardfork) -> U256 { - let blob_gas = self.data_gas(input, hardfork); + /// This is the cost of posting the transaction data to L1 for data availability. + /// + /// Formula: `(commitScalar * l1BaseFee + blobScalar * data.length * l1BlobBaseFee) / precision` + pub fn calculate_tx_l1_cost(&self, input: &[u8]) -> U256 { + let blob_gas = self.blob_gas(input); self.calldata_gas - .unwrap_or_default() .saturating_add(blob_gas) .wrapping_div(TX_L1_FEE_PRECISION) } - - /// Calculate the L1 data fee for a transaction. - /// - /// This is the cost of posting the transaction data to L1 for data availability. - /// The calculation method differs based on whether the Curie hardfork is active. - pub fn calculate_tx_l1_cost(&self, input: &[u8], hardfork: MorphHardfork) -> U256 { - if !hardfork.is_curie() { - self.calculate_tx_l1_cost_pre_curie(input, hardfork) - } else { - self.calculate_tx_l1_cost_curie(input, hardfork) - } - } } #[cfg(test)] @@ -164,56 +97,47 @@ mod tests { fn test_l1_block_info_default() { let info = L1BlockInfo::default(); assert_eq!(info.l1_base_fee, U256::ZERO); - assert_eq!(info.l1_fee_overhead, U256::ZERO); - assert_eq!(info.l1_base_fee_scalar, U256::ZERO); - assert!(info.l1_blob_base_fee.is_none()); - assert!(info.l1_commit_scalar.is_none()); - assert!(info.l1_blob_scalar.is_none()); - assert!(info.calldata_gas.is_none()); - } - - #[test] - fn test_data_gas_pre_curie() { - let info = L1BlockInfo { - l1_fee_overhead: U256::from(100), - ..Default::default() - }; - - // Test with mixed zero and non-zero bytes - let input = vec![0x00, 0x01, 0x00, 0xff]; - // 2 zero bytes * 4 + 2 non-zero bytes * 16 + 100 overhead + 64 extra = 200 - let gas = info.data_gas(&input, MorphHardfork::Bernoulli); - assert_eq!(gas, U256::from(2 * 4 + 2 * 16 + 100 + 64)); + assert_eq!(info.l1_blob_base_fee, U256::ZERO); + assert_eq!(info.l1_commit_scalar, U256::ZERO); + assert_eq!(info.l1_blob_scalar, U256::ZERO); + assert_eq!(info.calldata_gas, U256::ZERO); } #[test] - fn test_data_gas_curie() { + fn test_blob_gas() { let info = L1BlockInfo { - l1_blob_base_fee: Some(U256::from(10)), - l1_blob_scalar: Some(U256::from(2)), + l1_blob_base_fee: U256::from(10), + l1_blob_scalar: U256::from(2), ..Default::default() }; let input = vec![0x00, 0x01, 0x00, 0xff]; // length * blob_base_fee * blob_scalar = 4 * 10 * 2 = 80 - let gas = info.data_gas(&input, MorphHardfork::Curie); + let gas = info.blob_gas(&input); assert_eq!(gas, U256::from(80)); } #[test] - fn test_calculate_tx_l1_cost_pre_curie() { + fn test_calculate_tx_l1_cost() { + let l1_base_fee = U256::from(1_000_000_000); // 1 gwei + let l1_commit_scalar = U256::from(1_000_000_000); // 1.0 scaled + let l1_blob_base_fee = U256::from(10); + let l1_blob_scalar = U256::from(2); + let info = L1BlockInfo { - l1_base_fee: U256::from(1_000_000_000), // 1 gwei - l1_fee_overhead: U256::from(0), - l1_base_fee_scalar: U256::from(1_000_000_000), // 1.0 scaled - ..Default::default() + l1_base_fee, + l1_blob_base_fee, + l1_commit_scalar, + l1_blob_scalar, + calldata_gas: l1_commit_scalar.saturating_mul(l1_base_fee), }; - // 1 non-zero byte = 16 gas - // + 64 extra cost = 80 gas total - // cost = 80 * 1_000_000_000 * 1_000_000_000 / 1_000_000_000 = 80_000_000_000 - let input = vec![0xff]; - let cost = info.calculate_tx_l1_cost(&input, MorphHardfork::Bernoulli); - assert_eq!(cost, U256::from(80_000_000_000u64)); + // Formula: (commitScalar * l1BaseFee + blobScalar * data.length * l1BlobBaseFee) / precision + // = (1e9 * 1e9 + 2 * 4 * 10) / 1e9 + // = (1e18 + 80) / 1e9 + // = 1_000_000_000 (the +80 is negligible) + let input = vec![0x00, 0x01, 0x00, 0xff]; + let cost = info.calculate_tx_l1_cost(&input); + assert_eq!(cost, U256::from(1_000_000_000u64)); } } From aad4df8475b154090cc815b9676fde6909df0cf0 Mon Sep 17 00:00:00 2001 From: panos Date: Fri, 16 Jan 2026 21:38:31 +0800 Subject: [PATCH 06/13] style: fix clippy error --- crates/primitives/src/transaction/l1_transaction.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/primitives/src/transaction/l1_transaction.rs b/crates/primitives/src/transaction/l1_transaction.rs index dee23d7..3b1448e 100644 --- a/crates/primitives/src/transaction/l1_transaction.rs +++ b/crates/primitives/src/transaction/l1_transaction.rs @@ -526,8 +526,8 @@ mod tests { mem::size_of::() + // gas_limit mem::size_of::
() + // to mem::size_of::() + // value - 0 + // input (empty) mem::size_of::
(); // sender + // Note: input is empty so contributes 0 bytes assert_eq!(tx.size(), expected_size); } From 3891fab63dc3bf1b13f47bcf7552207e9653cd48 Mon Sep 17 00:00:00 2001 From: panos Date: Tue, 20 Jan 2026 10:58:10 +0800 Subject: [PATCH 07/13] feat: add payload builder --- Cargo.lock | 4 +- crates/chainspec/Cargo.toml | 3 +- crates/chainspec/src/spec.rs | 17 +- crates/consensus/src/validation.rs | 138 ++++--- crates/evm/Cargo.toml | 1 - crates/evm/src/assemble.rs | 15 +- crates/evm/src/block.rs | 8 +- crates/evm/src/engine.rs | 76 ---- crates/evm/src/lib.rs | 2 - crates/evm/src/system_contracts.rs | 12 +- crates/payload/builder/src/builder.rs | 47 ++- crates/payload/builder/src/config.rs | 13 +- crates/payload/builder/src/error.rs | 4 +- crates/payload/types/Cargo.toml | 1 - crates/payload/types/src/attributes.rs | 8 +- crates/payload/types/src/built.rs | 4 +- crates/payload/types/src/lib.rs | 120 +++--- crates/primitives/Cargo.toml | 3 + crates/primitives/src/header.rs | 349 ++++++++++++++++++ crates/primitives/src/lib.rs | 7 +- crates/primitives/src/transaction/envelope.rs | 4 +- .../src/transaction/l1_transaction.rs | 24 +- 22 files changed, 593 insertions(+), 267 deletions(-) delete mode 100644 crates/evm/src/engine.rs create mode 100644 crates/primitives/src/header.rs diff --git a/Cargo.lock b/Cargo.lock index 7249a73..532745e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3643,7 +3643,6 @@ name = "morph-chainspec" version = "0.7.5" dependencies = [ "alloy-chains", - "alloy-consensus", "alloy-eips", "alloy-evm", "alloy-genesis", @@ -3652,6 +3651,7 @@ dependencies = [ "alloy-serde", "auto_impl", "eyre", + "morph-primitives", "reth-chainspec", "reth-cli", "reth-network-peers", @@ -3740,7 +3740,6 @@ dependencies = [ "alloy-serde", "morph-primitives", "rand 0.8.5", - "reth-engine-primitives", "reth-payload-builder", "reth-payload-primitives", "reth-primitives-traits", @@ -3766,6 +3765,7 @@ dependencies = [ "reth-primitives-traits", "reth-zstd-compressors", "serde", + "serde_json", ] [[package]] diff --git a/crates/chainspec/Cargo.toml b/crates/chainspec/Cargo.toml index 64cf406..188adc9 100644 --- a/crates/chainspec/Cargo.toml +++ b/crates/chainspec/Cargo.toml @@ -12,12 +12,13 @@ publish.workspace = true workspace = true [dependencies] +morph-primitives.workspace = true + reth-cli = { workspace = true, optional = true } reth-chainspec.workspace = true reth-network-peers.workspace = true alloy-chains.workspace = true -alloy-consensus.workspace = true alloy-evm.workspace = true alloy-genesis.workspace = true alloy-primitives.workspace = true diff --git a/crates/chainspec/src/spec.rs b/crates/chainspec/src/spec.rs index 837f459..9a8c872 100644 --- a/crates/chainspec/src/spec.rs +++ b/crates/chainspec/src/spec.rs @@ -6,11 +6,11 @@ use crate::{ hardfork::{MorphHardfork, MorphHardforks}, }; use alloy_chains::Chain; -use alloy_consensus::Header; use alloy_eips::eip7840::BlobParams; use alloy_evm::eth::spec::EthExecutorSpec; use alloy_genesis::Genesis; use alloy_primitives::{Address, B256, U256}; +use morph_primitives::MorphHeader; use reth_chainspec::{ BaseFeeParams, ChainSpec, DepositContract, DisplayHardforks, EthChainSpec, EthereumHardfork, EthereumHardforks, ForkCondition, ForkFilter, ForkId, Hardfork, Hardforks, Head, @@ -86,14 +86,14 @@ impl ChainConfig for MorphChainSpec { /// Morph chain spec type. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MorphChainSpec { - /// [`ChainSpec`]. - pub inner: ChainSpec
, + /// [`ChainSpec`] with MorphHeader. + pub inner: ChainSpec, pub info: MorphGenesisInfo, } impl MorphChainSpec { /// Create a new [`MorphChainSpec`] with the given inner spec and config. - pub fn new(inner: ChainSpec
, info: MorphGenesisInfo) -> Self { + pub fn new(inner: ChainSpec, info: MorphGenesisInfo) -> Self { Self { inner, info } } @@ -151,8 +151,11 @@ impl From for MorphChainSpec { base_spec.hardforks.extend(timestamp_forks); + // Convert ChainSpec
to ChainSpec using map_header + let morph_spec: ChainSpec = base_spec.map_header(MorphHeader::from); + Self { - inner: base_spec, + inner: morph_spec, info: chain_info, } } @@ -185,7 +188,7 @@ impl Hardforks for MorphChainSpec { } impl EthChainSpec for MorphChainSpec { - type Header = Header; + type Header = MorphHeader; fn chain(&self) -> Chain { self.inner.chain() @@ -238,7 +241,7 @@ impl EthChainSpec for MorphChainSpec { self.inner.get_final_paris_total_difficulty() } - fn next_block_base_fee(&self, _parent: &Header, _target_timestamp: u64) -> Option { + fn next_block_base_fee(&self, _parent: &MorphHeader, _target_timestamp: u64) -> Option { Some(MORPH_BASE_FEE) } } diff --git a/crates/consensus/src/validation.rs b/crates/consensus/src/validation.rs index db1b6b0..9809c2f 100644 --- a/crates/consensus/src/validation.rs +++ b/crates/consensus/src/validation.rs @@ -10,7 +10,7 @@ use alloy_consensus::{BlockHeader as _, EMPTY_OMMER_ROOT_HASH, TxReceipt}; use alloy_evm::block::BlockExecutionResult; use alloy_primitives::{B256, Bloom}; use morph_chainspec::MorphChainSpec; -use morph_primitives::{Block, BlockBody, MorphReceipt, MorphTxEnvelope}; +use morph_primitives::{Block, BlockBody, MorphHeader, MorphReceipt, MorphTxEnvelope}; use reth_consensus::{Consensus, ConsensusError, FullConsensus, HeaderValidator}; use reth_consensus_common::validation::{ validate_against_parent_hash_number, validate_body_against_header, @@ -67,11 +67,8 @@ impl MorphConsensus { // HeaderValidator Implementation // ============================================================================ -impl HeaderValidator for MorphConsensus { - fn validate_header( - &self, - header: &SealedHeader, - ) -> Result<(), ConsensusError> { +impl HeaderValidator for MorphConsensus { + fn validate_header(&self, header: &SealedHeader) -> Result<(), ConsensusError> { // Extra data must be empty (Morph L2 specific - stricter than max length) if !header.extra_data().is_empty() { return Err(ConsensusError::ExtraDataExceedsMax { @@ -145,8 +142,8 @@ impl HeaderValidator for MorphConsensus { fn validate_header_against_parent( &self, - header: &SealedHeader, - parent: &SealedHeader, + header: &SealedHeader, + parent: &SealedHeader, ) -> Result<(), ConsensusError> { // Validate parent hash and block number validate_against_parent_hash_number(header.header(), parent)?; @@ -171,7 +168,7 @@ impl Consensus for MorphConsensus { fn validate_body_against_header( &self, body: &BlockBody, - header: &SealedHeader, + header: &SealedHeader, ) -> Result<(), Self::Error> { validate_body_against_header(body, header.header()) } @@ -453,6 +450,11 @@ mod tests { MorphTxEnvelope::Legacy(Signed::new_unchecked(tx, sig, B256::ZERO)) } + /// Create a MorphHeader from a standard Header + fn create_morph_header(inner: Header) -> MorphHeader { + inner.into() + } + #[test] fn test_morph_consensus_creation() { let chain_spec = create_test_chainspec(); @@ -486,12 +488,12 @@ mod tests { fn test_validate_header_extra_data_not_empty() { let chain_spec = create_test_chainspec(); let consensus = MorphConsensus::new(chain_spec); - let header = Header { + let header = create_morph_header(Header { extra_data: Bytes::from([1, 2, 3].as_slice()), nonce: B64::ZERO, ommers_hash: EMPTY_OMMER_ROOT_HASH, ..Default::default() - }; + }); let sealed = SealedHeader::seal_slow(header); let result = consensus.validate_header(&sealed); assert!(matches!( @@ -504,12 +506,12 @@ mod tests { fn test_validate_header_invalid_difficulty() { let chain_spec = create_test_chainspec(); let consensus = MorphConsensus::new(chain_spec); - let header = Header { + let header = create_morph_header(Header { difficulty: U256::from(1), ommers_hash: EMPTY_OMMER_ROOT_HASH, nonce: B64::ZERO, ..Default::default() - }; + }); let sealed = SealedHeader::seal_slow(header); let result = consensus.validate_header(&sealed); assert!(matches!( @@ -522,11 +524,11 @@ mod tests { fn test_validate_header_invalid_nonce() { let chain_spec = create_test_chainspec(); let consensus = MorphConsensus::new(chain_spec); - let header = Header { + let header = create_morph_header(Header { nonce: B64::from(1u64), ommers_hash: EMPTY_OMMER_ROOT_HASH, ..Default::default() - }; + }); let sealed = SealedHeader::seal_slow(header); let result = consensus.validate_header(&sealed); assert!(matches!( @@ -539,11 +541,11 @@ mod tests { fn test_validate_header_invalid_ommers() { let chain_spec = create_test_chainspec(); let consensus = MorphConsensus::new(chain_spec); - let header = Header { + let header = create_morph_header(Header { nonce: B64::ZERO, ommers_hash: B256::ZERO, // not EMPTY_OMMER_ROOT_HASH ..Default::default() - }; + }); let sealed = SealedHeader::seal_slow(header); let result = consensus.validate_header(&sealed); assert!(matches!( @@ -556,13 +558,13 @@ mod tests { fn test_validate_header_gas_used_exceeds_limit() { let chain_spec = create_test_chainspec(); let consensus = MorphConsensus::new(chain_spec); - let header = Header { + let header = create_morph_header(Header { nonce: B64::ZERO, ommers_hash: EMPTY_OMMER_ROOT_HASH, gas_limit: 1000, gas_used: 2000, // exceeds gas_limit ..Default::default() - }; + }); let sealed = SealedHeader::seal_slow(header); let result = consensus.validate_header(&sealed); assert!(matches!( @@ -580,7 +582,7 @@ mod tests { .duration_since(std::time::SystemTime::UNIX_EPOCH) .unwrap() .as_secs(); - let header = Header { + let header = create_morph_header(Header { nonce: B64::ZERO, ommers_hash: EMPTY_OMMER_ROOT_HASH, gas_limit: 30_000_000, @@ -588,7 +590,7 @@ mod tests { timestamp: now - 10, // 10 seconds ago base_fee_per_gas: Some(1_000_000), // 0.001 Gwei (after Curie) ..Default::default() - }; + }); let sealed = SealedHeader::seal_slow(header); let result = consensus.validate_header(&sealed); assert!(result.is_ok()); @@ -699,14 +701,14 @@ mod tests { .as_secs() + 3600; // 1 hour in the future - let header = Header { + let header = create_morph_header(Header { nonce: B64::ZERO, ommers_hash: EMPTY_OMMER_ROOT_HASH, gas_limit: 30_000_000, timestamp: future_ts, base_fee_per_gas: Some(1_000_000), ..Default::default() - }; + }); let sealed = SealedHeader::seal_slow(header); let result = consensus.validate_header(&sealed); assert!(matches!( @@ -724,14 +726,14 @@ mod tests { .unwrap() .as_secs(); - let header = Header { + let header = create_morph_header(Header { nonce: B64::ZERO, ommers_hash: EMPTY_OMMER_ROOT_HASH, gas_limit: MAX_GAS_LIMIT + 1, // Exceeds max timestamp: now - 10, base_fee_per_gas: Some(1_000_000), ..Default::default() - }; + }); let sealed = SealedHeader::seal_slow(header); let result = consensus.validate_header(&sealed); assert!(matches!( @@ -749,14 +751,14 @@ mod tests { .unwrap() .as_secs(); - let header = Header { + let header = create_morph_header(Header { nonce: B64::ZERO, ommers_hash: EMPTY_OMMER_ROOT_HASH, gas_limit: 30_000_000, timestamp: now - 10, base_fee_per_gas: Some(MORPH_MAXIMUM_BASE_FEE + 1), // Over limit ..Default::default() - }; + }); let sealed = SealedHeader::seal_slow(header); let result = consensus.validate_header(&sealed); assert!(result.is_err()); @@ -773,14 +775,14 @@ mod tests { .unwrap() .as_secs(); - let header = Header { + let header = create_morph_header(Header { nonce: B64::ZERO, ommers_hash: EMPTY_OMMER_ROOT_HASH, gas_limit: 30_000_000, timestamp: now - 10, base_fee_per_gas: None, // Missing (required) ..Default::default() - }; + }); let sealed = SealedHeader::seal_slow(header); let result = consensus.validate_header(&sealed); assert!(matches!(result, Err(ConsensusError::BaseFeeMissing))); @@ -795,14 +797,14 @@ mod tests { .unwrap() .as_secs(); - let header = Header { + let header = create_morph_header(Header { nonce: B64::ZERO, ommers_hash: EMPTY_OMMER_ROOT_HASH, gas_limit: 30_000_000, timestamp: now - 10, base_fee_per_gas: Some(MORPH_MAXIMUM_BASE_FEE), // Exactly at max (valid) ..Default::default() - }; + }); let sealed = SealedHeader::seal_slow(header); let result = consensus.validate_header(&sealed); assert!(result.is_ok()); @@ -812,8 +814,8 @@ mod tests { // Header Against Parent Validation Tests // ======================================================================== - fn create_valid_header(timestamp: u64, gas_limit: u64, number: u64) -> Header { - Header { + fn create_valid_morph_header(timestamp: u64, gas_limit: u64, number: u64) -> MorphHeader { + create_morph_header(Header { nonce: B64::ZERO, ommers_hash: EMPTY_OMMER_ROOT_HASH, gas_limit, @@ -821,7 +823,7 @@ mod tests { number, base_fee_per_gas: Some(1_000_000), ..Default::default() - } + }) } #[test] @@ -829,11 +831,11 @@ mod tests { let chain_spec = create_test_chainspec(); let consensus = MorphConsensus::new(chain_spec); - let parent = create_valid_header(1000, 30_000_000, 100); + let parent = create_valid_morph_header(1000, 30_000_000, 100); let parent_sealed = SealedHeader::seal_slow(parent); - let mut child = create_valid_header(1001, 30_000_000, 101); - child.parent_hash = parent_sealed.hash(); + let mut child = create_valid_morph_header(1001, 30_000_000, 101); + child.inner.parent_hash = parent_sealed.hash(); let child_sealed = SealedHeader::seal_slow(child); let result = consensus.validate_header_against_parent(&child_sealed, &parent_sealed); @@ -845,11 +847,11 @@ mod tests { let chain_spec = create_test_chainspec(); let consensus = MorphConsensus::new(chain_spec); - let parent = create_valid_header(1000, 30_000_000, 100); + let parent = create_valid_morph_header(1000, 30_000_000, 100); let parent_sealed = SealedHeader::seal_slow(parent); - let mut child = create_valid_header(999, 30_000_000, 101); // timestamp < parent - child.parent_hash = parent_sealed.hash(); + let mut child = create_valid_morph_header(999, 30_000_000, 101); // timestamp < parent + child.inner.parent_hash = parent_sealed.hash(); let child_sealed = SealedHeader::seal_slow(child); let result = consensus.validate_header_against_parent(&child_sealed, &parent_sealed); @@ -864,11 +866,11 @@ mod tests { let chain_spec = create_test_chainspec(); let consensus = MorphConsensus::new(chain_spec); - let parent = create_valid_header(1000, 30_000_000, 100); + let parent = create_valid_morph_header(1000, 30_000_000, 100); let parent_sealed = SealedHeader::seal_slow(parent); - let mut child = create_valid_header(1000, 30_000_000, 101); // timestamp == parent (valid) - child.parent_hash = parent_sealed.hash(); + let mut child = create_valid_morph_header(1000, 30_000_000, 101); // timestamp == parent (valid) + child.inner.parent_hash = parent_sealed.hash(); let child_sealed = SealedHeader::seal_slow(child); let result = consensus.validate_header_against_parent(&child_sealed, &parent_sealed); @@ -884,12 +886,12 @@ mod tests { let parent_gas_limit = 30_000_000u64; let max_increase = parent_gas_limit / GAS_LIMIT_BOUND_DIVISOR; - let parent = create_valid_header(1000, parent_gas_limit, 100); + let parent = create_valid_morph_header(1000, parent_gas_limit, 100); let parent_sealed = SealedHeader::seal_slow(parent); // Increase by more than allowed - let mut child = create_valid_header(1001, parent_gas_limit + max_increase + 1, 101); - child.parent_hash = parent_sealed.hash(); + let mut child = create_valid_morph_header(1001, parent_gas_limit + max_increase + 1, 101); + child.inner.parent_hash = parent_sealed.hash(); let child_sealed = SealedHeader::seal_slow(child); let result = consensus.validate_header_against_parent(&child_sealed, &parent_sealed); @@ -907,12 +909,12 @@ mod tests { let parent_gas_limit = 30_000_000u64; let max_decrease = parent_gas_limit / GAS_LIMIT_BOUND_DIVISOR; - let parent = create_valid_header(1000, parent_gas_limit, 100); + let parent = create_valid_morph_header(1000, parent_gas_limit, 100); let parent_sealed = SealedHeader::seal_slow(parent); // Decrease by more than allowed - let mut child = create_valid_header(1001, parent_gas_limit - max_decrease - 1, 101); - child.parent_hash = parent_sealed.hash(); + let mut child = create_valid_morph_header(1001, parent_gas_limit - max_decrease - 1, 101); + child.inner.parent_hash = parent_sealed.hash(); let child_sealed = SealedHeader::seal_slow(child); let result = consensus.validate_header_against_parent(&child_sealed, &parent_sealed); @@ -930,12 +932,12 @@ mod tests { let parent_gas_limit = 30_000_000u64; let max_change = parent_gas_limit / GAS_LIMIT_BOUND_DIVISOR; - let parent = create_valid_header(1000, parent_gas_limit, 100); + let parent = create_valid_morph_header(1000, parent_gas_limit, 100); let parent_sealed = SealedHeader::seal_slow(parent); // Increase by exactly the allowed amount (valid) - let mut child = create_valid_header(1001, parent_gas_limit + max_change, 101); - child.parent_hash = parent_sealed.hash(); + let mut child = create_valid_morph_header(1001, parent_gas_limit + max_change, 101); + child.inner.parent_hash = parent_sealed.hash(); let child_sealed = SealedHeader::seal_slow(child); let result = consensus.validate_header_against_parent(&child_sealed, &parent_sealed); @@ -950,11 +952,11 @@ mod tests { // Use a parent gas limit that allows decreasing to below minimum within bounds // Parent = MINIMUM_GAS_LIMIT, so max decrease = MINIMUM_GAS_LIMIT / 1024 = 4 // Child = MINIMUM_GAS_LIMIT - 1 = 4999, change = 1 which is < 4 (within bounds) - let parent = create_valid_header(1000, MINIMUM_GAS_LIMIT, 100); + let parent = create_valid_morph_header(1000, MINIMUM_GAS_LIMIT, 100); let parent_sealed = SealedHeader::seal_slow(parent); - let mut child = create_valid_header(1001, MINIMUM_GAS_LIMIT - 1, 101); - child.parent_hash = parent_sealed.hash(); + let mut child = create_valid_morph_header(1001, MINIMUM_GAS_LIMIT - 1, 101); + child.inner.parent_hash = parent_sealed.hash(); let child_sealed = SealedHeader::seal_slow(child); let result = consensus.validate_header_against_parent(&child_sealed, &parent_sealed); @@ -969,11 +971,11 @@ mod tests { let chain_spec = create_test_chainspec(); let consensus = MorphConsensus::new(chain_spec); - let parent = create_valid_header(1000, 30_000_000, 100); + let parent = create_valid_morph_header(1000, 30_000_000, 100); let parent_sealed = SealedHeader::seal_slow(parent); - let mut child = create_valid_header(1001, 30_000_000, 101); - child.parent_hash = B256::random(); // Wrong parent hash + let mut child = create_valid_morph_header(1001, 30_000_000, 101); + child.inner.parent_hash = B256::random(); // Wrong parent hash let child_sealed = SealedHeader::seal_slow(child); let result = consensus.validate_header_against_parent(&child_sealed, &parent_sealed); @@ -985,11 +987,11 @@ mod tests { let chain_spec = create_test_chainspec(); let consensus = MorphConsensus::new(chain_spec); - let parent = create_valid_header(1000, 30_000_000, 100); + let parent = create_valid_morph_header(1000, 30_000_000, 100); let parent_sealed = SealedHeader::seal_slow(parent); - let mut child = create_valid_header(1001, 30_000_000, 102); // Should be 101 - child.parent_hash = parent_sealed.hash(); + let mut child = create_valid_morph_header(1001, 30_000_000, 102); // Should be 101 + child.inner.parent_hash = parent_sealed.hash(); let child_sealed = SealedHeader::seal_slow(child); let result = consensus.validate_header_against_parent(&child_sealed, &parent_sealed); @@ -1042,8 +1044,22 @@ mod tests { // ======================================================================== // Gas Limit Validation Helper Tests + // These use Header directly since the generic helper functions work + // on any type implementing BlockHeader trait. // ======================================================================== + fn create_valid_header(timestamp: u64, gas_limit: u64, number: u64) -> Header { + Header { + nonce: B64::ZERO, + ommers_hash: EMPTY_OMMER_ROOT_HASH, + gas_limit, + timestamp, + number, + base_fee_per_gas: Some(1_000_000), + ..Default::default() + } + } + #[test] fn test_validate_against_parent_gas_limit_no_change() { let parent = create_valid_header(1000, 30_000_000, 100); diff --git a/crates/evm/Cargo.toml b/crates/evm/Cargo.toml index 3026693..6cf23c7 100644 --- a/crates/evm/Cargo.toml +++ b/crates/evm/Cargo.toml @@ -39,4 +39,3 @@ alloy-genesis.workspace = true [features] default = ["rpc"] rpc = ["dep:reth-rpc-eth-api", "morph-revm/rpc"] -engine = [] diff --git a/crates/evm/src/assemble.rs b/crates/evm/src/assemble.rs index 4221edd..f2142ac 100644 --- a/crates/evm/src/assemble.rs +++ b/crates/evm/src/assemble.rs @@ -6,6 +6,7 @@ use morph_chainspec::MorphChainSpec; use morph_primitives::MorphHeader; use reth_evm::execute::{BlockAssembler, BlockAssemblerInput}; use reth_evm_ethereum::EthBlockAssembler; +use reth_primitives_traits::SealedHeader; use std::sync::Arc; /// Assembler for Morph blocks. @@ -41,18 +42,26 @@ impl BlockAssembler for MorphBlockAssembler { .. } = input; + // Convert MorphHeader parent to standard Header for the inner assembler. + // We extract the inner Header since EthBlockAssembler works with standard Headers. + let inner_parent = SealedHeader::new_unhashed(parent.header().inner.clone()); + // Delegate block building to the inner assembler - self.inner.assemble_block(BlockAssemblerInput::< + let block = self.inner.assemble_block(BlockAssemblerInput::< EthBlockExecutorFactory, >::new( evm_env, inner, - parent, + &inner_parent, transactions, output, bundle_state, state_provider, state_root, - )) + ))?; + + // Convert the standard Header back to MorphHeader. + // The next_l1_msg_index and batch_hash will be set by the payload builder. + Ok(block.map_header(MorphHeader::from)) } } diff --git a/crates/evm/src/block.rs b/crates/evm/src/block.rs index 3454111..4a8e5b9 100644 --- a/crates/evm/src/block.rs +++ b/crates/evm/src/block.rs @@ -1,5 +1,6 @@ use crate::{ - MorphBlockExecutionCtx, evm::MorphEvm, + MorphBlockExecutionCtx, + evm::MorphEvm, system_contracts::{ BLOB_ENABLED, GPO_IS_BLOB_ENABLED_SLOT, L1_GAS_PRICE_ORACLE_ADDRESS, L1_GAS_PRICE_ORACLE_INIT_STORAGE, @@ -18,10 +19,7 @@ use morph_chainspec::MorphChainSpec; use morph_primitives::{MorphReceipt, MorphTransactionReceipt, MorphTxEnvelope, MorphTxType}; use morph_revm::{MorphHaltReason, evm::MorphContext}; use reth_revm::{Inspector, State, context::result::ResultAndState}; -use revm::{ - Database as RevmDatabase, - database::states::StorageSlot, -}; +use revm::{Database as RevmDatabase, database::states::StorageSlot}; /// Builder for [`MorphReceipt`]. #[derive(Debug, Clone, Copy, Default)] diff --git a/crates/evm/src/engine.rs b/crates/evm/src/engine.rs deleted file mode 100644 index 0e7bde4..0000000 --- a/crates/evm/src/engine.rs +++ /dev/null @@ -1,76 +0,0 @@ -use crate::MorphEvmConfig; -use alloy_consensus::crypto::RecoveryError; -use alloy_primitives::Address; -use morph_payload_types::MorphExecutionData; -use morph_primitives::{Block, MorphTxEnvelope}; -use morph_revm::MorphTxEnv; -use reth_evm::{ - ConfigureEngineEvm, ConfigureEvm, EvmEnvFor, ExecutableTxIterator, ExecutionCtxFor, - FromRecoveredTx, RecoveredTx, ToTxEnv, -}; -use reth_primitives_traits::{SealedBlock, SignedTransaction}; -use std::sync::Arc; - -impl ConfigureEngineEvm for MorphEvmConfig { - fn evm_env_for_payload( - &self, - payload: &MorphExecutionData, - ) -> Result, Self::Error> { - self.evm_env(&payload.block) - } - - fn context_for_payload<'a>( - &self, - payload: &'a MorphExecutionData, - ) -> Result, Self::Error> { - self.context_for_block(&payload.block) - } - - fn tx_iterator_for_payload( - &self, - payload: &MorphExecutionData, - ) -> Result, Self::Error> { - let block = payload.block.clone(); - let transactions = - (0..payload.block.body().transactions.len()).map(move |i| (block.clone(), i)); - - Ok((transactions, RecoveredInBlock::new)) - } -} - -/// A [`reth_evm::execute::ExecutableTxFor`] implementation that contains a pointer to the -/// block and the transaction index, allowing to prepare a [`MorphTxEnv`] without having to -/// clone block or transaction. -#[derive(Clone)] -struct RecoveredInBlock { - block: Arc>, - index: usize, - sender: Address, -} - -impl RecoveredInBlock { - fn new((block, index): (Arc>, usize)) -> Result { - let sender = block.body().transactions[index].try_recover()?; - Ok(Self { - block, - index, - sender, - }) - } -} - -impl RecoveredTx for RecoveredInBlock { - fn tx(&self) -> &MorphTxEnvelope { - &self.block.body().transactions[self.index] - } - - fn signer(&self) -> &alloy_primitives::Address { - &self.sender - } -} - -impl ToTxEnv for RecoveredInBlock { - fn to_tx_env(&self) -> MorphTxEnv { - MorphTxEnv::from_recovered_tx(self.tx(), *self.signer()) - } -} diff --git a/crates/evm/src/lib.rs b/crates/evm/src/lib.rs index 4abb95c..b63a7e5 100644 --- a/crates/evm/src/lib.rs +++ b/crates/evm/src/lib.rs @@ -4,8 +4,6 @@ #![cfg_attr(docsrs, feature(doc_cfg))] mod assemble; -#[cfg(feature = "engine")] -mod engine; use alloy_consensus::BlockHeader as _; pub use assemble::MorphBlockAssembler; mod block; diff --git a/crates/evm/src/system_contracts.rs b/crates/evm/src/system_contracts.rs index 5b11b06..dc541d4 100644 --- a/crates/evm/src/system_contracts.rs +++ b/crates/evm/src/system_contracts.rs @@ -21,8 +21,7 @@ pub const L1_GAS_PRICE_ORACLE_ADDRESS: Address = /// /// Manages the L1-to-L2 message queue and stores the withdraw trie root. /// See: -pub const L2_MESSAGE_QUEUE_ADDRESS: Address = - address!("5300000000000000000000000000000000000001"); +pub const L2_MESSAGE_QUEUE_ADDRESS: Address = address!("5300000000000000000000000000000000000001"); // ============================================================================= // L2 Message Queue Storage Slots @@ -50,16 +49,17 @@ pub const L2_MESSAGE_QUEUE_WITHDRAW_TRIE_ROOT_SLOT: U256 = U256::from_limbs([33, /// which stores the Merkle root for L2→L1 withdrawal messages. /// pub fn read_withdraw_trie_root(db: &mut DB) -> Result { - let value = db.storage(L2_MESSAGE_QUEUE_ADDRESS, L2_MESSAGE_QUEUE_WITHDRAW_TRIE_ROOT_SLOT)?; + let value = db.storage( + L2_MESSAGE_QUEUE_ADDRESS, + L2_MESSAGE_QUEUE_WITHDRAW_TRIE_ROOT_SLOT, + )?; Ok(B256::from(value)) } /// Morph Fee Vault contract address. /// /// Collects transaction fees. -pub const MORPH_FEE_VAULT_ADDRESS: Address = - address!("530000000000000000000000000000000000000a"); - +pub const MORPH_FEE_VAULT_ADDRESS: Address = address!("530000000000000000000000000000000000000a"); /// Morph Token Registry contract address. pub const MORPH_TOKEN_REGISTRY_ADDRESS: Address = diff --git a/crates/payload/builder/src/builder.rs b/crates/payload/builder/src/builder.rs index 483d710..f04a434 100644 --- a/crates/payload/builder/src/builder.rs +++ b/crates/payload/builder/src/builder.rs @@ -1,30 +1,30 @@ //! Morph payload builder implementation. -use crate::{config::PayloadBuildingBreaker, MorphBuilderConfig, MorphPayloadBuilderError}; +use crate::{MorphBuilderConfig, MorphPayloadBuilderError, config::PayloadBuildingBreaker}; use alloy_consensus::{BlockHeader, Transaction, Typed2718}; use alloy_eips::eip2718::Encodable2718; use alloy_primitives::{Bytes, U256}; use alloy_rlp::Encodable; use morph_chainspec::MorphChainSpec; use morph_evm::{ - system_contracts::read_withdraw_trie_root, MorphEvmConfig, MorphNextBlockEnvAttributes, + MorphEvmConfig, MorphNextBlockEnvAttributes, system_contracts::read_withdraw_trie_root, }; use morph_payload_types::{ExecutableL2Data, MorphBuiltPayload, MorphPayloadBuilderAttributes}; use morph_primitives::{MorphHeader, MorphTxEnvelope}; use reth_basic_payload_builder::{ - is_better_payload, BuildArguments, BuildOutcome, BuildOutcomeKind, MissingPayloadBehaviour, - PayloadBuilder, PayloadConfig, + BuildArguments, BuildOutcome, BuildOutcomeKind, MissingPayloadBehaviour, PayloadBuilder, + PayloadConfig, is_better_payload, }; use reth_chainspec::ChainSpecProvider; use reth_evm::{ + ConfigureEvm, Database, Evm, NextBlockEnvAttributes, block::{BlockExecutionError, BlockValidationError}, execute::{BlockBuilder, BlockBuilderOutcome}, - ConfigureEvm, Database, Evm, NextBlockEnvAttributes, }; use reth_payload_builder::PayloadId; use reth_payload_primitives::{PayloadBuilderAttributes, PayloadBuilderError}; use reth_payload_util::{BestPayloadTransactions, NoopPayloadTransactions, PayloadTransactions}; -use reth_primitives_traits::SealedHeader; +use reth_primitives_traits::{RecoveredBlock, SealedHeader}; use reth_revm::{database::StateProviderDatabase, db::State}; use reth_storage_api::{StateProvider, StateProviderFactory}; use reth_transaction_pool::{BestTransactionsAttributes, PoolTransaction, TransactionPool}; @@ -492,13 +492,13 @@ struct ExecutionInfo { } impl ExecutionInfo { - /// Creates a new [`ExecutionInfo`]. - const fn new() -> Self { + /// Creates a new [`ExecutionInfo`] with the initial next L1 message index from parent. + const fn new(next_l1_message_index: u64) -> Self { Self { cumulative_gas_used: 0, cumulative_da_bytes_used: 0, total_fees: U256::ZERO, - next_l1_message_index: 0, + next_l1_message_index, } } @@ -538,7 +538,7 @@ where target: "payload_builder", id = %ctx.payload_id(), parent_hash = ?ctx.parent().hash(), - parent_number = ctx.parent().number, + parent_number = ctx.parent().number(), "building new payload" ); @@ -553,7 +553,7 @@ where timestamp: attributes.inner.timestamp, suggested_fee_recipient: attributes.inner.suggested_fee_recipient, prev_randao: attributes.inner.prev_randao, - gas_limit: ctx.parent().gas_limit, + gas_limit: ctx.parent().gas_limit(), withdrawals: Some(attributes.inner.withdrawals.clone()), parent_beacon_block_root: attributes.inner.parent_beacon_block_root, extra_data: Default::default(), @@ -572,7 +572,8 @@ where PayloadBuilderError::Internal(err.into()) })?; - let mut info = ExecutionInfo::new(); + // Initialize next_l1_message_index from parent header + let mut info = ExecutionInfo::new(ctx.parent().next_l1_msg_index); let base_fee = builder.evm().block().basefee(); let block_gas_limit = builder.evm().block().gas_limit(); @@ -585,7 +586,13 @@ where // 3. Execute pool transactions (best transactions from mempool) let best_txs = best(ctx.best_transaction_attributes(base_fee)); if ctx - .execute_pool_transactions(&mut builder, &mut info, &mut executed_txs, best_txs, &breaker)? + .execute_pool_transactions( + &mut builder, + &mut info, + &mut executed_txs, + best_txs, + &breaker, + )? .is_some() { // Check if it was a cancellation or just breaker triggered @@ -618,10 +625,22 @@ where // 6. Finish building the block let BlockBuilderOutcome { execution_result, - block, + mut block, .. } = builder.finish(state_provider)?; + // 7. Update MorphHeader with next_l1_msg_index. + // Since hash_slow() only hashes the inner header, we can update the + // MorphHeader's L2-specific fields without changing the block hash. + let (mut morph_block, senders) = block.split(); + morph_block = morph_block.map_header(|mut header: MorphHeader| { + header.next_l1_msg_index = info.next_l1_message_index; + // batch_hash remains B256::ZERO - it will be set by the batch submitter + header + }); + block = RecoveredBlock::new_unhashed(morph_block, senders); + + // Get the sealed block from the recovered block let sealed_block = Arc::new(block.sealed_block().clone()); let header = sealed_block.header(); diff --git a/crates/payload/builder/src/config.rs b/crates/payload/builder/src/config.rs index 2e498fe..9260173 100644 --- a/crates/payload/builder/src/config.rs +++ b/crates/payload/builder/src/config.rs @@ -57,7 +57,11 @@ impl MorphBuilderConfig { time_limit: Duration, max_da_block_size: Option, ) -> Self { - Self { gas_limit, time_limit, max_da_block_size } + Self { + gas_limit, + time_limit, + max_da_block_size, + } } /// Sets the gas limit. @@ -192,11 +196,8 @@ mod tests { #[test] fn test_breaker_should_break_on_time_limit() { - let breaker = PayloadBuildingBreaker::new( - Duration::from_millis(100), - 30_000_000, - Some(128 * 1024), - ); + let breaker = + PayloadBuildingBreaker::new(Duration::from_millis(100), 30_000_000, Some(128 * 1024)); // Should not break immediately assert!(!breaker.should_break(0, 0)); diff --git a/crates/payload/builder/src/error.rs b/crates/payload/builder/src/error.rs index 2516b4b..967ef23 100644 --- a/crates/payload/builder/src/error.rs +++ b/crates/payload/builder/src/error.rs @@ -15,7 +15,9 @@ pub enum MorphPayloadBuilderError { TransactionEcRecoverFailed, /// Block gas limit exceeded by sequencer transactions. - #[error("block gas limit {gas} exceeded by sequencer transactions, gas spent by tx: {gas_spent_by_tx:?}")] + #[error( + "block gas limit {gas} exceeded by sequencer transactions, gas spent by tx: {gas_spent_by_tx:?}" + )] BlockGasLimitExceededBySequencerTransactions { /// Gas spent by each transaction. gas_spent_by_tx: Vec, diff --git a/crates/payload/types/Cargo.toml b/crates/payload/types/Cargo.toml index 74e4e3e..f6df09e 100644 --- a/crates/payload/types/Cargo.toml +++ b/crates/payload/types/Cargo.toml @@ -16,7 +16,6 @@ workspace = true morph-primitives = { workspace = true, features = ["serde"] } # Reth -reth-engine-primitives.workspace = true reth-payload-builder.workspace = true reth-payload-primitives.workspace = true reth-primitives-traits.workspace = true diff --git a/crates/payload/types/src/attributes.rs b/crates/payload/types/src/attributes.rs index e8dbd5b..2871227 100644 --- a/crates/payload/types/src/attributes.rs +++ b/crates/payload/types/src/attributes.rs @@ -1,7 +1,7 @@ //! Morph payload attributes types. -use alloy_eips::eip4895::{Withdrawal, Withdrawals}; use alloy_eips::eip2718::Decodable2718; +use alloy_eips::eip4895::{Withdrawal, Withdrawals}; use alloy_primitives::{Address, B256, Bytes}; use alloy_rpc_types_engine::{PayloadAttributes, PayloadId}; use morph_primitives::MorphTxEnvelope; @@ -145,11 +145,7 @@ impl MorphPayloadBuilderAttributes { /// Compute payload ID from parent hash and attributes. /// /// Uses SHA-256 hashing with the version byte as the first byte of the result. -fn payload_id_morph( - parent: &B256, - attributes: &MorphPayloadAttributes, - version: u8, -) -> PayloadId { +fn payload_id_morph(parent: &B256, attributes: &MorphPayloadAttributes, version: u8) -> PayloadId { let mut hasher = Sha256::new(); // Hash parent diff --git a/crates/payload/types/src/built.rs b/crates/payload/types/src/built.rs index 5abdf2c..150d572 100644 --- a/crates/payload/types/src/built.rs +++ b/crates/payload/types/src/built.rs @@ -87,11 +87,11 @@ impl BuiltPayload for MorphBuiltPayload { mod tests { use super::*; use alloy_consensus::Header; - use morph_primitives::BlockBody; + use morph_primitives::{BlockBody, MorphHeader}; use reth_primitives_traits::Block as _; fn create_test_block() -> SealedBlock { - let header = Header::default(); + let header: MorphHeader = Header::default().into(); let body = BlockBody::default(); let block = Block::new(header, body); block.seal_slow() diff --git a/crates/payload/types/src/lib.rs b/crates/payload/types/src/lib.rs index 57c1075..bcbaa0a 100644 --- a/crates/payload/types/src/lib.rs +++ b/crates/payload/types/src/lib.rs @@ -5,10 +5,9 @@ //! - [`SafeL2Data`]: Safe block data for NewSafeL2Block (derivation) //! - [`MorphPayloadAttributes`]: Extended payload attributes for block building //! - [`MorphBuiltPayload`]: Built payload result -//! - [`MorphEngineTypes`]: Engine types for the Morph Engine API -//! - [`MorphPayloadTypes`]: Default payload types implementation +//! - [`MorphPayloadTypes`]: Payload types for reth node framework //! -//! These types are designed to be compatible with the standard Ethereum Engine API. +//! These types are designed to be compatible with the Morph L2 Engine API. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] @@ -19,17 +18,14 @@ mod executable_l2_data; mod params; mod safe_l2_data; -use core::marker::PhantomData; - -use alloy_rpc_types_engine::{ - ExecutionData, ExecutionPayload, ExecutionPayloadEnvelopeV2, ExecutionPayloadEnvelopeV3, - ExecutionPayloadEnvelopeV4, ExecutionPayloadV1, -}; +use alloy_consensus::BlockHeader as _; +use alloy_eips::eip4895::Withdrawal; +use alloy_primitives::{B256, Bytes}; use morph_primitives::Block; -use reth_engine_primitives::EngineTypes; -use reth_payload_primitives::{BuiltPayload, PayloadTypes}; +use reth_payload_primitives::{BuiltPayload, ExecutionPayload, PayloadTypes}; use reth_primitives_traits::{NodePrimitives, SealedBlock}; use serde::{Deserialize, Serialize}; +use std::sync::Arc; // Re-export main types pub use attributes::{MorphPayloadAttributes, MorphPayloadBuilderAttributes}; @@ -38,61 +34,69 @@ pub use executable_l2_data::ExecutableL2Data; pub use params::{AssembleL2BlockParams, GenericResponse}; pub use safe_l2_data::SafeL2Data; -/// The types used in the Morph beacon consensus engine. +// ============================================================================= +// MorphPayloadTypes - Required for reth NodeBuilder framework +// ============================================================================= + +/// Payload types for Morph node. /// -/// This is a generic wrapper that allows customizing the payload types. -#[derive(Debug, Default, Clone, Serialize, Deserialize)] +/// This type is required by reth's `NodeTypes` trait to define how payloads +/// are built and represented in the node framework. +#[derive(Debug, Clone, Copy, Default)] #[non_exhaustive] -pub struct MorphEngineTypes { - _marker: PhantomData, -} +pub struct MorphPayloadTypes; -impl PayloadTypes for MorphEngineTypes -where - T: PayloadTypes, - T::BuiltPayload: BuiltPayload>, -{ - type ExecutionData = T::ExecutionData; - type BuiltPayload = T::BuiltPayload; - type PayloadAttributes = T::PayloadAttributes; - type PayloadBuilderAttributes = T::PayloadBuilderAttributes; +/// Execution data for Morph node. Simply wraps a sealed block. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MorphExecutionData { + /// The built block. + pub block: Arc>, +} - fn block_to_payload( - block: SealedBlock< - <::Primitives as NodePrimitives>::Block, - >, - ) -> ExecutionData { - let (payload, sidecar) = - ExecutionPayload::from_block_unchecked(block.hash(), &block.into_block()); - ExecutionData { payload, sidecar } +impl MorphExecutionData { + /// Creates a new `MorphExecutionData` from a sealed block. + pub fn new(block: Arc>) -> Self { + Self { block } } } -impl EngineTypes for MorphEngineTypes -where - T: PayloadTypes, - T::BuiltPayload: BuiltPayload> - + TryInto - + TryInto - + TryInto - + TryInto, -{ - type ExecutionPayloadEnvelopeV1 = ExecutionPayloadV1; - type ExecutionPayloadEnvelopeV2 = ExecutionPayloadEnvelopeV2; - type ExecutionPayloadEnvelopeV3 = ExecutionPayloadEnvelopeV3; - type ExecutionPayloadEnvelopeV4 = ExecutionPayloadEnvelopeV4; - type ExecutionPayloadEnvelopeV5 = ExecutionPayloadEnvelopeV4; -} +impl ExecutionPayload for MorphExecutionData { + fn parent_hash(&self) -> B256 { + self.block.parent_hash() + } -/// A default payload type for [`MorphEngineTypes`]. -/// -/// This type provides the concrete implementations for Morph's payload handling. -#[derive(Debug, Default, Clone, Serialize, Deserialize)] -#[non_exhaustive] -pub struct MorphPayloadTypes; + fn block_hash(&self) -> B256 { + self.block.hash() + } + + fn block_number(&self) -> u64 { + self.block.number() + } + + fn withdrawals(&self) -> Option<&Vec> { + // Morph L2 doesn't have withdrawals + None + } + + fn parent_beacon_block_root(&self) -> Option { + self.block.parent_beacon_block_root() + } + + fn timestamp(&self) -> u64 { + self.block.timestamp() + } + + fn gas_used(&self) -> u64 { + self.block.gas_used() + } + + fn block_access_list(&self) -> Option<&Bytes> { + None + } +} impl PayloadTypes for MorphPayloadTypes { - type ExecutionData = ExecutionData; + type ExecutionData = MorphExecutionData; type BuiltPayload = MorphBuiltPayload; type PayloadAttributes = MorphPayloadAttributes; type PayloadBuilderAttributes = MorphPayloadBuilderAttributes; @@ -102,8 +106,6 @@ impl PayloadTypes for MorphPayloadTypes { <::Primitives as NodePrimitives>::Block, >, ) -> Self::ExecutionData { - let (payload, sidecar) = - ExecutionPayload::from_block_unchecked(block.hash(), &block.into_block()); - ExecutionData { payload, sidecar } + MorphExecutionData::new(Arc::new(block)) } } diff --git a/crates/primitives/Cargo.toml b/crates/primitives/Cargo.toml index 2d4bdf2..7f1a6cb 100644 --- a/crates/primitives/Cargo.toml +++ b/crates/primitives/Cargo.toml @@ -32,6 +32,7 @@ modular-bitfield = { version = "0.11.2", optional = true } [dev-dependencies] +serde_json.workspace = true [features] default = ["serde", "reth-codec"] @@ -41,6 +42,8 @@ serde = [ "alloy-primitives/serde", "alloy-eips/serde", "alloy-consensus/serde", + "reth-primitives-traits/serde", + "reth-ethereum-primitives?/serde", ] reth = [ "dep:reth-ethereum-primitives", diff --git a/crates/primitives/src/header.rs b/crates/primitives/src/header.rs new file mode 100644 index 0000000..bb6af1e --- /dev/null +++ b/crates/primitives/src/header.rs @@ -0,0 +1,349 @@ +//! Morph block header type. +//! +//! This module defines the Morph-specific header type that includes: +//! - `next_l1_msg_index`: The next L1 message queue index to process +//! - `batch_hash`: The batch hash (non-zero if this block is a batch point) +//! +//! These fields are NOT included in the block hash calculation to maintain +//! compatibility with standard Ethereum header hashing (matching go-ethereum's behavior). + +use alloy_consensus::{BlockHeader, Header, Sealable}; +use alloy_primitives::{Address, B64, B256, BlockNumber, Bloom, Bytes, U256}; +use alloy_rlp::{RlpDecodable, RlpEncodable}; + +/// Morph block header. +/// +/// This header extends the standard Ethereum header with Morph-specific fields: +/// - `next_l1_msg_index`: Next L1 message queue index to process +/// - `batch_hash`: Non-zero if this block is a batch point +/// +/// **Important**: The `hash_slow()` method only hashes the inner Ethereum header, +/// excluding `next_l1_msg_index` and `batch_hash`. This matches go-ethereum's +/// `Header.Hash()` behavior where these L2-specific fields are not part of the +/// block hash calculation. +/// +/// Note: The `inner` field must be placed last for the `Compact` derive macro, +/// as fields with unknown size must come last in the struct definition. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, RlpEncodable, RlpDecodable)] +#[cfg_attr(feature = "reth-codec", derive(reth_codecs::Compact))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +pub struct MorphHeader { + /// Next L1 message queue index to process. + /// Not part of the header hash calculation. + #[cfg_attr(feature = "serde", serde(with = "alloy_serde::quantity"))] + pub next_l1_msg_index: u64, + + /// Batch hash - non-zero if this block is a batch point. + /// Not part of the header hash calculation. + pub batch_hash: B256, + + /// Standard Ethereum header (flattened in JSON serialization). + /// Must be placed last due to Compact derive requirements. + #[cfg_attr(feature = "serde", serde(flatten))] + pub inner: Header, +} + +impl MorphHeader { + /// Returns true if this block is a batch point (batch_hash is non-zero). + pub fn is_batch_point(&self) -> bool { + self.batch_hash != B256::ZERO + } +} + +impl From
for MorphHeader { + fn from(inner: Header) -> Self { + Self { + inner, + next_l1_msg_index: 0, + batch_hash: B256::ZERO, + } + } +} + +impl AsRef for MorphHeader { + fn as_ref(&self) -> &Self { + self + } +} + +// Implement BlockHeader trait by delegating to inner header +impl BlockHeader for MorphHeader { + fn parent_hash(&self) -> B256 { + self.inner.parent_hash() + } + + fn ommers_hash(&self) -> B256 { + self.inner.ommers_hash() + } + + fn beneficiary(&self) -> Address { + self.inner.beneficiary() + } + + fn state_root(&self) -> B256 { + self.inner.state_root() + } + + fn transactions_root(&self) -> B256 { + self.inner.transactions_root() + } + + fn receipts_root(&self) -> B256 { + self.inner.receipts_root() + } + + fn withdrawals_root(&self) -> Option { + self.inner.withdrawals_root() + } + + fn logs_bloom(&self) -> Bloom { + self.inner.logs_bloom() + } + + fn difficulty(&self) -> U256 { + self.inner.difficulty() + } + + fn number(&self) -> BlockNumber { + self.inner.number() + } + + fn gas_limit(&self) -> u64 { + self.inner.gas_limit() + } + + fn gas_used(&self) -> u64 { + self.inner.gas_used() + } + + fn timestamp(&self) -> u64 { + self.inner.timestamp() + } + + fn mix_hash(&self) -> Option { + self.inner.mix_hash() + } + + fn nonce(&self) -> Option { + self.inner.nonce() + } + + fn base_fee_per_gas(&self) -> Option { + self.inner.base_fee_per_gas() + } + + fn blob_gas_used(&self) -> Option { + self.inner.blob_gas_used() + } + + fn excess_blob_gas(&self) -> Option { + self.inner.excess_blob_gas() + } + + fn parent_beacon_block_root(&self) -> Option { + self.inner.parent_beacon_block_root() + } + + fn requests_hash(&self) -> Option { + self.inner.requests_hash() + } + + fn extra_data(&self) -> &Bytes { + self.inner.extra_data() + } +} + +/// Sealable implementation for MorphHeader. +/// +/// **Critical**: The `hash_slow()` method only hashes the inner Ethereum header, +/// NOT the `next_l1_msg_index` and `batch_hash` fields. This matches go-ethereum's +/// `Header.Hash()` behavior which explicitly excludes these L2-specific fields +/// from the hash calculation. +impl Sealable for MorphHeader { + fn hash_slow(&self) -> B256 { + // Only hash the inner header to match go-ethereum behavior. + // next_l1_msg_index and batch_hash are NOT part of the hash. + self.inner.hash_slow() + } +} + +#[cfg(feature = "reth")] +impl reth_primitives_traits::InMemorySize for MorphHeader { + fn size(&self) -> usize { + reth_primitives_traits::InMemorySize::size(&self.inner) + + core::mem::size_of::() // next_l1_msg_index + + core::mem::size_of::() // batch_hash + } +} + +#[cfg(feature = "reth")] +impl reth_primitives_traits::BlockHeader for MorphHeader {} + +#[cfg(feature = "reth")] +impl reth_primitives_traits::header::HeaderMut for MorphHeader { + fn set_parent_hash(&mut self, hash: B256) { + self.inner.set_parent_hash(hash); + } + + fn set_block_number(&mut self, number: BlockNumber) { + self.inner.set_block_number(number); + } + + fn set_timestamp(&mut self, timestamp: u64) { + self.inner.set_timestamp(timestamp); + } + + fn set_state_root(&mut self, state_root: B256) { + self.inner.set_state_root(state_root); + } + + fn set_difficulty(&mut self, difficulty: U256) { + self.inner.set_difficulty(difficulty); + } +} + +#[cfg(feature = "reth-codec")] +impl reth_db_api::table::Compress for MorphHeader { + type Compressed = Vec; + + fn compress_to_buf>(&self, buf: &mut B) { + let _ = reth_codecs::Compact::to_compact(self, buf); + } +} + +#[cfg(feature = "reth-codec")] +impl reth_db_api::table::Decompress for MorphHeader { + fn decompress(value: &[u8]) -> Result { + let (obj, _) = reth_codecs::Compact::from_compact(value, value.len()); + Ok(obj) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{Bytes, address, b256}; + + fn create_test_header() -> Header { + Header { + parent_hash: b256!("0000000000000000000000000000000000000000000000000000000000000001"), + ommers_hash: b256!("0000000000000000000000000000000000000000000000000000000000000002"), + beneficiary: address!("0000000000000000000000000000000000000011"), + state_root: b256!("0000000000000000000000000000000000000000000000000000000000000003"), + transactions_root: b256!( + "0000000000000000000000000000000000000000000000000000000000000004" + ), + receipts_root: b256!( + "0000000000000000000000000000000000000000000000000000000000000005" + ), + logs_bloom: Bloom::default(), + difficulty: U256::from(1u64), + number: 100, + gas_limit: 30_000_000, + gas_used: 21_000, + timestamp: 1234567890, + extra_data: Bytes::default(), + mix_hash: B256::ZERO, + nonce: B64::ZERO, + base_fee_per_gas: Some(1000000000), + withdrawals_root: None, + blob_gas_used: None, + excess_blob_gas: None, + parent_beacon_block_root: None, + requests_hash: None, + } + } + + #[test] + fn test_morph_header_from_header() { + let inner = create_test_header(); + let header: MorphHeader = inner.clone().into(); + + assert_eq!(header.inner, inner); + assert_eq!(header.next_l1_msg_index, 0); + assert_eq!(header.batch_hash, B256::ZERO); + assert!(!header.is_batch_point()); + } + + #[test] + fn test_morph_header_with_fields() { + let inner = create_test_header(); + let batch_hash = b256!("0000000000000000000000000000000000000000000000000000000000000abc"); + let header = MorphHeader { + inner, + next_l1_msg_index: 100, + batch_hash, + }; + + assert_eq!(header.next_l1_msg_index, 100); + assert_eq!(header.batch_hash, batch_hash); + assert!(header.is_batch_point()); + } + + #[test] + fn test_morph_header_hash_excludes_l2_fields() { + let inner = create_test_header(); + + // Create two headers with different L2 fields + let header1: MorphHeader = inner.clone().into(); + let header2 = MorphHeader { + inner: inner.clone(), + next_l1_msg_index: 999, + batch_hash: b256!("1111111111111111111111111111111111111111111111111111111111111111"), + }; + + // Both should have the same hash since L2 fields are excluded + assert_eq!(header1.hash_slow(), header2.hash_slow()); + + // And they should match the inner header's hash + assert_eq!(header1.hash_slow(), inner.hash_slow()); + } + + #[test] + fn test_morph_header_field_mutation() { + let inner = create_test_header(); + let mut header: MorphHeader = inner.into(); + + header.next_l1_msg_index = 50; + assert_eq!(header.next_l1_msg_index, 50); + + let batch_hash = b256!("2222222222222222222222222222222222222222222222222222222222222222"); + header.batch_hash = batch_hash; + assert_eq!(header.batch_hash, batch_hash); + assert!(header.is_batch_point()); + } + + #[test] + fn test_morph_header_block_header_delegation() { + let inner = create_test_header(); + let header: MorphHeader = inner.clone().into(); + + // Test that all BlockHeader methods delegate correctly + assert_eq!(header.parent_hash(), inner.parent_hash()); + assert_eq!(header.beneficiary(), inner.beneficiary()); + assert_eq!(header.state_root(), inner.state_root()); + assert_eq!(header.number(), inner.number()); + assert_eq!(header.gas_limit(), inner.gas_limit()); + assert_eq!(header.gas_used(), inner.gas_used()); + assert_eq!(header.timestamp(), inner.timestamp()); + assert_eq!(header.base_fee_per_gas(), inner.base_fee_per_gas()); + } + + #[cfg(feature = "serde")] + #[test] + fn test_morph_header_serde() { + let inner = create_test_header(); + let header = MorphHeader { + inner, + next_l1_msg_index: 42, + batch_hash: b256!("3333333333333333333333333333333333333333333333333333333333333333"), + }; + + let json = serde_json::to_string(&header).expect("serialization failed"); + let deserialized: MorphHeader = + serde_json::from_str(&json).expect("deserialization failed"); + + assert_eq!(header, deserialized); + } +} diff --git a/crates/primitives/src/lib.rs b/crates/primitives/src/lib.rs index 03d30ee..c9ac4bc 100644 --- a/crates/primitives/src/lib.rs +++ b/crates/primitives/src/lib.rs @@ -16,13 +16,12 @@ use reth_ethereum_primitives as _; #[cfg(feature = "reth-codec")] use reth_zstd_compressors as _; +pub mod header; pub mod receipt; pub mod transaction; -// Re-export standard Ethereum types -pub use alloy_consensus::Header; -/// Header alias for backwards compatibility. -pub type MorphHeader = Header; +// Re-export header type +pub use header::MorphHeader; use reth_primitives_traits::NodePrimitives; diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index 9cfe59c..23c6587 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -1,4 +1,6 @@ -use alloy_consensus::{Sealed, Signed, TransactionEnvelope, TxEip1559, TxEip2930, TxEip7702, TxLegacy}; +use alloy_consensus::{ + Sealed, Signed, TransactionEnvelope, TxEip1559, TxEip2930, TxEip7702, TxLegacy, +}; use alloy_eips::eip2718::Encodable2718; use alloy_primitives::{B256, Bytes}; use alloy_rlp::BytesMut; diff --git a/crates/primitives/src/transaction/l1_transaction.rs b/crates/primitives/src/transaction/l1_transaction.rs index 3b1448e..6883834 100644 --- a/crates/primitives/src/transaction/l1_transaction.rs +++ b/crates/primitives/src/transaction/l1_transaction.rs @@ -9,7 +9,10 @@ use alloy_consensus::{ SignableTransaction, Transaction, transaction::{RlpEcdsaDecodableTx, RlpEcdsaEncodableTx}, }; -use alloy_eips::{Typed2718, eip2718::{Decodable2718, Eip2718Error, Eip2718Result, Encodable2718}}; +use alloy_eips::{ + Typed2718, + eip2718::{Decodable2718, Eip2718Error, Eip2718Result, Encodable2718}, +}; use alloy_primitives::{Address, B256, Bytes, ChainId, Signature, TxKind, U256, keccak256}; use alloy_rlp::{BufMut, Decodable, Encodable, Header}; use core::mem; @@ -37,7 +40,10 @@ pub struct TxL1Msg { pub queue_index: u64, /// The gas limit for the transaction. Gas is paid for when message is sent from the L1. - #[cfg_attr(feature = "serde", serde(with = "alloy_serde::quantity", rename = "gas"))] + #[cfg_attr( + feature = "serde", + serde(with = "alloy_serde::quantity", rename = "gas") + )] pub gas_limit: u64, /// The destination address for the transaction. @@ -91,12 +97,12 @@ impl TxL1Msg { /// Field order matches go-ethereum: queue_index, gas_limit, to, value, input, sender #[doc(hidden)] pub fn fields_len(&self) -> usize { - self.queue_index.length() + - self.gas_limit.length() + - self.to.length() + - self.value.length() + - self.input.0.length() + - self.sender.length() + self.queue_index.length() + + self.gas_limit.length() + + self.to.length() + + self.value.length() + + self.input.0.length() + + self.sender.length() } /// Encode the transaction fields (without the RLP header). @@ -527,7 +533,7 @@ mod tests { mem::size_of::
() + // to mem::size_of::() + // value mem::size_of::
(); // sender - // Note: input is empty so contributes 0 bytes + // Note: input is empty so contributes 0 bytes assert_eq!(tx.size(), expected_size); } From 976d50578f80ca43f6b3e355646f98bd693c5573 Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 21 Jan 2026 10:06:08 +0800 Subject: [PATCH 08/13] refactor: change sepc genesis parse --- crates/chainspec/src/spec.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/chainspec/src/spec.rs b/crates/chainspec/src/spec.rs index 736d58e..daa2c45 100644 --- a/crates/chainspec/src/spec.rs +++ b/crates/chainspec/src/spec.rs @@ -129,7 +129,7 @@ impl From for MorphChainSpec { impl From for MorphChainSpec { fn from(genesis: Genesis) -> Self { let chain_info = MorphGenesisInfo::extract_from(&genesis.config.extra_fields) - .unwrap_or_default(); + .expect("failed to extract morph genesis info"); let hardfork_info = chain_info.hard_fork_info.clone().unwrap_or_default(); From 934b28e7473baa9bb4642b7ae3cc86da94daa02f Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 21 Jan 2026 14:59:26 +0800 Subject: [PATCH 09/13] refactor: change gas price oracle constants define --- crates/chainspec/src/lib.rs | 20 +- crates/consensus/src/lib.rs | 1 - crates/consensus/src/validation.rs | 137 +++++++- crates/evm/src/block.rs | 59 ++-- crates/evm/src/lib.rs | 35 +- crates/evm/src/system_contracts.rs | 191 ----------- crates/payload/builder/src/builder.rs | 32 +- crates/primitives/Cargo.toml | 2 +- crates/primitives/src/lib.rs | 34 +- crates/primitives/src/receipt/mod.rs | 31 +- .../src/transaction/l1_transaction.rs | 1 + crates/revm/src/handler.rs | 307 ++++++++++++++--- crates/revm/src/l1block.rs | 319 +++++++++++++----- crates/revm/src/lib.rs | 21 +- 14 files changed, 800 insertions(+), 390 deletions(-) delete mode 100644 crates/evm/src/system_contracts.rs diff --git a/crates/chainspec/src/lib.rs b/crates/chainspec/src/lib.rs index 1a7a159..5a8c68c 100644 --- a/crates/chainspec/src/lib.rs +++ b/crates/chainspec/src/lib.rs @@ -20,25 +20,32 @@ //! //! # Example //! -//! ```ignore +//! ``` //! use morph_chainspec::{MorphChainSpec, MORPH_MAINNET}; //! use morph_chainspec::hardfork::MorphHardforks; //! //! // Use predefined mainnet spec //! let mainnet = MORPH_MAINNET.clone(); //! assert!(mainnet.is_bernoulli_active_at_block(0)); +//! ``` +//! +//! Create from genesis JSON: //! -//! // Or create from genesis JSON -//! let genesis: Genesis = serde_json::from_str(genesis_json)?; +//! ```no_run +//! use alloy_genesis::Genesis; +//! use morph_chainspec::MorphChainSpec; +//! +//! let genesis_json = std::fs::read_to_string("genesis.json").unwrap(); +//! let genesis: Genesis = serde_json::from_str(&genesis_json).unwrap(); //! let chain_spec = MorphChainSpec::from(genesis); //! ``` #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![cfg_attr(docsrs, feature(doc_cfg))] -// Used only in tests or for serde support, but declared here to silence unused_crate_dependencies warning +// Used only for feature propagation (alloy-consensus/serde), but declared here +// to silence the unused_crate_dependencies warning. use alloy_consensus as _; -use serde_json as _; pub mod constants; pub mod genesis; @@ -53,6 +60,9 @@ pub use constants::*; // Re-export genesis types pub use genesis::{MorphChainConfig, MorphGenesisInfo, MorphHardforkInfo}; +// Re-export hardfork types +pub use hardfork::{MorphHardfork, MorphHardforks}; + pub use morph::MORPH_MAINNET; pub use morph_hoodi::MORPH_HOODI; pub use spec::MorphChainSpec; diff --git a/crates/consensus/src/lib.rs b/crates/consensus/src/lib.rs index 4b608bd..36ce633 100644 --- a/crates/consensus/src/lib.rs +++ b/crates/consensus/src/lib.rs @@ -18,7 +18,6 @@ //! //! # Example //! -//! ```ignore //! use morph_consensus::MorphConsensus; //! use std::sync::Arc; //! diff --git a/crates/consensus/src/validation.rs b/crates/consensus/src/validation.rs index 6c46c66..727f279 100644 --- a/crates/consensus/src/validation.rs +++ b/crates/consensus/src/validation.rs @@ -1,9 +1,38 @@ //! Morph L2 consensus validation. //! -//! This module provides consensus validation for Morph L2 blocks, including: -//! - Header validation -//! - Body validation (L1 messages ordering) -//! - Block pre/post execution validation +//! This module provides consensus validation for Morph L2 blocks, implementing +//! reth's `Consensus`, `HeaderValidator`, and `FullConsensus` traits. +//! +//! # Validation Rules +//! +//! ## Header Validation +//! +//! - Extra data must be empty (Morph L2 specific) +//! - Nonce must be 0 (post-merge) +//! - Ommers hash must be empty (post-merge) +//! - Difficulty must be 0 (post-merge) +//! - Coinbase must be zero when FeeVault is enabled +//! - Timestamp cannot be in the future +//! - Gas limit must be within bounds +//! - Base fee must be set after Curie hardfork +//! +//! ## L1 Message Rules +//! +//! - All L1 messages must be at the beginning of the block +//! - L1 messages must have strictly sequential `queue_index` +//! - No gaps allowed in the queue index sequence +//! +//! ## Block Body Validation +//! +//! - No uncle blocks allowed +//! - Withdrawals must be empty +//! - Transaction root must be valid +//! +//! ## Post-Execution Validation +//! +//! - Gas used must match cumulative gas from receipts +//! - Receipts root must be valid +//! - Logs bloom must be valid //! use crate::MorphConsensusError; use alloy_consensus::{BlockHeader as _, EMPTY_OMMER_ROOT_HASH, TxReceipt}; @@ -68,6 +97,19 @@ impl MorphConsensus { // ============================================================================ impl HeaderValidator for MorphConsensus { + /// Validates a block header according to Morph L2 consensus rules. + /// + /// # Validation Steps + /// + /// 1. **Extra Data**: Must be empty (Morph L2 specific) + /// 2. **Nonce**: Must be 0 (post-merge Ethereum) + /// 3. **Ommers Hash**: Must be empty ommer root hash (post-merge) + /// 4. **Difficulty**: Must be 0 (post-merge) + /// 5. **Coinbase**: Must be zero address if FeeVault is enabled + /// 6. **Timestamp**: Must not be in the future + /// 7. **Gas Limit**: Must be <= MAX_GAS_LIMIT + /// 8. **Gas Used**: Must be <= gas limit + /// 9. **Base Fee**: Must be set after Curie hardfork and <= 10 Gwei fn validate_header(&self, header: &SealedHeader) -> Result<(), ConsensusError> { // Extra data must be empty (Morph L2 specific - stricter than max length) if !header.extra_data().is_empty() { @@ -140,6 +182,14 @@ impl HeaderValidator for MorphConsensus { Ok(()) } + /// Validates a block header against its parent header. + /// + /// # Validation Steps + /// + /// 1. **Parent Hash**: Header's parent_hash must match parent's hash + /// 2. **Block Number**: Header's number must be parent's number + 1 + /// 3. **Timestamp**: Header's timestamp must be >= parent's timestamp + /// 4. **Gas Limit**: Change must be within 1/1024 of parent's limit fn validate_header_against_parent( &self, header: &SealedHeader, @@ -151,7 +201,7 @@ impl HeaderValidator for MorphConsensus { // Validate timestamp against parent validate_against_parent_timestamp(header.header(), parent.header())?; - // Validate gas limit change (before Curie only) + // Validate gas limit change validate_against_parent_gas_limit(header.header(), parent.header())?; Ok(()) @@ -165,6 +215,9 @@ impl HeaderValidator for MorphConsensus { impl Consensus for MorphConsensus { type Error = ConsensusError; + /// Validates the block body against the header. + /// + /// Checks that the body's computed transaction root matches the header's. fn validate_body_against_header( &self, body: &BlockBody, @@ -173,6 +226,15 @@ impl Consensus for MorphConsensus { validate_body_against_header(body, header.header()) } + /// Validates the block before execution. + /// + /// # Validation Steps + /// + /// 1. **No Uncle Blocks**: Morph L2 doesn't support uncle blocks + /// 2. **Ommers Hash**: Must be the empty ommer root hash + /// 3. **Transaction Root**: Must be valid + /// 4. **Withdrawals**: Must be empty (Morph L2 doesn't support withdrawals) + /// 5. **L1 Messages**: Must be ordered correctly (sequential queue indices, L1 before L2) fn validate_block_pre_execution(&self, block: &SealedBlock) -> Result<(), Self::Error> { // Check no uncles allowed (Morph L2 has no uncle blocks) let ommers_len = block.body().ommers().map(|o| o.len()).unwrap_or_default(); @@ -216,6 +278,18 @@ impl Consensus for MorphConsensus { // ============================================================================ impl FullConsensus for MorphConsensus { + /// Validates the block after execution. + /// + /// This is called after all transactions have been executed and compares + /// the execution results against the block header. + /// + /// # Validation Steps + /// + /// 1. **Gas Used**: The cumulative gas used from the last receipt must match + /// the header's `gas_used` field. + /// 2. **Receipts Root**: The computed receipts root must match the header's. + /// 3. **Logs Bloom**: The combined bloom filter of all receipts must match + /// the header's `logs_bloom` field. fn validate_block_post_execution( &self, block: &RecoveredBlock, @@ -247,7 +321,16 @@ impl FullConsensus for MorphConsensus { } } -// +/// Validates that the header's timestamp is not before the parent's timestamp. +/// +/// # Errors +/// +/// Returns [`ConsensusError::TimestampIsInPast`] if the header's timestamp +/// is less than the parent's timestamp. +/// +/// # Note +/// +/// Equal timestamps are allowed - only strictly less than is rejected. #[inline] fn validate_against_parent_timestamp( header: &H, @@ -264,7 +347,16 @@ fn validate_against_parent_timestamp( /// Validates gas limit change against parent. /// -/// - Gas limit change must be within bounds (parent / GAS_LIMIT_BOUND_DIVISOR) +/// The gas limit change between consecutive blocks must not exceed +/// `parent_gas_limit / GAS_LIMIT_BOUND_DIVISOR` (1/1024 of parent's limit). +/// +/// Additionally, the gas limit must be at least [`MINIMUM_GAS_LIMIT`] (5000). +/// +/// # Errors +/// +/// - [`ConsensusError::GasLimitInvalidIncrease`] if gas limit increased too much +/// - [`ConsensusError::GasLimitInvalidDecrease`] if gas limit decreased too much +/// - [`ConsensusError::GasLimitInvalidMinimum`] if gas limit is below minimum #[inline] fn validate_against_parent_gas_limit( header: &H, @@ -301,8 +393,35 @@ fn validate_against_parent_gas_limit( /// Validates L1 message ordering in a block's transactions. /// -/// - All L1 messages must be at the beginning of the block -/// - L1 messages must have strictly sequential queue indices +/// L1 messages are special transactions that originate from L1 (deposits, etc.). +/// They must follow strict ordering rules to ensure deterministic block execution. +/// +/// # Rules +/// +/// 1. **Position**: All L1 messages must appear at the beginning of the block. +/// Once a regular (L2) transaction appears, no more L1 messages are allowed. +/// +/// 2. **Sequential Queue Index**: L1 messages must have strictly sequential +/// `queue_index` values. If the first L1 message has `queue_index = N`, +/// the next must have `queue_index = N+1`, and so on. +/// +/// # Errors +/// +/// - [`MorphConsensusError::MalformedL1Message`] if an L1 message is missing its queue_index +/// - [`MorphConsensusError::L1MessagesNotInOrder`] if queue indices are not sequential +/// - [`MorphConsensusError::InvalidL1MessageOrder`] if L1 message appears after L2 transaction +/// +/// # Example (Valid) +/// +/// ```text +/// [L1Msg(queue=0), L1Msg(queue=1), L1Msg(queue=2), RegularTx, RegularTx] +/// ``` +/// +/// # Example (Invalid - L1 after L2) +/// +/// ```text +/// [L1Msg(queue=0), RegularTx, L1Msg(queue=1)] // ❌ L1 after L2 +/// ``` #[inline] fn validate_l1_messages(txs: &[&MorphTxEnvelope]) -> Result<(), ConsensusError> { // Find the starting queue index from the first L1 message diff --git a/crates/evm/src/block.rs b/crates/evm/src/block.rs index 4a8e5b9..e1fc685 100644 --- a/crates/evm/src/block.rs +++ b/crates/evm/src/block.rs @@ -1,11 +1,4 @@ -use crate::{ - MorphBlockExecutionCtx, - evm::MorphEvm, - system_contracts::{ - BLOB_ENABLED, GPO_IS_BLOB_ENABLED_SLOT, L1_GAS_PRICE_ORACLE_ADDRESS, - L1_GAS_PRICE_ORACLE_INIT_STORAGE, - }, -}; +use crate::{MorphBlockExecutionCtx, evm::MorphEvm}; use alloy_consensus::Receipt; use alloy_evm::{ Database, Evm, @@ -15,9 +8,12 @@ use alloy_evm::{ receipt_builder::{ReceiptBuilder, ReceiptBuilderCtx}, }, }; -use morph_chainspec::MorphChainSpec; +use morph_chainspec::{MorphChainSpec, MorphHardfork, MorphHardforks}; use morph_primitives::{MorphReceipt, MorphTransactionReceipt, MorphTxEnvelope, MorphTxType}; -use morph_revm::{MorphHaltReason, evm::MorphContext}; +use morph_revm::{ + CURIE_L1_GAS_PRICE_ORACLE_STORAGE, L1_GAS_PRICE_ORACLE_ADDRESS, MorphHaltReason, + evm::MorphContext, +}; use reth_revm::{Inspector, State, context::result::ResultAndState}; use revm::{Database as RevmDatabase, database::states::StorageSlot}; @@ -69,9 +65,6 @@ pub(crate) struct MorphBlockExecutor<'a, DB: Database, I> { &'a MorphChainSpec, MorphReceiptBuilder, >, - /// Reference to the chain spec for hardfork checks. - #[allow(dead_code)] - chain_spec: &'a MorphChainSpec, } impl<'a, DB, I> MorphBlockExecutor<'a, DB, I> @@ -91,7 +84,6 @@ where chain_spec, MorphReceiptBuilder::default(), ), - chain_spec, } } } @@ -117,10 +109,18 @@ where .load_cache_account(L1_GAS_PRICE_ORACLE_ADDRESS) .map_err(BlockExecutionError::other)?; - // 3. Initialize L1 gas price oracle storage (idempotent - only applies if not already done) - if let Err(err) = init_l1_gas_price_oracle_storage(self.inner.evm_mut().db_mut()) { + // 3. Apply Curie hardfork at the transition block + // Only executes once at the exact block where Curie activates + let block_number = self.inner.evm.block().number.saturating_to(); + if self + .inner + .spec + .morph_fork_activation(MorphHardfork::Curie) + .transitions_at_block(block_number) + && let Err(err) = apply_curie_hard_fork(self.inner.evm_mut().db_mut()) + { return Err(BlockExecutionError::msg(format!( - "error occurred at L1 gas price oracle initialization: {err:?}" + "error occurred at Curie fork: {err:?}" ))); } @@ -161,34 +161,23 @@ where } } -/// Initializes L1 gas price oracle storage slots for blob-based L1 fee calculations. +/// Applies the Morph Curie hard fork to the state. /// /// Updates L1GasPriceOracle storage slots: -/// - Sets `isBlobEnabled` slot to 1 (true) /// - Sets `l1BlobBaseFee` slot to 1 /// - Sets `commitScalar` slot to initial value /// - Sets `blobScalar` slot to initial value +/// - Sets `isCurie` slot to 1 (true) /// -/// This function is idempotent - if already applied (isBlobEnabled == 1), it's a no-op. -fn init_l1_gas_price_oracle_storage( - state: &mut State, -) -> Result<(), DB::Error> { - // No-op if already applied (check isBlobEnabled slot). - // This makes the function idempotent so we can call it on every block. - if state - .database - .storage(L1_GAS_PRICE_ORACLE_ADDRESS, GPO_IS_BLOB_ENABLED_SLOT)? - == BLOB_ENABLED - { - return Ok(()); - } - - tracing::info!(target: "morph::evm", "Initializing L1 gas price oracle storage slots"); +/// This function should only be called once at the Curie transition block. +/// Reference: `consensus/misc/curie.go` in morph go-ethereum +fn apply_curie_hard_fork(state: &mut State) -> Result<(), DB::Error> { + tracing::info!(target: "morph::evm", "Applying Curie hard fork"); let oracle = state.load_cache_account(L1_GAS_PRICE_ORACLE_ADDRESS)?; // Create storage updates - let new_storage = L1_GAS_PRICE_ORACLE_INIT_STORAGE + let new_storage = CURIE_L1_GAS_PRICE_ORACLE_STORAGE .into_iter() .map(|(slot, present_value)| { ( diff --git a/crates/evm/src/lib.rs b/crates/evm/src/lib.rs index 3a6ab5a..334fe77 100644 --- a/crates/evm/src/lib.rs +++ b/crates/evm/src/lib.rs @@ -1,4 +1,38 @@ //! Morph EVM implementation. +//! +//! This crate provides the EVM configuration and block execution logic for Morph L2. +//! +//! # Main Components +//! +//! - [`MorphEvmConfig`]: Main EVM configuration that implements `BlockExecutorFactory` +//! - [`MorphEvmFactory`]: Factory for creating Morph EVM instances +//! - [`MorphBlockAssembler`]: Block assembly logic for payload building +//! - [`MorphBlockExecutionCtx`]: Execution context for block processing +//! +//! # Architecture +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────────┐ +//! │ MorphEvmConfig │ +//! │ ┌─────────────────────┐ ┌─────────────────────────────────┐ │ +//! │ │ EthEvmConfig │ │ MorphBlockAssembler │ │ +//! │ │ (inner config) │ │ (block building) │ │ +//! │ └─────────────────────┘ └─────────────────────────────────┘ │ +//! │ │ │ +//! │ ▼ │ +//! │ ┌─────────────────────────────────────────────────────────┐ │ +//! │ │ MorphBlockExecutor │ │ +//! │ │ - Executes transactions │ │ +//! │ │ - Handles L1 messages │ │ +//! │ │ - Calculates L1 data fee │ │ +//! │ └─────────────────────────────────────────────────────────┘ │ +//! └─────────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! # Features +//! +//! - `reth-codec`: Enable `ConfigureEvm` implementation for reth integration +//! - `engine`: Enable engine API types #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![cfg_attr(docsrs, feature(doc_cfg))] @@ -15,7 +49,6 @@ pub use context::{MorphBlockExecutionCtx, MorphNextBlockEnvAttributes}; mod error; pub use error::MorphEvmError; pub mod evm; -pub mod system_contracts; use std::sync::Arc; use alloy_evm::{ diff --git a/crates/evm/src/system_contracts.rs b/crates/evm/src/system_contracts.rs deleted file mode 100644 index dc541d4..0000000 --- a/crates/evm/src/system_contracts.rs +++ /dev/null @@ -1,191 +0,0 @@ -//! Morph system contract addresses and storage slots. -//! -//! This module defines the pre-deployed system contract addresses and their storage layouts -//! for the Morph L2 network. These contracts are deployed at genesis and provide essential -//! L2 functionality like L1 fee calculation and message queuing. - -use alloy_primitives::{Address, B256, U256, address}; - -// ============================================================================= -// System Contract Addresses -// ============================================================================= - -/// L1 Gas Price Oracle contract address. -/// -/// This contract stores L1 gas prices used for calculating L1 data fees. -/// Reference: `rollup/rcfg/config.go` in go-ethereum -pub const L1_GAS_PRICE_ORACLE_ADDRESS: Address = - address!("530000000000000000000000000000000000000F"); - -/// L2 Message Queue contract address. -/// -/// Manages the L1-to-L2 message queue and stores the withdraw trie root. -/// See: -pub const L2_MESSAGE_QUEUE_ADDRESS: Address = address!("5300000000000000000000000000000000000001"); - -// ============================================================================= -// L2 Message Queue Storage Slots -// ============================================================================= - -// forge inspect contracts/L2/predeploys/L2MessageQueue.sol:L2MessageQueue storageLayout -// The contract inherits from AppendOnlyMerkleTree which has: -// - slot 0-31: branches[32] array -// - slot 32: nextIndex -// - slot 33: messageRoot (the withdraw trie root) - -/// Storage slot for the withdraw trie root (`messageRoot`) in L2MessageQueue contract. -/// -/// This is slot 33, which stores the Merkle root for L2→L1 messages. -/// See: -pub const L2_MESSAGE_QUEUE_WITHDRAW_TRIE_ROOT_SLOT: U256 = U256::from_limbs([33, 0, 0, 0]); - -// ============================================================================= -// L2 Message Queue Functions -// ============================================================================= - -/// Reads the withdraw trie root from the L2MessageQueue contract storage. -/// -/// This function reads the `messageRoot` slot from the L2MessageQueue contract, -/// which stores the Merkle root for L2→L1 withdrawal messages. -/// -pub fn read_withdraw_trie_root(db: &mut DB) -> Result { - let value = db.storage( - L2_MESSAGE_QUEUE_ADDRESS, - L2_MESSAGE_QUEUE_WITHDRAW_TRIE_ROOT_SLOT, - )?; - Ok(B256::from(value)) -} - -/// Morph Fee Vault contract address. -/// -/// Collects transaction fees. -pub const MORPH_FEE_VAULT_ADDRESS: Address = address!("530000000000000000000000000000000000000a"); - -/// Morph Token Registry contract address. -pub const MORPH_TOKEN_REGISTRY_ADDRESS: Address = - address!("5300000000000000000000000000000000000021"); - -/// Sequencer contract address. -/// -/// Manages sequencer operations. -pub const SEQUENCER_ADDRESS: Address = address!("5300000000000000000000000000000000000017"); - -// ============================================================================= -// L1 Gas Price Oracle Storage Slots -// ============================================================================= - -// forge inspect contracts/l2/system/GasPriceOracle.sol:GasPriceOracle storageLayout -// +-----------------+---------------------+------+--------+-------+ -// | Name | Type | Slot | Offset | Bytes | -// +=================================================================+ -// | owner | address | 0 | 0 | 20 | -// +-----------------+---------------------+------+--------+-------+ -// | l1BaseFee | uint256 | 1 | 0 | 32 | -// +-----------------+---------------------+------+--------+-------+ -// | overhead | uint256 | 2 | 0 | 32 | -// +-----------------+---------------------+------+--------+-------+ -// | scalar | uint256 | 3 | 0 | 32 | -// +-----------------+---------------------+------+--------+-------+ -// | whitelist | contract IWhitelist | 4 | 0 | 20 | -// +-----------------+---------------------+------+--------+-------+ -// | __deprecated0 | uint256 | 5 | 0 | 32 | -// +-----------------+---------------------+------+--------+-------+ -// | l1BlobBaseFee | uint256 | 6 | 0 | 32 | -// +-----------------+---------------------+------+--------+-------+ -// | commitScalar | uint256 | 7 | 0 | 32 | -// +-----------------+---------------------+------+--------+-------+ -// | blobScalar | uint256 | 8 | 0 | 32 | -// +-----------------+---------------------+------+--------+-------+ -// | isBlobEnabled | bool | 9 | 0 | 1 | -// +-----------------+---------------------+------+--------+-------+ - -/// Storage slot for `l1BaseFee` in the `L1GasPriceOracle` contract. -pub const GPO_L1_BASE_FEE_SLOT: U256 = U256::from_limbs([1, 0, 0, 0]); - -/// Storage slot for `overhead` in the `L1GasPriceOracle` contract. -pub const GPO_OVERHEAD_SLOT: U256 = U256::from_limbs([2, 0, 0, 0]); - -/// Storage slot for `scalar` in the `L1GasPriceOracle` contract. -pub const GPO_SCALAR_SLOT: U256 = U256::from_limbs([3, 0, 0, 0]); - -/// Storage slot for `l1BlobBaseFee` in the `L1GasPriceOracle` contract. -pub const GPO_L1_BLOB_BASE_FEE_SLOT: U256 = U256::from_limbs([6, 0, 0, 0]); - -/// Storage slot for `commitScalar` in the `L1GasPriceOracle` contract. -pub const GPO_COMMIT_SCALAR_SLOT: U256 = U256::from_limbs([7, 0, 0, 0]); - -/// Storage slot for `blobScalar` in the `L1GasPriceOracle` contract. -pub const GPO_BLOB_SCALAR_SLOT: U256 = U256::from_limbs([8, 0, 0, 0]); - -/// Storage slot for `isBlobEnabled` in the `L1GasPriceOracle` contract. -pub const GPO_IS_BLOB_ENABLED_SLOT: U256 = U256::from_limbs([9, 0, 0, 0]); - -// ============================================================================= -// L1 Gas Price Oracle Initial Values -// ============================================================================= - -/// The initial blob base fee used by the oracle contract. -pub const INITIAL_L1_BLOB_BASE_FEE: U256 = U256::from_limbs([1, 0, 0, 0]); - -/// The initial commit scalar used by the oracle contract. -/// Reference: `rcfg.InitialCommitScalar` in go-ethereum (230759955285) -pub const INITIAL_COMMIT_SCALAR: U256 = U256::from_limbs([230759955285, 0, 0, 0]); - -/// The initial blob scalar used by the oracle contract. -/// Reference: `rcfg.InitialBlobScalar` in go-ethereum (417565260) -pub const INITIAL_BLOB_SCALAR: U256 = U256::from_limbs([417565260, 0, 0, 0]); - -/// Blob enabled flag is set to 1 (true). -pub const BLOB_ENABLED: U256 = U256::from_limbs([1, 0, 0, 0]); - -/// Storage updates for L1 gas price oracle initialization. -pub const L1_GAS_PRICE_ORACLE_INIT_STORAGE: [(U256, U256); 4] = [ - (GPO_L1_BLOB_BASE_FEE_SLOT, INITIAL_L1_BLOB_BASE_FEE), - (GPO_COMMIT_SCALAR_SLOT, INITIAL_COMMIT_SCALAR), - (GPO_BLOB_SCALAR_SLOT, INITIAL_BLOB_SCALAR), - (GPO_IS_BLOB_ENABLED_SLOT, BLOB_ENABLED), -]; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_system_contract_addresses() { - // Verify addresses match go-ethereum definitions - assert_eq!( - L1_GAS_PRICE_ORACLE_ADDRESS, - address!("530000000000000000000000000000000000000F") - ); - assert_eq!( - L2_MESSAGE_QUEUE_ADDRESS, - address!("5300000000000000000000000000000000000001") - ); - assert_eq!( - MORPH_FEE_VAULT_ADDRESS, - address!("530000000000000000000000000000000000000a") - ); - assert_eq!( - SEQUENCER_ADDRESS, - address!("5300000000000000000000000000000000000017") - ); - } - - #[test] - fn test_storage_slots() { - assert_eq!(GPO_L1_BASE_FEE_SLOT, U256::from(1)); - assert_eq!(GPO_OVERHEAD_SLOT, U256::from(2)); - assert_eq!(GPO_SCALAR_SLOT, U256::from(3)); - assert_eq!(GPO_L1_BLOB_BASE_FEE_SLOT, U256::from(6)); - assert_eq!(GPO_COMMIT_SCALAR_SLOT, U256::from(7)); - assert_eq!(GPO_BLOB_SCALAR_SLOT, U256::from(8)); - assert_eq!(GPO_IS_BLOB_ENABLED_SLOT, U256::from(9)); - } - - #[test] - fn test_initial_values() { - // Values from go-ethereum rcfg/config.go - assert_eq!(INITIAL_COMMIT_SCALAR, U256::from(230759955285u64)); - assert_eq!(INITIAL_BLOB_SCALAR, U256::from(417565260u64)); - } -} diff --git a/crates/payload/builder/src/builder.rs b/crates/payload/builder/src/builder.rs index f04a434..64182dd 100644 --- a/crates/payload/builder/src/builder.rs +++ b/crates/payload/builder/src/builder.rs @@ -3,12 +3,10 @@ use crate::{MorphBuilderConfig, MorphPayloadBuilderError, config::PayloadBuildingBreaker}; use alloy_consensus::{BlockHeader, Transaction, Typed2718}; use alloy_eips::eip2718::Encodable2718; -use alloy_primitives::{Bytes, U256}; +use alloy_primitives::{Address, B256, Bytes, U256, address}; use alloy_rlp::Encodable; use morph_chainspec::MorphChainSpec; -use morph_evm::{ - MorphEvmConfig, MorphNextBlockEnvAttributes, system_contracts::read_withdraw_trie_root, -}; +use morph_evm::{MorphEvmConfig, MorphNextBlockEnvAttributes}; use morph_payload_types::{ExecutableL2Data, MorphBuiltPayload, MorphPayloadBuilderAttributes}; use morph_primitives::{MorphHeader, MorphTxEnvelope}; use reth_basic_payload_builder::{ @@ -31,6 +29,32 @@ use reth_transaction_pool::{BestTransactionsAttributes, PoolTransaction, Transac use revm::context_interface::Block as RevmBlock; use std::sync::Arc; +// ============================================================================= +// L2 Message Queue Constants +// ============================================================================= + +/// L2 Message Queue contract address. +/// +/// Manages the L1-to-L2 message queue and stores the withdraw trie root. +const L2_MESSAGE_QUEUE_ADDRESS: Address = address!("5300000000000000000000000000000000000001"); + +/// Storage slot for the withdraw trie root (`messageRoot`) in L2MessageQueue contract. +/// This is slot 33, which stores the Merkle root for L2→L1 messages. +const L2_MESSAGE_QUEUE_WITHDRAW_TRIE_ROOT_SLOT: U256 = U256::from_limbs([33, 0, 0, 0]); + +/// Reads the withdraw trie root from the L2MessageQueue contract storage. +fn read_withdraw_trie_root(db: &mut DB) -> Result { + let value = db.storage( + L2_MESSAGE_QUEUE_ADDRESS, + L2_MESSAGE_QUEUE_WITHDRAW_TRIE_ROOT_SLOT, + )?; + Ok(B256::from(value)) +} + +// ============================================================================= +// Payload Transactions +// ============================================================================= + /// A type that returns the [`PayloadTransactions`] that should be included in the pool. pub trait MorphPayloadTransactions: Clone + Send + Sync + Unpin + 'static { /// Returns an iterator that yields the transactions in the order they should get included in diff --git a/crates/primitives/Cargo.toml b/crates/primitives/Cargo.toml index 7f1a6cb..2180863 100644 --- a/crates/primitives/Cargo.toml +++ b/crates/primitives/Cargo.toml @@ -35,7 +35,7 @@ modular-bitfield = { version = "0.11.2", optional = true } serde_json.workspace = true [features] -default = ["serde", "reth-codec"] +default = ["serde", "reth"] serde = [ "dep:serde", "dep:alloy-serde", diff --git a/crates/primitives/src/lib.rs b/crates/primitives/src/lib.rs index c9ac4bc..cde8bef 100644 --- a/crates/primitives/src/lib.rs +++ b/crates/primitives/src/lib.rs @@ -1,6 +1,33 @@ //! Morph primitive types //! -//! Re-exports standard Ethereum types for use in the Morph EVM. +//! This crate provides core data types for Morph L2, including custom transaction +//! types, receipt types, and type aliases for blocks and headers. +//! +//! # Transaction Types +//! +//! Morph L2 extends Ethereum's transaction types with: +//! +//! - [`TxL1Msg`]: L1 message transactions (type `0x7E`) - deposits from L1 +//! - [`TxAltFee`]: Alternative fee transactions (type `0x7F`) - pay gas with ERC20 tokens +//! - [`MorphTxEnvelope`]: Transaction envelope containing all supported transaction types +//! +//! # Receipt Types +//! +//! - [`MorphReceipt`]: Receipt enum for all transaction types +//! - [`MorphTransactionReceipt`]: Extended receipt with L1 fee and AltFee fields +//! +//! # Block Types +//! +//! - [`Block`]: Morph block type alias +//! - [`BlockBody`]: Morph block body type alias +//! - [`MorphHeader`]: Header type alias (same as Ethereum) +//! +//! # Node Primitives +//! +//! [`MorphPrimitives`] implements reth's `NodePrimitives` trait, providing +//! all the type bindings needed for a Morph node. +//! +//! Note: `NodePrimitives` implementation requires the `reth-codec` feature. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![cfg_attr(docsrs, feature(doc_cfg), allow(unexpected_cfgs))] @@ -23,8 +50,6 @@ pub mod transaction; // Re-export header type pub use header::MorphHeader; -use reth_primitives_traits::NodePrimitives; - /// Morph block. pub type Block = alloy_consensus::Block; @@ -44,7 +69,8 @@ pub use transaction::{ #[non_exhaustive] pub struct MorphPrimitives; -impl NodePrimitives for MorphPrimitives { +#[cfg(feature = "reth-codec")] +impl reth_primitives_traits::NodePrimitives for MorphPrimitives { type Block = Block; type BlockHeader = MorphHeader; type BlockBody = BlockBody; diff --git a/crates/primitives/src/receipt/mod.rs b/crates/primitives/src/receipt/mod.rs index ac3aab8..06e6d11 100644 --- a/crates/primitives/src/receipt/mod.rs +++ b/crates/primitives/src/receipt/mod.rs @@ -412,10 +412,26 @@ impl InMemorySize for MorphReceipt { } } -/// Calculates the root hash of a list of receipts. -pub fn calculate_receipt_root(receipts: &[MorphReceipt]) -> B256 { +/// Calculates the receipt root for a header. +/// +/// This function computes the Merkle root of receipts using the standard encoding +/// that includes the bloom filter, which is required for consensus validation. +/// +/// NOTE: Prefer `alloy_consensus::proofs::calculate_receipt_root` if you have +/// log blooms already memoized (e.g., from bloom validation). +/// +/// # Example +/// +/// ``` +/// use morph_primitives::receipt::{MorphReceipt, calculate_receipt_root_no_memo}; +/// use alloy_consensus::Receipt; +/// +/// let receipts: Vec = vec![]; +/// let receipts_root = calculate_receipt_root_no_memo(&receipts); +/// ``` +pub fn calculate_receipt_root_no_memo(receipts: &[MorphReceipt]) -> B256 { alloy_consensus::proofs::ordered_trie_root_with_encoder(receipts, |r, buf| { - r.encode_2718(buf); + r.with_bloom_ref().encode_2718(buf) }) } @@ -504,8 +520,13 @@ mod compact { logs: logs.into_owned(), }; + // L1Msg uses plain Receipt, others use MorphTransactionReceipt + if tx_type == MorphTxType::L1Msg { + return Self::L1Msg(inner); + } + let morph_receipt = MorphTransactionReceipt { - inner: inner.clone(), + inner, l1_fee, fee_token_id: fee_token_id.map(|id| id as u16), fee_rate, @@ -518,7 +539,7 @@ mod compact { MorphTxType::Eip2930 => Self::Eip2930(morph_receipt), MorphTxType::Eip1559 => Self::Eip1559(morph_receipt), MorphTxType::Eip7702 => Self::Eip7702(morph_receipt), - MorphTxType::L1Msg => Self::L1Msg(inner), + MorphTxType::L1Msg => unreachable!("L1Msg handled above"), MorphTxType::AltFee => Self::AltFee(morph_receipt), } } diff --git a/crates/primitives/src/transaction/l1_transaction.rs b/crates/primitives/src/transaction/l1_transaction.rs index 6883834..17f5c9d 100644 --- a/crates/primitives/src/transaction/l1_transaction.rs +++ b/crates/primitives/src/transaction/l1_transaction.rs @@ -37,6 +37,7 @@ pub const L1_TX_TYPE_ID: u8 = 0x7E; #[cfg_attr(feature = "reth-codec", derive(reth_codecs::Compact))] pub struct TxL1Msg { /// The queue index of the message in the L1 contract queue. + #[cfg_attr(feature = "serde", serde(with = "alloy_serde::quantity"))] pub queue_index: u64, /// The gas limit for the transaction. Gas is paid for when message is sent from the L1. diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index d6d2dc1..5c25a37 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -1,21 +1,21 @@ //! Morph EVM Handler implementation. -use std::fmt::Debug; - -use alloy_primitives::U256; +use alloy_primitives::{Address, Bytes, U256}; +use morph_primitives::L1_TX_TYPE_ID; use revm::{ + ExecuteEvm, context::{ Cfg, ContextTr, JournalTr, Transaction, result::{EVMError, ExecutionResult, InvalidTransaction}, }, context_interface::Block, - handler::{EvmTr, FrameTr, Handler, MainnetHandler, pre_execution, validation}, + handler::{EvmTr, FrameTr, Handler, MainnetHandler, post_execution, pre_execution, validation}, inspector::{Inspector, InspectorHandler}, - interpreter::{InitialAndFloorGas, interpreter::EthInterpreter}, + interpreter::{Gas, InitialAndFloorGas, interpreter::EthInterpreter}, }; use crate::{ - MorphEvm, MorphInvalidTransaction, + MorphEvm, MorphInvalidTransaction, MorphTxEnv, error::MorphHaltReason, evm::MorphContext, l1block::L1BlockInfo, @@ -115,16 +115,22 @@ where fn reimburse_caller( &self, evm: &mut Self::Evm, - _exec_result: &mut <::Frame as FrameTr>::FrameResult, + exec_result: &mut <::Frame as FrameTr>::FrameResult, ) -> Result<(), Self::Error> { // For L1 message transactions, no reimbursement is needed if evm.ctx_ref().tx().is_l1_msg() { return Ok(()); } - // For Morph L2, we don't reimburse caller - // The L2 execution fee is handled by the sequencer - // L1 data fee is a fixed cost that is not refunded + // Check if transaction is AltFeeTx (tx_type 0x7F) which uses token fee + if evm.ctx_ref().tx().is_alt_fee_tx() { + // Get fee_token_id directly from MorphTxEnv + let token_id = evm.ctx_ref().tx().fee_token_id.unwrap_or_default(); + return self.reimburse_caller_token_fee(evm, exec_result.gas(), token_id); + } + + // Standard ETH-based fee handling + post_execution::reimburse_caller(evm.ctx(), exec_result.gas(), U256::ZERO)?; Ok(()) } @@ -148,8 +154,11 @@ where let basefee = evm.ctx_ref().block().basefee() as u128; let effective_gas_price = evm.ctx_ref().tx().effective_gas_price(basefee); + // Get the current hardfork for L1 fee calculation + let hardfork = evm.ctx_ref().cfg().spec(); + // Fetch L1 block info from the L1 Gas Price Oracle contract - let l1_block_info = L1BlockInfo::try_fetch(evm.ctx_mut().db_mut())?; + let l1_block_info = L1BlockInfo::try_fetch(evm.ctx_mut().db_mut(), hardfork)?; // Get RLP-encoded transaction bytes for L1 fee calculation // This represents the full transaction data posted to L1 for data availability @@ -162,14 +171,12 @@ where .unwrap_or_default(); // Calculate L1 data fee based on full RLP-encoded transaction - let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes); + let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes, hardfork); - // Get mutable access to context components + // Get mutable access to journal components let journal = evm.ctx().journal_mut(); - let gas_spent = exec_result.gas().spent(); - let gas_refunded = exec_result.gas().refunded() as u64; - let gas_used = gas_spent - gas_refunded; + let gas_used = exec_result.gas().used(); let execution_fee = U256::from(effective_gas_price).saturating_mul(U256::from(gas_used)); @@ -243,8 +250,11 @@ where &self, evm: &mut MorphEvm, ) -> Result<(), EVMError> { + // Get the current hardfork for L1 fee calculation + let hardfork = evm.ctx_ref().cfg().spec(); + // Fetch L1 block info from the L1 Gas Price Oracle contract - let l1_block_info = L1BlockInfo::try_fetch(evm.ctx_mut().db_mut())?; + let l1_block_info = L1BlockInfo::try_fetch(evm.ctx_mut().db_mut(), hardfork)?; // Get RLP-encoded transaction bytes for L1 fee calculation // This represents the full transaction data posted to L1 for data availability @@ -257,7 +267,7 @@ where .unwrap_or_default(); // Calculate L1 data fee based on full RLP-encoded transaction - let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes); + let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes, hardfork); // Get mutable access to context components let (block, tx, cfg, journal, _, _) = evm.ctx().all_mut(); @@ -292,45 +302,114 @@ where /// Validate and deduct token-based gas fees. /// /// This handles gas payment using ERC20 tokens instead of ETH. - fn validate_and_deduct_token_fee( + fn reimburse_caller_token_fee( &self, evm: &mut MorphEvm, + gas: &Gas, token_id: u16, ) -> Result<(), EVMError> { // Get caller address - let caller_addr = evm.ctx_ref().tx().caller(); + let caller = evm.ctx_ref().tx().caller(); // Get coinbase address let beneficiary = evm.ctx_ref().block().beneficiary(); + let basefee = evm.ctx.block().basefee() as u128; + let effective_gas_price = evm.ctx.tx().effective_gas_price(basefee); + + let reimburse_eth = U256::from( + effective_gas_price.saturating_mul((gas.remaining() + gas.refunded() as u64) as u128), + ); + + if reimburse_eth.is_zero() { + return Ok(()); + } + + // Fetch token fee info from Token Registry + let token_fee_info = TokenFeeInfo::try_fetch(evm.ctx_mut().db_mut(), token_id, caller)? + .ok_or(MorphInvalidTransaction::TokenNotRegistered(token_id))?; + + // Check if token is active + if !token_fee_info.is_active { + return Err(MorphInvalidTransaction::TokenNotActive(token_id).into()); + } + + // Calculate token amount required for total fee + let token_amount_required = token_fee_info.calculate_token_amount(reimburse_eth); + + // Get mutable access to journal components + let journal = evm.ctx().journal_mut(); + + if let Some(balance_slot) = token_fee_info.balance_slot { + // Transfer with token slot. + let _ = transfer_erc20_with_slot( + journal, + beneficiary, + caller, + token_fee_info.token_address, + token_amount_required, + balance_slot, + )?; + } else { + // Transfer with evm call. + transfer_erc20_with_evm( + evm, + beneficiary, + token_fee_info.caller, + token_fee_info.token_address, + token_amount_required, + )?; + } + Ok(()) + } + + /// Validate and deduct token-based gas fees. + /// + /// This handles gas payment using ERC20 tokens instead of ETH. + fn validate_and_deduct_token_fee( + &self, + evm: &mut MorphEvm, + token_id: u16, + ) -> Result<(), EVMError> { + // Token ID 0 not supported for gas payment. + if token_id == 0 { + return Err(MorphInvalidTransaction::TokenIdZeroNotSupported.into()); + } + + let (block, tx, cfg, journal, _, _) = evm.ctx_mut().all_mut(); + + // Get caller address + let caller_addr = tx.caller(); + // Get coinbase address + let beneficiary = block.beneficiary(); // Fetch token fee info from Token Registry - let token_fee_info = - TokenFeeInfo::try_fetch(evm.ctx_mut().db_mut(), token_id, caller_addr)? - .ok_or(MorphInvalidTransaction::TokenNotRegistered(token_id))?; + let token_fee_info = TokenFeeInfo::try_fetch(journal.db_mut(), token_id, caller_addr)? + .ok_or(MorphInvalidTransaction::TokenNotRegistered(token_id))?; // Check if token is active if !token_fee_info.is_active { return Err(MorphInvalidTransaction::TokenNotActive(token_id).into()); } + // Get the current hardfork for L1 fee calculation + let hardfork = cfg.spec(); + // Fetch L1 block info from the L1 Gas Price Oracle contract - let l1_block_info = L1BlockInfo::try_fetch(evm.ctx_mut().db_mut())?; + let l1_block_info = L1BlockInfo::try_fetch(journal.db_mut(), hardfork)?; // Get RLP-encoded transaction bytes for L1 fee calculation // This represents the full transaction data posted to L1 for data availability - let rlp_bytes = evm - .ctx_ref() - .tx() + let rlp_bytes = tx .rlp_bytes .as_ref() .map(|b| b.as_ref()) .unwrap_or_default(); // Calculate L1 data fee (in ETH) based on full RLP-encoded transaction - let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes); + let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes, hardfork); // Calculate L2 gas fee (in ETH) - let gas_limit = evm.ctx_ref().tx().gas_limit(); - let gas_price = evm.ctx_ref().tx().gas_price(); + let gas_limit = tx.gas_limit(); + let gas_price = tx.gas_price(); let l2_gas_fee = U256::from(gas_limit).saturating_mul(U256::from(gas_price)); // Total fee in ETH @@ -339,55 +418,80 @@ where // Calculate token amount required for total fee let token_amount_required = token_fee_info.calculate_token_amount(total_eth_fee); + // Determine fee limit + let mut fee_limit = tx.fee_limit.unwrap_or_default(); + if fee_limit.is_zero() || fee_limit > token_fee_info.balance { + fee_limit = token_fee_info.balance + } + // Check if caller has sufficient token balance - if token_fee_info.balance < token_amount_required { + if fee_limit < token_amount_required { return Err(MorphInvalidTransaction::InsufficientTokenBalance { required: token_amount_required, - available: token_fee_info.balance, + available: fee_limit, } .into()); } - // Get mutable access to context components - let (_, tx, cfg, journal, _, _) = evm.ctx().all_mut(); - - // First, deduct token fee from caller's ERC20 balance - // This updates the ERC20 token's storage directly if let Some(balance_slot) = token_fee_info.balance_slot { - // Sub amount - let token_storage_slot = get_mapping_account_slot(balance_slot, caller_addr); - let new_token_balance = token_fee_info.balance.saturating_sub(token_amount_required); - journal.sstore( + // Transfer with token slot. + // Ensure token account is loaded into the journal state, because `sload`/`sstore` + // assume the account is present. + let _ = journal.load_account_mut(token_fee_info.token_address)?; + journal.touch(token_fee_info.token_address); + let (from_storage_slot, to_storage_slot) = transfer_erc20_with_slot( + journal, + caller_addr, + beneficiary, token_fee_info.token_address, - token_storage_slot, - new_token_balance, + token_amount_required, + balance_slot, )?; - - // Add amount - let token_storage_slot = get_mapping_account_slot(balance_slot, beneficiary); - let balance = journal - .sload(token_fee_info.token_address, token_storage_slot) - .unwrap_or_default(); - journal.sstore( + // We don't want the fee-token account/slots we touched during validation to become + // warm for the rest of the transaction execution. + if let Some(token_acc) = journal.state.get_mut(&token_fee_info.token_address) { + token_acc.mark_cold(); + if let Some(slot) = token_acc.storage.get_mut(&from_storage_slot) { + slot.mark_cold(); + } + if let Some(slot) = token_acc.storage.get_mut(&to_storage_slot) { + slot.mark_cold(); + } + } + } else { + // Transfer with evm call. + let tx_origin = evm.tx.clone(); + transfer_erc20_with_evm( + evm, + token_fee_info.caller, beneficiary, - token_storage_slot, - balance.saturating_sub(token_amount_required), + token_fee_info.token_address, + token_amount_required, )?; + // restore the original transaction + evm.tx = tx_origin; } + let (_, tx, cfg, journal, _, _) = evm.ctx().all_mut(); + + // Extract the required tx fields (Copy) before mutating accounts. + let caller_addr = tx.caller(); + let nonce = tx.nonce(); + let is_call = tx.kind().is_call(); + // Load caller's account for nonce/code validation - let mut caller = journal.load_account_with_code_mut(tx.caller())?.data; + let mut caller = journal.load_account_with_code_mut(caller_addr)?.data; // Validate account nonce and code (EIP-3607) pre_execution::validate_account_nonce_and_code( &caller.info, - tx.nonce(), + nonce, cfg.is_eip3607_disabled(), cfg.is_nonce_check_disabled(), )?; // Bump nonce for calls (CREATE nonce is bumped in make_create_frame) - if tx.kind().is_call() { + if is_call { caller.bump_nonce(); } @@ -395,6 +499,99 @@ where } } +/// Performs an ERC20 balance transfer by directly `sload`/`sstore`-ing the token contract storage +/// using the known `balance` mapping base slot, returning the computed storage slots for `from`/`to`. +fn transfer_erc20_with_slot( + journal: &mut revm::Journal, + from: Address, + to: Address, + token: Address, + token_amount: U256, + token_balance_slot: U256, +) -> Result<(U256, U256), EVMError<::Error, MorphInvalidTransaction>> +where + DB: alloy_evm::Database, +{ + // Sub amount + let from_storage_slot = get_mapping_account_slot(token_balance_slot, from); + let balance = journal.sload(token, from_storage_slot)?; + journal.sstore( + token, + from_storage_slot, + balance.saturating_sub(token_amount), + )?; + + // Add amount + let to_storage_slot = get_mapping_account_slot(token_balance_slot, to); + let balance = journal.sload(token, to_storage_slot)?; + journal.sstore(token, to_storage_slot, balance.saturating_add(token_amount))?; + Ok((from_storage_slot, to_storage_slot)) +} + +/// Gas limit for ERC20 transfer calls. +const TRANSFER_GAS_LIMIT: u64 = 200000; + +/// Transfers ERC20 tokens by executing a `transfer(address,uint256)` call via the EVM. +fn transfer_erc20_with_evm( + evm: &mut MorphEvm, + caller: Address, + to: Address, + token_address: Address, + token_amount: U256, +) -> Result<(), EVMError> +where + DB: alloy_evm::Database, +{ + let calldata = build_transfer_calldata(to, token_amount); + + let tx_env = revm::context::TxEnv { + caller, + gas_limit: TRANSFER_GAS_LIMIT, + kind: token_address.into(), + data: calldata, + tx_type: L1_TX_TYPE_ID, // Mark as L1 message to skip gas validation + ..Default::default() + }; + + let tx = MorphTxEnv { + inner: tx_env, + rlp_bytes: None, + ..Default::default() + }; + match evm.transact_one(tx) { + Ok(result) => { + if !result.is_success() { + return Err(MorphInvalidTransaction::TokenTransferFailed { + reason: format!("{result:?}"), + } + .into()); + } + } + Err(e) => { + return Err(MorphInvalidTransaction::TokenTransferFailed { + reason: format!("Error: {e:?}"), + } + .into()); + } + }; + Ok(()) +} + +/// Build the calldata for ERC20 transfer(address,amount) call. +/// +/// Method signature: `transfer(address,amount) -> 0xa9059cbb` +fn build_transfer_calldata(to: Address, token_amount: alloy_primitives::Uint<256, 4>) -> Bytes { + let method_id = [0xa9u8, 0x05, 0x9c, 0xbb]; + // Encode calldata: method_id + padded to address + amount + let mut calldata = Vec::with_capacity(68); + calldata.extend_from_slice(&method_id); + let mut address_bytes = [0u8; 32]; + address_bytes[12..32].copy_from_slice(to.as_slice()); + calldata.extend_from_slice(&address_bytes); + calldata.extend_from_slice(&token_amount.to_be_bytes::<32>()); + Bytes::from(calldata) +} + /// Calculate the new balance after deducting L2 fees and L1 data fee. /// /// This is a Morph-specific version of `pre_execution::calculate_caller_fee` that diff --git a/crates/revm/src/l1block.rs b/crates/revm/src/l1block.rs index 852a84e..d570862 100644 --- a/crates/revm/src/l1block.rs +++ b/crates/revm/src/l1block.rs @@ -1,92 +1,246 @@ -//! L1 Block Info for Morph L2 fee calculation. +//! L1 Block Info and Gas Price Oracle constants for Morph L2. //! -//! This module provides the infrastructure for calculating L1 data fees on Morph L2. -//! The fee parameters are read from the L1 Gas Price Oracle contract deployed on L2. +//! This module provides: +//! - [`L1BlockInfo`]: L1 gas price oracle data for L1 data fee calculation +//! - Gas Price Oracle contract address and storage slot constants //! -//! L1 fees are calculated using the blob-based model with commit scalar and blob scalar. +//! # Storage Layout +//! +//! The L1 Gas Price Oracle contract has the following storage layout: +//! +//! | Name | Type | Slot | Description | +//! |---------------|---------|------|--------------------------------| +//! | owner | address | 0 | Contract owner | +//! | l1BaseFee | uint256 | 1 | L1 base fee | +//! | overhead | uint256 | 2 | L1 overhead gas | +//! | scalar | uint256 | 3 | L1 fee scalar | +//! | whitelist | address | 4 | Whitelist contract | +//! | __deprecated0 | uint256 | 5 | Deprecated | +//! | l1BlobBaseFee | uint256 | 6 | L1 blob base fee (Curie+) | +//! | commitScalar | uint256 | 7 | Commit scalar (Curie+) | +//! | blobScalar | uint256 | 8 | Blob scalar (Curie+) | +//! | isCurie | bool | 9 | Curie hardfork flag | use alloy_primitives::{Address, U256, address}; +use morph_chainspec::hardfork::MorphHardfork; use revm::Database; +// ============================================================================= +// Gas Price Oracle Constants +// ============================================================================= + +/// Gas cost for zero bytes in calldata. +const ZERO_BYTE_COST: u64 = 4; +/// Gas cost for non-zero bytes in calldata. +const NON_ZERO_BYTE_COST: u64 = 16; + +/// Extra cost added to L1 commit gas calculation. +const TX_L1_COMMIT_EXTRA_COST: U256 = U256::from_limbs([64u64, 0, 0, 0]); /// Precision factor for L1 fee calculation (1e9). const TX_L1_FEE_PRECISION: U256 = U256::from_limbs([1_000_000_000u64, 0, 0, 0]); +// ============================================================================= +// L1 Gas Price Oracle Address +// ============================================================================= + /// L1 Gas Price Oracle contract address on Morph L2. +/// +/// This contract stores L1 gas prices used for calculating L1 data fees. +/// Reference: `rollup/rcfg/config.go` in go-ethereum pub const L1_GAS_PRICE_ORACLE_ADDRESS: Address = address!("530000000000000000000000000000000000000F"); -/// Storage slot for L1 base fee. -const L1_BASE_FEE_SLOT: U256 = U256::from_limbs([1u64, 0, 0, 0]); -/// Storage slot for L1 blob base fee. -const L1_BLOB_BASE_FEE_SLOT: U256 = U256::from_limbs([6u64, 0, 0, 0]); -/// Storage slot for L1 commit scalar. -const L1_COMMIT_SCALAR_SLOT: U256 = U256::from_limbs([7u64, 0, 0, 0]); -/// Storage slot for L1 blob scalar. -const L1_BLOB_SCALAR_SLOT: U256 = U256::from_limbs([8u64, 0, 0, 0]); +// ============================================================================= +// L1 Gas Price Oracle Storage Slots +// ============================================================================= + +/// Storage slot for `owner` in the `L1GasPriceOracle` contract. +pub const GPO_OWNER_SLOT: U256 = U256::from_limbs([0, 0, 0, 0]); + +/// Storage slot for `l1BaseFee` in the `L1GasPriceOracle` contract. +pub const GPO_L1_BASE_FEE_SLOT: U256 = U256::from_limbs([1, 0, 0, 0]); + +/// Storage slot for `overhead` in the `L1GasPriceOracle` contract. +pub const GPO_OVERHEAD_SLOT: U256 = U256::from_limbs([2, 0, 0, 0]); + +/// Storage slot for `scalar` in the `L1GasPriceOracle` contract. +pub const GPO_SCALAR_SLOT: U256 = U256::from_limbs([3, 0, 0, 0]); + +/// Storage slot for `whitelist` in the `L1GasPriceOracle` contract. +pub const GPO_WHITELIST_SLOT: U256 = U256::from_limbs([4, 0, 0, 0]); + +/// Storage slot for `l1BlobBaseFee` in the `L1GasPriceOracle` contract. +/// Added in the Curie hardfork. +pub const GPO_L1_BLOB_BASE_FEE_SLOT: U256 = U256::from_limbs([6, 0, 0, 0]); + +/// Storage slot for `commitScalar` in the `L1GasPriceOracle` contract. +/// Added in the Curie hardfork. +pub const GPO_COMMIT_SCALAR_SLOT: U256 = U256::from_limbs([7, 0, 0, 0]); + +/// Storage slot for `blobScalar` in the `L1GasPriceOracle` contract. +/// Added in the Curie hardfork. +pub const GPO_BLOB_SCALAR_SLOT: U256 = U256::from_limbs([8, 0, 0, 0]); + +/// Storage slot for `isCurie` in the `L1GasPriceOracle` contract. +/// Added in the Curie hardfork. Set to 1 (true) after Curie activation. +pub const GPO_IS_CURIE_SLOT: U256 = U256::from_limbs([9, 0, 0, 0]); + +// ============================================================================= +// L1 Gas Price Oracle Initial Values (for Curie hardfork) +// ============================================================================= + +/// The initial blob base fee used by the oracle contract at Curie activation. +pub const INITIAL_L1_BLOB_BASE_FEE: U256 = U256::from_limbs([1, 0, 0, 0]); + +/// The initial commit scalar used by the oracle contract at Curie activation. +/// Reference: `rcfg.InitialCommitScalar` in go-ethereum (230759955285) +pub const INITIAL_COMMIT_SCALAR: U256 = U256::from_limbs([230759955285, 0, 0, 0]); + +/// The initial blob scalar used by the oracle contract at Curie activation. +/// Reference: `rcfg.InitialBlobScalar` in go-ethereum (417565260) +pub const INITIAL_BLOB_SCALAR: U256 = U256::from_limbs([417565260, 0, 0, 0]); + +/// Curie hardfork flag value (1 = true). +pub const IS_CURIE: U256 = U256::from_limbs([1, 0, 0, 0]); + +/// Storage updates for L1 gas price oracle Curie hardfork initialization. +/// +/// These storage slots are initialized when the Curie hardfork activates: +/// - l1BlobBaseFee = 1 +/// - commitScalar = InitialCommitScalar +/// - blobScalar = InitialBlobScalar +/// - isCurie = 1 (true) +pub const CURIE_L1_GAS_PRICE_ORACLE_STORAGE: [(U256, U256); 4] = [ + (GPO_L1_BLOB_BASE_FEE_SLOT, INITIAL_L1_BLOB_BASE_FEE), + (GPO_COMMIT_SCALAR_SLOT, INITIAL_COMMIT_SCALAR), + (GPO_BLOB_SCALAR_SLOT, INITIAL_BLOB_SCALAR), + (GPO_IS_CURIE_SLOT, IS_CURIE), +]; + +// ============================================================================= +// L1 Block Info +// ============================================================================= /// L1 block info for fee calculation. /// /// Contains the fee parameters fetched from the L1 Gas Price Oracle contract. -/// These parameters are used to calculate the L1 data fee for transactions -/// using the blob-based model. +/// These parameters are used to calculate the L1 data fee for transactions. #[derive(Clone, Debug, Default)] pub struct L1BlockInfo { /// The base fee of the L1 origin block. pub l1_base_fee: U256, - /// The current L1 blob base fee. - pub l1_blob_base_fee: U256, - /// The current L1 commit scalar. - pub l1_commit_scalar: U256, - /// The current L1 blob scalar. - pub l1_blob_scalar: U256, - /// Pre-computed calldata gas component (l1_commit_scalar * l1_base_fee). - pub calldata_gas: U256, + /// The current L1 fee overhead. + pub l1_fee_overhead: U256, + /// The current L1 fee scalar. + pub l1_base_fee_scalar: U256, + /// The current L1 blob base fee, None if before Curie. + pub l1_blob_base_fee: Option, + /// The current L1 commit scalar, None if before Curie. + pub l1_commit_scalar: Option, + /// The current L1 blob scalar, None if before Curie. + pub l1_blob_scalar: Option, + /// The current call data gas (l1_commit_scalar * l1_base_fee), None if before Curie. + pub calldata_gas: Option, } impl L1BlockInfo { /// Try to fetch the L1 block info from the database. /// - /// This reads the fee parameters from the L1 Gas Price Oracle contract storage - /// for blob-based L1 fee calculation. - pub fn try_fetch(db: &mut DB) -> Result { - let l1_base_fee = db.storage(L1_GAS_PRICE_ORACLE_ADDRESS, L1_BASE_FEE_SLOT)?; - let l1_blob_base_fee = db.storage(L1_GAS_PRICE_ORACLE_ADDRESS, L1_BLOB_BASE_FEE_SLOT)?; - let l1_commit_scalar = db.storage(L1_GAS_PRICE_ORACLE_ADDRESS, L1_COMMIT_SCALAR_SLOT)?; - let l1_blob_scalar = db.storage(L1_GAS_PRICE_ORACLE_ADDRESS, L1_BLOB_SCALAR_SLOT)?; - - // Pre-compute calldata gas component: commitScalar * l1BaseFee - let calldata_gas = l1_commit_scalar.saturating_mul(l1_base_fee); - - Ok(Self { - l1_base_fee, - l1_blob_base_fee, - l1_commit_scalar, - l1_blob_scalar, - calldata_gas, - }) + /// This reads the fee parameters from the L1 Gas Price Oracle contract storage. + /// Different parameters are fetched depending on whether the Curie hardfork is active. + pub fn try_fetch( + db: &mut DB, + hardfork: MorphHardfork, + ) -> Result { + let l1_base_fee = db.storage(L1_GAS_PRICE_ORACLE_ADDRESS, GPO_L1_BASE_FEE_SLOT)?; + let l1_fee_overhead = db.storage(L1_GAS_PRICE_ORACLE_ADDRESS, GPO_OVERHEAD_SLOT)?; + let l1_base_fee_scalar = db.storage(L1_GAS_PRICE_ORACLE_ADDRESS, GPO_SCALAR_SLOT)?; + + if !hardfork.is_curie() { + Ok(Self { + l1_base_fee, + l1_fee_overhead, + l1_base_fee_scalar, + ..Default::default() + }) + } else { + let l1_blob_base_fee = + db.storage(L1_GAS_PRICE_ORACLE_ADDRESS, GPO_L1_BLOB_BASE_FEE_SLOT)?; + let l1_commit_scalar = + db.storage(L1_GAS_PRICE_ORACLE_ADDRESS, GPO_COMMIT_SCALAR_SLOT)?; + let l1_blob_scalar = db.storage(L1_GAS_PRICE_ORACLE_ADDRESS, GPO_BLOB_SCALAR_SLOT)?; + + // calldata component of commit fees (calldata gas + execution) + let calldata_gas = l1_commit_scalar.saturating_mul(l1_base_fee); + + Ok(Self { + l1_base_fee, + l1_fee_overhead, + l1_base_fee_scalar, + l1_blob_base_fee: Some(l1_blob_base_fee), + l1_commit_scalar: Some(l1_commit_scalar), + l1_blob_scalar: Some(l1_blob_scalar), + calldata_gas: Some(calldata_gas), + }) + } } - /// Calculate the blob gas cost for transaction data. + /// Calculate the data gas for posting the transaction on L1. + /// + /// Before Curie: Calldata costs 16 gas per non-zero byte and 4 gas per zero byte, + /// plus overhead and extra commit cost. /// - /// Formula: `data.length * l1BlobBaseFee * blobScalar` - pub fn blob_gas(&self, input: &[u8]) -> U256 { - U256::from(input.len()) - .saturating_mul(self.l1_blob_base_fee) - .saturating_mul(self.l1_blob_scalar) + /// After Curie: Uses blob-based calculation with blob base fee and blob scalar. + pub fn data_gas(&self, input: &[u8], hardfork: MorphHardfork) -> U256 { + if !hardfork.is_curie() { + U256::from(input.iter().fold(0, |acc, byte| { + acc + if *byte == 0x00 { + ZERO_BYTE_COST + } else { + NON_ZERO_BYTE_COST + } + })) + .saturating_add(self.l1_fee_overhead) + .saturating_add(TX_L1_COMMIT_EXTRA_COST) + } else { + U256::from(input.len()) + .saturating_mul(self.l1_blob_base_fee.unwrap_or_default()) + .saturating_mul(self.l1_blob_scalar.unwrap_or_default()) + } } - /// Calculate the L1 data fee for a transaction. - /// - /// This is the cost of posting the transaction data to L1 for data availability. + /// Calculate L1 cost for a transaction before Curie hardfork. + fn calculate_tx_l1_cost_pre_curie(&self, input: &[u8], hardfork: MorphHardfork) -> U256 { + let tx_l1_gas = self.data_gas(input, hardfork); + tx_l1_gas + .saturating_mul(self.l1_base_fee) + .saturating_mul(self.l1_base_fee_scalar) + .wrapping_div(TX_L1_FEE_PRECISION) + } + + /// Calculate L1 cost for a transaction after Curie hardfork. /// - /// Formula: `(commitScalar * l1BaseFee + blobScalar * data.length * l1BlobBaseFee) / precision` - pub fn calculate_tx_l1_cost(&self, input: &[u8]) -> U256 { - let blob_gas = self.blob_gas(input); + /// Formula: `commitScalar * l1BaseFee + blobScalar * _data.length * l1BlobBaseFee` + fn calculate_tx_l1_cost_curie(&self, input: &[u8], hardfork: MorphHardfork) -> U256 { + let blob_gas = self.data_gas(input, hardfork); self.calldata_gas + .unwrap_or_default() .saturating_add(blob_gas) .wrapping_div(TX_L1_FEE_PRECISION) } + + /// Calculate the L1 data fee for a transaction. + /// + /// This is the cost of posting the transaction data to L1 for data availability. + /// The calculation method differs based on whether the Curie hardfork is active. + pub fn calculate_tx_l1_cost(&self, input: &[u8], hardfork: MorphHardfork) -> U256 { + if !hardfork.is_curie() { + self.calculate_tx_l1_cost_pre_curie(input, hardfork) + } else { + self.calculate_tx_l1_cost_curie(input, hardfork) + } + } } #[cfg(test)] @@ -97,47 +251,56 @@ mod tests { fn test_l1_block_info_default() { let info = L1BlockInfo::default(); assert_eq!(info.l1_base_fee, U256::ZERO); - assert_eq!(info.l1_blob_base_fee, U256::ZERO); - assert_eq!(info.l1_commit_scalar, U256::ZERO); - assert_eq!(info.l1_blob_scalar, U256::ZERO); - assert_eq!(info.calldata_gas, U256::ZERO); + assert_eq!(info.l1_fee_overhead, U256::ZERO); + assert_eq!(info.l1_base_fee_scalar, U256::ZERO); + assert!(info.l1_blob_base_fee.is_none()); + assert!(info.l1_commit_scalar.is_none()); + assert!(info.l1_blob_scalar.is_none()); + assert!(info.calldata_gas.is_none()); + } + + #[test] + fn test_data_gas_pre_curie() { + let info = L1BlockInfo { + l1_fee_overhead: U256::from(100), + ..Default::default() + }; + + // Test with mixed zero and non-zero bytes + let input = vec![0x00, 0x01, 0x00, 0xff]; + // 2 zero bytes * 4 + 2 non-zero bytes * 16 + 100 overhead + 64 extra = 200 + let gas = info.data_gas(&input, MorphHardfork::Bernoulli); + assert_eq!(gas, U256::from(2 * 4 + 2 * 16 + 100 + 64)); } #[test] - fn test_blob_gas() { + fn test_data_gas_curie() { let info = L1BlockInfo { - l1_blob_base_fee: U256::from(10), - l1_blob_scalar: U256::from(2), + l1_blob_base_fee: Some(U256::from(10)), + l1_blob_scalar: Some(U256::from(2)), ..Default::default() }; let input = vec![0x00, 0x01, 0x00, 0xff]; // length * blob_base_fee * blob_scalar = 4 * 10 * 2 = 80 - let gas = info.blob_gas(&input); + let gas = info.data_gas(&input, MorphHardfork::Curie); assert_eq!(gas, U256::from(80)); } #[test] - fn test_calculate_tx_l1_cost() { - let l1_base_fee = U256::from(1_000_000_000); // 1 gwei - let l1_commit_scalar = U256::from(1_000_000_000); // 1.0 scaled - let l1_blob_base_fee = U256::from(10); - let l1_blob_scalar = U256::from(2); - + fn test_calculate_tx_l1_cost_pre_curie() { let info = L1BlockInfo { - l1_base_fee, - l1_blob_base_fee, - l1_commit_scalar, - l1_blob_scalar, - calldata_gas: l1_commit_scalar.saturating_mul(l1_base_fee), + l1_base_fee: U256::from(1_000_000_000), // 1 gwei + l1_fee_overhead: U256::from(0), + l1_base_fee_scalar: U256::from(1_000_000_000), // 1.0 scaled + ..Default::default() }; - // Formula: (commitScalar * l1BaseFee + blobScalar * data.length * l1BlobBaseFee) / precision - // = (1e9 * 1e9 + 2 * 4 * 10) / 1e9 - // = (1e18 + 80) / 1e9 - // = 1_000_000_000 (the +80 is negligible) - let input = vec![0x00, 0x01, 0x00, 0xff]; - let cost = info.calculate_tx_l1_cost(&input); - assert_eq!(cost, U256::from(1_000_000_000u64)); + // 1 non-zero byte = 16 gas + // + 64 extra cost = 80 gas total + // cost = 80 * 1_000_000_000 * 1_000_000_000 / 1_000_000_000 = 80_000_000_000 + let input = vec![0xff]; + let cost = info.calculate_tx_l1_cost(&input, MorphHardfork::Bernoulli); + assert_eq!(cost, U256::from(80_000_000_000u64)); } } diff --git a/crates/revm/src/lib.rs b/crates/revm/src/lib.rs index 1cbd0fc..9b9016d 100644 --- a/crates/revm/src/lib.rs +++ b/crates/revm/src/lib.rs @@ -55,6 +55,25 @@ mod tx; pub use block::MorphBlockEnv; pub use error::{MorphHaltReason, MorphInvalidTransaction}; pub use evm::MorphEvm; -pub use l1block::{L1_GAS_PRICE_ORACLE_ADDRESS, L1BlockInfo}; +pub use l1block::{ + CURIE_L1_GAS_PRICE_ORACLE_STORAGE, + GPO_BLOB_SCALAR_SLOT, + GPO_COMMIT_SCALAR_SLOT, + GPO_IS_CURIE_SLOT, + GPO_L1_BASE_FEE_SLOT, + GPO_L1_BLOB_BASE_FEE_SLOT, + GPO_OVERHEAD_SLOT, + // Storage slots + GPO_OWNER_SLOT, + GPO_SCALAR_SLOT, + GPO_WHITELIST_SLOT, + INITIAL_BLOB_SCALAR, + INITIAL_COMMIT_SCALAR, + // Curie initial values + INITIAL_L1_BLOB_BASE_FEE, + IS_CURIE, + L1_GAS_PRICE_ORACLE_ADDRESS, + L1BlockInfo, +}; pub use token_fee::{L2_TOKEN_REGISTRY_ADDRESS, TokenFeeInfo, get_erc20_balance_with_evm}; pub use tx::{MorphTxEnv, MorphTxExt}; From 290b5c73cf54a54a99857cc45cb8d5c8f9495d09 Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 21 Jan 2026 15:47:36 +0800 Subject: [PATCH 10/13] style: fix ci comments --- crates/chainspec/src/spec.rs | 2 +- crates/payload/builder/src/builder.rs | 12 ++++++------ crates/primitives/src/lib.rs | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/chainspec/src/spec.rs b/crates/chainspec/src/spec.rs index daa2c45..67a7576 100644 --- a/crates/chainspec/src/spec.rs +++ b/crates/chainspec/src/spec.rs @@ -138,7 +138,7 @@ impl From for MorphChainSpec { // Add Morph hardforks // Note: Bernoulli and Curie use block-based activation, - // while Morph203, Viridian, and Emerald use timestamp-based activation. + // while Morph203, Viridian, Emerald, and MPTFork use timestamp-based activation. let block_forks = vec![ (MorphHardfork::Bernoulli, hardfork_info.bernoulli_block), (MorphHardfork::Curie, hardfork_info.curie_block), diff --git a/crates/payload/builder/src/builder.rs b/crates/payload/builder/src/builder.rs index 64182dd..8312e68 100644 --- a/crates/payload/builder/src/builder.rs +++ b/crates/payload/builder/src/builder.rs @@ -604,10 +604,10 @@ where // Create breaker for early exit from pool transaction execution let breaker = ctx.builder_config.breaker(block_gas_limit); - // 2. Execute sequencer transactions (L1 messages and forced transactions) + // Execute sequencer transactions (L1 messages and forced transactions) let mut executed_txs = ctx.execute_sequencer_transactions(&mut builder, &mut info)?; - // 3. Execute pool transactions (best transactions from mempool) + // Execute pool transactions (best transactions from mempool) let best_txs = best(ctx.best_transaction_attributes(base_fee)); if ctx .execute_pool_transactions( @@ -634,14 +634,14 @@ where ); } - // 4. Check if this payload is better than the previous one + // Check if this payload is better than the previous one if !ctx.is_better_payload(info.total_fees) { return Ok(BuildOutcomeKind::Aborted { fees: info.total_fees, }); } - // 5. Read withdraw_trie_root from L2MessageQueue contract storage + // Read withdraw_trie_root from L2MessageQueue contract storage // This must be done before finish() consumes the builder let withdraw_trie_root = read_withdraw_trie_root(builder.evm_mut().db_mut()) .map_err(|err| PayloadBuilderError::other(MorphPayloadBuilderError::Database(err)))?; @@ -653,7 +653,7 @@ where .. } = builder.finish(state_provider)?; - // 7. Update MorphHeader with next_l1_msg_index. + // Update MorphHeader with next_l1_msg_index. // Since hash_slow() only hashes the inner header, we can update the // MorphHeader's L2-specific fields without changing the block hash. let (mut morph_block, senders) = block.split(); @@ -675,7 +675,7 @@ where "sealed built block" ); - // 6. Build ExecutableL2Data from the sealed block + // Build ExecutableL2Data from the sealed block let mut logs_bloom_bytes = Vec::new(); header.logs_bloom().encode(&mut logs_bloom_bytes); diff --git a/crates/primitives/src/lib.rs b/crates/primitives/src/lib.rs index cde8bef..39c8421 100644 --- a/crates/primitives/src/lib.rs +++ b/crates/primitives/src/lib.rs @@ -20,7 +20,7 @@ //! //! - [`Block`]: Morph block type alias //! - [`BlockBody`]: Morph block body type alias -//! - [`MorphHeader`]: Header type alias (same as Ethereum) +//! - [`MorphHeader`]: Morph header type alias //! //! # Node Primitives //! From d126b761b0c600117653e162fb119bfd5f6f8a6f Mon Sep 17 00:00:00 2001 From: panos Date: Fri, 23 Jan 2026 15:16:30 +0800 Subject: [PATCH 11/13] refactor: update block receipt builder --- crates/evm/src/block.rs | 205 -------------------------------- crates/evm/src/block/curie.rs | 137 +++++++++++++++++++++ crates/evm/src/block/mod.rs | 130 ++++++++++++++++++++ crates/evm/src/block/receipt.rs | 53 +++++++++ crates/evm/src/context.rs | 4 +- crates/evm/src/evm.rs | 18 ++- crates/evm/src/lib.rs | 3 +- crates/revm/src/lib.rs | 1 + 8 files changed, 340 insertions(+), 211 deletions(-) delete mode 100644 crates/evm/src/block.rs create mode 100644 crates/evm/src/block/curie.rs create mode 100644 crates/evm/src/block/mod.rs create mode 100644 crates/evm/src/block/receipt.rs diff --git a/crates/evm/src/block.rs b/crates/evm/src/block.rs deleted file mode 100644 index e1fc685..0000000 --- a/crates/evm/src/block.rs +++ /dev/null @@ -1,205 +0,0 @@ -use crate::{MorphBlockExecutionCtx, evm::MorphEvm}; -use alloy_consensus::Receipt; -use alloy_evm::{ - Database, Evm, - block::{BlockExecutionError, BlockExecutionResult, BlockExecutor, ExecutableTx, OnStateHook}, - eth::{ - EthBlockExecutor, - receipt_builder::{ReceiptBuilder, ReceiptBuilderCtx}, - }, -}; -use morph_chainspec::{MorphChainSpec, MorphHardfork, MorphHardforks}; -use morph_primitives::{MorphReceipt, MorphTransactionReceipt, MorphTxEnvelope, MorphTxType}; -use morph_revm::{ - CURIE_L1_GAS_PRICE_ORACLE_STORAGE, L1_GAS_PRICE_ORACLE_ADDRESS, MorphHaltReason, - evm::MorphContext, -}; -use reth_revm::{Inspector, State, context::result::ResultAndState}; -use revm::{Database as RevmDatabase, database::states::StorageSlot}; - -/// Builder for [`MorphReceipt`]. -#[derive(Debug, Clone, Copy, Default)] -#[non_exhaustive] -pub(crate) struct MorphReceiptBuilder; - -impl ReceiptBuilder for MorphReceiptBuilder { - type Transaction = MorphTxEnvelope; - type Receipt = MorphReceipt; - - fn build_receipt( - &self, - ctx: ReceiptBuilderCtx<'_, Self::Transaction, E>, - ) -> Self::Receipt { - let ReceiptBuilderCtx { - tx, - result, - cumulative_gas_used, - .. - } = ctx; - - let inner = Receipt { - status: result.is_success().into(), - cumulative_gas_used, - logs: result.into_logs(), - }; - - // Create the appropriate receipt variant based on transaction type - // TODO: Add L1 fee calculation from execution context - match tx.tx_type() { - MorphTxType::Legacy => MorphReceipt::Legacy(MorphTransactionReceipt::new(inner)), - MorphTxType::Eip2930 => MorphReceipt::Eip2930(MorphTransactionReceipt::new(inner)), - MorphTxType::Eip1559 => MorphReceipt::Eip1559(MorphTransactionReceipt::new(inner)), - MorphTxType::Eip7702 => MorphReceipt::Eip7702(MorphTransactionReceipt::new(inner)), - MorphTxType::L1Msg => MorphReceipt::L1Msg(inner), - MorphTxType::AltFee => MorphReceipt::AltFee(MorphTransactionReceipt::new(inner)), - } - } -} - -/// Block executor for Morph. Wraps an inner [`EthBlockExecutor`]. -pub(crate) struct MorphBlockExecutor<'a, DB: Database, I> { - /// Inner Ethereum block executor. - pub(crate) inner: EthBlockExecutor< - 'a, - MorphEvm<&'a mut State, I>, - &'a MorphChainSpec, - MorphReceiptBuilder, - >, -} - -impl<'a, DB, I> MorphBlockExecutor<'a, DB, I> -where - DB: Database, - I: Inspector>>, -{ - pub(crate) fn new( - evm: MorphEvm<&'a mut State, I>, - ctx: MorphBlockExecutionCtx<'a>, - chain_spec: &'a MorphChainSpec, - ) -> Self { - Self { - inner: EthBlockExecutor::new( - evm, - ctx.inner, - chain_spec, - MorphReceiptBuilder::default(), - ), - } - } -} - -impl<'a, DB, I> BlockExecutor for MorphBlockExecutor<'a, DB, I> -where - DB: Database, - I: Inspector>>, -{ - type Transaction = MorphTxEnvelope; - type Receipt = MorphReceipt; - type Evm = MorphEvm<&'a mut State, I>; - - fn apply_pre_execution_changes(&mut self) -> Result<(), BlockExecutionError> { - // 1. Apply base Ethereum pre-execution changes (state clear flag, EIP-2935, EIP-4788) - self.inner.apply_pre_execution_changes()?; - - // 2. Load L1 gas oracle contract into cache for L1 fee calculations - let _ = self - .inner - .evm_mut() - .db_mut() - .load_cache_account(L1_GAS_PRICE_ORACLE_ADDRESS) - .map_err(BlockExecutionError::other)?; - - // 3. Apply Curie hardfork at the transition block - // Only executes once at the exact block where Curie activates - let block_number = self.inner.evm.block().number.saturating_to(); - if self - .inner - .spec - .morph_fork_activation(MorphHardfork::Curie) - .transitions_at_block(block_number) - && let Err(err) = apply_curie_hard_fork(self.inner.evm_mut().db_mut()) - { - return Err(BlockExecutionError::msg(format!( - "error occurred at Curie fork: {err:?}" - ))); - } - - Ok(()) - } - - fn execute_transaction_without_commit( - &mut self, - tx: impl ExecutableTx, - ) -> Result, BlockExecutionError> { - self.inner.execute_transaction_without_commit(tx) - } - - fn commit_transaction( - &mut self, - output: ResultAndState, - tx: impl ExecutableTx, - ) -> Result { - self.inner.commit_transaction(output, tx) - } - - fn finish( - self, - ) -> Result<(Self::Evm, BlockExecutionResult), BlockExecutionError> { - self.inner.finish() - } - - fn set_state_hook(&mut self, hook: Option>) { - self.inner.set_state_hook(hook) - } - - fn evm_mut(&mut self) -> &mut Self::Evm { - self.inner.evm_mut() - } - - fn evm(&self) -> &Self::Evm { - self.inner.evm() - } -} - -/// Applies the Morph Curie hard fork to the state. -/// -/// Updates L1GasPriceOracle storage slots: -/// - Sets `l1BlobBaseFee` slot to 1 -/// - Sets `commitScalar` slot to initial value -/// - Sets `blobScalar` slot to initial value -/// - Sets `isCurie` slot to 1 (true) -/// -/// This function should only be called once at the Curie transition block. -/// Reference: `consensus/misc/curie.go` in morph go-ethereum -fn apply_curie_hard_fork(state: &mut State) -> Result<(), DB::Error> { - tracing::info!(target: "morph::evm", "Applying Curie hard fork"); - - let oracle = state.load_cache_account(L1_GAS_PRICE_ORACLE_ADDRESS)?; - - // Create storage updates - let new_storage = CURIE_L1_GAS_PRICE_ORACLE_STORAGE - .into_iter() - .map(|(slot, present_value)| { - ( - slot, - StorageSlot { - present_value, - previous_or_original_value: oracle.storage_slot(slot).unwrap_or_default(), - }, - ) - }) - .collect(); - - // Get existing account info or use default - let oracle_info = oracle.account_info().unwrap_or_default(); - - // Create transition for oracle storage update - let transition = oracle.change(oracle_info, new_storage); - - // Add transition to state - if let Some(s) = state.transition_state.as_mut() { - s.add_transitions(vec![(L1_GAS_PRICE_ORACLE_ADDRESS, transition)]); - } - - Ok(()) -} diff --git a/crates/evm/src/block/curie.rs b/crates/evm/src/block/curie.rs new file mode 100644 index 0000000..ab19cf0 --- /dev/null +++ b/crates/evm/src/block/curie.rs @@ -0,0 +1,137 @@ +//! Curie fork transition for Morph. +//! +//! The Curie hardfork introduced blob-based DA cost calculation for Morph L2. +//! +//! ## Changes +//! +//! 1. **Fee reduction** - Uses compressed blobs on L1 for lower DA costs. +//! 2. **Updated L1 gas oracle** - New storage slots for blob-based fee calculation: +//! - `l1BlobBaseFee` slot initialized to 1 +//! - `commitScalar` slot initialized to `InitialCommitScalar` +//! - `blobScalar` slot initialized to `InitialBlobScalar` +//! - `isCurie` slot set to 1 (true) +//! +//! ## DA Cost Formula +//! +//! - Pre-Curie: `(l1GasUsed(txRlp) + overhead) * l1BaseFee * scalar` +//! - Post-Curie: `l1BaseFee * commitScalar + len(txRlp) * l1BlobBaseFee * blobScalar` +//! +//! Reference: `consensus/misc/curie.go` in morph go-ethereum + +use morph_revm::{CURIE_L1_GAS_PRICE_ORACLE_STORAGE, L1_GAS_PRICE_ORACLE_ADDRESS}; +use revm::{ + Database, + database::{State, states::StorageSlot}, +}; + +/// Applies the Morph Curie hard fork to the state. +/// +/// Updates L1GasPriceOracle storage slots: +/// - Sets `l1BlobBaseFee` slot to 1 +/// - Sets `commitScalar` slot to initial value +/// - Sets `blobScalar` slot to initial value +/// - Sets `isCurie` slot to 1 (true) +/// +/// This function should only be called once at the Curie transition block. +pub(crate) fn apply_curie_hard_fork(state: &mut State) -> Result<(), DB::Error> { + tracing::info!(target: "morph::evm", "Applying Curie hard fork"); + + let oracle = state.load_cache_account(L1_GAS_PRICE_ORACLE_ADDRESS)?; + + // Create storage updates + let new_storage = CURIE_L1_GAS_PRICE_ORACLE_STORAGE + .into_iter() + .map(|(slot, present_value)| { + ( + slot, + StorageSlot { + present_value, + previous_or_original_value: oracle.storage_slot(slot).unwrap_or_default(), + }, + ) + }) + .collect(); + + // Get existing account info or use default + let oracle_info = oracle.account_info().unwrap_or_default(); + + // Create transition for oracle storage update + let transition = oracle.change(oracle_info, new_storage); + + // Add transition to state + if let Some(s) = state.transition_state.as_mut() { + s.add_transitions(vec![(L1_GAS_PRICE_ORACLE_ADDRESS, transition)]); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use morph_revm::{ + GPO_BLOB_SCALAR_SLOT, GPO_COMMIT_SCALAR_SLOT, GPO_IS_CURIE_SLOT, + GPO_L1_BLOB_BASE_FEE_SLOT, GPO_L1_BASE_FEE_SLOT, GPO_OVERHEAD_SLOT, + GPO_OWNER_SLOT, GPO_SCALAR_SLOT, GPO_WHITELIST_SLOT, + INITIAL_BLOB_SCALAR, INITIAL_COMMIT_SCALAR, INITIAL_L1_BLOB_BASE_FEE, IS_CURIE, + }; + use revm::{ + database::{ + EmptyDB, State, + states::{bundle_state::BundleRetention, plain_account::PlainStorage, StorageSlot}, + }, + primitives::U256, + state::AccountInfo, + }; + + #[test] + fn test_apply_curie_fork() { + // init state + let db = EmptyDB::new(); + let mut state = State::builder() + .with_database(db) + .with_bundle_update() + .without_state_clear() + .build(); + + // oracle pre fork state + let oracle_pre_fork = AccountInfo::default(); + let oracle_storage_pre_fork = PlainStorage::from_iter([ + (GPO_OWNER_SLOT, U256::from(0x15f50e5eu64)), // placeholder owner + (GPO_L1_BASE_FEE_SLOT, U256::from(0x15f50e5eu64)), + (GPO_OVERHEAD_SLOT, U256::from(0x38u64)), + (GPO_SCALAR_SLOT, U256::from(0x3e95ba80u64)), + (GPO_WHITELIST_SLOT, U256::from(0x53u64)), // placeholder whitelist + ]); + state.insert_account_with_storage( + L1_GAS_PRICE_ORACLE_ADDRESS, + oracle_pre_fork.clone(), + oracle_storage_pre_fork.clone(), + ); + + // apply curie fork + apply_curie_hard_fork(&mut state).expect("should apply curie fork"); + + // merge transitions + state.merge_transitions(BundleRetention::Reverts); + let bundle = state.take_bundle(); + + // check oracle contract contains storage changeset + let oracle = bundle.state.get(&L1_GAS_PRICE_ORACLE_ADDRESS).unwrap().clone(); + let mut storage = oracle.storage.into_iter().collect::>(); + storage.sort_by(|(a, _), (b, _)| a.cmp(b)); + + let expected_storage = [ + (GPO_L1_BLOB_BASE_FEE_SLOT, INITIAL_L1_BLOB_BASE_FEE), + (GPO_COMMIT_SCALAR_SLOT, INITIAL_COMMIT_SCALAR), + (GPO_BLOB_SCALAR_SLOT, INITIAL_BLOB_SCALAR), + (GPO_IS_CURIE_SLOT, IS_CURIE), + ]; + + for (got, expected) in storage.into_iter().zip(expected_storage) { + assert_eq!(got.0, expected.0); + assert_eq!(got.1, StorageSlot { present_value: expected.1, ..Default::default() }); + } + } +} + diff --git a/crates/evm/src/block/mod.rs b/crates/evm/src/block/mod.rs new file mode 100644 index 0000000..9a3a77e --- /dev/null +++ b/crates/evm/src/block/mod.rs @@ -0,0 +1,130 @@ +//! Block execution for Morph L2. +//! +//! This module provides block execution functionality for Morph, including: +//! - [`MorphBlockExecutor`]: The main block executor +//! - [`MorphReceiptBuilder`]: Receipt construction for transactions +//! - Hardfork application logic (Curie, etc.) + +pub(crate) mod curie; +mod receipt; + +pub(crate) use receipt::MorphReceiptBuilder; + +use crate::{MorphBlockExecutionCtx, evm::MorphEvm}; +use alloy_evm::{ + Database, Evm, + block::{BlockExecutionError, BlockExecutionResult, BlockExecutor, ExecutableTx, OnStateHook}, + eth::EthBlockExecutor, +}; +use curie::apply_curie_hard_fork; +use morph_chainspec::{MorphChainSpec, MorphHardfork, MorphHardforks}; +use morph_primitives::{MorphReceipt, MorphTxEnvelope}; +use morph_revm::{MorphHaltReason, L1_GAS_PRICE_ORACLE_ADDRESS, evm::MorphContext}; +use reth_revm::{Inspector, State, context::result::ResultAndState}; + +/// Block executor for Morph. Wraps an inner [`EthBlockExecutor`]. +pub(crate) struct MorphBlockExecutor<'a, DB: Database, I> { + /// Inner Ethereum block executor. + pub(crate) inner: EthBlockExecutor< + 'a, + MorphEvm<&'a mut State, I>, + &'a MorphChainSpec, + MorphReceiptBuilder, + >, +} + +impl<'a, DB, I> MorphBlockExecutor<'a, DB, I> +where + DB: Database, + I: Inspector>>, +{ + pub(crate) fn new( + evm: MorphEvm<&'a mut State, I>, + ctx: MorphBlockExecutionCtx<'a>, + chain_spec: &'a MorphChainSpec, + ) -> Self { + Self { + inner: EthBlockExecutor::new( + evm, + ctx.inner, + chain_spec, + MorphReceiptBuilder::default(), + ), + } + } +} + +impl<'a, DB, I> BlockExecutor for MorphBlockExecutor<'a, DB, I> +where + DB: Database, + I: Inspector>>, +{ + type Transaction = MorphTxEnvelope; + type Receipt = MorphReceipt; + type Evm = MorphEvm<&'a mut State, I>; + + fn apply_pre_execution_changes(&mut self) -> Result<(), BlockExecutionError> { + // 1. Apply base Ethereum pre-execution changes (state clear flag, EIP-2935, EIP-4788) + self.inner.apply_pre_execution_changes()?; + + // 2. Load L1 gas oracle contract into cache for Curie hardfork upgrade + let _ = self + .inner + .evm_mut() + .db_mut() + .load_cache_account(L1_GAS_PRICE_ORACLE_ADDRESS) + .map_err(BlockExecutionError::other)?; + + // 3. Apply Curie hardfork at the transition block + // Only executes once at the exact block where Curie activates + let block_number = self.inner.evm.block().number.saturating_to(); + if self + .inner + .spec + .morph_fork_activation(MorphHardfork::Curie) + .transitions_at_block(block_number) + { + if let Err(err) = apply_curie_hard_fork(self.inner.evm_mut().db_mut()) { + return Err(BlockExecutionError::msg(format!( + "error occurred at Curie fork: {err:?}" + ))); + } + } + + Ok(()) + } + + fn execute_transaction_without_commit( + &mut self, + tx: impl ExecutableTx, + ) -> Result, BlockExecutionError> { + self.inner.execute_transaction_without_commit(tx) + } + + fn commit_transaction( + &mut self, + output: ResultAndState, + tx: impl ExecutableTx, + ) -> Result { + self.inner.commit_transaction(output, tx) + } + + fn finish( + self, + ) -> Result<(Self::Evm, BlockExecutionResult), BlockExecutionError> { + self.inner.finish() + } + + fn set_state_hook(&mut self, hook: Option>) { + self.inner.set_state_hook(hook) + } + + fn evm_mut(&mut self) -> &mut Self::Evm { + self.inner.evm_mut() + } + + fn evm(&self) -> &Self::Evm { + self.inner.evm() + } +} + diff --git a/crates/evm/src/block/receipt.rs b/crates/evm/src/block/receipt.rs new file mode 100644 index 0000000..dd1233a --- /dev/null +++ b/crates/evm/src/block/receipt.rs @@ -0,0 +1,53 @@ +//! Receipt builder for Morph block execution. +//! +//! This module provides the [`MorphReceiptBuilder`] which constructs receipts +//! for executed transactions based on their type. + +use alloy_consensus::Receipt; +use alloy_evm::{ + Evm, + eth::receipt_builder::{ReceiptBuilder, ReceiptBuilderCtx}, +}; +use morph_primitives::{MorphReceipt, MorphTransactionReceipt, MorphTxEnvelope, MorphTxType}; + +/// Builder for [`MorphReceipt`]. +/// +/// Creates the appropriate receipt variant based on transaction type. +#[derive(Debug, Clone, Copy, Default)] +#[non_exhaustive] +pub(crate) struct MorphReceiptBuilder; + +impl ReceiptBuilder for MorphReceiptBuilder { + type Transaction = MorphTxEnvelope; + type Receipt = MorphReceipt; + + fn build_receipt( + &self, + ctx: ReceiptBuilderCtx<'_, Self::Transaction, E>, + ) -> Self::Receipt { + let ReceiptBuilderCtx { + tx, + result, + cumulative_gas_used, + .. + } = ctx; + + let inner = Receipt { + status: result.is_success().into(), + cumulative_gas_used, + logs: result.into_logs(), + }; + + // Create the appropriate receipt variant based on transaction type + // TODO: Add L1 fee calculation from execution context + match tx.tx_type() { + MorphTxType::Legacy => MorphReceipt::Legacy(MorphTransactionReceipt::new(inner)), + MorphTxType::Eip2930 => MorphReceipt::Eip2930(MorphTransactionReceipt::new(inner)), + MorphTxType::Eip1559 => MorphReceipt::Eip1559(MorphTransactionReceipt::new(inner)), + MorphTxType::Eip7702 => MorphReceipt::Eip7702(MorphTransactionReceipt::new(inner)), + MorphTxType::L1Msg => MorphReceipt::L1Msg(inner), + MorphTxType::AltFee => MorphReceipt::AltFee(MorphTransactionReceipt::new(inner)), + } + } +} + diff --git a/crates/evm/src/context.rs b/crates/evm/src/context.rs index 70584df..534eefa 100644 --- a/crates/evm/src/context.rs +++ b/crates/evm/src/context.rs @@ -1,5 +1,7 @@ use alloy_evm::eth::EthBlockExecutionCtx; use reth_evm::NextBlockEnvAttributes; +#[cfg(feature = "rpc")] +use reth_primitives_traits::SealedHeader; /// Execution context for Morph block. #[derive(Debug, Clone, derive_more::Deref)] @@ -21,7 +23,7 @@ pub struct MorphNextBlockEnvAttributes { impl reth_rpc_eth_api::helpers::pending_block::BuildPendingEnv for MorphNextBlockEnvAttributes { - fn build_pending_env(parent: &crate::SealedHeader) -> Self { + fn build_pending_env(parent: &SealedHeader) -> Self { Self { inner: NextBlockEnvAttributes::build_pending_env(parent), } diff --git a/crates/evm/src/evm.rs b/crates/evm/src/evm.rs index af467a5..4a873ef 100644 --- a/crates/evm/src/evm.rs +++ b/crates/evm/src/evm.rs @@ -1,5 +1,6 @@ use alloy_evm::{ Database, Evm, EvmEnv, EvmFactory, + precompiles::PrecompilesMap, revm::{ Context, ExecuteEvm, InspectEvm, Inspector, SystemCallEvm, context::result::{EVMError, ResultAndState}, @@ -29,7 +30,7 @@ impl EvmFactory for MorphEvmFactory { type HaltReason = MorphHaltReason; type Spec = MorphHardfork; type BlockEnv = MorphBlockEnv; - type Precompiles = MorphPrecompiles; + type Precompiles = PrecompilesMap; fn create_evm( &self, @@ -57,20 +58,28 @@ impl EvmFactory for MorphEvmFactory { #[expect(missing_debug_implementations)] pub struct MorphEvm { inner: morph_revm::MorphEvm, + /// Precompiles wrapped as PrecompilesMap for reth compatibility + precompiles_map: PrecompilesMap, inspect: bool, } impl MorphEvm { /// Create a new [`MorphEvm`] instance. pub fn new(db: DB, input: EvmEnv) -> Self { + let spec = input.cfg_env.spec; let ctx = Context::mainnet() .with_db(db) .with_block(input.block_env) .with_cfg(input.cfg_env) .with_tx(Default::default()); + // Create precompiles for the hardfork and wrap in PrecompilesMap + let morph_precompiles = MorphPrecompiles::new_with_spec(spec); + let precompiles_map = PrecompilesMap::from_static(morph_precompiles.precompiles()); + Self { inner: morph_revm::MorphEvm::new(ctx, NoOpInspector {}), + precompiles_map, inspect: false, } } @@ -91,6 +100,7 @@ impl MorphEvm { pub fn with_inspector(self, inspector: OINSP) -> MorphEvm { MorphEvm { inner: self.inner.with_inspector(inspector), + precompiles_map: self.precompiles_map, inspect: true, } } @@ -143,7 +153,7 @@ where type HaltReason = MorphHaltReason; type Spec = MorphHardfork; type BlockEnv = MorphBlockEnv; - type Precompiles = MorphPrecompiles; + type Precompiles = PrecompilesMap; type Inspector = I; fn block(&self) -> &Self::BlockEnv { @@ -193,7 +203,7 @@ where ( &self.inner.inner.ctx.journaled_state.database, &self.inner.inner.inspector, - &self.inner.inner.precompiles, + &self.precompiles_map, ) } @@ -201,7 +211,7 @@ where ( &mut self.inner.inner.ctx.journaled_state.database, &mut self.inner.inner.inspector, - &mut self.inner.inner.precompiles, + &mut self.precompiles_map, ) } } diff --git a/crates/evm/src/lib.rs b/crates/evm/src/lib.rs index 334fe77..f554657 100644 --- a/crates/evm/src/lib.rs +++ b/crates/evm/src/lib.rs @@ -181,6 +181,7 @@ mod tests { let activation = evm_config .chain_spec() .morph_fork_activation(MorphHardfork::Morph203); - assert_eq!(activation, reth_chainspec::ForkCondition::Timestamp(0)); + // Morph203 is configured at timestamp 0, so it should be active at timestamp 0 + assert!(activation.active_at_timestamp(0)); } } diff --git a/crates/revm/src/lib.rs b/crates/revm/src/lib.rs index 801d7a8..e1c78df 100644 --- a/crates/revm/src/lib.rs +++ b/crates/revm/src/lib.rs @@ -56,6 +56,7 @@ mod tx; pub use block::MorphBlockEnv; pub use error::{MorphHaltReason, MorphInvalidTransaction}; pub use evm::MorphEvm; +pub use precompiles::MorphPrecompiles; pub use l1block::{ CURIE_L1_GAS_PRICE_ORACLE_STORAGE, GPO_BLOB_SCALAR_SLOT, From caa91d04cc09348a8898af8306463bce228356b1 Mon Sep 17 00:00:00 2001 From: panos Date: Fri, 23 Jan 2026 15:28:45 +0800 Subject: [PATCH 12/13] style: clippy and fmt --- crates/evm/src/block/curie.rs | 38 ++++++++++++++++++++++----------- crates/evm/src/block/mod.rs | 12 +++++------ crates/evm/src/block/receipt.rs | 1 - crates/revm/src/lib.rs | 2 +- 4 files changed, 31 insertions(+), 22 deletions(-) diff --git a/crates/evm/src/block/curie.rs b/crates/evm/src/block/curie.rs index ab19cf0..36c7358 100644 --- a/crates/evm/src/block/curie.rs +++ b/crates/evm/src/block/curie.rs @@ -70,15 +70,15 @@ pub(crate) fn apply_curie_hard_fork(state: &mut State) -> Resu mod tests { use super::*; use morph_revm::{ - GPO_BLOB_SCALAR_SLOT, GPO_COMMIT_SCALAR_SLOT, GPO_IS_CURIE_SLOT, - GPO_L1_BLOB_BASE_FEE_SLOT, GPO_L1_BASE_FEE_SLOT, GPO_OVERHEAD_SLOT, - GPO_OWNER_SLOT, GPO_SCALAR_SLOT, GPO_WHITELIST_SLOT, - INITIAL_BLOB_SCALAR, INITIAL_COMMIT_SCALAR, INITIAL_L1_BLOB_BASE_FEE, IS_CURIE, + GPO_BLOB_SCALAR_SLOT, GPO_COMMIT_SCALAR_SLOT, GPO_IS_CURIE_SLOT, GPO_L1_BASE_FEE_SLOT, + GPO_L1_BLOB_BASE_FEE_SLOT, GPO_OVERHEAD_SLOT, GPO_OWNER_SLOT, GPO_SCALAR_SLOT, + GPO_WHITELIST_SLOT, INITIAL_BLOB_SCALAR, INITIAL_COMMIT_SCALAR, INITIAL_L1_BLOB_BASE_FEE, + IS_CURIE, }; use revm::{ database::{ EmptyDB, State, - states::{bundle_state::BundleRetention, plain_account::PlainStorage, StorageSlot}, + states::{StorageSlot, bundle_state::BundleRetention, plain_account::PlainStorage}, }, primitives::U256, state::AccountInfo, @@ -97,16 +97,16 @@ mod tests { // oracle pre fork state let oracle_pre_fork = AccountInfo::default(); let oracle_storage_pre_fork = PlainStorage::from_iter([ - (GPO_OWNER_SLOT, U256::from(0x15f50e5eu64)), // placeholder owner + (GPO_OWNER_SLOT, U256::from(0x15f50e5eu64)), // placeholder owner (GPO_L1_BASE_FEE_SLOT, U256::from(0x15f50e5eu64)), (GPO_OVERHEAD_SLOT, U256::from(0x38u64)), (GPO_SCALAR_SLOT, U256::from(0x3e95ba80u64)), - (GPO_WHITELIST_SLOT, U256::from(0x53u64)), // placeholder whitelist + (GPO_WHITELIST_SLOT, U256::from(0x53u64)), // placeholder whitelist ]); state.insert_account_with_storage( L1_GAS_PRICE_ORACLE_ADDRESS, - oracle_pre_fork.clone(), - oracle_storage_pre_fork.clone(), + oracle_pre_fork, + oracle_storage_pre_fork, ); // apply curie fork @@ -117,8 +117,15 @@ mod tests { let bundle = state.take_bundle(); // check oracle contract contains storage changeset - let oracle = bundle.state.get(&L1_GAS_PRICE_ORACLE_ADDRESS).unwrap().clone(); - let mut storage = oracle.storage.into_iter().collect::>(); + let oracle = bundle + .state + .get(&L1_GAS_PRICE_ORACLE_ADDRESS) + .unwrap() + .clone(); + let mut storage = oracle + .storage + .into_iter() + .collect::>(); storage.sort_by(|(a, _), (b, _)| a.cmp(b)); let expected_storage = [ @@ -130,8 +137,13 @@ mod tests { for (got, expected) in storage.into_iter().zip(expected_storage) { assert_eq!(got.0, expected.0); - assert_eq!(got.1, StorageSlot { present_value: expected.1, ..Default::default() }); + assert_eq!( + got.1, + StorageSlot { + present_value: expected.1, + ..Default::default() + } + ); } } } - diff --git a/crates/evm/src/block/mod.rs b/crates/evm/src/block/mod.rs index 9a3a77e..f6e6839 100644 --- a/crates/evm/src/block/mod.rs +++ b/crates/evm/src/block/mod.rs @@ -19,7 +19,7 @@ use alloy_evm::{ use curie::apply_curie_hard_fork; use morph_chainspec::{MorphChainSpec, MorphHardfork, MorphHardforks}; use morph_primitives::{MorphReceipt, MorphTxEnvelope}; -use morph_revm::{MorphHaltReason, L1_GAS_PRICE_ORACLE_ADDRESS, evm::MorphContext}; +use morph_revm::{L1_GAS_PRICE_ORACLE_ADDRESS, MorphHaltReason, evm::MorphContext}; use reth_revm::{Inspector, State, context::result::ResultAndState}; /// Block executor for Morph. Wraps an inner [`EthBlockExecutor`]. @@ -83,12 +83,11 @@ where .spec .morph_fork_activation(MorphHardfork::Curie) .transitions_at_block(block_number) + && let Err(err) = apply_curie_hard_fork(self.inner.evm_mut().db_mut()) { - if let Err(err) = apply_curie_hard_fork(self.inner.evm_mut().db_mut()) { - return Err(BlockExecutionError::msg(format!( - "error occurred at Curie fork: {err:?}" - ))); - } + return Err(BlockExecutionError::msg(format!( + "error occurred at Curie fork: {err:?}" + ))); } Ok(()) @@ -127,4 +126,3 @@ where self.inner.evm() } } - diff --git a/crates/evm/src/block/receipt.rs b/crates/evm/src/block/receipt.rs index dd1233a..c2bf1d3 100644 --- a/crates/evm/src/block/receipt.rs +++ b/crates/evm/src/block/receipt.rs @@ -50,4 +50,3 @@ impl ReceiptBuilder for MorphReceiptBuilder { } } } - diff --git a/crates/revm/src/lib.rs b/crates/revm/src/lib.rs index e1c78df..94635b0 100644 --- a/crates/revm/src/lib.rs +++ b/crates/revm/src/lib.rs @@ -56,7 +56,6 @@ mod tx; pub use block::MorphBlockEnv; pub use error::{MorphHaltReason, MorphInvalidTransaction}; pub use evm::MorphEvm; -pub use precompiles::MorphPrecompiles; pub use l1block::{ CURIE_L1_GAS_PRICE_ORACLE_STORAGE, GPO_BLOB_SCALAR_SLOT, @@ -77,5 +76,6 @@ pub use l1block::{ L1_GAS_PRICE_ORACLE_ADDRESS, L1BlockInfo, }; +pub use precompiles::MorphPrecompiles; pub use token_fee::{L2_TOKEN_REGISTRY_ADDRESS, TokenFeeInfo, get_erc20_balance_with_evm}; pub use tx::{MorphTxEnv, MorphTxExt}; From f4840d20ec6d496a414d8313e1a24a177ba7d304 Mon Sep 17 00:00:00 2001 From: panos Date: Fri, 23 Jan 2026 15:59:40 +0800 Subject: [PATCH 13/13] refactor: remove l1 message signature --- .../primitives/src/transaction/l1_transaction.rs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/crates/primitives/src/transaction/l1_transaction.rs b/crates/primitives/src/transaction/l1_transaction.rs index 17f5c9d..4df1f90 100644 --- a/crates/primitives/src/transaction/l1_transaction.rs +++ b/crates/primitives/src/transaction/l1_transaction.rs @@ -71,11 +71,6 @@ impl TxL1Msg { L1_TX_TYPE_ID } - /// Returns an empty signature for the [`TxL1Msg`], which don't include a signature. - pub const fn signature() -> Signature { - Signature::new(U256::ZERO, U256::ZERO, false) - } - /// Validates the transaction according to the spec rules. /// /// L1 message transactions have minimal validation requirements. @@ -576,13 +571,4 @@ mod tests { assert!(!buf.is_empty()); assert_eq!(buf.len(), tx.fields_len()); } - - #[test] - fn test_l1_transaction_signature() { - // L1 messages should return an empty signature - let sig = TxL1Msg::signature(); - assert_eq!(sig.r(), U256::ZERO); - assert_eq!(sig.s(), U256::ZERO); - assert!(!sig.v()); - } }