diff --git a/Cargo.lock b/Cargo.lock index d930672..3ab63de 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", @@ -3711,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", ] @@ -5029,6 +5053,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..aeee60a 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 } @@ -90,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", @@ -115,7 +118,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..f03c3d4 --- /dev/null +++ b/crates/consensus/src/validation.rs @@ -0,0 +1,1097 @@ +//! 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 alloy_primitives::{B256, Bloom}; +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, + ), + }); + } + + // Verify the receipts logs bloom and root + verify_receipts(block.receipts_root(), block.logs_bloom(), &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(()) +} + +// ============================================================================ +// 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 +// ============================================================================ + +#[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 = [ + 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 = [ + 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([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!( + 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()); + } + + // ======================================================================== + // L1 Message Validation Tests + // ======================================================================== + + #[test] + fn test_validate_l1_messages_empty_block() { + 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 = [ + 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 = [ + 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 = [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 = [ + 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 = [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 = [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 = [ + 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: [MorphReceipt; 0] = []; + 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: [MorphReceipt; 0] = []; + 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: [MorphReceipt; 0] = []; + 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/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/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 2e367f0..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 { @@ -56,6 +59,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 { @@ -91,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/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, 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};