Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,7 @@ openssl rand -hex 32 > jwt.hex

| Flag | Default | Description |
|------|---------|-------------|
| `--morph.max-tx-payload-bytes` | 122880 (120KB) | Maximum transaction payload bytes per block |
| `--morph.max-tx-per-block` | None (unlimited) | Maximum number of transactions per block |
| `--morph.max-tx-payload-bytes` | 737280 (720 KiB) | Maximum L2 tx payload bytes per block (fits one uncompressed 6-blob batch) |
| `--rpc.eth-proof-window` | 0 (disabled) | Max historical blocks for `eth_getProof` (up to 1209600) |

### Running Tests
Expand Down
14 changes: 14 additions & 0 deletions crates/chainspec/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ pub const MORPH_HOODI_CHAIN_ID: u64 = 2910;
/// The sequencer has the right to set any base fee below `MORPH_MAX_BASE_FEE`.
pub const MORPH_BASE_FEE: u64 = 1_000_000;

/// Maximum L2 transaction payload bytes per block (L1 messages excluded).
///
/// Matches morph-geth `params.MorphMaxTxPayloadBytesPerBlock` (`720 * 1024`).
/// `720 KiB = 120 KiB × 6`, sized so one uncompressed L2 block fits in a 6-blob
/// batch (`6 × 4096 × 31 = 761_856` usable bytes). Enforced on import by Morph
/// consensus and used as the sequencer packing default.
pub const MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK: u64 = 720 * 1024;

/// Default priority fee returned by `eth_maxPriorityFeePerGas` when the gas
/// price oracle has no usable block samples (cold start or empty/zero-tip
/// blocks, the common case on Morph L2).
Expand Down Expand Up @@ -110,4 +118,10 @@ mod tests {
fn test_base_fee() {
assert_eq!(MORPH_BASE_FEE, 1_000_000);
}

#[test]
fn test_max_tx_payload_bytes_per_block() {
assert_eq!(MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK, 720 * 1024);
assert_eq!(MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK, 737_280);
}
}
47 changes: 31 additions & 16 deletions crates/chainspec/src/genesis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,22 @@ impl TryFrom<&OtherFields> for MorphHardforkInfo {
}

/// The configuration for the Morph chain.
///
/// The genesis keys `maxTxPayloadBytesPerBlock` (122880 on mainnet/hoodi) and
/// `maxTxPerBlock` are still present in the genesis JSON but are deliberately not
/// read here. Sequencer packing uses `--morph.max-tx-payload-bytes` (default
/// [`crate::MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK`]) rather than the leftover
/// zkEVM genesis field.
///
/// Import-time body validation in morph-geth (`IsValidBlockSize`) and morph-reth
/// (`MorphConsensus::validate_block_pre_execution`) both enforce that same
/// 720 KiB binary constant, not the stored genesis 122880.
#[derive(Default, 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<Address>,
/// The maximum tx payload size per block in bytes.
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tx_payload_bytes_per_block: Option<usize>,
}

impl MorphChainConfig {
Expand All @@ -101,13 +108,6 @@ impl MorphChainConfig {
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 {
Expand Down Expand Up @@ -174,19 +174,34 @@ mod tests {
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_default_config() {
let config = MorphChainConfig::default();
assert!(!config.is_fee_vault_enabled());
assert_eq!(config.fee_vault_address, None);
assert_eq!(config.max_tx_payload_bytes_per_block, None);
// Without max size limit, any size is valid
assert!(config.is_valid_block_size(usize::MAX));
}

#[test]
fn test_ignores_unused_packing_fields() {
let config_str = r#"
{
"morph": {
"feeVaultAddress": "0x530000000000000000000000000000000000000a",
"maxTxPayloadBytesPerBlock": 122880,
"maxTxPerBlock": 100
}
}
"#;

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"))
);
}
}
17 changes: 4 additions & 13 deletions crates/chainspec/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -301,16 +301,6 @@ impl MorphChainSpec {
pub fn fee_vault_address(&self) -> Option<Address> {
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<usize> {
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<ChainSpec> for MorphChainSpec {
Expand Down Expand Up @@ -716,9 +706,10 @@ mod tests {
let chainspec = MorphChainSpec::from(genesis);

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));
assert_eq!(
chainspec.fee_vault_address(),
Some(address!("530000000000000000000000000000000000000a"))
);
}

#[test]
Expand Down
1 change: 1 addition & 0 deletions crates/consensus/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ reth-primitives-traits.workspace = true

# Alloy
alloy-consensus.workspace = true
alloy-eips.workspace = true
alloy-evm.workspace = true
alloy-primitives.workspace = true
alloy-rlp.workspace = true
Expand Down
12 changes: 12 additions & 0 deletions crates/consensus/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@ pub enum MorphConsensusError {
/// Withdrawals are not empty.
#[error("Withdrawals are not empty")]
WithdrawalsNonEmpty,

/// L2 transaction payload exceeds the per-block DA cap.
///
/// Matches go-ethereum `ErrInvalidBlockPayloadSize`. L1 messages are
/// excluded from `size`.
#[error("invalid block payload size: {size} exceeds limit {limit}")]
InvalidBlockPayloadSize {
/// Encoded L2 payload bytes (EIP-2718, L1 messages excluded).
size: u64,
/// Maximum allowed payload bytes.
limit: u64,
},
}

impl From<alloy_rlp::Error> for MorphConsensusError {
Expand Down
134 changes: 129 additions & 5 deletions crates/consensus/src/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
//! - No uncle blocks allowed
//! - Withdrawals field must not be present
//! - Transaction root must be valid
//! - L2 transaction payload (EIP-2718 encoded, L1 messages excluded) must not
//! exceed [`morph_chainspec::MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK`]
//!
//! ## Post-Execution Validation
//!
Expand All @@ -36,9 +38,10 @@
//!
use crate::MorphConsensusError;
use alloy_consensus::{BlockHeader as _, EMPTY_OMMER_ROOT_HASH, TxReceipt};
use alloy_eips::eip2718::Encodable2718;
use alloy_evm::block::BlockExecutionResult;
use alloy_primitives::{B256, Bloom};
use morph_chainspec::{MorphChainSpec, MorphHardforks};
use morph_chainspec::{MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK, MorphChainSpec, MorphHardforks};
use morph_primitives::{
Block, BlockBody, MorphHeader, MorphReceipt, MorphTxEnvelope,
transaction::morph_transaction::MORPH_TX_VERSION_1,
Expand Down Expand Up @@ -271,7 +274,9 @@ impl Consensus<Block> for MorphConsensus {
/// 2. **Ommers Hash**: Must be the empty ommer root hash
/// 3. **Transaction Root**: Must be valid
/// 4. **Withdrawals**: Must be empty (Morph L2 doesn't support withdrawals)
/// 5. **L1 Messages**: Must be ordered correctly (sequential queue indices, L1 before L2)
/// 5. **L2 Payload Size**: Encoded L2 txs (L1 messages excluded) must not
/// exceed [`MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK`]
/// 6. **L1 Messages**: Must be ordered correctly (sequential queue indices, L1 before L2)
fn validate_block_pre_execution(
&self,
block: &SealedBlock<Block>,
Expand Down Expand Up @@ -305,6 +310,9 @@ impl Consensus<Block> for MorphConsensus {
));
}

// Matches go-ethereum's BlockValidator.ValidateBody() → IsValidBlockSize().
validate_l2_tx_payload_size(&block.body().transactions)?;

// Validate MorphTx activation, version and field constraints.
// Matches go-ethereum's BlockValidator.ValidateBody() → ValidateMorphTxVersion().
let is_emerald = self
Expand Down Expand Up @@ -467,6 +475,37 @@ fn validate_against_parent_gas_limit<H: BlockHeader>(
Ok(())
}

// ============================================================================
// L2 Payload Size Validation
// ============================================================================

/// Sum of EIP-2718 encoded L2 transaction bytes.
///
/// Matches go-ethereum `Block.PayloadSize()`: L1 messages are excluded because
/// their calldata is already published on L1.
fn l2_tx_payload_bytes(txs: &[MorphTxEnvelope]) -> u64 {
txs.iter()
.filter(|tx| !tx.is_l1_msg())
.map(|tx| tx.encode_2718_len() as u64)
.fold(0, u64::saturating_add)
}

/// Rejects blocks whose L2 payload exceeds [`MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK`].
///
/// Matches go-ethereum `MorphConfig.IsValidBlockSize` (`size <= limit`).
fn validate_l2_tx_payload_size(txs: &[MorphTxEnvelope]) -> Result<(), ConsensusError> {
let size = l2_tx_payload_bytes(txs);
if size > MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK {
return Err(ConsensusError::other(
MorphConsensusError::InvalidBlockPayloadSize {
size,
limit: MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK,
},
));
}
Ok(())
}

// ============================================================================
// L1 Message Validation
// ============================================================================
Expand Down Expand Up @@ -766,40 +805,61 @@ mod tests {
}

fn create_l1_msg_tx(queue_index: u64) -> MorphTxEnvelope {
create_l1_msg_tx_with_input(queue_index, Bytes::default())
}

fn create_l1_msg_tx_with_input(queue_index: u64, input: Bytes) -> MorphTxEnvelope {
use alloy_consensus::Sealed;
let tx = TxL1Msg {
queue_index,
gas_limit: 21000,
to: Address::ZERO,
value: U256::ZERO,
input: Bytes::default(),
input,
sender: Address::ZERO,
};
// L1 messages have no signature - use Sealed instead of Signed
MorphTxEnvelope::L1Msg(Sealed::new(tx))
}

fn create_regular_tx() -> MorphTxEnvelope {
create_legacy_tx_with_input(Bytes::default())
}

fn create_legacy_tx_with_input(input: Bytes) -> MorphTxEnvelope {
use alloy_consensus::TxLegacy;
let tx = TxLegacy::default();
let tx = TxLegacy {
gas_limit: 1_000_000,
input,
..Default::default()
};
let sig = Signature::new(U256::ZERO, U256::ZERO, false);
MorphTxEnvelope::Legacy(Signed::new_unchecked(tx, sig, B256::ZERO))
}

fn create_sealed_block(
timestamp: u64,
transactions: Vec<MorphTxEnvelope>,
) -> SealedBlock<Block> {
create_sealed_block_with_next_l1(timestamp, transactions, 0)
}

fn create_sealed_block_with_next_l1(
timestamp: u64,
transactions: Vec<MorphTxEnvelope>,
next_l1_msg_index: u64,
) -> SealedBlock<Block> {
use alloy_consensus::proofs::calculate_transaction_root;
use reth_primitives_traits::Block as _;

let transactions_root = calculate_transaction_root(&transactions);
let header = create_morph_header(Header {
let mut header = create_morph_header(Header {
timestamp,
transactions_root,
ommers_hash: EMPTY_OMMER_ROOT_HASH,
..Default::default()
});
header.next_l1_msg_index = next_l1_msg_index;
Block::new(
header,
BlockBody {
Expand Down Expand Up @@ -1865,6 +1925,70 @@ mod tests {
);
}

#[test]
fn test_l2_tx_payload_bytes_excludes_l1_messages() {
let l1 = create_l1_msg_tx_with_input(
0,
Bytes::from(vec![1u8; MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK as usize + 1]),
);
let l2 = create_regular_tx();

assert_eq!(l2_tx_payload_bytes(std::slice::from_ref(&l1)), 0);
assert_eq!(
l2_tx_payload_bytes(&[l1, l2.clone()]),
l2_tx_payload_bytes(std::slice::from_ref(&l2))
);
}

#[test]
fn test_validate_block_pre_execution_accepts_payload_at_limit() {
let consensus = MorphConsensus::new(create_test_chainspec());
// A default legacy tx is well under the 720 KiB cap.
let block = create_sealed_block(0, vec![create_regular_tx()]);

assert!(consensus.validate_block_pre_execution(&block).is_ok());
assert!(
l2_tx_payload_bytes(&block.body().transactions) <= MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK
);
}

#[test]
fn test_validate_block_pre_execution_rejects_oversized_l2_payload() {
let consensus = MorphConsensus::new(create_test_chainspec());
let oversized = create_legacy_tx_with_input(Bytes::from(vec![
0u8;
MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK
as usize
]));
let size = l2_tx_payload_bytes(std::slice::from_ref(&oversized));
assert!(size > MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK);

let block = create_sealed_block(0, vec![oversized]);
let result = consensus.validate_block_pre_execution(&block);

assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("invalid block payload size"),
"unexpected error: {err}"
);
}

#[test]
fn test_validate_block_pre_execution_ignores_large_l1_messages() {
let consensus = MorphConsensus::new(create_test_chainspec());
let huge_l1 = create_l1_msg_tx_with_input(
0,
Bytes::from(vec![
1u8;
MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK as usize + 1024
]),
);
let block = create_sealed_block_with_next_l1(0, vec![huge_l1, create_regular_tx()], 1);

assert!(consensus.validate_block_pre_execution(&block).is_ok());
}

#[test]
fn test_validate_morph_tx_v1_fee_token_0_with_fee_limit_rejected() {
use alloy_consensus::Signed;
Expand Down
Loading