From d309ece2989c42c987f2c3dfa4fa83be73adacb9 Mon Sep 17 00:00:00 2001 From: panos Date: Thu, 5 Mar 2026 10:50:36 +0800 Subject: [PATCH 01/35] test: add stress test script --- local-test/common.sh | 7 ++++++- local-test/node-start.sh | 16 ++++++++++++++-- local-test/sync-test.sh | 8 +++++++- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/local-test/common.sh b/local-test/common.sh index 2ab2f36..0777245 100755 --- a/local-test/common.sh +++ b/local-test/common.sh @@ -10,6 +10,9 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" : "${NODE_HOME:=./local-test/node-data}" : "${JWT_SECRET:=./local-test/jwt-secret.txt}" : "${NODE_LOG_FILE:=./local-test/node.log}" +: "${MORPH_NODE_L1_RPC:=${MORPH_NODE_L1_ETH_RPC:-https://ethereum.publicnode.com}}" +: "${MORPH_NODE_DEPOSIT_CONTRACT:=${MORPH_NODE_SYNC_DEPOSIT_CONTRACT_ADDRESS:-0x3931ade842f5bb8763164bdd81e5361dce6cc1ef}}" +: "${MORPH_NODE_EXTRA_FLAGS:=--mainnet}" : "${DOWNLOAD_CONFIG_IF_MISSING:=1}" : "${MAINNET_CONFIG_ZIP_URL:=https://raw.githubusercontent.com/morph-l2/run-morph-node/main/mainnet/data.zip}" : "${CONFIG_ZIP_PATH:=./local-test/mainnet-data.zip}" @@ -32,7 +35,9 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" : "${RETH_BOOTNODES:=}" : "${MORPH_MAX_TX_PAYLOAD_BYTES:=122880}" : "${MORPH_MAX_TX_PER_BLOCK:=}" -: "${MORPH_GETH_RPC_URL:=http://localhost:8546}" +# Keep disabled by default for fair local benchmarks. +# Set MORPH_GETH_RPC_URL explicitly when cross-validation is needed. +: "${MORPH_GETH_RPC_URL:=}" check_binary() { local bin_path="$1" diff --git a/local-test/node-start.sh b/local-test/node-start.sh index d950781..67d0b6b 100755 --- a/local-test/node-start.sh +++ b/local-test/node-start.sh @@ -22,12 +22,24 @@ fi # Ensure log directory exists mkdir -p "$(dirname "${NODE_LOG_FILE}")" -# Start morphnode with pm2 -pm2 start "${MORPHNODE_BIN}" --name morph-node -- \ +# Build node args +args=( --home "${NODE_HOME}" \ --l2.jwt-secret "${JWT_SECRET}" \ --l2.eth "http://${RETH_HTTP_ADDR}:${RETH_HTTP_PORT}" \ --l2.engine "http://${RETH_AUTHRPC_ADDR}:${RETH_AUTHRPC_PORT}" \ + --l1.rpc "${MORPH_NODE_L1_RPC}" \ + --sync.depositContractAddr "${MORPH_NODE_DEPOSIT_CONTRACT}" \ --log.filename "${NODE_LOG_FILE}" +) + +if [[ -n "${MORPH_NODE_EXTRA_FLAGS}" ]]; then + # shellcheck disable=SC2206 + extra_flags=(${MORPH_NODE_EXTRA_FLAGS}) + args+=("${extra_flags[@]}") +fi + +# Start morphnode with pm2 +pm2 start "${MORPHNODE_BIN}" --name morph-node -- "${args[@]}" echo "Logs: pm2 logs morph-node" diff --git a/local-test/sync-test.sh b/local-test/sync-test.sh index 51640e1..7859a93 100755 --- a/local-test/sync-test.sh +++ b/local-test/sync-test.sh @@ -12,6 +12,7 @@ cd "${REPO_ROOT}" : "${SAMPLE_INTERVAL:=10}" # seconds between BPS samples : "${SKIP_GETH:=0}" # set to 1 to skip geth test : "${SKIP_RETH:=0}" # set to 1 to skip reth test +: "${RETH_DISABLE_GETH_RPC_COMPARE:=1}" # 1: do NOT pass --morph.geth-rpc-url during reth benchmark : "${MAINNET_TIP:=21100000}" # approximate current mainnet tip for ETA calc # ─── Helpers ────────────────────────────────────────────────────────────────── @@ -242,7 +243,12 @@ if [[ "${SKIP_RETH}" != "1" ]]; then # Start reth echo " Starting morph-reth..." - "${SCRIPT_DIR}/reth-start.sh" + if [[ "${RETH_DISABLE_GETH_RPC_COMPARE}" == "1" ]]; then + echo " Reth benchmark mode: disabling morph.geth-rpc-url" + MORPH_GETH_RPC_URL="" "${SCRIPT_DIR}/reth-start.sh" + else + "${SCRIPT_DIR}/reth-start.sh" + fi # Wait for reth RPC wait_for_rpc "reth" From ecd5b2c5b85ba23116cd57fbb592d02f081a1ae1 Mon Sep 17 00:00:00 2001 From: panos Date: Fri, 6 Mar 2026 17:03:46 +0800 Subject: [PATCH 02/35] fix: pre-audit security fixes across multiple crates Fixes from internal security audit before external audit engagement: - consensus: eliminate AtomicU64 call-order coupling in L1 message validation, split into independent stateless checks (validate_l1_messages_in_block + header monotonicity check) - consensus: add timestamp validation for pre-Emerald (strict >) vs post-Emerald (>=) - revm/handler: add disable_fee_charge check in token fee path for eth_call simulation correctness - revm/handler: handle missing cached_token_fee_info in reimburse_caller when fee charge was disabled - revm/handler: use checked_sub in transfer_erc20_with_slot for from balance - revm/l1block: cap L1 data fee to u64::MAX for circuit compatibility - engine-api: fix ValidateL2Block error semantics (internal vs invalid) - engine-api: fix FeeVault coinbase override - rpc: use TxMorph::validate() instead of validate_version() for complete validation at RPC layer - primitives: minor TxMorph validation improvements - txpool: improve error types for MorphTx validation --- bin/morph-reth/src/main.rs | 4 +- crates/consensus/src/validation.rs | 332 ++++++++++++++---- crates/engine-api/src/builder.rs | 21 +- crates/evm/src/block/mod.rs | 30 +- crates/payload/builder/src/error.rs | 19 - crates/primitives/src/receipt/envelope.rs | 21 +- .../src/transaction/morph_transaction.rs | 11 +- crates/revm/src/evm.rs | 7 +- crates/revm/src/handler.rs | 207 +++++++---- crates/revm/src/l1block.rs | 13 +- crates/revm/src/token_fee.rs | 11 +- crates/revm/src/tx.rs | 2 +- crates/rpc/src/eth/transaction.rs | 12 +- crates/txpool/src/error.rs | 28 +- crates/txpool/src/validator.rs | 16 +- 15 files changed, 482 insertions(+), 252 deletions(-) diff --git a/bin/morph-reth/src/main.rs b/bin/morph-reth/src/main.rs index 3033721..6be14ee 100644 --- a/bin/morph-reth/src/main.rs +++ b/bin/morph-reth/src/main.rs @@ -18,7 +18,9 @@ fn main() { // Install signal handler for segmentation faults sigsegv_handler::install(); - // Enable backtraces by default + // Enable backtraces by default. + // SAFETY: Called at process startup before any other threads are spawned, + // so there are no concurrent readers of the environment. if std::env::var_os("RUST_BACKTRACE").is_none() { unsafe { std::env::set_var("RUST_BACKTRACE", "1") }; } diff --git a/crates/consensus/src/validation.rs b/crates/consensus/src/validation.rs index a885930..3ee47cd 100644 --- a/crates/consensus/src/validation.rs +++ b/crates/consensus/src/validation.rs @@ -38,7 +38,7 @@ use crate::MorphConsensusError; use alloy_consensus::{BlockHeader as _, EMPTY_OMMER_ROOT_HASH, TxReceipt}; use alloy_evm::block::BlockExecutionResult; use alloy_primitives::{B256, Bloom}; -use morph_chainspec::MorphChainSpec; +use morph_chainspec::{MorphChainSpec, MorphHardforks}; use morph_primitives::{Block, BlockBody, MorphHeader, MorphReceipt, MorphTxEnvelope}; use reth_consensus::{Consensus, ConsensusError, FullConsensus, HeaderValidator, ReceiptRootBloom}; use reth_consensus_common::validation::{ @@ -74,6 +74,25 @@ const GAS_LIMIT_BOUND_DIVISOR: u64 = 1024; /// /// Validates Morph L2 blocks according to the L2 consensus rules. /// See module-level documentation for detailed validation rules. +/// +/// # L1 Message Validation Architecture +/// +/// L1 message ordering requires both body data (transactions) and parent header data. +/// Since reth's `Consensus` trait methods provide these separately — `validate_block_pre_execution` +/// has the block body but not the parent header, while `validate_header_against_parent` has +/// both headers but not the body — the validation is split into two independent checks: +/// +/// 1. **Internal consistency** (`validate_block_pre_execution`): L1 messages are at the block +/// start, have sequential queue indices, and are consistent with `header.next_l1_msg_index`. +/// 2. **Cross-block monotonicity** (`validate_header_against_parent`): `header.next_l1_msg_index` +/// is monotonically non-decreasing relative to the parent. +/// +/// These two methods have no ordering dependency and share no mutable state. The strict +/// cross-block equality check (`header.next == parent.next + l1_count`) requires simultaneous +/// access to both parent header and block body, which reth's trait API does not provide in +/// any single method. In Morph's single-sequencer model, the remaining gap (queue index +/// skipping) is prevented by the trusted sequencer and verified by the L1 message queue +/// contract. #[derive(Debug, Clone)] pub struct MorphConsensus { /// Chain specification containing hardfork information and chain config. @@ -82,7 +101,7 @@ pub struct MorphConsensus { impl MorphConsensus { /// Creates a new [`MorphConsensus`] instance. - pub const fn new(chain_spec: Arc) -> Self { + pub fn new(chain_spec: Arc) -> Self { Self { chain_spec } } @@ -198,12 +217,28 @@ impl HeaderValidator for MorphConsensus { // 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 timestamp against parent (pre-Emerald: strict >, post-Emerald: >=) + let is_emerald = self + .chain_spec + .is_emerald_active_at_timestamp(header.timestamp()); + validate_against_parent_timestamp(header.header(), parent.header(), is_emerald)?; // Validate gas limit change validate_against_parent_gas_limit(header.header(), parent.header())?; + // Cross-block L1 message index monotonicity: next_l1_msg_index must never + // decrease across blocks. This is the header-only half of L1 message + // validation; the body-level half is in validate_block_pre_execution. + if header.next_l1_msg_index < parent.next_l1_msg_index { + return Err(ConsensusError::Other( + MorphConsensusError::InvalidNextL1MessageIndex { + expected: parent.next_l1_msg_index, + actual: header.next_l1_msg_index, + } + .to_string(), + )); + } + Ok(()) } } @@ -266,9 +301,13 @@ impl Consensus for MorphConsensus { )); } - // Validate L1 messages ordering + // Validate L1 messages ordering and internal consistency with header. + // This is the body-level half of L1 validation; it verifies that the L1 + // messages within this block are internally consistent with the header's + // next_l1_msg_index. The cross-block monotonicity check (ensuring + // next_l1_msg_index >= parent's value) is in validate_header_against_parent. let txs: Vec<_> = block.body().transactions().collect(); - validate_l1_messages(&txs)?; + validate_l1_messages_in_block(&txs, block.header().next_l1_msg_index)?; Ok(()) } @@ -349,22 +388,28 @@ impl FullConsensus for MorphConsensus { } } -/// Validates that the header's timestamp is not before the parent's timestamp. +/// Validates that the header's timestamp is valid relative to the parent's timestamp. /// -/// # Errors +/// # Hardfork Behavior /// -/// Returns [`ConsensusError::TimestampIsInPast`] if the header's timestamp -/// is less than the parent's timestamp. +/// - **Pre-Emerald**: Timestamp must be strictly greater than parent's timestamp. +/// - **Post-Emerald**: Timestamp must be greater than or equal to parent's timestamp. +/// +/// This matches go-ethereum's `consensus/l2/consensus.go:155-157`. /// -/// # Note +/// # Errors /// -/// Equal timestamps are allowed - only strictly less than is rejected. +/// Returns [`ConsensusError::TimestampIsInPast`] if the header's timestamp +/// violates the hardfork-specific constraint. #[inline] fn validate_against_parent_timestamp( header: &H, parent: &H, + is_emerald: bool, ) -> Result<(), ConsensusError> { - if header.timestamp() < parent.timestamp() { + if header.timestamp() < parent.timestamp() + || (header.timestamp() == parent.timestamp() && !is_emerald) + { return Err(ConsensusError::TimestampIsInPast { parent_timestamp: parent.timestamp(), timestamp: header.timestamp(), @@ -392,7 +437,7 @@ fn validate_against_parent_gas_limit( ) -> Result<(), ConsensusError> { let diff = header.gas_limit().abs_diff(parent.gas_limit()); let limit = parent.gas_limit() / GAS_LIMIT_BOUND_DIVISOR; - if diff > limit { + if diff >= limit { return if header.gas_limit() > parent.gas_limit() { Err(ConsensusError::GasLimitInvalidIncrease { parent_gas_limit: parent.gas_limit(), @@ -419,30 +464,34 @@ fn validate_against_parent_gas_limit( // L1 Message Validation // ============================================================================ -/// Validates L1 message ordering in a block's transactions. +/// Validates L1 message ordering and internal consistency within a single block. /// -/// L1 messages are special transactions that originate from L1 (deposits, etc.). -/// They must follow strict ordering rules to ensure deterministic block execution. +/// This is a **stateless** validation that uses only the block's own transactions +/// and header — it does not require the parent header or any shared mutable state. /// -/// # Rules +/// # Checks Performed /// /// 1. **Position**: All L1 messages must appear at the beginning of the block. /// Once a regular (L2) transaction appears, no more L1 messages are allowed. /// /// 2. **Sequential Queue Index**: L1 messages must have strictly sequential -/// `queue_index` values. If the first L1 message has `queue_index = N`, -/// the next must have `queue_index = N+1`, and so on. +/// `queue_index` values (each = previous + 1). /// -/// # Errors +/// 3. **Header Consistency**: If L1 messages are present, the last L1 message's +/// `queue_index + 1` must equal `header.next_l1_msg_index`. This ensures the +/// header correctly reflects the transactions in the body. /// -/// - [`MorphConsensusError::MalformedL1Message`] if an L1 message is missing its queue_index -/// - [`MorphConsensusError::L1MessagesNotInOrder`] if queue indices are not sequential -/// - [`MorphConsensusError::InvalidL1MessageOrder`] if L1 message appears after L2 transaction +/// # Cross-Block Validation +/// +/// The cross-block check (ensuring `next_l1_msg_index >= parent.next_l1_msg_index`) +/// is performed separately in `validate_header_against_parent`, which has access to +/// the parent header. See the [`MorphConsensus`] doc comment for the full architecture. /// /// # Example (Valid) /// /// ```text -/// [L1Msg(queue=0), L1Msg(queue=1), L1Msg(queue=2), RegularTx, RegularTx] +/// [L1Msg(queue=5), L1Msg(queue=6), L1Msg(queue=7), RegularTx] +/// // header.next_l1_msg_index = 8 ✓ /// ``` /// /// # Example (Invalid - L1 after L2) @@ -451,41 +500,63 @@ fn validate_against_parent_gas_limit( /// [L1Msg(queue=0), RegularTx, L1Msg(queue=1)] // ❌ L1 after L2 /// ``` #[inline] -fn validate_l1_messages(txs: &[&MorphTxEnvelope]) -> Result<(), ConsensusError> { - // Find the starting queue index from the first L1 message - let mut queue_index = txs - .iter() - .find(|tx| tx.is_l1_msg()) - .and_then(|tx| tx.queue_index()) - .unwrap_or_default(); - +fn validate_l1_messages_in_block( + txs: &[&MorphTxEnvelope], + header_next_l1_msg_index: u64, +) -> Result<(), ConsensusError> { + let mut l1_msg_count = 0u64; let mut saw_l2_transaction = false; + let mut prev_queue_index: Option = None; for tx in txs { - // Check queue index is strictly sequential if tx.is_l1_msg() { + // Check L1 messages are only at the start of the block (before any L2 tx) + if saw_l2_transaction { + return Err(ConsensusError::Other( + MorphConsensusError::InvalidL1MessageOrder.to_string(), + )); + } + let tx_queue_index = tx.queue_index().ok_or_else(|| { ConsensusError::Other(MorphConsensusError::MalformedL1Message.to_string()) })?; - if tx_queue_index != queue_index { + + // Check queue indices are strictly sequential (each = previous + 1) + if let Some(prev) = prev_queue_index + && tx_queue_index != prev + 1 + { return Err(ConsensusError::Other( MorphConsensusError::L1MessagesNotInOrder { - expected: queue_index, + expected: prev + 1, actual: tx_queue_index, } .to_string(), )); } - queue_index = tx_queue_index + 1; + + prev_queue_index = Some(tx_queue_index); + l1_msg_count += 1; + } else { + saw_l2_transaction = true; } + } - // Check L1 messages are only at the start of the block - if tx.is_l1_msg() && saw_l2_transaction { + // Validate header consistency: header.next_l1_msg_index must equal + // last_queue_index + 1 (i.e., first_queue_index + l1_msg_count). + // For blocks with no L1 messages, this check is skipped — the cross-block + // monotonicity check in validate_header_against_parent handles that case. + if l1_msg_count > 0 { + let last_queue_index = prev_queue_index.expect("l1_msg_count > 0 implies prev is Some"); + let expected_next = last_queue_index + 1; + if header_next_l1_msg_index != expected_next { return Err(ConsensusError::Other( - MorphConsensusError::InvalidL1MessageOrder.to_string(), + MorphConsensusError::InvalidNextL1MessageIndex { + expected: expected_next, + actual: header_next_l1_msg_index, + } + .to_string(), )); } - saw_l2_transaction = !tx.is_l1_msg(); } Ok(()) @@ -615,25 +686,26 @@ mod tests { } #[test] - fn test_validate_l1_messages_valid() { + fn test_validate_l1_messages_in_block_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()); + // L1 msgs: 0, 1 → last+1=2==header_next + assert!(validate_l1_messages_in_block(&txs_refs, 2).is_ok()); } #[test] - fn test_validate_l1_messages_after_regular() { + fn test_validate_l1_messages_in_block_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()); + assert!(validate_l1_messages_in_block(&txs_refs, 2).is_err()); } #[test] @@ -753,40 +825,47 @@ mod tests { // ======================================================================== #[test] - fn test_validate_l1_messages_empty_block() { + fn test_validate_l1_messages_in_block_empty_block() { let txs: [MorphTxEnvelope; 0] = []; let txs_refs: Vec<_> = txs.iter().collect(); - assert!(validate_l1_messages(&txs_refs).is_ok()); + // Empty block: no L1 messages → internal check always passes. + // Any header_next value is accepted because the cross-block + // monotonicity check is in validate_header_against_parent. + assert!(validate_l1_messages_in_block(&txs_refs, 0).is_ok()); + assert!(validate_l1_messages_in_block(&txs_refs, 5).is_ok()); + assert!(validate_l1_messages_in_block(&txs_refs, 100).is_ok()); } #[test] - fn test_validate_l1_messages_only_l1_messages() { + fn test_validate_l1_messages_in_block_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()); + // last=2, 2+1=3==header_next + assert!(validate_l1_messages_in_block(&txs_refs, 3).is_ok()); } #[test] - fn test_validate_l1_messages_only_regular_txs() { + fn test_validate_l1_messages_in_block_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()); + // No L1 messages → internal check passes (header_next not checked) + assert!(validate_l1_messages_in_block(&txs_refs, 0).is_ok()); } #[test] - fn test_validate_l1_messages_skipped_index() { - // Skip index 1: 0, 2 + fn test_validate_l1_messages_in_block_skipped_index() { + // Block has 0 then 2 (skipping 1) — caught by sequential check 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); + let result = validate_l1_messages_in_block(&txs_refs, 3); assert!(result.is_err()); let err_str = result.unwrap_err().to_string(); assert!(err_str.contains("expected 1")); @@ -794,23 +873,24 @@ mod tests { } #[test] - fn test_validate_l1_messages_non_zero_start_index() { - // Starting from index 100 is valid + fn test_validate_l1_messages_in_block_non_zero_start_index() { + // Block starts L1 messages at queue_index 100 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()); + // last=101, 101+1=102==header_next + assert!(validate_l1_messages_in_block(&txs_refs, 102).is_ok()); } #[test] - fn test_validate_l1_messages_duplicate_index() { - // Duplicate index: 0, 0 + fn test_validate_l1_messages_in_block_duplicate_index() { + // Duplicate index: 0, 0 — caught by sequential check (prev=0, expected 1, got 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); + let result = validate_l1_messages_in_block(&txs_refs, 1); assert!(result.is_err()); let err_str = result.unwrap_err().to_string(); assert!(err_str.contains("expected 1")); @@ -818,16 +898,34 @@ mod tests { } #[test] - fn test_validate_l1_messages_out_of_order() { - // Reversed order: 1, 0 + fn test_validate_l1_messages_in_block_out_of_order() { + // Block has 1 then 0 — caught by sequential check (prev=1, expected 2, got 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); + let result = validate_l1_messages_in_block(&txs_refs, 2); assert!(result.is_err()); } #[test] - fn test_validate_l1_messages_multiple_l1_after_regular() { + fn test_validate_l1_messages_in_block_wrong_next_l1_msg_index() { + // Valid sequential L1 messages (0, 1, 2) but wrong next_l1_msg_index in header + let txs = [ + create_l1_msg_tx(0), + create_l1_msg_tx(1), + create_l1_msg_tx(2), + create_regular_tx(), + ]; + let txs_refs: Vec<_> = txs.iter().collect(); + // Header says 100 but should be 3 (last=2, 2+1=3) + let result = validate_l1_messages_in_block(&txs_refs, 100); + assert!(result.is_err()); + let err_str = result.unwrap_err().to_string(); + assert!(err_str.contains("expected 3")); + assert!(err_str.contains("got 100")); + } + + #[test] + fn test_validate_l1_messages_in_block_multiple_l1_after_regular() { // Multiple L1 messages after regular tx let txs = [ create_l1_msg_tx(0), @@ -836,7 +934,7 @@ mod tests { create_l1_msg_tx(2), ]; let txs_refs: Vec<_> = txs.iter().collect(); - assert!(validate_l1_messages(&txs_refs).is_err()); + assert!(validate_l1_messages_in_block(&txs_refs, 3).is_err()); } // ======================================================================== @@ -994,6 +1092,50 @@ mod tests { assert!(result.is_ok()); } + #[test] + fn test_validate_header_against_parent_l1_msg_index_monotonicity() { + let chain_spec = create_test_chainspec(); + let consensus = MorphConsensus::new(chain_spec); + + // Parent has next_l1_msg_index = 10 + let mut parent = create_valid_morph_header(1000, 30_000_000, 100); + parent.next_l1_msg_index = 10; + let parent_sealed = SealedHeader::seal_slow(parent); + + // Child with next_l1_msg_index = 15 (increased, valid) + let mut child = create_valid_morph_header(1001, 30_000_000, 101); + child.inner.parent_hash = parent_sealed.hash(); + child.next_l1_msg_index = 15; + let child_sealed = SealedHeader::seal_slow(child); + assert!( + consensus + .validate_header_against_parent(&child_sealed, &parent_sealed) + .is_ok() + ); + + // Child with next_l1_msg_index = 10 (unchanged, valid — no L1 msgs in block) + let mut child_same = create_valid_morph_header(1001, 30_000_000, 101); + child_same.inner.parent_hash = parent_sealed.hash(); + child_same.next_l1_msg_index = 10; + let child_same_sealed = SealedHeader::seal_slow(child_same); + assert!( + consensus + .validate_header_against_parent(&child_same_sealed, &parent_sealed) + .is_ok() + ); + + // Child with next_l1_msg_index = 5 (decreased, INVALID) + let mut child_dec = create_valid_morph_header(1001, 30_000_000, 101); + child_dec.inner.parent_hash = parent_sealed.hash(); + child_dec.next_l1_msg_index = 5; + let child_dec_sealed = SealedHeader::seal_slow(child_dec); + let result = consensus.validate_header_against_parent(&child_dec_sealed, &parent_sealed); + assert!(result.is_err()); + let err_str = result.unwrap_err().to_string(); + assert!(err_str.contains("expected 10")); + assert!(err_str.contains("got 5")); + } + #[test] fn test_validate_header_against_parent_timestamp_less_than_parent() { let chain_spec = create_test_chainspec(); @@ -1087,13 +1229,30 @@ mod tests { let parent = create_valid_morph_header(1000, parent_gas_limit, 100); let parent_sealed = SealedHeader::seal_slow(parent); - // Increase by exactly the allowed amount (valid) - let mut child = create_valid_morph_header(1001, parent_gas_limit + max_change, 101); - child.inner.parent_hash = parent_sealed.hash(); - let child_sealed = SealedHeader::seal_slow(child); + // Increase by exactly the boundary (diff == limit) should be REJECTED, + // matching go-ethereum's `diff >= limit` check. + let mut child_at_boundary = + create_valid_morph_header(1001, parent_gas_limit + max_change, 101); + child_at_boundary.inner.parent_hash = parent_sealed.hash(); + let child_sealed = SealedHeader::seal_slow(child_at_boundary); let result = consensus.validate_header_against_parent(&child_sealed, &parent_sealed); - assert!(result.is_ok()); + assert!( + matches!(result, Err(ConsensusError::GasLimitInvalidIncrease { .. })), + "gas limit change exactly at boundary should be rejected" + ); + + // Increase by one less than the boundary should be ACCEPTED + let mut child_within = + create_valid_morph_header(1001, parent_gas_limit + max_change - 1, 101); + child_within.inner.parent_hash = parent_sealed.hash(); + let child_sealed = SealedHeader::seal_slow(child_within); + + let result = consensus.validate_header_against_parent(&child_sealed, &parent_sealed); + assert!( + result.is_ok(), + "gas limit change within boundary should be accepted" + ); } #[test] @@ -1226,17 +1385,32 @@ mod tests { 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()); + // Both pre-Emerald and post-Emerald: strictly greater is always ok + assert!(validate_against_parent_timestamp(&child, &parent, false).is_ok()); + assert!(validate_against_parent_timestamp(&child, &parent, true).is_ok()); + } + + #[test] + fn test_validate_against_parent_timestamp_equal_pre_emerald() { + let parent = create_valid_header(1000, 30_000_000, 100); + let child = create_valid_header(1000, 30_000_000, 101); // Same timestamp + + // Pre-Emerald: equal timestamp is rejected + let result = validate_against_parent_timestamp(&child, &parent, false); + assert!(matches!( + result, + Err(ConsensusError::TimestampIsInPast { .. }) + )); } #[test] - fn test_validate_against_parent_timestamp_equal() { + fn test_validate_against_parent_timestamp_equal_post_emerald() { 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 + // Post-Emerald: equal timestamp is allowed + let result = validate_against_parent_timestamp(&child, &parent, true); + assert!(result.is_ok()); } #[test] @@ -1244,9 +1418,13 @@ mod tests { 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); + // Both pre-Emerald and post-Emerald: strictly less is always rejected assert!(matches!( - result, + validate_against_parent_timestamp(&child, &parent, false), + Err(ConsensusError::TimestampIsInPast { .. }) + )); + assert!(matches!( + validate_against_parent_timestamp(&child, &parent, true), Err(ConsensusError::TimestampIsInPast { .. }) )); } diff --git a/crates/engine-api/src/builder.rs b/crates/engine-api/src/builder.rs index 8b85082..899051c 100644 --- a/crates/engine-api/src/builder.rs +++ b/crates/engine-api/src/builder.rs @@ -170,6 +170,8 @@ where ); // 1. Enforce canonical continuity against the current head. + // Matching go-ethereum: returns error (not GenericResponse{false}) for + // discontinuous block number or parent hash mismatch. let current_head = self.current_head()?; if data.number != current_head.number + 1 { tracing::warn!( @@ -178,7 +180,10 @@ where actual = data.number, "cannot validate block with discontinuous block number" ); - return Ok(GenericResponse { success: false }); + return Err(MorphEngineApiError::DiscontinuousBlockNumber { + expected: current_head.number + 1, + actual: data.number, + }); } if data.parent_hash != current_head.hash { @@ -188,7 +193,10 @@ where actual = %data.parent_hash, "parent hash mismatch" ); - return Ok(GenericResponse { success: false }); + return Err(MorphEngineApiError::WrongParentHash { + expected: current_head.hash, + actual: data.parent_hash, + }); } // 2. Convert and forward to reth engine tree (`newPayload` path). @@ -630,12 +638,19 @@ impl RealMorphL2EngineApi { let cancun_active = self .chain_spec .is_cancun_active_at_timestamp(data.timestamp); + // Override coinbase to empty address when FeeVault is enabled, + // matching go-ethereum's executableDataToBlock (l2_api.go:292-293). + let beneficiary = if self.chain_spec.is_fee_vault_enabled() { + Address::ZERO + } else { + data.miner + }; let header = MorphHeader { next_l1_msg_index: data.next_l1_message_index, inner: Header { parent_hash: data.parent_hash, ommers_hash: EMPTY_OMMER_ROOT_HASH, - beneficiary: data.miner, + beneficiary, state_root: data.state_root, transactions_root: calculate_transaction_root(&txs), receipts_root: data.receipts_root, diff --git a/crates/evm/src/block/mod.rs b/crates/evm/src/block/mod.rs index 110c1d9..8b9605e 100644 --- a/crates/evm/src/block/mod.rs +++ b/crates/evm/src/block/mod.rs @@ -22,7 +22,7 @@ use alloy_evm::{ Database, Evm, block::{BlockExecutionError, BlockExecutionResult, BlockExecutor, ExecutableTx, OnStateHook}, }; -use alloy_primitives::U256; +use alloy_primitives::{Address, U256}; use curie::apply_curie_hard_fork; use morph_chainspec::{MorphChainSpec, MorphHardfork, MorphHardforks}; use morph_primitives::{MorphReceipt, MorphTxEnvelope}; @@ -32,7 +32,6 @@ use morph_revm::{ use reth_chainspec::EthereumHardforks; use reth_revm::{DatabaseCommit, Inspector, State, context::result::ResultAndState}; use revm::context::Block; -use std::marker::PhantomData; /// Block executor for Morph L2 blocks. /// @@ -71,8 +70,6 @@ pub(crate) struct MorphBlockExecutor<'a, DB: Database, I> { receipts: Vec, /// Total gas used by executed transactions gas_used: u64, - /// Phantom data for inspector type - _phantom: PhantomData, } impl<'a, DB, I> MorphBlockExecutor<'a, DB, I> @@ -97,7 +94,6 @@ where receipt_builder, receipts: Vec::new(), gas_used: 0, - _phantom: PhantomData, } } @@ -170,11 +166,11 @@ where /// # Errors /// Returns error if: /// - MorphTx is missing `fee_token_id` or `fee_limit` - /// - Transaction sender cannot be extracted /// - L2TokenRegistry contract cannot be queried fn get_morph_tx_fields( &mut self, tx: &MorphTxEnvelope, + sender: Address, hardfork: MorphHardfork, ) -> Result, BlockExecutionError> { // Only MorphTx transactions have these fields @@ -194,13 +190,21 @@ where let reference = tx.reference(); let memo = tx.memo(); - // Fetch token fee info from L2TokenRegistry contract - // Note: We use the transaction sender as the caller address - // This is needed to check token balance when validating MorphTx - let sender = tx - .signer_unchecked() - .map_err(|_| BlockExecutionError::msg("Failed to extract signer from MorphTx"))?; + // For fee_token_id==0 (ETH fee MorphTx, V1 only), no token registry lookup needed. + // Still preserve version/reference/memo in the receipt. + if fee_token_id == 0 { + return Ok(Some(MorphReceiptTxFields { + version, + fee_token_id: 0, + fee_rate: U256::ZERO, + token_scale: U256::ZERO, + fee_limit, + reference, + memo, + })); + } + // Fetch token fee info from L2TokenRegistry contract using the already-recovered sender let token_info = TokenFeeInfo::load_for_caller(self.evm.db_mut(), fee_token_id, sender, hardfork) .map_err(|e| { @@ -349,7 +353,7 @@ where let l1_fee = self.calculate_l1_fee(tx.tx(), hardfork)?; // Get MorphTx-specific fields for MorphTx transactions - let morph_tx_fields = self.get_morph_tx_fields(tx.tx(), hardfork)?; + let morph_tx_fields = self.get_morph_tx_fields(tx.tx(), *tx.signer(), hardfork)?; // Update cumulative gas used let gas_used = result.gas_used(); diff --git a/crates/payload/builder/src/error.rs b/crates/payload/builder/src/error.rs index ac1d14e..3c726ab 100644 --- a/crates/payload/builder/src/error.rs +++ b/crates/payload/builder/src/error.rs @@ -1,6 +1,5 @@ //! Morph payload builder error types. -use alloy_primitives::B256; use reth_evm::execute::ProviderError; use reth_revm::db::bal::EvmDatabaseError; @@ -37,28 +36,10 @@ pub enum MorphPayloadBuilderError { #[error("failed to decode transaction: {0}")] TransactionDecodeError(#[from] alloy_rlp::Error), - /// Invalid L1 message queue index. - #[error("invalid L1 message queue index: expected {expected}, got {actual}")] - InvalidL1MessageQueueIndex { - /// Expected queue index. - expected: u64, - /// Actual queue index. - actual: u64, - }, - /// L1 message appears after regular transaction. #[error("L1 message appears after regular transaction")] L1MessageAfterRegularTx, - /// Invalid transaction hash. - #[error("invalid transaction hash: expected {expected}, got {actual}")] - InvalidTransactionHash { - /// Expected hash. - expected: B256, - /// Actual hash. - actual: B256, - }, - /// Database error when reading contract storage. #[error("database error: {0}")] Database(#[from] EvmDatabaseError), diff --git a/crates/primitives/src/receipt/envelope.rs b/crates/primitives/src/receipt/envelope.rs index a1bd41d..7f5c3d8 100644 --- a/crates/primitives/src/receipt/envelope.rs +++ b/crates/primitives/src/receipt/envelope.rs @@ -82,7 +82,7 @@ impl MorphReceiptEnvelope { /// Returns the success status of the receipt's transaction. pub const fn status(&self) -> bool { - self.as_receipt().unwrap().status.coerce_status() + self.as_receipt().status.coerce_status() } /// Return true if the transaction was successful. @@ -92,12 +92,12 @@ impl MorphReceiptEnvelope { /// Returns the cumulative gas used at this receipt. pub const fn cumulative_gas_used(&self) -> u64 { - self.as_receipt().unwrap().cumulative_gas_used + self.as_receipt().cumulative_gas_used } /// Return the receipt logs. pub fn logs(&self) -> &[T] { - &self.as_receipt().unwrap().logs + &self.as_receipt().logs } /// Return the receipt's bloom. @@ -128,16 +128,15 @@ impl MorphReceiptEnvelope { } } - /// Return the inner receipt. Currently this is infallible, however, future - /// receipt types may be added. - pub const fn as_receipt(&self) -> Option<&Receipt> { + /// Return the inner receipt. + pub const fn as_receipt(&self) -> &Receipt { match self { Self::Legacy(t) | Self::Eip2930(t) | Self::Eip1559(t) | Self::Eip7702(t) | Self::L1Message(t) - | Self::Morph(t) => Some(&t.receipt), + | Self::Morph(t) => &t.receipt, } } } @@ -172,11 +171,11 @@ where type Log = T; fn status_or_post_state(&self) -> Eip658Value { - self.as_receipt().unwrap().status + self.as_receipt().status } fn status(&self) -> bool { - self.as_receipt().unwrap().status.coerce_status() + self.as_receipt().status.coerce_status() } fn bloom(&self) -> Bloom { @@ -188,11 +187,11 @@ where } fn cumulative_gas_used(&self) -> u64 { - self.as_receipt().unwrap().cumulative_gas_used + self.as_receipt().cumulative_gas_used } fn logs(&self) -> &[T] { - &self.as_receipt().unwrap().logs + &self.as_receipt().logs } } diff --git a/crates/primitives/src/transaction/morph_transaction.rs b/crates/primitives/src/transaction/morph_transaction.rs index d9eb415..63c241c 100644 --- a/crates/primitives/src/transaction/morph_transaction.rs +++ b/crates/primitives/src/transaction/morph_transaction.rs @@ -74,8 +74,9 @@ pub struct TxMorph { /// A scalar value equal to the maximum amount of gas that should be used /// in executing this transaction. This is paid up-front, before any /// computation is done and may not be increased later. + /// Matches go-ethereum's `AltFeeTx.Gas` (uint64). #[cfg_attr(feature = "serde", serde(with = "alloy_serde::quantity"))] - pub gas_limit: u128, + pub gas_limit: u64, /// A scalar value equal to the maximum amount of gas that should be used /// in executing this transaction. This is paid up-front, before any @@ -240,7 +241,7 @@ impl TxMorph { pub fn size(&self) -> usize { mem::size_of::() + // chain_id mem::size_of::() + // nonce - mem::size_of::() + // gas_limit + mem::size_of::() + // gas_limit mem::size_of::() + // max_fee_per_gas mem::size_of::() + // max_priority_fee_per_gas self.to.size() + // to @@ -594,7 +595,7 @@ impl Transaction for TxMorph { } fn gas_limit(&self) -> u64 { - self.gas_limit as u64 + self.gas_limit } fn gas_price(&self) -> Option { @@ -894,7 +895,7 @@ impl reth_codecs::Compact for TxMorph { fn from_compact(buf: &[u8], len: usize) -> (Self, &[u8]) { let (chain_id, buf) = ChainId::from_compact(buf, len); let (nonce, buf) = u64::from_compact(buf, len); - let (gas_limit, buf) = u128::from_compact(buf, len); + let (gas_limit, buf) = u64::from_compact(buf, len); let (max_fee_per_gas, buf) = u128::from_compact(buf, len); let (max_priority_fee_per_gas, buf) = u128::from_compact(buf, len); let (to, buf) = TxKind::from_compact(buf, len); @@ -1877,7 +1878,7 @@ mod tests { 0u64.encode(&mut inner_buf); // nonce 0u128.encode(&mut inner_buf); // max_priority_fee_per_gas 1000u128.encode(&mut inner_buf); // max_fee_per_gas - 21000u128.encode(&mut inner_buf); // gas_limit + 21000u64.encode(&mut inner_buf); // gas_limit alloy_primitives::TxKind::Create.encode(&mut inner_buf); // to U256::ZERO.encode(&mut inner_buf); // value Bytes::new().encode(&mut inner_buf); // input diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index 4a311f5..32ee912 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -1,4 +1,4 @@ -use crate::{MorphBlockEnv, MorphTxEnv, precompiles::MorphPrecompiles}; +use crate::{MorphBlockEnv, MorphTxEnv, precompiles::MorphPrecompiles, token_fee::TokenFeeInfo}; use alloy_evm::Database; use alloy_primitives::{Log, U256, keccak256}; use morph_chainspec::hardfork::MorphHardfork; @@ -167,6 +167,10 @@ pub struct MorphEvm { >, /// Preserved logs from the last transaction pub logs: Vec, + /// Cached token fee info from the validation/deduction phase. + /// Ensures consistent price_ratio/scale between deduct and reimburse, + /// matching go-ethereum's `st.feeRate`/`st.tokenScale` caching pattern. + pub(crate) cached_token_fee_info: Option, } impl MorphEvm { @@ -222,6 +226,7 @@ impl MorphEvm { Self { inner, logs: Vec::new(), + cached_token_fee_info: None, } } } diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index cdb6c92..aa7abbc 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -18,7 +18,7 @@ use crate::{ error::MorphHaltReason, evm::MorphContext, l1block::L1BlockInfo, - token_fee::{TokenFeeInfo, compute_mapping_slot_for_address}, + token_fee::{TokenFeeInfo, compute_mapping_slot_for_address, query_erc20_balance}, tx::MorphTxExt, }; @@ -138,7 +138,12 @@ where if tx.is_morph_tx() { let token_id = tx.fee_token_id.unwrap_or_default(); if token_id > 0 { - return self.reimburse_caller_token_fee(evm, exec_result.gas(), token_id); + // When fee charge was disabled (eth_call), no token was deducted and + // cached_token_fee_info was not set — skip reimbursement entirely. + if evm.cached_token_fee_info.is_none() { + return Ok(()); + } + return self.reimburse_caller_token_fee(evm, exec_result.gas()); } // fee_token_id == 0 follows standard ETH reimbursement flow post_execution::reimburse_caller(evm.ctx(), exec_result.gas(), U256::ZERO)?; @@ -376,14 +381,14 @@ where Ok(()) } - /// Validate and deduct token-based gas fees. + /// Reimburse unused gas fees in ERC20 tokens. /// - /// This handles gas payment using ERC20 tokens instead of ETH. + /// Uses the cached `TokenFeeInfo` from the deduction phase to ensure + /// consistent price_ratio/scale, matching go-ethereum's `st.feeRate`/`st.tokenScale`. fn reimburse_caller_token_fee( &self, evm: &mut MorphEvm, gas: &Gas, - token_id: u16, ) -> Result<(), EVMError> { // Get caller address let caller = evm.ctx_ref().tx().caller(); @@ -400,16 +405,14 @@ where return Ok(()); } - // Fetch token fee info from Token Registry - let spec = *evm.ctx_ref().cfg().spec(); - let token_fee_info = - TokenFeeInfo::load_for_caller(evm.ctx_mut().db_mut(), token_id, caller, spec)? - .ok_or(MorphInvalidTransaction::TokenNotRegistered(token_id))?; - - // Check if token is active - if !token_fee_info.is_active { - return Err(MorphInvalidTransaction::TokenNotActive(token_id).into()); - } + // Use cached token fee info from the deduction phase (set in validate_and_deduct_token_fee). + // This ensures the same price_ratio/scale is used for both deduction and reimbursement. + // `take()` since reimburse is called once per tx and the cache is no longer needed. + let token_fee_info = evm.cached_token_fee_info.take().ok_or( + MorphInvalidTransaction::TokenTransferFailed { + reason: "cached_token_fee_info not set by validate_and_deduct_token_fee".into(), + }, + )?; // Calculate token amount required for total fee let token_amount_required = token_fee_info.eth_to_token_amount(reimburse_eth); @@ -428,13 +431,14 @@ where balance_slot, )?; } else { - // Transfer with evm call. + // Transfer with evm call (from=beneficiary, balance not pre-fetched). transfer_erc20_with_evm( evm, beneficiary, - token_fee_info.caller, + caller, token_fee_info.token_address, token_amount_required, + None, )?; } Ok(()) @@ -453,14 +457,44 @@ where return Err(MorphInvalidTransaction::TokenIdZeroNotSupported.into()); } - let (block, tx, cfg, journal, _, _) = evm.ctx_mut().all_mut(); + { + let (_, tx, cfg, journal, _, _) = evm.ctx_mut().all_mut(); + let caller_addr = tx.caller(); + let nonce = tx.nonce(); + + // Validate account nonce and code (EIP-3607) BEFORE any state mutations, + // matching the order used in validate_and_deduct_eth_fee. + let caller = journal.load_account_with_code_mut(caller_addr)?.data; + pre_execution::validate_account_nonce_and_code( + &caller.account().info, + nonce, + cfg.is_eip3607_disabled(), + cfg.is_nonce_check_disabled(), + )?; + } - // Get caller address + let (_, tx, cfg, journal, _, _) = evm.ctx_mut().all_mut(); + let caller_addr = tx.caller(); + let is_call = tx.kind().is_call(); + + // eth_call (disable_fee_charge): skip token fee deduction entirely. + // Only nonce/code validation (above) and nonce bump are needed. + // This matches the ETH path's disable_fee_charge semantics and ensures + // eth_call is a pure simulation without token registry lookups, balance + // checks, or ERC20 transfers. + if cfg.is_fee_charge_disabled() { + if is_call { + let mut caller = journal.load_account_with_code_mut(caller_addr)?.data; + caller.bump_nonce(); + } + return Ok(()); + } + + let (block, tx, cfg, journal, _, _) = evm.ctx_mut().all_mut(); let caller_addr = tx.caller(); - // Get coinbase address let beneficiary = block.beneficiary(); - // Get the current hardfork for L1 fee calculation let hardfork = *cfg.spec(); + let is_call = tx.kind().is_call(); // Fetch token fee info from Token Registry let token_fee_info = @@ -557,59 +591,38 @@ where } } } else { - // Transfer with evm call. + // Transfer with evm call (from=caller, balance known from token registry). transfer_erc20_with_evm( evm, - token_fee_info.caller, + caller_addr, beneficiary, token_fee_info.token_address, token_amount_required, + Some(token_fee_info.balance), )?; // State changes should be marked cold to avoid warm access in the main tx execution. - // Also save original_value for changed slots (see workaround above). let mut state = evm.finalize(); - state.iter_mut().for_each(|(addr, acc)| { + state.iter_mut().for_each(|(_, acc)| { acc.mark_cold(); - acc.storage.iter_mut().for_each(|(key, slot)| { - if slot.original_value != slot.present_value { - fee_slot_saves.push((*addr, *key, slot.original_value)); - } - slot.mark_cold(); - }); + acc.storage + .iter_mut() + .for_each(|(_, slot)| slot.mark_cold()); }); evm.ctx_mut().journal_mut().state.extend(state); } - // Store the saved original values in the tx env. We access the `tx` field - // directly because `ContextTr::all_mut()` returns tx as `&Self::Tx` (immutable). - if !fee_slot_saves.is_empty() { - evm.inner.ctx.tx.fee_slot_original_values = fee_slot_saves; - } - - let (_, tx, cfg, journal, _, _) = evm.ctx().all_mut(); - - // Extract the required tx fields (Copy) before mutating accounts. - let caller_addr = tx.caller(); - let nonce = tx.nonce(); - let is_call = tx.kind().is_call(); - - // Load caller's account for nonce/code validation - let mut caller = journal.load_account_with_code_mut(caller_addr)?.data; - - // Validate account nonce and code (EIP-3607) - pre_execution::validate_account_nonce_and_code( - &caller.account().info, - nonce, - cfg.is_eip3607_disabled(), - cfg.is_nonce_check_disabled(), - )?; - // Bump nonce for calls (CREATE nonce is bumped in make_create_frame) if is_call { + let (_, _, _, journal, _, _) = evm.ctx().all_mut(); + let mut caller = journal.load_account_with_code_mut(caller_addr)?.data; caller.bump_nonce(); } + // Cache token fee info for the reimburse phase, ensuring consistent + // price_ratio/scale between deduction and reimbursement. + evm.cached_token_fee_info = Some(token_fee_info); + Ok(()) } } @@ -627,14 +640,17 @@ fn transfer_erc20_with_slot( where DB: alloy_evm::Database, { - // Sub amount + // Sub amount (checked: reject if insufficient, matching go-ethereum's + // changeAltTokenBalanceByState which returns an error on underflow) let from_storage_slot = compute_mapping_slot_for_address(token_balance_slot, from); - let balance = journal.sload(token, from_storage_slot)?; - journal.sstore( - token, - from_storage_slot, - balance.saturating_sub(token_amount), + let balance = *journal.sload(token, from_storage_slot)?; + let new_balance = balance.checked_sub(token_amount).ok_or( + MorphInvalidTransaction::InsufficientTokenBalance { + required: token_amount, + available: balance, + }, )?; + journal.sstore(token, from_storage_slot, new_balance)?; // Add amount let to_storage_slot = compute_mapping_slot_for_address(token_balance_slot, to); @@ -644,40 +660,85 @@ where } /// Transfers ERC20 tokens by executing a `transfer(address,uint256)` call via the EVM. +/// +/// Matches go-ethereum's `transferAltTokenByEVM` validation: +/// 1. Checks EVM call succeeded (no revert) +/// 2. Validates ABI-decoded bool return value (supports old tokens with no return data) +/// 3. Verifies sender balance changed by the expected amount +/// +/// `from_balance_before` is the sender's balance before the transfer. If `None`, +/// the balance is queried via EVM call (matching go-eth's nil `userBalanceBefore`). fn transfer_erc20_with_evm( evm: &mut MorphEvm, - caller: Address, + from: Address, to: Address, token_address: Address, token_amount: U256, + from_balance_before: Option, ) -> Result<(), EVMError> where DB: alloy_evm::Database, { let tx_origin = evm.tx.clone(); + // Read sender balance before transfer if not provided + let from_balance_before = match from_balance_before { + Some(b) => b, + None => query_erc20_balance(evm, token_address, from).unwrap_or(U256::ZERO), + }; + let calldata = build_transfer_calldata(to, token_amount); - let res = match evm.system_call_one_with_caller(caller, token_address, calldata) { + match evm.system_call_one_with_caller(from, token_address, calldata) { Ok(result) => { - if result.is_success() { - Ok(()) - } else { - Err(MorphInvalidTransaction::TokenTransferFailed { + if !result.is_success() { + evm.tx = tx_origin; + return Err(MorphInvalidTransaction::TokenTransferFailed { reason: format!("{result:?}"), } - .into()) + .into()); + } + + // Validate ABI bool return value, matching go-ethereum behavior: + // - No return data: accepted (old tokens that don't return bool) + // - 32+ bytes with last byte == 1: accepted (standard ERC20) + // - Otherwise: rejected + if let Some(output) = result.output() + && !output.is_empty() + && (output.len() < 32 || output[31] != 1) + { + evm.tx = tx_origin; + return Err(MorphInvalidTransaction::TokenTransferFailed { + reason: "alt token transfer returned failure".to_string(), + } + .into()); } } - Err(e) => Err(MorphInvalidTransaction::TokenTransferFailed { - reason: format!("Error: {e:?}"), + Err(e) => { + evm.tx = tx_origin; + return Err(MorphInvalidTransaction::TokenTransferFailed { + reason: format!("Error: {e:?}"), + } + .into()); } - .into()), }; - // restore the original transaction + // Verify sender balance changed by the expected amount, matching go-ethereum. + let from_balance_after = query_erc20_balance(evm, token_address, from).unwrap_or(U256::ZERO); + + // Restore the original transaction evm.tx = tx_origin; - res + let expected_balance = from_balance_before.saturating_sub(token_amount); + if from_balance_after != expected_balance { + return Err(MorphInvalidTransaction::TokenTransferFailed { + reason: format!( + "sender balance mismatch: expected {expected_balance}, got {from_balance_after}" + ), + } + .into()); + } + + Ok(()) } /// Build the calldata for ERC20 transfer(address,amount) call. diff --git a/crates/revm/src/l1block.rs b/crates/revm/src/l1block.rs index d570862..e023c18 100644 --- a/crates/revm/src/l1block.rs +++ b/crates/revm/src/l1block.rs @@ -103,6 +103,10 @@ pub const INITIAL_BLOB_SCALAR: U256 = U256::from_limbs([417565260, 0, 0, 0]); /// Curie hardfork flag value (1 = true). pub const IS_CURIE: U256 = U256::from_limbs([1, 0, 0, 0]); +/// Maximum L1 data fee cap for circuit compatibility. +/// Matches go-ethereum's `CalculateL1DataFee` cap in `rollup/fees/rollup_fee.go`. +const L1_FEE_CAP: U256 = U256::from_limbs([u64::MAX, 0, 0, 0]); + /// Storage updates for L1 gas price oracle Curie hardfork initialization. /// /// These storage slots are initialized when the Curie hardfork activates: @@ -234,12 +238,17 @@ impl L1BlockInfo { /// /// This is the cost of posting the transaction data to L1 for data availability. /// The calculation method differs based on whether the Curie hardfork is active. + /// + /// The result is capped to `u64::MAX` for circuit compatibility, matching go-ethereum's + /// `CalculateL1DataFee` behavior in `rollup/fees/rollup_fee.go`. pub fn calculate_tx_l1_cost(&self, input: &[u8], hardfork: MorphHardfork) -> U256 { - if !hardfork.is_curie() { + let fee = if !hardfork.is_curie() { self.calculate_tx_l1_cost_pre_curie(input, hardfork) } else { self.calculate_tx_l1_cost_curie(input, hardfork) - } + }; + // Cap to u64::MAX for circuit compatibility (go-ethereum: rollup_fee.go:248-249) + fee.min(L1_FEE_CAP) } } diff --git a/crates/revm/src/token_fee.rs b/crates/revm/src/token_fee.rs index a6f5f0f..105855b 100644 --- a/crates/revm/src/token_fee.rs +++ b/crates/revm/src/token_fee.rs @@ -9,7 +9,7 @@ use alloy_evm::Database; use alloy_primitives::{Address, Bytes, U256, address, keccak256}; use morph_chainspec::hardfork::MorphHardfork; use revm::SystemCallEvm; -use revm::{Inspector, context_interface::result::EVMError, inspector::NoOpInspector}; +use revm::{context_interface::result::EVMError, inspector::NoOpInspector}; use crate::evm::MorphContext; use crate::{MorphEvm, MorphInvalidTransaction}; @@ -98,8 +98,10 @@ impl TokenFeeInfo { /// /// Uses the price ratio and scale to convert ETH value to token amount. pub fn eth_to_token_amount(&self, eth_amount: U256) -> U256 { + // If price_ratio or scale is zero (misconfigured token), return MAX to prevent + // free-ride transactions. The caller's balance check will reject the tx. if self.price_ratio.is_zero() || self.scale.is_zero() { - return U256::ZERO; + return U256::MAX; } // token_amount = eth_amount * scale / price_ratio @@ -250,7 +252,6 @@ fn query_balance_via_system_call( ) -> Result> where DB: Database, - I: Inspector>, { let calldata = encode_balance_of_calldata(account); match evm.system_call_one(token, calldata) { @@ -277,7 +278,6 @@ pub fn query_erc20_balance( ) -> Result> where DB: Database, - I: Inspector>, { query_balance_via_system_call(evm, token, account) } @@ -353,7 +353,8 @@ mod tests { let eth_amount = U256::from(1_000_000_000_000_000_000u128); let token_amount = info.eth_to_token_amount(eth_amount); - assert_eq!(token_amount, U256::ZERO); + // Misconfigured token returns MAX to prevent free-ride transactions + assert_eq!(token_amount, U256::MAX); } #[test] diff --git a/crates/revm/src/tx.rs b/crates/revm/src/tx.rs index dbcabb5..f4e9010 100644 --- a/crates/revm/src/tx.rs +++ b/crates/revm/src/tx.rs @@ -140,7 +140,7 @@ impl MorphTxEnv { Some(TxMorph { chain_id: self.chain_id().unwrap_or(fallback_chain_id), nonce: self.inner.nonce, - gas_limit: self.gas_limit() as u128, + gas_limit: self.gas_limit(), max_fee_per_gas: self.max_fee_per_gas(), max_priority_fee_per_gas: self.max_priority_fee_per_gas().unwrap_or_default(), to: self.kind(), diff --git a/crates/rpc/src/eth/transaction.rs b/crates/rpc/src/eth/transaction.rs index cae8b72..5599089 100644 --- a/crates/rpc/src/eth/transaction.rs +++ b/crates/rpc/src/eth/transaction.rs @@ -267,7 +267,7 @@ fn try_build_morph_tx_from_request( let chain_id = req .chain_id .ok_or("missing chain_id for morph transaction")?; - let gas_limit = req.gas.unwrap_or_default() as u128; + let gas_limit = req.gas.unwrap_or_default(); let nonce = req.nonce.unwrap_or_default(); let max_fee_per_gas = req.max_fee_per_gas.or(req.gas_price).unwrap_or_default(); let max_priority_fee_per_gas = req.max_priority_fee_per_gas.unwrap_or_default(); @@ -275,7 +275,7 @@ fn try_build_morph_tx_from_request( let input = req.input.clone().into_input().unwrap_or_default(); let to = req.to.unwrap_or(TxKind::Create); - Ok(Some(TxMorph { + let morph_tx = TxMorph { chain_id, nonce, gas_limit, @@ -290,7 +290,13 @@ fn try_build_morph_tx_from_request( version, reference, memo, - })) + }; + + // Validate all MorphTx constraints: version-specific rules, gas fee ordering, + // and memo length. This catches invalid combinations early at the RPC layer. + morph_tx.validate()?; + + Ok(Some(morph_tx)) } #[cfg(test)] diff --git a/crates/txpool/src/error.rs b/crates/txpool/src/error.rs index 8d0ccce..3d9199f 100644 --- a/crates/txpool/src/error.rs +++ b/crates/txpool/src/error.rs @@ -33,14 +33,6 @@ pub enum MorphTxError { token_id: u16, }, - /// The fee_limit is lower than the required token amount. - FeeLimitTooLow { - /// The fee_limit specified in the transaction. - fee_limit: U256, - /// The required token amount for the transaction. - required: U256, - }, - /// Insufficient ERC20 token balance to pay for gas. InsufficientTokenBalance { /// The token ID. @@ -89,15 +81,6 @@ impl fmt::Display for MorphTxError { Self::InvalidPriceRatio { token_id } => { write!(f, "token ID {token_id} has invalid price ratio (zero)") } - Self::FeeLimitTooLow { - fee_limit, - required, - } => { - write!( - f, - "fee_limit ({fee_limit}) is lower than required token amount ({required})" - ) - } Self::InsufficientTokenBalance { token_id, token_address, @@ -137,9 +120,7 @@ impl PoolTransactionError for MorphTxError { // Invalid price ratio - configuration issue, not penalizable Self::InvalidPriceRatio { .. } => false, // Insufficient balance or fee limit - normal validation failure - Self::FeeLimitTooLow { .. } - | Self::InsufficientTokenBalance { .. } - | Self::InsufficientEthForValue { .. } => false, + Self::InsufficientTokenBalance { .. } | Self::InsufficientEthForValue { .. } => false, // Fetch failures - infrastructure issue, not penalizable Self::TokenInfoFetchFailed { .. } => false, } @@ -186,13 +167,6 @@ mod tests { assert!(err.to_string().contains("token ID 2")); assert!(err.to_string().contains("not active")); - let err = MorphTxError::FeeLimitTooLow { - fee_limit: U256::from(100), - required: U256::from(200), - }; - assert!(err.to_string().contains("100")); - assert!(err.to_string().contains("200")); - let err = MorphTxError::InsufficientTokenBalance { token_id: 1, token_address: address!("1234567890123456789012345678901234567890"), diff --git a/crates/txpool/src/validator.rs b/crates/txpool/src/validator.rs index b53ac94..d69799b 100644 --- a/crates/txpool/src/validator.rs +++ b/crates/txpool/src/validator.rs @@ -9,7 +9,7 @@ use crate::MorphTxError; use alloy_consensus::{BlockHeader, Transaction}; -use alloy_eips::Encodable2718; +use alloy_eips::{Encodable2718, Typed2718}; use alloy_primitives::{Address, U256}; use morph_chainspec::hardfork::MorphHardforks; use morph_primitives::MorphTxEnvelope; @@ -442,19 +442,13 @@ where } /// Helper function to check if a transaction is an L1 message. -fn is_l1_message(tx: &Tx) -> bool -where - Tx: EthPoolTransaction, -{ - tx.clone_into_consensus().is_l1_msg() +fn is_l1_message(tx: &impl Typed2718) -> bool { + tx.ty() == morph_primitives::L1_TX_TYPE_ID } /// Helper function to check if a transaction is a MorphTx (0x7F). -fn is_morph_tx(tx: &Tx) -> bool -where - Tx: EthPoolTransaction, -{ - tx.clone_into_consensus().is_morph_tx() +fn is_morph_tx(tx: &impl Typed2718) -> bool { + tx.ty() == morph_primitives::MORPH_TX_TYPE_ID } #[cfg(test)] From 1a00546bfd5166f81753fedd2a2a7005c1308a99 Mon Sep 17 00:00:00 2001 From: panos Date: Fri, 6 Mar 2026 17:49:02 +0800 Subject: [PATCH 03/35] fix: additional pre-audit security hardening - consensus: use checked_add for queue_index overflow protection (C-H1) - handler: add ETH value balance check in token fee path (X-6) - handler: checked_add for token recipient balance overflow (R-2) - handler: propagate error from effective_balance_spending (R-3) - handler: skip self-transfer post-check when from == to (GPT-3) - txpool: add TxMorph::validate() in shared validation path (GPT-4) - txpool: add InvalidFormat error variant for structural violations --- crates/consensus/src/validation.rs | 42 ++++++++---- crates/revm/src/handler.rs | 56 ++++++++++++---- crates/txpool/src/error.rs | 11 ++++ crates/txpool/src/morph_tx_validation.rs | 84 +++++++++++++++++++----- crates/txpool/src/validator.rs | 3 +- 5 files changed, 154 insertions(+), 42 deletions(-) diff --git a/crates/consensus/src/validation.rs b/crates/consensus/src/validation.rs index 3ee47cd..2055eae 100644 --- a/crates/consensus/src/validation.rs +++ b/crates/consensus/src/validation.rs @@ -521,17 +521,27 @@ fn validate_l1_messages_in_block( ConsensusError::Other(MorphConsensusError::MalformedL1Message.to_string()) })?; - // Check queue indices are strictly sequential (each = previous + 1) - if let Some(prev) = prev_queue_index - && tx_queue_index != prev + 1 - { - return Err(ConsensusError::Other( - MorphConsensusError::L1MessagesNotInOrder { - expected: prev + 1, - actual: tx_queue_index, - } - .to_string(), - )); + // Check queue indices are strictly sequential (each = previous + 1). + // Use checked_add to prevent overflow at u64::MAX. + if let Some(prev) = prev_queue_index { + let expected = prev.checked_add(1).ok_or_else(|| { + ConsensusError::Other( + MorphConsensusError::L1MessagesNotInOrder { + expected: u64::MAX, + actual: tx_queue_index, + } + .to_string(), + ) + })?; + if tx_queue_index != expected { + return Err(ConsensusError::Other( + MorphConsensusError::L1MessagesNotInOrder { + expected, + actual: tx_queue_index, + } + .to_string(), + )); + } } prev_queue_index = Some(tx_queue_index); @@ -547,7 +557,15 @@ fn validate_l1_messages_in_block( // monotonicity check in validate_header_against_parent handles that case. if l1_msg_count > 0 { let last_queue_index = prev_queue_index.expect("l1_msg_count > 0 implies prev is Some"); - let expected_next = last_queue_index + 1; + let expected_next = last_queue_index.checked_add(1).ok_or_else(|| { + ConsensusError::Other( + MorphConsensusError::InvalidNextL1MessageIndex { + expected: u64::MAX, + actual: header_next_l1_msg_index, + } + .to_string(), + ) + })?; if header_next_l1_msg_index != expected_next { return Err(ConsensusError::Other( MorphConsensusError::InvalidNextL1MessageIndex { diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index aa7abbc..5c633c0 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -397,8 +397,9 @@ where let basefee = evm.ctx.block().basefee() as u128; let effective_gas_price = evm.ctx.tx().effective_gas_price(basefee); + let refunded = gas.refunded() as u64; let reimburse_eth = U256::from( - effective_gas_price.saturating_mul((gas.remaining() + gas.refunded() as u64) as u128), + effective_gas_price.saturating_mul(gas.remaining().saturating_add(refunded) as u128), ); if reimburse_eth.is_zero() { @@ -496,6 +497,26 @@ where let hardfork = *cfg.spec(); let is_call = tx.kind().is_call(); + // Check that caller has enough ETH to cover the value transfer. + // This matches go-ethereum's buyAltTokenGas() which checks + // state.GetBalance(from) >= value before proceeding. + // Without this, the tx would proceed to EVM execution and fail there + // (consuming gas), whereas go-ethereum rejects at the preCheck stage + // (not consuming gas). + let tx_value = tx.value(); + if !tx_value.is_zero() { + let caller_eth_balance = *journal.load_account_mut(caller_addr)?.data.balance(); + if caller_eth_balance < tx_value { + return Err(MorphInvalidTransaction::EthInvalidTransaction( + InvalidTransaction::LackOfFundForMaxFee { + fee: Box::new(tx_value), + balance: Box::new(caller_eth_balance), + }, + ) + .into()); + } + } + // Fetch token fee info from Token Registry let token_fee_info = TokenFeeInfo::load_for_caller(journal.db_mut(), token_id, caller_addr, hardfork)? @@ -652,10 +673,16 @@ where )?; journal.sstore(token, from_storage_slot, new_balance)?; - // Add amount + // Add amount (checked: reject on overflow to maintain token conservation, + // matching go-ethereum's big.Int Add which is unbounded) let to_storage_slot = compute_mapping_slot_for_address(token_balance_slot, to); let balance = journal.sload(token, to_storage_slot)?; - journal.sstore(token, to_storage_slot, balance.saturating_add(token_amount))?; + let new_to_balance = balance.checked_add(token_amount).ok_or( + MorphInvalidTransaction::TokenTransferFailed { + reason: "recipient token balance overflow".into(), + }, + )?; + journal.sstore(token, to_storage_slot, new_to_balance)?; Ok((from_storage_slot, to_storage_slot)) } @@ -728,14 +755,19 @@ where // Restore the original transaction evm.tx = tx_origin; - let expected_balance = from_balance_before.saturating_sub(token_amount); - if from_balance_after != expected_balance { - return Err(MorphInvalidTransaction::TokenTransferFailed { - reason: format!( - "sender balance mismatch: expected {expected_balance}, got {from_balance_after}" - ), + // When from == to (self-transfer), the net balance change is zero because the + // ERC20 transfer subtracts then adds the same amount to the same account. + // Only check the balance decrease when sender and recipient are different. + if from != to { + let expected_balance = from_balance_before.saturating_sub(token_amount); + if from_balance_after != expected_balance { + return Err(MorphInvalidTransaction::TokenTransferFailed { + reason: format!( + "sender balance mismatch: expected {expected_balance}, got {from_balance_after}" + ), + } + .into()); } - .into()); } Ok(()) @@ -783,9 +815,7 @@ fn calculate_caller_fee_with_l1_cost( let is_fee_charge_disabled = cfg.is_fee_charge_disabled(); // Calculate L2 effective balance spending (gas + value + blob fees) - let effective_balance_spending = tx - .effective_balance_spending(basefee, blob_price) - .expect("effective balance is always smaller than max balance so it can't overflow"); + let effective_balance_spending = tx.effective_balance_spending(basefee, blob_price)?; // Total spending = L2 fees + L1 data fee let total_spending = effective_balance_spending.saturating_add(l1_data_fee); diff --git a/crates/txpool/src/error.rs b/crates/txpool/src/error.rs index 3d9199f..497c885 100644 --- a/crates/txpool/src/error.rs +++ b/crates/txpool/src/error.rs @@ -61,6 +61,12 @@ pub enum MorphTxError { /// Error message. message: String, }, + + /// MorphTx format validation failed (version, memo length, gas fee ordering). + InvalidFormat { + /// Reason for the validation failure. + reason: String, + }, } impl fmt::Display for MorphTxError { @@ -102,6 +108,9 @@ impl fmt::Display for MorphTxError { Self::TokenInfoFetchFailed { token_id, message } => { write!(f, "failed to fetch token info for ID {token_id}: {message}") } + Self::InvalidFormat { reason } => { + write!(f, "invalid MorphTx format: {reason}") + } } } } @@ -115,6 +124,8 @@ impl PoolTransactionError for MorphTxError { match self { // Missing/invalid MorphTx fee fields indicate malformed transaction input. Self::InvalidTokenId => true, + // Format violations (bad version, memo too long, etc.) are malformed input. + Self::InvalidFormat { .. } => true, // Token not found or not active - could be due to temporary state, not penalizable Self::TokenNotFound { .. } | Self::TokenNotActive { .. } => false, // Invalid price ratio - configuration issue, not penalizable diff --git a/crates/txpool/src/morph_tx_validation.rs b/crates/txpool/src/morph_tx_validation.rs index 2a781d8..f1ae559 100644 --- a/crates/txpool/src/morph_tx_validation.rs +++ b/crates/txpool/src/morph_tx_validation.rs @@ -4,7 +4,6 @@ //! that is used by both the validator (for new transactions) and the maintenance //! task (for revalidating existing transactions). -use alloy_consensus::Transaction; use alloy_evm::Database; use alloy_primitives::{Address, U256}; use morph_chainspec::hardfork::MorphHardfork; @@ -46,15 +45,29 @@ pub struct MorphTxValidationResult { /// Validates a MorphTx transaction's token-related fields. /// /// This is the main entry point for MorphTx validation. It: -/// 1. Validates ETH balance >= tx.value() (value is still paid in ETH) -/// 2. For `fee_token_id > 0`, validates token balance with REVM-compatible fee_limit semantics -/// 3. For `fee_token_id == 0`, validates ETH can cover full tx cost + L1 data fee +/// 1. Validates structural MorphTx rules (`version`, `fee_limit`, memo length, fee ordering) +/// 2. Validates ETH balance >= tx.value() (value is still paid in ETH) +/// 3. For `fee_token_id > 0`, validates token balance with REVM-compatible fee_limit semantics +/// 4. For `fee_token_id == 0`, validates ETH can cover full tx cost + L1 data fee /// pub fn validate_morph_tx( db: &mut DB, input: &MorphTxValidationInput<'_>, ) -> Result { - let tx_value = input.consensus_tx.value(); + // Keep MorphTx structural validation in the shared path so both initial + // admission and background revalidation enforce the same invariants. + let morph_tx = match input.consensus_tx { + MorphTxEnvelope::Morph(signed) => signed.tx(), + _ => return Err(MorphTxError::InvalidTokenId), + }; + + if let Err(reason) = morph_tx.validate() { + return Err(MorphTxError::InvalidFormat { + reason: reason.to_string(), + }); + } + + let tx_value = morph_tx.value; if tx_value > input.eth_balance { return Err(MorphTxError::InsufficientEthForValue { balance: input.eth_balance, @@ -62,16 +75,12 @@ pub fn validate_morph_tx( }); } - let fields = input - .consensus_tx - .morph_fields() - .ok_or(MorphTxError::InvalidTokenId)?; - let fee_token_id = fields.fee_token_id; - let fee_limit = fields.fee_limit; + let fee_token_id = morph_tx.fee_token_id; + let fee_limit = morph_tx.fee_limit; // Shared fee components used by both ETH-fee and token-fee branches. - let gas_limit = U256::from(input.consensus_tx.gas_limit()); - let max_fee_per_gas = U256::from(input.consensus_tx.max_fee_per_gas()); + let gas_limit = U256::from(morph_tx.gas_limit); + let max_fee_per_gas = U256::from(morph_tx.max_fee_per_gas); let gas_fee = gas_limit.saturating_mul(max_fee_per_gas); let total_eth_fee = gas_fee.saturating_add(input.l1_data_fee); let total_eth_cost = total_eth_fee.saturating_add(tx_value); @@ -147,12 +156,14 @@ pub fn validate_morph_tx( #[cfg(test)] mod tests { use super::*; - use alloy_primitives::address; + use alloy_consensus::Signed; + use alloy_primitives::{B256, Signature, TxKind, address}; + use morph_primitives::{TxMorph, transaction::morph_transaction::MORPH_TX_VERSION_1}; + use reth_revm::revm::database::EmptyDB; #[test] fn test_morph_tx_validation_input_construction() { - use alloy_consensus::{Signed, TxEip1559}; - use alloy_primitives::{B256, Signature, TxKind}; + use alloy_consensus::TxEip1559; let sender = address!("1000000000000000000000000000000000000001"); @@ -187,4 +198,45 @@ mod tests { assert_eq!(input.eth_balance, U256::from(1_000_000_000_000_000_000u128)); assert_eq!(input.l1_data_fee, U256::from(100_000)); } + + #[test] + fn test_validate_morph_tx_rejects_invalid_format_before_state_checks() { + let sender = address!("1000000000000000000000000000000000000001"); + let tx = TxMorph { + chain_id: 2818, + nonce: 0, + gas_limit: 21_000, + max_fee_per_gas: 2_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + value: U256::ZERO, + access_list: Default::default(), + version: MORPH_TX_VERSION_1, + fee_token_id: 0, + fee_limit: U256::from(1u64), + reference: Some(B256::ZERO), + memo: None, + input: Default::default(), + }; + let envelope = + MorphTxEnvelope::Morph(Signed::new_unchecked(tx, Signature::test_signature(), B256::ZERO)); + let input = MorphTxValidationInput { + consensus_tx: &envelope, + sender, + eth_balance: U256::from(1_000_000_000_000_000_000u128), + l1_data_fee: U256::ZERO, + hardfork: MorphHardfork::Viridian, + }; + let mut db = EmptyDB::default(); + + let err = validate_morph_tx(&mut db, &input).unwrap_err(); + + assert_eq!( + err, + MorphTxError::InvalidFormat { + reason: "version 1 MorphTx cannot have FeeLimit when FeeTokenID is 0" + .to_string(), + } + ); + } } diff --git a/crates/txpool/src/validator.rs b/crates/txpool/src/validator.rs index d69799b..bca2ed5 100644 --- a/crates/txpool/src/validator.rs +++ b/crates/txpool/src/validator.rs @@ -266,7 +266,8 @@ where let l1_data_fee = l1_block_info.calculate_tx_l1_cost(&encoded, hardfork); if is_morph_tx { - // MorphTx: validate ERC20 token balance + // MorphTx: validate structural rules and ERC20 token balance via + // the shared helper used by both admission and maintenance. let sender = valid_tx.transaction().sender(); let validation = match self.validate_morph_tx_balance( valid_tx.transaction(), From 42b0bcfd65b0a4c01cc92bfe5c430e73ebe7782d Mon Sep 17 00:00:00 2001 From: panos Date: Fri, 6 Mar 2026 21:46:42 +0800 Subject: [PATCH 04/35] fix: pre-audit fixes for security review Precompile divergences with go-eth (H-1 ~ H-4): - Add modexp 32-byte input limit for Bernoulli~Viridian hardforks - Add BN256 pairing 4-pair limit from Morph203 onwards - Rewrite emerald() to explicitly list precompiles, excluding 0x0a (KZG) - Upgrade emerald modexp to eip7823+eip7883 (removing 32-byte limit) Balance check fix (M-1): - Use max_fee_per_gas for balance validation, effective_gas_price for deduction, matching go-eth's buyGas() semantics Token transfer safety (M-5): - Replace saturating_sub with checked_sub in ERC20 balance verification to catch underflow instead of silently returning zero Transaction pool (D-1): - Add disable_balance_check() to MorphPoolBuilder so MorphTx with fee_token_id > 0 can pass inner EthTransactionValidator Compact decode documentation (M-2): - Document panic! in MorphTxType::from_compact as intentional for DB corruption (Compact trait is infallible by reth convention) Defensive error handling (L-2): - Replace .expect() with .ok_or() in L1 message queue index validation Documentation (L-5): - Improve take_revert_logs() comment explaining the workaround context --- crates/consensus/src/validation.rs | 62 ++-- crates/engine-api/src/builder.rs | 45 ++- crates/evm/src/block/mod.rs | 102 +++--- crates/evm/src/evm.rs | 31 +- crates/node/src/components/pool.rs | 6 + crates/payload/builder/src/builder.rs | 2 +- crates/primitives/src/transaction/envelope.rs | 29 +- crates/revm/src/evm.rs | 38 ++- crates/revm/src/handler.rs | 122 +++---- crates/revm/src/l1block.rs | 43 ++- crates/revm/src/precompiles.rs | 322 ++++++++++++++---- crates/revm/src/token_fee.rs | 51 +-- crates/txpool/src/maintain.rs | 5 +- crates/txpool/src/validator.rs | 19 +- 14 files changed, 578 insertions(+), 299 deletions(-) diff --git a/crates/consensus/src/validation.rs b/crates/consensus/src/validation.rs index 2055eae..9201561 100644 --- a/crates/consensus/src/validation.rs +++ b/crates/consensus/src/validation.rs @@ -306,8 +306,10 @@ impl Consensus for MorphConsensus { // messages within this block are internally consistent with the header's // next_l1_msg_index. The cross-block monotonicity check (ensuring // next_l1_msg_index >= parent's value) is in validate_header_against_parent. - let txs: Vec<_> = block.body().transactions().collect(); - validate_l1_messages_in_block(&txs, block.header().next_l1_msg_index)?; + validate_l1_messages_in_block( + &block.body().transactions, + block.header().next_l1_msg_index, + )?; Ok(()) } @@ -501,7 +503,7 @@ fn validate_against_parent_gas_limit( /// ``` #[inline] fn validate_l1_messages_in_block( - txs: &[&MorphTxEnvelope], + txs: &[MorphTxEnvelope], header_next_l1_msg_index: u64, ) -> Result<(), ConsensusError> { let mut l1_msg_count = 0u64; @@ -556,7 +558,11 @@ fn validate_l1_messages_in_block( // For blocks with no L1 messages, this check is skipped — the cross-block // monotonicity check in validate_header_against_parent handles that case. if l1_msg_count > 0 { - let last_queue_index = prev_queue_index.expect("l1_msg_count > 0 implies prev is Some"); + let last_queue_index = prev_queue_index.ok_or_else(|| { + ConsensusError::Other( + "internal error: l1_msg_count > 0 but prev_queue_index is None".to_string(), + ) + })?; let expected_next = last_queue_index.checked_add(1).ok_or_else(|| { ConsensusError::Other( MorphConsensusError::InvalidNextL1MessageIndex { @@ -710,9 +716,9 @@ mod tests { create_l1_msg_tx(1), create_regular_tx(), ]; - let txs_refs: Vec<_> = txs.iter().collect(); + // L1 msgs: 0, 1 → last+1=2==header_next - assert!(validate_l1_messages_in_block(&txs_refs, 2).is_ok()); + assert!(validate_l1_messages_in_block(&txs, 2).is_ok()); } #[test] @@ -722,8 +728,8 @@ mod tests { create_regular_tx(), create_l1_msg_tx(1), ]; - let txs_refs: Vec<_> = txs.iter().collect(); - assert!(validate_l1_messages_in_block(&txs_refs, 2).is_err()); + + assert!(validate_l1_messages_in_block(&txs, 2).is_err()); } #[test] @@ -845,13 +851,13 @@ mod tests { #[test] fn test_validate_l1_messages_in_block_empty_block() { let txs: [MorphTxEnvelope; 0] = []; - let txs_refs: Vec<_> = txs.iter().collect(); + // Empty block: no L1 messages → internal check always passes. // Any header_next value is accepted because the cross-block // monotonicity check is in validate_header_against_parent. - assert!(validate_l1_messages_in_block(&txs_refs, 0).is_ok()); - assert!(validate_l1_messages_in_block(&txs_refs, 5).is_ok()); - assert!(validate_l1_messages_in_block(&txs_refs, 100).is_ok()); + assert!(validate_l1_messages_in_block(&txs, 0).is_ok()); + assert!(validate_l1_messages_in_block(&txs, 5).is_ok()); + assert!(validate_l1_messages_in_block(&txs, 100).is_ok()); } #[test] @@ -861,9 +867,9 @@ mod tests { create_l1_msg_tx(1), create_l1_msg_tx(2), ]; - let txs_refs: Vec<_> = txs.iter().collect(); + // last=2, 2+1=3==header_next - assert!(validate_l1_messages_in_block(&txs_refs, 3).is_ok()); + assert!(validate_l1_messages_in_block(&txs, 3).is_ok()); } #[test] @@ -873,17 +879,17 @@ mod tests { create_regular_tx(), create_regular_tx(), ]; - let txs_refs: Vec<_> = txs.iter().collect(); + // No L1 messages → internal check passes (header_next not checked) - assert!(validate_l1_messages_in_block(&txs_refs, 0).is_ok()); + assert!(validate_l1_messages_in_block(&txs, 0).is_ok()); } #[test] fn test_validate_l1_messages_in_block_skipped_index() { // Block has 0 then 2 (skipping 1) — caught by sequential check let txs = [create_l1_msg_tx(0), create_l1_msg_tx(2)]; - let txs_refs: Vec<_> = txs.iter().collect(); - let result = validate_l1_messages_in_block(&txs_refs, 3); + + let result = validate_l1_messages_in_block(&txs, 3); assert!(result.is_err()); let err_str = result.unwrap_err().to_string(); assert!(err_str.contains("expected 1")); @@ -898,17 +904,17 @@ mod tests { create_l1_msg_tx(101), create_regular_tx(), ]; - let txs_refs: Vec<_> = txs.iter().collect(); + // last=101, 101+1=102==header_next - assert!(validate_l1_messages_in_block(&txs_refs, 102).is_ok()); + assert!(validate_l1_messages_in_block(&txs, 102).is_ok()); } #[test] fn test_validate_l1_messages_in_block_duplicate_index() { // Duplicate index: 0, 0 — caught by sequential check (prev=0, expected 1, got 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_in_block(&txs_refs, 1); + + let result = validate_l1_messages_in_block(&txs, 1); assert!(result.is_err()); let err_str = result.unwrap_err().to_string(); assert!(err_str.contains("expected 1")); @@ -919,8 +925,8 @@ mod tests { fn test_validate_l1_messages_in_block_out_of_order() { // Block has 1 then 0 — caught by sequential check (prev=1, expected 2, got 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_in_block(&txs_refs, 2); + + let result = validate_l1_messages_in_block(&txs, 2); assert!(result.is_err()); } @@ -933,9 +939,9 @@ mod tests { create_l1_msg_tx(2), create_regular_tx(), ]; - let txs_refs: Vec<_> = txs.iter().collect(); + // Header says 100 but should be 3 (last=2, 2+1=3) - let result = validate_l1_messages_in_block(&txs_refs, 100); + let result = validate_l1_messages_in_block(&txs, 100); assert!(result.is_err()); let err_str = result.unwrap_err().to_string(); assert!(err_str.contains("expected 3")); @@ -951,8 +957,8 @@ mod tests { create_l1_msg_tx(1), create_l1_msg_tx(2), ]; - let txs_refs: Vec<_> = txs.iter().collect(); - assert!(validate_l1_messages_in_block(&txs_refs, 3).is_err()); + + assert!(validate_l1_messages_in_block(&txs, 3).is_err()); } // ======================================================================== diff --git a/crates/engine-api/src/builder.rs b/crates/engine-api/src/builder.rs index 899051c..8ca22fc 100644 --- a/crates/engine-api/src/builder.rs +++ b/crates/engine-api/src/builder.rs @@ -310,19 +310,21 @@ where }); } - let imported_header = self.import_l2_block_via_engine(data).await?; + let block_hash = data.hash; + let block_number = data.number; + self.import_l2_block_via_engine(data).await?; tracing::debug!( target: "morph::engine", - block_hash = %imported_header.hash_slow(), - block_number = imported_header.number(), + block_hash = %block_hash, + block_number, "L2 block accepted via engine tree" ); Ok(()) } - async fn new_safe_l2_block(&self, data: SafeL2Data) -> EngineApiResult { + async fn new_safe_l2_block(&self, mut data: SafeL2Data) -> EngineApiResult { tracing::debug!( target: "morph::engine", block_number = data.number, @@ -342,29 +344,32 @@ where // 2. Assemble the block from SafeL2Data inputs. let assemble_params = AssembleL2BlockParams { number: data.number, - transactions: data.transactions.clone(), + // Move transactions out of data to avoid cloning the full Vec. + transactions: std::mem::take(&mut data.transactions), timestamp: Some(data.timestamp), }; let built_payload = self .build_l2_payload(assemble_params, Some(data.gas_limit), data.base_fee_per_gas) .await?; - let executable_data = built_payload.executable_data.clone(); + let executable_data = built_payload.executable_data; + // Save hash before moving executable_data into the import call. + let block_hash = executable_data.hash; // 3. Import the block through reth engine tree and return the in-path header // (do not rely on immediate DB visibility after FCU). let header = self - .import_l2_block_via_engine(executable_data.clone()) + .import_l2_block_via_engine(executable_data) .await?; // Update safe block tag separately, matching geth's decoupled design. // Best-effort: block import already succeeded, so don't fail the whole // call if only the tag update encounters an issue. The tag can be // corrected later via engine_setBlockTags. - if let Err(e) = self.set_block_tags(executable_data.hash, B256::ZERO).await { + if let Err(e) = self.set_block_tags(block_hash, B256::ZERO).await { tracing::warn!( target: "morph::engine", - block_hash = %executable_data.hash, + block_hash = %block_hash, error = %e, "failed to update safe tag after block import; tag can be set later via setBlockTags" ); @@ -372,7 +377,7 @@ where tracing::debug!( target: "morph::engine", - block_hash = %header.hash_slow(), + block_hash = %block_hash, "safe L2 block imported successfully" ); @@ -483,7 +488,7 @@ impl RealMorphL2EngineApi { withdrawals: Some(Vec::new()), parent_beacon_block_root: None, }, - transactions: Some(params.transactions.clone()), + transactions: Some(params.transactions), gas_limit: gas_limit_override, base_fee_per_gas: base_fee_override, }; @@ -568,12 +573,11 @@ impl RealMorphL2EngineApi { // canonical_in_memory_state asynchronously; without this call, morph-node // would see eth_blockNumber return the old block number and reject the next // block as ErrWrongBlockNumber. + self.engine_state_tracker + .record_local_head(data.number, data.hash, data.timestamp); self.provider .set_canonical_head(SealedHeader::new(header.clone(), data.hash)); - self.engine_state_tracker - .record_local_head(header.number(), data.hash, header.timestamp()); - tracing::info!( target: "morph::engine", block_number = data.number, @@ -676,15 +680,20 @@ impl RealMorphL2EngineApi { ommers: Default::default(), withdrawals: None, }; - let sealed_block = SealedBlock::seal_slow(Block::new(header.clone(), body)); - if sealed_block.hash() != data.hash { + // Compute header hash once and verify against expected hash before + // constructing the sealed block. This avoids the clone + re-hash that + // seal_slow would perform, saving one keccak256 + one MorphHeader clone + // per block import. + let computed_hash = header.hash_slow(); + if computed_hash != data.hash { return Err(MorphEngineApiError::ValidationFailed(format!( "block hash mismatch: expected {}, computed {}", - data.hash, - sealed_block.hash() + data.hash, computed_hash ))); } + let sealed_block = + SealedBlock::new_unchecked(Block::new(header.clone(), body), computed_hash); Ok(( MorphExecutionData::with_expected_withdraw_trie_root( diff --git a/crates/evm/src/block/mod.rs b/crates/evm/src/block/mod.rs index 8b9605e..08459c6 100644 --- a/crates/evm/src/block/mod.rs +++ b/crates/evm/src/block/mod.rs @@ -70,6 +70,9 @@ pub(crate) struct MorphBlockExecutor<'a, DB: Database, I> { receipts: Vec, /// Total gas used by executed transactions gas_used: u64, + /// Cached hardfork for this block (constant across all transactions). + /// Set in `apply_pre_execution_changes`, reused in `commit_transaction`. + hardfork: MorphHardfork, } impl<'a, DB, I> MorphBlockExecutor<'a, DB, I> @@ -94,51 +97,19 @@ where receipt_builder, receipts: Vec::new(), gas_used: 0, + hardfork: MorphHardfork::default(), } } - /// Calculate the L1 data fee for a transaction. + /// Returns the L1 data fee for the most recently executed transaction. /// - /// The L1 fee compensates for the cost of posting transaction data to Ethereum L1. - /// This is a key component of L2 transaction costs on Morph. - /// - /// # Calculation Steps - /// 1. Check if transaction is an L1 message (which don't pay L1 fees) - /// 2. Get RLP-encoded transaction bytes - /// 3. Fetch L1 block info from L1 Gas Price Oracle contract - /// 4. Calculate fee based on transaction size and L1 gas price - /// - /// # Arguments - /// * `tx` - The transaction to calculate L1 fee for - /// * `hardfork` - The current Morph hardfork (affects fee calculation formula) - /// - /// # Returns - /// - `Ok(U256::ZERO)` for L1 message transactions - /// - `Ok(fee)` for regular transactions, where fee = f(tx_size, l1_gas_price, hardfork) - /// - `Err` if L1 block info cannot be fetched - /// - /// # Errors - /// Returns error if the L1 Gas Price Oracle contract state cannot be read. - fn calculate_l1_fee( - &mut self, - tx: &MorphTxEnvelope, - hardfork: MorphHardfork, - ) -> Result { - // L1 message transactions don't pay L1 fees - if tx.is_l1_msg() { - return Ok(U256::ZERO); - } - - // Get the RLP-encoded transaction bytes - let rlp_bytes = tx.rlp(); - - // Fetch L1 block info from the L1 Gas Price Oracle contract - let l1_block_info = L1BlockInfo::try_fetch(self.evm.db_mut(), hardfork).map_err(|e| { - BlockExecutionError::msg(format!("Failed to fetch L1 block info: {e:?}")) - })?; - - // Calculate L1 data fee - Ok(l1_block_info.calculate_tx_l1_cost(rlp_bytes.as_ref(), hardfork)) + /// Reads from the handler's per-transaction cache (set during + /// `validate_and_deduct_eth_fee` / `validate_and_deduct_token_fee`), + /// avoiding re-encoding the full transaction RLP. + /// For L1 messages (which skip handler fee logic) the cache is ZERO. + #[inline] + fn cached_l1_fee(&self) -> U256 { + self.evm.cached_l1_data_fee() } /// Extract MorphTx-specific fields for MorphTx (0x7F) transactions. @@ -204,12 +175,19 @@ where })); } - // Fetch token fee info from L2TokenRegistry contract using the already-recovered sender - let token_info = - TokenFeeInfo::load_for_caller(self.evm.db_mut(), fee_token_id, sender, hardfork) - .map_err(|e| { - BlockExecutionError::msg(format!("Failed to fetch token fee info: {e:?}")) - })?; + // Reuse cached token fee info from handler validation to avoid redundant DB reads. + // Falls back to DB read if cache is empty (e.g., in test scenarios). + let token_info = match self.evm.cached_token_fee_info() { + Some(info) => Some(info), + None => { + TokenFeeInfo::load_for_caller(self.evm.db_mut(), fee_token_id, sender, hardfork) + .map_err(|e| { + BlockExecutionError::msg(format!( + "Failed to fetch token fee info: {e:?}" + )) + })? + } + }; Ok(token_info.map(|info| MorphReceiptTxFields { version, @@ -255,13 +233,25 @@ where let state_clear_flag = self.spec.is_spurious_dragon_active_at_block(block_number); self.evm.db_mut().set_state_clear_flag(state_clear_flag); - // 2. Load L1 gas oracle contract into cache + // 2. Load L1 gas oracle contract into cache and fetch L1BlockInfo once per block. + // The fetched L1BlockInfo is stored in the context's chain field so that + // all transactions in this block can access it without repeated DB reads. let _ = self .evm .db_mut() .load_cache_account(L1_GAS_PRICE_ORACLE_ADDRESS) .map_err(BlockExecutionError::other)?; + let hardfork = self + .spec + .morph_hardfork_at(block_number, self.evm.block().timestamp.to::()); + self.hardfork = hardfork; + let l1_block_info = + L1BlockInfo::try_fetch(self.evm.db_mut(), hardfork).map_err(|e| { + BlockExecutionError::msg(format!("Failed to fetch L1 block info: {e:?}")) + })?; + self.evm.ctx_mut().chain = l1_block_info; + // 3. Apply Curie hardfork at the transition block // Only executes once at the exact block where Curie activates if self @@ -337,6 +327,7 @@ where /// /// # Errors /// Returns error if L1 fee calculation or token fee info extraction fails. + #[inline] fn commit_transaction( &mut self, output: ResultAndState, @@ -344,16 +335,13 @@ where ) -> Result { let ResultAndState { result, state } = output; - // Determine hardfork once and reuse for both L1 fee and token fee calculations - let block_number: u64 = self.evm.block().number.to(); - let timestamp: u64 = self.evm.block().timestamp.to(); - let hardfork = self.spec.morph_hardfork_at(block_number, timestamp); - - // Calculate L1 fee for the transaction - let l1_fee = self.calculate_l1_fee(tx.tx(), hardfork)?; + // Read L1 fee from handler cache (set during validate_and_deduct_*). + let l1_fee = self.cached_l1_fee(); - // Get MorphTx-specific fields for MorphTx transactions - let morph_tx_fields = self.get_morph_tx_fields(tx.tx(), *tx.signer(), hardfork)?; + // Get MorphTx-specific fields for MorphTx transactions. + // Uses the hardfork cached in apply_pre_execution_changes (constant per block). + let morph_tx_fields = + self.get_morph_tx_fields(tx.tx(), *tx.signer(), self.hardfork)?; // Update cumulative gas used let gas_used = result.gas_used(); diff --git a/crates/evm/src/evm.rs b/crates/evm/src/evm.rs index 4a873ef..6e4fa65 100644 --- a/crates/evm/src/evm.rs +++ b/crates/evm/src/evm.rs @@ -71,7 +71,8 @@ impl MorphEvm { .with_db(db) .with_block(input.block_env) .with_cfg(input.cfg_env) - .with_tx(Default::default()); + .with_tx(Default::default()) + .with_chain(morph_revm::L1BlockInfo::default()); // Create precompiles for the hardfork and wrap in PrecompilesMap let morph_precompiles = MorphPrecompiles::new_with_spec(spec); @@ -107,15 +108,35 @@ impl MorphEvm { /// Takes the inner EVM's revert logs. /// - /// This is used as a work around to allow logs to be - /// included for reverting transactions. - /// - /// TODO: remove once revm supports emitting logs for reverted transactions + /// Morph requires logs from reverted transactions to be included in receipts + /// (matching go-ethereum's `morph-l2/go-ethereum` behavior). Standard revm + /// discards logs on revert, so we capture them from the EVM's internal log + /// buffer before they are cleared. /// + /// This workaround is needed until revm natively supports emitting logs for + /// reverted transactions. Tracked in: /// pub fn take_revert_logs(&mut self) -> Vec { std::mem::take(&mut self.inner.logs) } + + /// Returns the cached token fee info from the handler's validation phase. + /// + /// Avoids redundant DB reads when the block executor needs token fee + /// parameters (e.g., for receipt construction). + #[inline] + pub fn cached_token_fee_info(&self) -> Option { + self.inner.cached_token_fee_info() + } + + /// Returns the L1 data fee cached during handler validation. + /// + /// Avoids re-encoding the full transaction RLP in the block executor's + /// receipt-building path. + #[inline] + pub fn cached_l1_data_fee(&self) -> alloy_primitives::U256 { + self.inner.cached_l1_data_fee() + } } impl Deref for MorphEvm diff --git a/crates/node/src/components/pool.rs b/crates/node/src/components/pool.rs index c892af1..8aad6a0 100644 --- a/crates/node/src/components/pool.rs +++ b/crates/node/src/components/pool.rs @@ -42,6 +42,12 @@ where .with_additional_tasks(ctx.config().txpool.additional_validation_tasks) // Register MorphTx (0x7F) type for ERC20 gas payment .with_custom_tx_type(morph_primitives::MORPH_TX_TYPE_ID) + // Disable the inner EthTransactionValidator's balance check. + // MorphTx (fee_token_id > 0) users may have zero ETH but pay gas in ERC20 tokens. + // Without this, the inner validator rejects them before reaching MorphTransactionValidator's + // token fee validation. The MorphTransactionValidator already performs its own balance + // checks for all tx types (including L1 data fee), so this is safe. + .disable_balance_check() // Note: L1Message (0x7E) is NOT registered - it will be rejected by // EthTransactionValidator as TxTypeNotSupported, which is correct since // L1 messages should only be included by the sequencer during block building diff --git a/crates/payload/builder/src/builder.rs b/crates/payload/builder/src/builder.rs index c84a212..7a4fe99 100644 --- a/crates/payload/builder/src/builder.rs +++ b/crates/payload/builder/src/builder.rs @@ -516,7 +516,7 @@ impl MorphPayloadBuilderCtx { info.total_fees += U256::from(effective_tip) * U256::from(gas_used); // Store the transaction bytes for ExecutableL2Data - let mut tx_bytes = Vec::new(); + let mut tx_bytes = Vec::with_capacity(tx.encode_2718_len()); tx.encode_2718(&mut tx_bytes); executed_txs.push(Bytes::from(tx_bytes)); } diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index c514f75..c0c5e67 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -135,15 +135,8 @@ impl MorphTxEnvelope { /// 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 { - let mut bytes = BytesMut::new(); - match self { - Self::Legacy(tx) => tx.encode_2718(&mut bytes), - Self::Eip2930(tx) => tx.encode_2718(&mut bytes), - Self::Eip1559(tx) => tx.encode_2718(&mut bytes), - Self::Eip7702(tx) => tx.encode_2718(&mut bytes), - Self::L1Msg(tx) => tx.encode_2718(&mut bytes), - Self::Morph(tx) => tx.encode_2718(&mut bytes), - } + let mut bytes = BytesMut::with_capacity(self.encode_2718_len()); + self.encode_2718(&mut bytes); Bytes(bytes.freeze()) } } @@ -378,6 +371,14 @@ mod codec { // For backwards compatibility purposes only 2 bits of the type are encoded in the identifier // parameter. In the case of a [`COMPACT_EXTENDED_IDENTIFIER_FLAG`], the full transaction type // is read from the buffer as a single byte. + /// Decodes `MorphTxType` from its compact (database) representation. + /// + /// # Panics + /// + /// Panics on unknown identifiers. The `Compact` trait is infallible by design + /// (reth convention) — compact encoding is only used for internal DB storage, + /// never for untrusted network data (which goes through RLP and returns `Result`). + /// An unknown identifier here indicates database corruption, which is unrecoverable. fn from_compact(mut buf: &[u8], identifier: usize) -> (Self, &[u8]) { use bytes::Buf; ( @@ -391,10 +392,16 @@ mod codec { EIP7702_TX_TYPE_ID => Self::Eip7702, crate::transaction::L1_TX_TYPE_ID => Self::L1Msg, crate::transaction::MORPH_TX_TYPE_ID => Self::Morph, - _ => panic!("Unsupported TxType identifier: {extended_identifier}"), + _ => panic!( + "Unsupported MorphTxType compact identifier: {extended_identifier} \ + (database corruption — compact encoding is only used for DB storage)" + ), } } - _ => panic!("Unknown identifier for TxType: {identifier}"), + _ => panic!( + "Unknown MorphTxType compact identifier: {identifier} \ + (database corruption — compact encoding is only used for DB storage)" + ), }, buf, ) diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index 32ee912..50d0fd6 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -1,11 +1,11 @@ -use crate::{MorphBlockEnv, MorphTxEnv, precompiles::MorphPrecompiles, token_fee::TokenFeeInfo}; +use crate::{MorphBlockEnv, MorphTxEnv, l1block::L1BlockInfo, precompiles::MorphPrecompiles, token_fee::TokenFeeInfo}; use alloy_evm::Database; use alloy_primitives::{Log, U256, keccak256}; use morph_chainspec::hardfork::MorphHardfork; use revm::{ Context, Inspector, - context::{CfgEnv, ContextError, Evm, FrameStack}, - context_interface::{cfg::gas::BLOCKHASH, host::LoadError}, + context::{CfgEnv, ContextError, Evm, FrameStack, Journal}, + context_interface::cfg::gas::BLOCKHASH, handler::{ EthFrame, EvmTr, FrameInitOrResult, FrameTr, ItemOrResult, instructions::EthInstructions, }, @@ -18,7 +18,11 @@ use revm::{ }; /// The Morph EVM context type. -pub type MorphContext = Context, DB>; +/// +/// Uses [`L1BlockInfo`] as the `CHAIN` parameter so that L1 fee parameters +/// are fetched once per block and shared across all transactions, avoiding +/// repeated storage reads in the handler hot path. +pub type MorphContext = Context, DB, Journal, L1BlockInfo>; #[inline] fn as_u64_saturated(value: U256) -> u64 { @@ -171,6 +175,11 @@ pub struct MorphEvm { /// Ensures consistent price_ratio/scale between deduct and reimburse, /// matching go-ethereum's `st.feeRate`/`st.tokenScale` caching pattern. pub(crate) cached_token_fee_info: Option, + /// Cached L1 data fee calculated during handler validation. + /// Avoids re-encoding the full transaction RLP in the block executor's + /// receipt-building path (the handler already has the encoded bytes via + /// `MorphTxEnv.rlp_bytes`). + pub(crate) cached_l1_data_fee: U256, } impl MorphEvm { @@ -227,6 +236,7 @@ impl MorphEvm { inner, logs: Vec::new(), cached_token_fee_info: None, + cached_l1_data_fee: U256::ZERO, } } } @@ -252,6 +262,26 @@ impl MorphEvm { pub fn take_logs(&mut self) -> Vec { std::mem::take(&mut self.logs) } + + /// Returns the cached token fee info set during handler validation. + /// + /// The cache is populated by `validate_and_deduct_token_fee` and persists + /// through the handler lifecycle so that post-execution code (e.g., the + /// block executor's receipt builder) can reuse it without re-reading the DB. + #[inline] + pub fn cached_token_fee_info(&self) -> Option { + self.cached_token_fee_info + } + + /// Returns the L1 data fee cached during handler validation. + /// + /// Set in `validate_and_deduct_eth_fee` / `validate_and_deduct_token_fee` and + /// reused by `reward_beneficiary` and the block executor's receipt builder, + /// avoiding redundant `calculate_tx_l1_cost` calls and RLP re-encoding. + #[inline] + pub fn cached_l1_data_fee(&self) -> U256 { + self.cached_l1_data_fee + } } impl EvmTr for MorphEvm diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 5c633c0..0d252ae 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -17,7 +17,6 @@ use crate::{ MorphEvm, MorphInvalidTransaction, error::MorphHaltReason, evm::MorphContext, - l1block::L1BlockInfo, token_fee::{TokenFeeInfo, compute_mapping_slot_for_address, query_erc20_balance}, tx::MorphTxExt, }; @@ -95,6 +94,10 @@ where &self, evm: &mut Self::Evm, ) -> Result<(), Self::Error> { + // Reset per-transaction caches from the previous iteration. + evm.cached_l1_data_fee = U256::ZERO; + evm.cached_token_fee_info = None; + let (_, tx, _, journal, _, _) = evm.ctx().all_mut(); // L1 message - skip fee validation @@ -180,7 +183,12 @@ where evm: &mut Self::Evm, exec_result: &mut <::Frame as FrameTr>::FrameResult, ) -> Result<(), Self::Error> { - let (block, tx, cfg, journal, _, _) = evm.ctx().all_mut(); + // Reuse the L1 data fee cached during validate_and_deduct_eth_fee / + // validate_and_deduct_token_fee, avoiding a redundant calculate_tx_l1_cost call. + // Read before ctx().all_mut() borrows evm. + let l1_data_fee = evm.cached_l1_data_fee; + + let (block, tx, _, journal, _, _) = evm.ctx().all_mut(); // L1 messages skip all reward. // Token-fee MorphTx rewards are already applied when token fee is deducted. @@ -193,23 +201,6 @@ where let basefee = block.basefee() as u128; let effective_gas_price = tx.effective_gas_price(basefee); - // Get the current hardfork for L1 fee calculation - let hardfork = cfg.spec(); - - // Fetch L1 block info from the L1 Gas Price Oracle contract - let l1_block_info = L1BlockInfo::try_fetch(journal.db_mut(), *hardfork)?; - - // Get RLP-encoded transaction bytes for L1 fee calculation - // This represents the full transaction data posted to L1 for data availability - let rlp_bytes = tx - .rlp_bytes - .as_ref() - .map(|b| b.as_ref()) - .unwrap_or_default(); - - // Calculate L1 data fee based on full RLP-encoded transaction - let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes, *hardfork); - let gas_used = exec_result.gas().used(); let execution_fee = U256::from(effective_gas_price).saturating_mul(U256::from(gas_used)); @@ -328,18 +319,14 @@ where DB: alloy_evm::Database, { /// Validate and deduct ETH-based gas fees. + #[inline] fn validate_and_deduct_eth_fee( &self, evm: &mut MorphEvm, ) -> Result<(), EVMError> { - // Get the current hardfork for L1 fee calculation let hardfork = *evm.ctx_ref().cfg().spec(); - // Fetch L1 block info from the L1 Gas Price Oracle contract - let l1_block_info = L1BlockInfo::try_fetch(evm.ctx_mut().db_mut(), hardfork)?; - // Get RLP-encoded transaction bytes for L1 fee calculation - // This represents the full transaction data posted to L1 for data availability let rlp_bytes = evm .ctx_ref() .tx() @@ -348,8 +335,9 @@ where .map(|b| b.as_ref()) .unwrap_or_default(); - // Calculate L1 data fee based on full RLP-encoded transaction - let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes, hardfork); + // Calculate L1 data fee using the cached L1BlockInfo from context chain field. + let l1_data_fee = evm.ctx_ref().chain.calculate_tx_l1_cost(rlp_bytes, hardfork); + evm.cached_l1_data_fee = l1_data_fee; // Get mutable access to context components let (block, tx, cfg, journal, _, _) = evm.ctx().all_mut(); @@ -385,6 +373,7 @@ where /// /// Uses the cached `TokenFeeInfo` from the deduction phase to ensure /// consistent price_ratio/scale, matching go-ethereum's `st.feeRate`/`st.tokenScale`. + #[inline] fn reimburse_caller_token_fee( &self, evm: &mut MorphEvm, @@ -408,8 +397,9 @@ where // Use cached token fee info from the deduction phase (set in validate_and_deduct_token_fee). // This ensures the same price_ratio/scale is used for both deduction and reimbursement. - // `take()` since reimburse is called once per tx and the cache is no longer needed. - let token_fee_info = evm.cached_token_fee_info.take().ok_or( + // The cache is kept populated (not taken) so the block executor's receipt builder + // can also read it without re-querying the DB. + let token_fee_info = evm.cached_token_fee_info.ok_or( MorphInvalidTransaction::TokenTransferFailed { reason: "cached_token_fee_info not set by validate_and_deduct_token_fee".into(), }, @@ -491,7 +481,7 @@ where return Ok(()); } - let (block, tx, cfg, journal, _, _) = evm.ctx_mut().all_mut(); + let (block, tx, cfg, journal, chain, _) = evm.ctx_mut().all_mut(); let caller_addr = tx.caller(); let beneficiary = block.beneficiary(); let hardfork = *cfg.spec(); @@ -527,24 +517,23 @@ where return Err(MorphInvalidTransaction::TokenNotActive(token_id).into()); } - // Fetch L1 block info from the L1 Gas Price Oracle contract - let l1_block_info = L1BlockInfo::try_fetch(journal.db_mut(), hardfork)?; - // Get RLP-encoded transaction bytes for L1 fee calculation - // This represents the full transaction data posted to L1 for data availability let rlp_bytes = tx .rlp_bytes .as_ref() .map(|b| b.as_ref()) .unwrap_or_default(); - // Calculate L1 data fee (in ETH) based on full RLP-encoded transaction - let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes, hardfork); + // Calculate L1 data fee using the cached L1BlockInfo from context chain field. + let l1_data_fee = chain.calculate_tx_l1_cost(rlp_bytes, hardfork); - // Calculate L2 gas fee (in ETH) + // Calculate L2 gas fee using effective_gas_price (= min(gasTipCap + baseFee, gasFeeCap)), + // matching go-ethereum's buyAltTokenGas() which uses st.gasPrice (effective gas price). + // tx.gas_price() returns max_fee_per_gas and would overcharge when tip + basefee < feeCap. let gas_limit = tx.gas_limit(); - let gas_price = tx.gas_price(); - let l2_gas_fee = U256::from(gas_limit).saturating_mul(U256::from(gas_price)); + let basefee = block.basefee() as u128; + let effective_gas_price = tx.effective_gas_price(basefee); + let l2_gas_fee = U256::from(gas_limit).saturating_mul(U256::from(effective_gas_price)); // Total fee in ETH let total_eth_fee = l2_gas_fee.saturating_add(l1_data_fee); @@ -643,6 +632,7 @@ where // Cache token fee info for the reimburse phase, ensuring consistent // price_ratio/scale between deduction and reimbursement. evm.cached_token_fee_info = Some(token_fee_info); + evm.cached_l1_data_fee = l1_data_fee; Ok(()) } @@ -650,6 +640,7 @@ where /// Performs an ERC20 balance transfer by directly `sload`/`sstore`-ing the token contract storage /// using the known `balance` mapping base slot, returning the computed storage slots for `from`/`to`. +#[inline] fn transfer_erc20_with_slot( journal: &mut revm::Journal, from: Address, @@ -706,7 +697,9 @@ fn transfer_erc20_with_evm( where DB: alloy_evm::Database, { - let tx_origin = evm.tx.clone(); + // Save the original tx by swapping in a default, avoiding a full clone of + // MorphTxEnv (which contains Bytes, AccessList, etc.). + let tx_origin = std::mem::take(&mut evm.tx); // Read sender balance before transfer if not provided let from_balance_before = match from_balance_before { @@ -759,7 +752,14 @@ where // ERC20 transfer subtracts then adds the same amount to the same account. // Only check the balance decrease when sender and recipient are different. if from != to { - let expected_balance = from_balance_before.saturating_sub(token_amount); + let expected_balance = + from_balance_before.checked_sub(token_amount).ok_or_else(|| { + EVMError::Transaction(MorphInvalidTransaction::TokenTransferFailed { + reason: format!( + "sender balance {from_balance_before} less than token amount {token_amount}" + ), + }) + })?; if from_balance_after != expected_balance { return Err(MorphInvalidTransaction::TokenTransferFailed { reason: format!( @@ -776,6 +776,7 @@ where /// Build the calldata for ERC20 transfer(address,amount) call. /// /// Method signature: `transfer(address,amount) -> 0xa9059cbb` +#[inline] fn build_transfer_calldata(to: Address, token_amount: alloy_primitives::Uint<256, 4>) -> Bytes { let method_id = [0xa9u8, 0x05, 0x9c, 0xbb]; // Encode calldata: method_id + padded to address + amount @@ -802,6 +803,7 @@ fn build_transfer_calldata(to: Address, token_amount: alloy_primitives::Uint<256 /// /// # Returns /// The new balance after deducting all fees, or an error if balance is insufficient. +#[inline] fn calculate_caller_fee_with_l1_cost( balance: U256, tx: impl Transaction, @@ -814,29 +816,33 @@ fn calculate_caller_fee_with_l1_cost( let is_balance_check_disabled = cfg.is_balance_check_disabled(); let is_fee_charge_disabled = cfg.is_fee_charge_disabled(); - // Calculate L2 effective balance spending (gas + value + blob fees) - let effective_balance_spending = tx.effective_balance_spending(basefee, blob_price)?; - - // Total spending = L2 fees + L1 data fee - let total_spending = effective_balance_spending.saturating_add(l1_data_fee); - - // Skip balance check if either: - // - Balance check is explicitly disabled (for special scenarios) - // - Fee charge is disabled (eth_call simulation - no point checking balance if fees won't be charged) - if !is_balance_check_disabled && !is_fee_charge_disabled && balance < total_spending { - return Err(InvalidTransaction::LackOfFundForMaxFee { - fee: Box::new(total_spending), - balance: Box::new(balance), - }); + // Validate balance against max possible spending using max_fee_per_gas (not effective_gas_price). + // go-eth's buyGas() checks: balance >= gasFeeCap * gas + value + l1DataFee. + // This ensures the sender can afford the worst-case gas cost before deducting the actual cost. + if !is_balance_check_disabled && !is_fee_charge_disabled { + let max_gas_spending = U256::from( + (tx.gas_limit() as u128) + .checked_mul(tx.max_fee_per_gas()) + .ok_or(InvalidTransaction::OverflowPaymentInTransaction)?, + ); + let max_spending = max_gas_spending + .checked_add(tx.value()) + .and_then(|v| v.checked_add(l1_data_fee)) + .ok_or(InvalidTransaction::OverflowPaymentInTransaction)?; + if balance < max_spending { + return Err(InvalidTransaction::LackOfFundForMaxFee { + fee: Box::new(max_spending), + balance: Box::new(balance), + }); + } } - // Calculate gas balance spending (excluding value transfer) + // Deduct using effective_gas_price (not max_fee_per_gas). + // go-eth's buyGas(): SubBalance(from, gasPrice * gas + l1DataFee) + let effective_balance_spending = tx.effective_balance_spending(basefee, blob_price)?; let gas_balance_spending = effective_balance_spending - tx.value(); - - // Total fee deduction = L2 gas fees + L1 data fee (value is transferred separately) let total_fee_deduction = gas_balance_spending.saturating_add(l1_data_fee); - // New balance after fee deduction let mut new_balance = balance.saturating_sub(total_fee_deduction); if is_balance_check_disabled { diff --git a/crates/revm/src/l1block.rs b/crates/revm/src/l1block.rs index e023c18..cbe5b6a 100644 --- a/crates/revm/src/l1block.rs +++ b/crates/revm/src/l1block.rs @@ -129,7 +129,7 @@ pub const CURIE_L1_GAS_PRICE_ORACLE_STORAGE: [(U256, U256); 4] = [ /// /// Contains the fee parameters fetched from the L1 Gas Price Oracle contract. /// These parameters are used to calculate the L1 data fee for transactions. -#[derive(Clone, Debug, Default)] +#[derive(Clone, Copy, Debug, Default)] pub struct L1BlockInfo { /// The base fee of the L1 origin block. pub l1_base_fee: U256, @@ -137,14 +137,14 @@ pub struct L1BlockInfo { pub l1_fee_overhead: U256, /// The current L1 fee scalar. pub l1_base_fee_scalar: U256, - /// The current L1 blob base fee, None if before Curie. - pub l1_blob_base_fee: Option, - /// The current L1 commit scalar, None if before Curie. - pub l1_commit_scalar: Option, - /// The current L1 blob scalar, None if before Curie. - pub l1_blob_scalar: Option, - /// The current call data gas (l1_commit_scalar * l1_base_fee), None if before Curie. - pub calldata_gas: Option, + /// The current L1 blob base fee (zero if before Curie). + pub l1_blob_base_fee: U256, + /// The current L1 commit scalar (zero if before Curie). + pub l1_commit_scalar: U256, + /// The current L1 blob scalar (zero if before Curie). + pub l1_blob_scalar: U256, + /// The current call data gas: `l1_commit_scalar * l1_base_fee` (zero if before Curie). + pub calldata_gas: U256, } impl L1BlockInfo { @@ -181,10 +181,10 @@ impl L1BlockInfo { l1_base_fee, l1_fee_overhead, l1_base_fee_scalar, - l1_blob_base_fee: Some(l1_blob_base_fee), - l1_commit_scalar: Some(l1_commit_scalar), - l1_blob_scalar: Some(l1_blob_scalar), - calldata_gas: Some(calldata_gas), + l1_blob_base_fee, + l1_commit_scalar, + l1_blob_scalar, + calldata_gas, }) } } @@ -208,8 +208,8 @@ impl L1BlockInfo { .saturating_add(TX_L1_COMMIT_EXTRA_COST) } else { U256::from(input.len()) - .saturating_mul(self.l1_blob_base_fee.unwrap_or_default()) - .saturating_mul(self.l1_blob_scalar.unwrap_or_default()) + .saturating_mul(self.l1_blob_base_fee) + .saturating_mul(self.l1_blob_scalar) } } @@ -229,7 +229,6 @@ impl L1BlockInfo { let blob_gas = self.data_gas(input, hardfork); self.calldata_gas - .unwrap_or_default() .saturating_add(blob_gas) .wrapping_div(TX_L1_FEE_PRECISION) } @@ -262,10 +261,10 @@ mod tests { assert_eq!(info.l1_base_fee, U256::ZERO); assert_eq!(info.l1_fee_overhead, U256::ZERO); assert_eq!(info.l1_base_fee_scalar, U256::ZERO); - assert!(info.l1_blob_base_fee.is_none()); - assert!(info.l1_commit_scalar.is_none()); - assert!(info.l1_blob_scalar.is_none()); - assert!(info.calldata_gas.is_none()); + assert_eq!(info.l1_blob_base_fee, U256::ZERO); + assert_eq!(info.l1_commit_scalar, U256::ZERO); + assert_eq!(info.l1_blob_scalar, U256::ZERO); + assert_eq!(info.calldata_gas, U256::ZERO); } #[test] @@ -285,8 +284,8 @@ mod tests { #[test] fn test_data_gas_curie() { let info = L1BlockInfo { - l1_blob_base_fee: Some(U256::from(10)), - l1_blob_scalar: Some(U256::from(2)), + l1_blob_base_fee: U256::from(10), + l1_blob_scalar: U256::from(2), ..Default::default() }; diff --git a/crates/revm/src/precompiles.rs b/crates/revm/src/precompiles.rs index 650fac9..6e43ab0 100644 --- a/crates/revm/src/precompiles.rs +++ b/crates/revm/src/precompiles.rs @@ -12,11 +12,11 @@ //! └── Emerald = Morph203 + Osaka precompiles //! ``` //! -//! | Hardfork | Base | Added | Notes | +//! | Hardfork | Base | Changes | Notes | //! |------------------|-----------|----------------------------------------------------------|-------------------------------| -//! | Bernoulli/Curie | Berlin | - | ripemd160/blake2f as disabled stubs | -//! | Morph203/Viridian| Bernoulli | blake2f, ripemd160 (working) | replaces disabled stubs | -//! | Emerald | Morph203 | Osaka (P256verify, BLS12-381, point eval, etc) | - | +//! | Bernoulli/Curie | Berlin | ripemd160/blake2f as disabled stubs; modexp 32B limit | - | +//! | Morph203/Viridian| Bernoulli | blake2f/ripemd160 re-enabled; BN256 pairing 4-pair limit | - | +//! | Emerald | Morph203 | BLS12-381, P256verify; modexp EIP-7823/7883 upgrade | NO KZG (0x0a) | //! //! ## Why Disabled Stubs? //! @@ -71,6 +71,20 @@ pub mod addresses { pub const BLAKE2F: Address = u64_to_address(9); /// point evaluation precompile address (10) - EIP-4844 pub const POINT_EVALUATION: Address = u64_to_address(10); + /// BLS12-381 G1 Add precompile address (0x0b) + pub const BLS12_G1ADD: Address = u64_to_address(0x0b); + /// BLS12-381 G1 MultiExp precompile address (0x0c) + pub const BLS12_G1MULTIEXP: Address = u64_to_address(0x0c); + /// BLS12-381 G2 Add precompile address (0x0d) + pub const BLS12_G2ADD: Address = u64_to_address(0x0d); + /// BLS12-381 G2 MultiExp precompile address (0x0e) + pub const BLS12_G2MULTIEXP: Address = u64_to_address(0x0e); + /// BLS12-381 Pairing precompile address (0x0f) + pub const BLS12_PAIRING: Address = u64_to_address(0x0f); + /// BLS12-381 Map FP to G1 precompile address (0x10) + pub const BLS12_MAP_FP_TO_G1: Address = u64_to_address(0x10); + /// BLS12-381 Map FP2 to G2 precompile address (0x11) + pub const BLS12_MAP_FP2_TO_G2: Address = u64_to_address(0x11); /// P256verify precompile address (256) - RIP-7212 pub const P256_VERIFY: Address = u64_to_address(256); } @@ -152,6 +166,73 @@ fn blake2f_disabled(_input: &[u8], _gas_limit: u64) -> PrecompileResult { )) } +/// Checks if a 32-byte big-endian length field at `offset` in `data` exceeds 32. +/// +/// Right-pads with zeros if `data` is shorter than `offset + 32`, matching +/// go-ethereum's `getData` semantics. +fn modexp_len_exceeds_32(data: &[u8], offset: usize) -> bool { + let mut buf = [0u8; 32]; + let start = offset.min(data.len()); + let end = (offset + 32).min(data.len()); + let n = end.saturating_sub(start); + if n > 0 { + buf[..n].copy_from_slice(&data[start..end]); + } + // A big-endian 256-bit value > 32 iff any of the high 31 bytes is non-zero, + // or the lowest byte exceeds 32. + buf[..31].iter().any(|&b| b != 0) || buf[31] > 32 +} + +/// Wraps Berlin modexp with go-ethereum's 32-byte input length limit. +/// +/// go-ethereum enforces `base_len, exp_len, mod_len <= 32` when `eip2565=true` +/// and neither `eip7823` nor `eip7883` is active (Bernoulli through Viridian). +/// Without this limit, morph-reth would accept arbitrarily large modexp inputs +/// that go-ethereum rejects, causing a consensus split. +/// +/// Ref: +fn modexp_with_32byte_limit(input: &[u8], gas_limit: u64) -> PrecompileResult { + // The first 96 bytes of modexp input are three 32-byte big-endian length fields: + // [0..32] = base_len, [32..64] = exp_len, [64..96] = mod_len + if modexp_len_exceeds_32(input, 0) + || modexp_len_exceeds_32(input, 32) + || modexp_len_exceeds_32(input, 64) + { + return Err(PrecompileError::Other( + "modexp temporarily only accepts inputs of 32 bytes (256 bits) or less".into(), + )); + } + + // Delegate to Berlin modexp (EIP-2565 gas pricing, standard computation) + Precompiles::berlin() + .get(&addresses::MODEXP) + .expect("Berlin precompiles must include modexp") + .execute(input, gas_limit) +} + +/// Wraps BN256 pairing with go-ethereum's 4-pair input length limit. +/// +/// go-ethereum limits BN256 pairing to at most 4 pairs (768 bytes) from Morph203 +/// onwards via `limitInputLength: true`. Without this limit, morph-reth would +/// accept larger pairing inputs, which can cause a consensus split if gas +/// accounting differs (the underlying computation is the same, but block gas +/// limits and metering become inconsistent). +/// +/// Ref: +fn bn256_pairing_with_4pair_limit(input: &[u8], gas_limit: u64) -> PrecompileResult { + if input.len() > 4 * 192 { + return Err(PrecompileError::Other( + "bad elliptic curve pairing size".into(), + )); + } + + // Delegate to Berlin/Istanbul BN256 pairing + Precompiles::berlin() + .get(&addresses::BN256_PAIRING) + .expect("Berlin precompiles must include BN256 pairing") + .execute(input, gas_limit) +} + /// Returns precompiles for Bernoulli hardfork. /// /// Based on Berlin with ripemd160 (0x03) and blake2f (0x09) replaced by disabled stubs. @@ -177,6 +258,15 @@ pub fn bernoulli() -> &'static Precompiles { Precompile::new(PrecompileId::Blake2F, addresses::BLAKE2F, blake2f_disabled), ]); + // Replace modexp (0x05) with 32-byte input limit wrapper. + // go-ethereum's Bernoulli modexp has eip2565=true but neither eip7823 nor eip7883, + // which enforces base/exp/mod <= 32 bytes. Berlin modexp in revm has no such limit. + precompiles.extend([Precompile::new( + PrecompileId::ModExp, + addresses::MODEXP, + modexp_with_32byte_limit, + )]); + precompiles }) } @@ -190,47 +280,79 @@ pub fn bernoulli() -> &'static Precompiles { pub fn morph203() -> &'static Precompiles { static INSTANCE: OnceLock = OnceLock::new(); INSTANCE.get_or_init(|| { - // Start from Bernoulli and add blake2f + ripemd160 + // Start from Bernoulli and re-enable blake2f + ripemd160 let mut precompiles = bernoulli().clone(); let berlin = Precompiles::berlin(); - // Add blake2f back (was disabled in Bernoulli) + // Re-enable blake2f (0x09) — was disabled stub in Bernoulli if let Some(blake2f) = berlin.get(&addresses::BLAKE2F) { precompiles.extend([blake2f.clone()]); } - // Add ripemd160 back (was disabled in Bernoulli) + // Re-enable ripemd160 (0x03) — was disabled stub in Bernoulli if let Some(ripemd) = berlin.get(&addresses::RIPEMD160) { precompiles.extend([ripemd.clone()]); } + // Replace BN256 pairing (0x08) with 4-pair limited version. + // go-ethereum's Morph203 uses `limitInputLength: true` which caps + // pairing input to 4 pairs (768 bytes). + precompiles.extend([Precompile::new( + PrecompileId::Bn254Pairing, + addresses::BN256_PAIRING, + bn256_pairing_with_4pair_limit, + )]); + precompiles }) } /// Returns precompiles for Emerald hardfork. /// -/// Based on Morph203/Viridian with Osaka precompiles added. -/// - All standard precompiles (ecrecover, sha256, ripemd160, identity, modexp, bn256 ops, blake2f) -/// - Osaka precompiles (P256verify RIP-7212, BLS12-381 EIP-2537, etc.) +/// Based on Morph203/Viridian with explicit additions matching go-ethereum's +/// `PrecompiledContractsEmerald`: /// -/// Matches: PrecompiledContractsEmerald in Go +/// - Upgrades modexp (0x05) to EIP-7823 (1024-byte input cap) + EIP-7883 (new gas formula) +/// - Adds BLS12-381 precompiles (0x0b-0x11) from EIP-2537 +/// - Adds P256verify (0x100) from RIP-7212 +/// - Does **NOT** include KZG Point Evaluation (0x0a) — go-ethereum omits it +/// +/// Ref: pub fn emerald() -> &'static Precompiles { static INSTANCE: OnceLock = OnceLock::new(); INSTANCE.get_or_init(|| { - // Start from Morph203/Viridian let mut precompiles = morph203().clone(); - - // Add Osaka precompiles (includes P256verify, BLS12-381, etc.) let osaka = Precompiles::osaka(); - for addr in osaka.addresses() { - // Skip precompiles we already have - if !precompiles.contains(addr) - && let Some(precompile) = osaka.get(addr) - { + + // Upgrade modexp (0x05) from 32-byte-limited wrapper to osaka version. + // Emerald uses eip7823=true (1024-byte input cap) + eip7883=true (new gas formula), + // which replaces the Bernoulli~Viridian 32-byte restriction. + if let Some(modexp) = osaka.get(&addresses::MODEXP) { + precompiles.extend([modexp.clone()]); + } + + // Add BLS12-381 precompiles (EIP-2537): 0x0b through 0x11 + for addr in [ + addresses::BLS12_G1ADD, + addresses::BLS12_G1MULTIEXP, + addresses::BLS12_G2ADD, + addresses::BLS12_G2MULTIEXP, + addresses::BLS12_PAIRING, + addresses::BLS12_MAP_FP_TO_G1, + addresses::BLS12_MAP_FP2_TO_G2, + ] { + if let Some(precompile) = osaka.get(&addr) { precompiles.extend([precompile.clone()]); } } + // Add P256verify (RIP-7212) at 0x100 + if let Some(p256) = osaka.get(&addresses::P256_VERIFY) { + precompiles.extend([p256.clone()]); + } + + // NOTE: KZG Point Evaluation (0x0a) is intentionally NOT included. + // go-ethereum's PrecompiledContractsEmerald skips 0x0a entirely. + precompiles }) } @@ -278,32 +400,49 @@ mod tests { fn test_bernoulli_precompiles() { let precompiles = bernoulli(); - // Should have ecrecover, sha256, identity, modexp, bn256 ops + // Should have all 9 Berlin addresses (ecrecover, sha256, ripemd160, identity, + // modexp, bn256 add/mul/pairing, blake2f) assert!(precompiles.contains(&addresses::ECRECOVER)); assert!(precompiles.contains(&addresses::SHA256)); assert!(precompiles.contains(&addresses::IDENTITY)); assert!(precompiles.contains(&addresses::MODEXP)); assert!(precompiles.contains(&addresses::BN256_ADD)); + assert!(precompiles.contains(&addresses::BN256_MUL)); + assert!(precompiles.contains(&addresses::BN256_PAIRING)); // ripemd160 (0x03) and blake2f (0x09) ARE present as disabled stubs. - // They must be in the precompile set so they get warmed (EIP-2929: 100 gas warm - // instead of 2600 cold), matching go-ethereum's PrecompiledContractsBernoulli - // which includes &ripemd160hashDisabled{} and &blake2FDisabled{}. assert!(precompiles.contains(&addresses::RIPEMD160)); assert!(precompiles.contains(&addresses::BLAKE2F)); + + // Exact count: 9 precompiles (matching go-eth PrecompiledContractsBernoulli) + assert_eq!(precompiles.len(), 9); + } + + #[test] + fn test_bernoulli_modexp_rejects_large_input() { + let precompiles = bernoulli(); + let modexp = precompiles.get(&addresses::MODEXP).unwrap(); + + // base_len=33 (exceeds 32-byte limit) — should be rejected + let mut input = vec![0u8; 96]; + input[31] = 33; // base_len = 33 + input[63] = 32; // exp_len = 32 + input[95] = 32; // mod_len = 32 + let result = modexp.execute(&input, 100_000); + assert!(result.is_err(), "modexp with base_len=33 should be rejected"); + + // base_len=32, exp_len=32, mod_len=32 — should succeed + input[31] = 32; + let result = modexp.execute(&input, 100_000); + assert!(result.is_ok(), "modexp with all lens=32 should succeed"); } #[test] fn test_curie_uses_bernoulli_precompiles() { - // Curie uses the same precompile set as Bernoulli - // Go implementation has no PrecompiledContractsCurie let bernoulli_p = MorphPrecompiles::new_with_spec(MorphHardfork::Bernoulli); let curie_p = MorphPrecompiles::new_with_spec(MorphHardfork::Curie); - // Both should have the same precompiles assert_eq!(bernoulli_p.precompiles().len(), curie_p.precompiles().len()); - - // Both should have sha256 enabled and 0x03/0x09 as disabled stubs (present in set) assert!(curie_p.contains(&addresses::SHA256)); assert!(curie_p.contains(&addresses::RIPEMD160)); assert!(curie_p.contains(&addresses::BLAKE2F)); @@ -313,79 +452,142 @@ mod tests { fn test_morph203_precompiles() { let precompiles = morph203(); - // Should have blake2f and ripemd160 re-enabled + // blake2f and ripemd160 re-enabled (working, not disabled stubs) assert!(precompiles.contains(&addresses::BLAKE2F)); assert!(precompiles.contains(&addresses::RIPEMD160)); - - // All standard precompiles assert!(precompiles.contains(&addresses::ECRECOVER)); assert!(precompiles.contains(&addresses::SHA256)); - // P256verify not yet added in Morph203 + // No Osaka-era precompiles yet assert!(!precompiles.contains(&addresses::P256_VERIFY)); + assert!(!precompiles.contains(&addresses::POINT_EVALUATION)); + + // Same count as Bernoulli (9 addresses, different implementations) + assert_eq!(precompiles.len(), 9); + } + + #[test] + fn test_morph203_pairing_rejects_large_input() { + let precompiles = morph203(); + let pairing = precompiles.get(&addresses::BN256_PAIRING).unwrap(); + + // 5 pairs (960 bytes) — exceeds 4-pair limit, should be rejected + let input = vec![0u8; 5 * 192]; + let result = pairing.execute(&input, 1_000_000); + assert!(result.is_err(), "pairing with 5 pairs should be rejected"); + + // 4 pairs (768 bytes) — within limit, should not be rejected for size + // (may still fail due to invalid curve points, but not for size) + let input = vec![0u8; 4 * 192]; + let result = pairing.execute(&input, 1_000_000); + // Zero-input pairing is valid and returns true + assert!(result.is_ok(), "pairing with 4 pairs should not be rejected for size"); } #[test] fn test_emerald_precompiles() { let precompiles = emerald(); - // All standard precompiles should be enabled + // All standard precompiles (0x01-0x09) assert!(precompiles.contains(&addresses::ECRECOVER)); assert!(precompiles.contains(&addresses::SHA256)); - assert!(precompiles.contains(&addresses::RIPEMD160)); // Now enabled! + assert!(precompiles.contains(&addresses::RIPEMD160)); assert!(precompiles.contains(&addresses::IDENTITY)); assert!(precompiles.contains(&addresses::MODEXP)); assert!(precompiles.contains(&addresses::BN256_ADD)); + assert!(precompiles.contains(&addresses::BN256_MUL)); + assert!(precompiles.contains(&addresses::BN256_PAIRING)); assert!(precompiles.contains(&addresses::BLAKE2F)); - // P256verify should be present + // BLS12-381 precompiles (0x0b-0x11) + assert!(precompiles.contains(&addresses::BLS12_G1ADD)); + assert!(precompiles.contains(&addresses::BLS12_G1MULTIEXP)); + assert!(precompiles.contains(&addresses::BLS12_G2ADD)); + assert!(precompiles.contains(&addresses::BLS12_G2MULTIEXP)); + assert!(precompiles.contains(&addresses::BLS12_PAIRING)); + assert!(precompiles.contains(&addresses::BLS12_MAP_FP_TO_G1)); + assert!(precompiles.contains(&addresses::BLS12_MAP_FP2_TO_G2)); + + // P256verify (0x100) assert!(precompiles.contains(&addresses::P256_VERIFY)); + + // KZG Point Evaluation (0x0a) must NOT be included + assert!( + !precompiles.contains(&addresses::POINT_EVALUATION), + "Emerald must NOT include KZG Point Evaluation (0x0a)" + ); + + // Exact count: 9 (standard) + 7 (BLS12-381) + 1 (P256verify) = 17 + // Matching go-eth PrecompiledContractsEmerald which has 17 entries + assert_eq!(precompiles.len(), 17); } #[test] - fn test_precompile_counts_increase() { - let bernoulli_count = bernoulli().len(); - let morph203_count = morph203().len(); - let emerald_count = emerald().len(); - - // Bernoulli and Morph203 have the same number of addresses (9), but - // Bernoulli has 0x03/0x09 as disabled stubs while Morph203 re-enables them. - assert_eq!(morph203_count, bernoulli_count); + fn test_emerald_modexp_accepts_large_input() { + let precompiles = emerald(); + let modexp = precompiles.get(&addresses::MODEXP).unwrap(); + + // base_len=64 — should succeed in Emerald (32-byte limit lifted) + let mut input = vec![0u8; 96 + 64 + 32 + 64]; // base_len + exp_len + mod_len + data + input[31] = 64; // base_len = 64 + input[63] = 32; // exp_len = 32 + input[95] = 64; // mod_len = 64 + let result = modexp.execute(&input, 1_000_000); + assert!(result.is_ok(), "Emerald modexp should accept base_len=64"); + } - // Emerald should have more than Morph203 (adds Osaka precompiles) - assert!(emerald_count > morph203_count); + #[test] + fn test_precompile_counts() { + assert_eq!(bernoulli().len(), 9); + assert_eq!(morph203().len(), 9); + assert_eq!(emerald().len(), 17); } #[test] fn test_hardfork_specific_precompiles() { - // Verify that each hardfork has the expected precompile configuration let bernoulli_p = MorphPrecompiles::new_with_spec(MorphHardfork::Bernoulli); let curie_p = MorphPrecompiles::new_with_spec(MorphHardfork::Curie); let morph203_p = MorphPrecompiles::new_with_spec(MorphHardfork::Morph203); let viridian_p = MorphPrecompiles::new_with_spec(MorphHardfork::Viridian); let emerald_p = MorphPrecompiles::new_with_spec(MorphHardfork::Emerald); - // Bernoulli and Curie: ripemd160 and blake2f are present as disabled stubs (same precompile set). - // They're in the set to ensure EIP-2929 warming matches go-ethereum. + // Bernoulli/Curie: disabled stubs present, same set assert!(bernoulli_p.contains(&addresses::RIPEMD160)); assert!(bernoulli_p.contains(&addresses::BLAKE2F)); - assert!(curie_p.contains(&addresses::RIPEMD160)); - assert!(curie_p.contains(&addresses::BLAKE2F)); + assert_eq!(bernoulli_p.precompiles().len(), curie_p.precompiles().len()); - // Morph203 and Viridian: blake2f + ripemd160 enabled, no P256verify (same precompile set) + // Morph203/Viridian: re-enabled, no P256verify, same set assert!(morph203_p.contains(&addresses::RIPEMD160)); assert!(morph203_p.contains(&addresses::BLAKE2F)); assert!(!morph203_p.contains(&addresses::P256_VERIFY)); - assert!(viridian_p.contains(&addresses::RIPEMD160)); - assert!(viridian_p.contains(&addresses::BLAKE2F)); - assert!(!viridian_p.contains(&addresses::P256_VERIFY)); - assert_eq!( - morph203_p.precompiles().len(), - viridian_p.precompiles().len() - ); + assert_eq!(morph203_p.precompiles().len(), viridian_p.precompiles().len()); - // Emerald: all precompiles enabled including Osaka precompiles (P256verify, BLS12-381, etc) - assert!(emerald_p.contains(&addresses::RIPEMD160)); + // Emerald: full set with BLS12-381 + P256verify, no KZG assert!(emerald_p.contains(&addresses::P256_VERIFY)); + assert!(emerald_p.contains(&addresses::BLS12_G1ADD)); + assert!(!emerald_p.contains(&addresses::POINT_EVALUATION)); + } + + #[test] + fn test_modexp_len_check() { + // Value = 0 (all zeros) — should NOT exceed 32 + assert!(!modexp_len_exceeds_32(&[0u8; 32], 0)); + + // Value = 32 — should NOT exceed 32 + let mut data = [0u8; 32]; + data[31] = 32; + assert!(!modexp_len_exceeds_32(&data, 0)); + + // Value = 33 — should exceed 32 + data[31] = 33; + assert!(modexp_len_exceeds_32(&data, 0)); + + // Value has non-zero high byte — definitely exceeds 32 + data[0] = 1; + data[31] = 0; + assert!(modexp_len_exceeds_32(&data, 0)); + + // Empty input (right-padded to all zeros) — value = 0, should NOT exceed + assert!(!modexp_len_exceeds_32(&[], 0)); } } diff --git a/crates/revm/src/token_fee.rs b/crates/revm/src/token_fee.rs index 105855b..9d0e813 100644 --- a/crates/revm/src/token_fee.rs +++ b/crates/revm/src/token_fee.rs @@ -27,7 +27,7 @@ const PRICE_RATIO_SLOT: U256 = U256::from_limbs([153u64, 0, 0, 0]); /// /// Contains the token parameters fetched from the L2 Token Registry contract. /// These parameters are used to calculate gas fees in alternative ERC20 tokens. -#[derive(Clone, Debug, Default)] +#[derive(Clone, Copy, Debug, Default)] pub struct TokenFeeInfo { /// The fee token address. pub token_address: Address, @@ -97,6 +97,7 @@ impl TokenFeeInfo { /// Calculate the token amount required for a given ETH amount. /// /// Uses the price ratio and scale to convert ETH value to token amount. + #[inline] pub fn eth_to_token_amount(&self, eth_amount: U256) -> U256 { // If price_ratio or scale is zero (misconfigured token), return MAX to prevent // free-ride transactions. The caller's balance check will reject the tx. @@ -124,7 +125,7 @@ fn read_registry_entry( // Get the base slot for this token_id in tokenRegistry mapping let mut token_id_bytes = [0u8; 32]; token_id_bytes[30..32].copy_from_slice(&token_id.to_be_bytes()); - let base = compute_mapping_slot(TOKEN_REGISTRY_SLOT, token_id_bytes.to_vec()); + let base = compute_mapping_slot(TOKEN_REGISTRY_SLOT, &token_id_bytes); // TokenInfo struct layout in storage (Solidity packing): // base + 0: tokenAddress (20 bytes) + padding @@ -158,7 +159,7 @@ fn read_registry_entry( db, L2_TOKEN_REGISTRY_ADDRESS, PRICE_RATIO_SLOT, - token_id_bytes.to_vec(), + &token_id_bytes, )?; Ok(Some(TokenRegistryEntry { @@ -175,10 +176,15 @@ fn read_registry_entry( /// /// For `mapping(keyType => valueType)` at slot `base_slot`, /// the value for `key` is at `keccak256(key ++ base_slot)`. -pub fn compute_mapping_slot(base_slot: U256, mut key: Vec) -> U256 { - let mut preimage = base_slot.to_be_bytes_vec(); - key.append(&mut preimage); - U256::from_be_bytes(keccak256(key).0) +/// +/// Uses a stack-allocated `[u8; 64]` preimage buffer (32-byte key + 32-byte slot) +/// to avoid heap allocations on every call. +#[inline] +pub fn compute_mapping_slot(base_slot: U256, key: &[u8; 32]) -> U256 { + let mut preimage = [0u8; 64]; + preimage[..32].copy_from_slice(key); + preimage[32..].copy_from_slice(&base_slot.to_be_bytes::<32>()); + U256::from_be_bytes(keccak256(preimage).0) } /// Calculate mapping slot for an address key (left-padded to 32 bytes). @@ -186,15 +192,16 @@ pub fn compute_mapping_slot(base_slot: U256, mut key: Vec) -> U256 { pub fn compute_mapping_slot_for_address(base_slot: U256, account: Address) -> U256 { let mut key = [0u8; 32]; key[12..32].copy_from_slice(account.as_slice()); - compute_mapping_slot(base_slot, key.to_vec()) + compute_mapping_slot(base_slot, &key) } /// Load a value from a mapping in contract storage. +#[inline] fn read_mapping_value( db: &mut DB, contract: Address, base_slot: U256, - key: Vec, + key: &[u8; 32], ) -> Result { db.storage(contract, compute_mapping_slot(base_slot, key)) } @@ -210,8 +217,8 @@ fn read_token_balance_with_fallback( balance_slot: Option, hardfork: MorphHardfork, ) -> Result { - if balance_slot.is_some() { - return read_balance_from_storage(db, token, account, balance_slot); + if let Some(slot) = balance_slot { + return read_balance_from_storage(db, token, account, slot); } // EVM fallback: construct temporary MorphEvm for balanceOf call @@ -226,22 +233,16 @@ fn read_token_balance_with_fallback( } /// Read ERC20 balance directly from storage slot. -/// -/// Returns zero if `balance_slot` is `None`. +#[inline] fn read_balance_from_storage( db: &mut DB, token: Address, account: Address, - balance_slot: Option, + balance_slot: U256, ) -> Result { - match balance_slot { - Some(slot) => { - let mut key = [0u8; 32]; - key[12..32].copy_from_slice(account.as_slice()); - read_mapping_value(db, token, slot, key.to_vec()) - } - None => Ok(U256::ZERO), - } + let mut key = [0u8; 32]; + key[12..32].copy_from_slice(account.as_slice()); + read_mapping_value(db, token, balance_slot, &key) } /// Execute EVM `balanceOf(address)` call. @@ -314,9 +315,9 @@ mod tests { fn test_mapping_slot() { // Test that mapping slot calculation produces deterministic results let slot = U256::from(151); - let key = vec![0u8; 32]; - let result1 = compute_mapping_slot(slot, key.clone()); - let result2 = compute_mapping_slot(slot, key); + let key = [0u8; 32]; + let result1 = compute_mapping_slot(slot, &key); + let result2 = compute_mapping_slot(slot, &key); assert_eq!(result1, result2); } diff --git a/crates/txpool/src/maintain.rs b/crates/txpool/src/maintain.rs index 2c32851..1e43c38 100644 --- a/crates/txpool/src/maintain.rs +++ b/crates/txpool/src/maintain.rs @@ -23,6 +23,7 @@ use alloy_consensus::Transaction; use alloy_eips::eip2718::Encodable2718; +use alloy_consensus::Typed2718; use alloy_primitives::{Address, TxHash, U256}; use futures::StreamExt; use morph_chainspec::hardfork::MorphHardforks; @@ -153,7 +154,7 @@ where .pending .iter() .chain(all_txs.queued.iter()) - .filter(|tx| tx.transaction.clone_into_consensus().is_morph_tx()) + .filter(|tx| tx.transaction.ty() == morph_primitives::MORPH_TX_TYPE_ID) .collect(); if morph_txs.is_empty() { @@ -206,7 +207,7 @@ where for (sender, mut sender_txs) in txs_by_sender { sender_txs - .sort_by_key(|pooled_tx| pooled_tx.transaction.clone_into_consensus().nonce()); + .sort_by_key(|pooled_tx| pooled_tx.transaction.nonce()); // Initialize sender ETH budget once. let eth_balance = match db.basic(sender) { diff --git a/crates/txpool/src/validator.rs b/crates/txpool/src/validator.rs index bca2ed5..d11c29c 100644 --- a/crates/txpool/src/validator.rs +++ b/crates/txpool/src/validator.rs @@ -51,7 +51,7 @@ impl MorphL1BlockInfo { /// Returns the current L1 block info. pub fn l1_block_info(&self) -> L1BlockInfo { - self.l1_block_info.read().clone() + *self.l1_block_info.read() } /// Updates the L1 block info. @@ -254,12 +254,13 @@ where authorities, } = outcome { - let l1_block_info = self.block_info.l1_block_info.read().clone(); + let l1_block_info = *self.block_info.l1_block_info.read(); let hardfork = self .chain_spec() .morph_hardfork_at(self.block_number(), self.block_timestamp()); - // Calculate L1 data fee (always calculated for all transactions) + // Calculate L1 data fee (always calculated for all transactions). + // Clone consensus tx once — reused for both L1 fee encoding and MorphTx validation. let consensus_tx = valid_tx.transaction().clone_into_consensus(); let mut encoded = Vec::with_capacity(consensus_tx.encode_2718_len()); consensus_tx.encode_2718(&mut encoded); @@ -268,9 +269,10 @@ where if is_morph_tx { // MorphTx: validate structural rules and ERC20 token balance via // the shared helper used by both admission and maintenance. + // Pass &MorphTxEnvelope directly to avoid a second clone_into_consensus(). let sender = valid_tx.transaction().sender(); let validation = match self.validate_morph_tx_balance( - valid_tx.transaction(), + &consensus_tx, sender, balance, l1_data_fee, @@ -336,6 +338,9 @@ where /// Validates MorphTx (0x7F) ERC20 token balance and fee_limit. /// + /// Accepts `&Recovered` directly (already cloned by the caller) + /// to avoid a redundant second `clone_into_consensus()`. + /// /// This method performs the following checks (reference: go-ethereum tx_pool.go:727-791): /// 1. `fee_token_id == 0`: ETH-fee path, require ETH affordability for `cost + l1_fee` /// 2. `fee_token_id > 0`: token must be registered and active in L2TokenRegistry @@ -344,14 +349,12 @@ where /// 5. ETH balance must be >= transaction value (value is still in ETH) fn validate_morph_tx_balance( &self, - tx: &Tx, + consensus_tx: &reth_primitives_traits::Recovered, sender: Address, eth_balance: U256, l1_data_fee: U256, hardfork: morph_chainspec::hardfork::MorphHardfork, ) -> Result { - let consensus_tx = tx.clone_into_consensus(); - // Get state provider for token info lookup let provider = self .client() @@ -365,7 +368,7 @@ where // Use shared validation logic with unified API (includes ETH balance check) let input = crate::MorphTxValidationInput { - consensus_tx: &consensus_tx, + consensus_tx, sender, eth_balance, l1_data_fee, From 67d6773198a858221c07f0c11fd7560576ff620f Mon Sep 17 00:00:00 2001 From: panos Date: Fri, 6 Mar 2026 22:59:50 +0800 Subject: [PATCH 05/35] fix: pre-audit security hardening (block size, MorphTx version, fee refund) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add block payload size validation in consensus (M-1), matching go-eth IsValidBlockSize(block.PayloadSize()) - Add MorphTx version validation in consensus (M-2), matching go-eth ValidateMorphTxVersion() with Jade fork gating for V1 - Fix token fee refund to silently continue on failure, matching go-eth "refund should not cause transaction to fail" semantics - Add defensive .max(0) for gas.refunded() i64→u64 cast (M-4) --- Cargo.lock | 1 - crates/consensus/src/validation.rs | 104 +++++++++++++++++- crates/engine-api/src/builder.rs | 4 +- crates/engine-api/src/rpc.rs | 20 ++-- crates/evm/src/block/mod.rs | 22 ++-- crates/evm/src/block/receipt.rs | 2 +- crates/node/src/validator.rs | 8 +- crates/payload/builder/src/builder.rs | 5 +- crates/primitives/src/transaction/envelope.rs | 4 +- crates/revm/Cargo.toml | 1 - crates/revm/src/evm.rs | 8 +- crates/revm/src/handler.rs | 57 ++++++---- crates/revm/src/precompiles.rs | 15 ++- crates/revm/src/tx.rs | 44 +++----- crates/rpc/src/eth/transaction.rs | 2 +- crates/txpool/src/maintain.rs | 53 ++++----- crates/txpool/src/morph_tx_validation.rs | 10 +- crates/txpool/src/validator.rs | 8 +- 18 files changed, 240 insertions(+), 128 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cbaa765..215866c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4934,7 +4934,6 @@ dependencies = [ "alloy-eips", "alloy-evm", "alloy-primitives", - "alloy-rlp", "alloy-sol-types", "auto_impl", "derive_more", diff --git a/crates/consensus/src/validation.rs b/crates/consensus/src/validation.rs index 9201561..49fc833 100644 --- a/crates/consensus/src/validation.rs +++ b/crates/consensus/src/validation.rs @@ -39,7 +39,10 @@ use alloy_consensus::{BlockHeader as _, EMPTY_OMMER_ROOT_HASH, TxReceipt}; use alloy_evm::block::BlockExecutionResult; use alloy_primitives::{B256, Bloom}; use morph_chainspec::{MorphChainSpec, MorphHardforks}; -use morph_primitives::{Block, BlockBody, MorphHeader, MorphReceipt, MorphTxEnvelope}; +use morph_primitives::{ + Block, BlockBody, MorphHeader, MorphReceipt, MorphTxEnvelope, + transaction::morph_transaction::{MAX_MEMO_LENGTH, MORPH_TX_VERSION_0, MORPH_TX_VERSION_1}, +}; use reth_consensus::{Consensus, ConsensusError, FullConsensus, HeaderValidator, ReceiptRootBloom}; use reth_consensus_common::validation::{ validate_against_parent_hash_number, validate_body_against_header, @@ -301,6 +304,13 @@ impl Consensus for MorphConsensus { )); } + // Validate MorphTx version and field constraints. + // Matches go-ethereum's BlockValidator.ValidateBody() → ValidateMorphTxVersion(). + let is_jade = self + .chain_spec + .is_jade_active_at_timestamp(block.header().timestamp()); + validate_morph_tx_versions(&block.body().transactions, is_jade)?; + // Validate L1 messages ordering and internal consistency with header. // This is the body-level half of L1 validation; it verifies that the L1 // messages within this block are internally consistent with the header's @@ -586,6 +596,98 @@ fn validate_l1_messages_in_block( Ok(()) } +/// Validates MorphTx version and field constraints for all transactions in a block. +/// +/// Matches go-ethereum's `BlockValidator.ValidateBody()` which: +/// 1. Rejects MorphTx V1 before the Jade fork is active +/// 2. Validates version-specific field constraints via `ValidateMorphTxVersion()` +/// +/// # Rules +/// +/// - **Version 0**: `fee_token_id` must be > 0; `reference` and `memo` must not be set +/// - **Version 1**: If `fee_token_id` is 0, `fee_limit` must be zero; `memo` must not +/// exceed [`MAX_MEMO_LENGTH`] bytes +/// - **Other versions**: rejected as unsupported +fn validate_morph_tx_versions( + txs: &[MorphTxEnvelope], + is_jade: bool, +) -> Result<(), ConsensusError> { + for tx in txs { + if !tx.is_morph_tx() { + continue; + } + + let version = tx.version().unwrap_or(0); + + // Reject MorphTx V1 before Jade fork + if !is_jade && version == MORPH_TX_VERSION_1 { + return Err(ConsensusError::Other( + MorphConsensusError::InvalidBody( + "MorphTx version 1 is not yet active (jade fork not reached)".into(), + ) + .to_string(), + )); + } + + match version { + MORPH_TX_VERSION_0 => { + // V0 requires fee_token_id > 0 and no reference/memo + let fee_token_id = tx.fee_token_id().unwrap_or(0); + let has_reference = tx + .reference() + .is_some_and(|r| r != alloy_primitives::B256::ZERO); + let has_memo = tx.memo().is_some_and(|m| !m.is_empty()); + + if fee_token_id == 0 || has_reference || has_memo { + return Err(ConsensusError::Other( + MorphConsensusError::InvalidBody( + "illegal extra parameters of version 0 MorphTx".into(), + ) + .to_string(), + )); + } + } + MORPH_TX_VERSION_1 => { + let fee_token_id = tx.fee_token_id().unwrap_or(0); + let fee_limit = tx.fee_limit().unwrap_or(alloy_primitives::U256::ZERO); + + // If fee_token_id is 0, fee_limit must not be set (must be zero) + if fee_token_id == 0 && !fee_limit.is_zero() { + return Err(ConsensusError::Other( + MorphConsensusError::InvalidBody( + "illegal extra parameters of version 1 MorphTx".into(), + ) + .to_string(), + )); + } + + // Validate memo length + if let Some(memo) = tx.memo() + && memo.len() > MAX_MEMO_LENGTH + { + return Err(ConsensusError::Other( + MorphConsensusError::InvalidBody(format!( + "memo exceeds maximum length of {MAX_MEMO_LENGTH} bytes, got {}", + memo.len() + )) + .to_string(), + )); + } + } + _ => { + return Err(ConsensusError::Other( + MorphConsensusError::InvalidBody(format!( + "unsupported MorphTx version: {version}" + )) + .to_string(), + )); + } + } + } + + Ok(()) +} + // ============================================================================ // Receipts Validation // ============================================================================ diff --git a/crates/engine-api/src/builder.rs b/crates/engine-api/src/builder.rs index 8ca22fc..6f0c703 100644 --- a/crates/engine-api/src/builder.rs +++ b/crates/engine-api/src/builder.rs @@ -358,9 +358,7 @@ where // 3. Import the block through reth engine tree and return the in-path header // (do not rely on immediate DB visibility after FCU). - let header = self - .import_l2_block_via_engine(executable_data) - .await?; + let header = self.import_l2_block_via_engine(executable_data).await?; // Update safe block tag separately, matching geth's decoupled design. // Best-effort: block import already succeeded, so don't fail the whole diff --git a/crates/engine-api/src/rpc.rs b/crates/engine-api/src/rpc.rs index 3282e17..3f43b12 100644 --- a/crates/engine-api/src/rpc.rs +++ b/crates/engine-api/src/rpc.rs @@ -95,51 +95,51 @@ where &self, params: AssembleL2BlockParams, ) -> RpcResult { - tracing::debug!(target: "morph::engine", block_number = params.number, "assembling L2 block"); + tracing::debug!(target: "rpc::engine", block_number = params.number, "assembling L2 block"); self.inner.assemble_l2_block(params).await.map_err(|e| { - tracing::error!(target: "morph::engine", error = %e, "failed to assemble L2 block"); + tracing::error!(target: "rpc::engine", error = %e, "failed to assemble L2 block"); e.into() }) } async fn validate_l2_block(&self, data: ExecutableL2Data) -> RpcResult { tracing::debug!( - target: "morph::engine", + target: "rpc::engine", block_number = data.number, block_hash = %data.hash, "validating L2 block" ); self.inner.validate_l2_block(data).await.map_err(|e| { - tracing::error!(target: "morph::engine", error = %e, "failed to validate L2 block"); + tracing::error!(target: "rpc::engine", error = %e, "failed to validate L2 block"); e.into() }) } async fn new_l2_block(&self, data: ExecutableL2Data) -> RpcResult<()> { tracing::debug!( - target: "morph::engine", + target: "rpc::engine", block_number = data.number, block_hash = %data.hash, "RPC newL2Block called" ); self.inner.new_l2_block(data).await.map_err(|e| { - tracing::error!(target: "morph::engine", error = %e, "failed to import L2 block"); + tracing::error!(target: "rpc::engine", error = %e, "failed to import L2 block"); e.into() }) } async fn new_safe_l2_block(&self, data: SafeL2Data) -> RpcResult { tracing::debug!( - target: "morph::engine", + target: "rpc::engine", block_number = data.number, "RPC newSafeL2Block called" ); self.inner.new_safe_l2_block(data).await.map_err(|e| { - tracing::error!(target: "morph::engine", error = %e, "failed to import safe L2 block"); + tracing::error!(target: "rpc::engine", error = %e, "failed to import safe L2 block"); e.into() }) } @@ -150,7 +150,7 @@ where finalized_block_hash: B256, ) -> RpcResult<()> { tracing::debug!( - target: "morph::engine", + target: "rpc::engine", %safe_block_hash, %finalized_block_hash, "RPC setBlockTags called" @@ -160,7 +160,7 @@ where .set_block_tags(safe_block_hash, finalized_block_hash) .await .map_err(|e| { - tracing::error!(target: "morph::engine", error = %e, "failed to set block tags"); + tracing::error!(target: "rpc::engine", error = %e, "failed to set block tags"); e.into() }) } diff --git a/crates/evm/src/block/mod.rs b/crates/evm/src/block/mod.rs index 08459c6..eb556a3 100644 --- a/crates/evm/src/block/mod.rs +++ b/crates/evm/src/block/mod.rs @@ -80,6 +80,10 @@ where DB: Database, I: Inspector>>, { + /// Default capacity hint for the receipts Vec — avoids reallocations for + /// typical L2 blocks (which usually contain tens to low-hundreds of txs). + const RECEIPT_CAPACITY_HINT: usize = 128; + /// Creates a new [`MorphBlockExecutor`]. /// /// # Arguments @@ -95,7 +99,7 @@ where evm, spec, receipt_builder, - receipts: Vec::new(), + receipts: Vec::with_capacity(Self::RECEIPT_CAPACITY_HINT), gas_used: 0, hardfork: MorphHardfork::default(), } @@ -159,7 +163,7 @@ where // Extract version, reference, and memo from the transaction let version = tx.version().unwrap_or(0); let reference = tx.reference(); - let memo = tx.memo(); + let memo = tx.memo().cloned(); // For fee_token_id==0 (ETH fee MorphTx, V1 only), no token registry lookup needed. // Still preserve version/reference/memo in the receipt. @@ -182,9 +186,7 @@ where None => { TokenFeeInfo::load_for_caller(self.evm.db_mut(), fee_token_id, sender, hardfork) .map_err(|e| { - BlockExecutionError::msg(format!( - "Failed to fetch token fee info: {e:?}" - )) + BlockExecutionError::msg(format!("Failed to fetch token fee info: {e:?}")) })? } }; @@ -246,10 +248,9 @@ where .spec .morph_hardfork_at(block_number, self.evm.block().timestamp.to::()); self.hardfork = hardfork; - let l1_block_info = - L1BlockInfo::try_fetch(self.evm.db_mut(), hardfork).map_err(|e| { - BlockExecutionError::msg(format!("Failed to fetch L1 block info: {e:?}")) - })?; + let l1_block_info = L1BlockInfo::try_fetch(self.evm.db_mut(), hardfork).map_err(|e| { + BlockExecutionError::msg(format!("Failed to fetch L1 block info: {e:?}")) + })?; self.evm.ctx_mut().chain = l1_block_info; // 3. Apply Curie hardfork at the transition block @@ -340,8 +341,7 @@ where // Get MorphTx-specific fields for MorphTx transactions. // Uses the hardfork cached in apply_pre_execution_changes (constant per block). - let morph_tx_fields = - self.get_morph_tx_fields(tx.tx(), *tx.signer(), self.hardfork)?; + let morph_tx_fields = self.get_morph_tx_fields(tx.tx(), *tx.signer(), self.hardfork)?; // Update cumulative gas used let gas_used = result.gas_used(); diff --git a/crates/evm/src/block/receipt.rs b/crates/evm/src/block/receipt.rs index eb7f411..56ec664 100644 --- a/crates/evm/src/block/receipt.rs +++ b/crates/evm/src/block/receipt.rs @@ -197,7 +197,7 @@ impl MorphReceiptBuilder for DefaultMorphReceiptBuilder { )) } else { warn!( - target: "morph::receipt", + target: "morph::evm", tx_hash = ?tx.tx_hash(), "MorphTx missing token fee fields - receipt will not include fee token info. \ This may indicate an unregistered/inactive token or a bug." diff --git a/crates/node/src/validator.rs b/crates/node/src/validator.rs index a29dde0..4b88d56 100644 --- a/crates/node/src/validator.rs +++ b/crates/node/src/validator.rs @@ -167,7 +167,7 @@ impl MorphEngineValidator { /// Sets the geth RPC URL for cross-validating MPT state root. pub fn with_geth_rpc_url(mut self, url: String) -> Self { - tracing::info!(target: "morph::validator", %url, "Enabled state root cross-validation via geth diskRoot RPC"); + tracing::info!(target: "engine::validator", %url, "Enabled state root cross-validation via geth diskRoot RPC"); self.geth_rpc_url = Some(url); self } @@ -335,7 +335,7 @@ impl StateRootValidator for MorphEngineValida Ok(disk_root) => { if computed_state_root == disk_root { tracing::debug!( - target: "morph::validator", + target: "engine::validator", block_number, ?computed_state_root, "State root cross-validation passed" @@ -343,7 +343,7 @@ impl StateRootValidator for MorphEngineValida Ok(()) } else { tracing::error!( - target: "morph::validator", + target: "engine::validator", block_number, ?computed_state_root, ?disk_root, @@ -360,7 +360,7 @@ impl StateRootValidator for MorphEngineValida } Err(err) => { tracing::warn!( - target: "morph::validator", + target: "engine::validator", block_number, %err, "Failed to fetch diskRoot from geth, skipping state root validation" diff --git a/crates/payload/builder/src/builder.rs b/crates/payload/builder/src/builder.rs index 7a4fe99..f1357c0 100644 --- a/crates/payload/builder/src/builder.rs +++ b/crates/payload/builder/src/builder.rs @@ -288,9 +288,10 @@ impl MorphPayloadBuilderCtx { ) -> Result, PayloadBuilderError> { let block_gas_limit = builder.evm().block().gas_limit(); let base_fee = builder.evm().block().basefee(); - let mut executed_txs: Vec = Vec::new(); + let l1_tx_count = self.attributes().transactions.len(); + let mut executed_txs: Vec = Vec::with_capacity(l1_tx_count); // Track gas spent by each transaction for error reporting - let mut gas_spent_by_transactions: Vec = Vec::new(); + let mut gas_spent_by_transactions: Vec = Vec::with_capacity(l1_tx_count); for (tx_idx, tx_with_encoded) in self.attributes().transactions.iter().enumerate() { // The transaction is already recovered in `try_new` via `try_into_recovered()`. diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index c0c5e67..6c874be 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -100,9 +100,9 @@ impl MorphTxEnvelope { } /// Returns the memo for MorphTx, or `None` for other transaction types. - pub fn memo(&self) -> Option { + pub fn memo(&self) -> Option<&alloy_primitives::Bytes> { match self { - Self::Morph(tx) => tx.tx().memo.clone(), + Self::Morph(tx) => tx.tx().memo.as_ref(), _ => None, } } diff --git a/crates/revm/Cargo.toml b/crates/revm/Cargo.toml index 2cafdbb..86295bb 100644 --- a/crates/revm/Cargo.toml +++ b/crates/revm/Cargo.toml @@ -25,7 +25,6 @@ alloy-evm.workspace = true alloy-primitives.workspace = true alloy-consensus.workspace = true alloy-sol-types.workspace = true -alloy-rlp.workspace = true alloy-eips.workspace = true auto_impl.workspace = true diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index 50d0fd6..6fb8e11 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -1,4 +1,7 @@ -use crate::{MorphBlockEnv, MorphTxEnv, l1block::L1BlockInfo, precompiles::MorphPrecompiles, token_fee::TokenFeeInfo}; +use crate::{ + MorphBlockEnv, MorphTxEnv, l1block::L1BlockInfo, precompiles::MorphPrecompiles, + token_fee::TokenFeeInfo, +}; use alloy_evm::Database; use alloy_primitives::{Log, U256, keccak256}; use morph_chainspec::hardfork::MorphHardfork; @@ -22,7 +25,8 @@ use revm::{ /// Uses [`L1BlockInfo`] as the `CHAIN` parameter so that L1 fee parameters /// are fetched once per block and shared across all transactions, avoiding /// repeated storage reads in the handler hot path. -pub type MorphContext = Context, DB, Journal, L1BlockInfo>; +pub type MorphContext = + Context, DB, Journal, L1BlockInfo>; #[inline] fn as_u64_saturated(value: U256) -> u64 { diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 0d252ae..4634b32 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -336,7 +336,10 @@ where .unwrap_or_default(); // Calculate L1 data fee using the cached L1BlockInfo from context chain field. - let l1_data_fee = evm.ctx_ref().chain.calculate_tx_l1_cost(rlp_bytes, hardfork); + let l1_data_fee = evm + .ctx_ref() + .chain + .calculate_tx_l1_cost(rlp_bytes, hardfork); evm.cached_l1_data_fee = l1_data_fee; // Get mutable access to context components @@ -386,7 +389,7 @@ where let basefee = evm.ctx.block().basefee() as u128; let effective_gas_price = evm.ctx.tx().effective_gas_price(basefee); - let refunded = gas.refunded() as u64; + let refunded = gas.refunded().max(0) as u64; let reimburse_eth = U256::from( effective_gas_price.saturating_mul(gas.remaining().saturating_add(refunded) as u128), ); @@ -399,11 +402,11 @@ where // This ensures the same price_ratio/scale is used for both deduction and reimbursement. // The cache is kept populated (not taken) so the block executor's receipt builder // can also read it without re-querying the DB. - let token_fee_info = evm.cached_token_fee_info.ok_or( - MorphInvalidTransaction::TokenTransferFailed { - reason: "cached_token_fee_info not set by validate_and_deduct_token_fee".into(), - }, - )?; + let token_fee_info = + evm.cached_token_fee_info + .ok_or(MorphInvalidTransaction::TokenTransferFailed { + reason: "cached_token_fee_info not set by validate_and_deduct_token_fee".into(), + })?; // Calculate token amount required for total fee let token_amount_required = token_fee_info.eth_to_token_amount(reimburse_eth); @@ -411,18 +414,20 @@ where // Get mutable access to journal components let journal = evm.ctx().journal_mut(); - if let Some(balance_slot) = token_fee_info.balance_slot { - // Transfer with token slot. - let _ = transfer_erc20_with_slot( + // Attempt token refund. Matches go-ethereum's refundGas() which silently logs + // and continues on failure: "Continue execution even if refund fails - refund + // should not cause transaction to fail" (state_transition.go:698). + let refund_result = if let Some(balance_slot) = token_fee_info.balance_slot { + transfer_erc20_with_slot( journal, beneficiary, caller, token_fee_info.token_address, token_amount_required, balance_slot, - )?; + ) + .map(|_| ()) } else { - // Transfer with evm call (from=beneficiary, balance not pre-fetched). transfer_erc20_with_evm( evm, beneficiary, @@ -430,8 +435,18 @@ where token_fee_info.token_address, token_amount_required, None, - )?; + ) + }; + + if let Err(err) = refund_result { + tracing::error!( + target: "morph::evm", + token_id = ?evm.ctx_ref().tx().fee_token_id, + %err, + "failed to refund alt token gas, continuing execution" + ); } + Ok(()) } @@ -668,11 +683,12 @@ where // matching go-ethereum's big.Int Add which is unbounded) let to_storage_slot = compute_mapping_slot_for_address(token_balance_slot, to); let balance = journal.sload(token, to_storage_slot)?; - let new_to_balance = balance.checked_add(token_amount).ok_or( - MorphInvalidTransaction::TokenTransferFailed { - reason: "recipient token balance overflow".into(), - }, - )?; + let new_to_balance = + balance + .checked_add(token_amount) + .ok_or(MorphInvalidTransaction::TokenTransferFailed { + reason: "recipient token balance overflow".into(), + })?; journal.sstore(token, to_storage_slot, new_to_balance)?; Ok((from_storage_slot, to_storage_slot)) } @@ -752,8 +768,9 @@ where // ERC20 transfer subtracts then adds the same amount to the same account. // Only check the balance decrease when sender and recipient are different. if from != to { - let expected_balance = - from_balance_before.checked_sub(token_amount).ok_or_else(|| { + let expected_balance = from_balance_before + .checked_sub(token_amount) + .ok_or_else(|| { EVMError::Transaction(MorphInvalidTransaction::TokenTransferFailed { reason: format!( "sender balance {from_balance_before} less than token amount {token_amount}" diff --git a/crates/revm/src/precompiles.rs b/crates/revm/src/precompiles.rs index 6e43ab0..0dc8b12 100644 --- a/crates/revm/src/precompiles.rs +++ b/crates/revm/src/precompiles.rs @@ -429,7 +429,10 @@ mod tests { input[63] = 32; // exp_len = 32 input[95] = 32; // mod_len = 32 let result = modexp.execute(&input, 100_000); - assert!(result.is_err(), "modexp with base_len=33 should be rejected"); + assert!( + result.is_err(), + "modexp with base_len=33 should be rejected" + ); // base_len=32, exp_len=32, mod_len=32 — should succeed input[31] = 32; @@ -481,7 +484,10 @@ mod tests { let input = vec![0u8; 4 * 192]; let result = pairing.execute(&input, 1_000_000); // Zero-input pairing is valid and returns true - assert!(result.is_ok(), "pairing with 4 pairs should not be rejected for size"); + assert!( + result.is_ok(), + "pairing with 4 pairs should not be rejected for size" + ); } #[test] @@ -560,7 +566,10 @@ mod tests { assert!(morph203_p.contains(&addresses::RIPEMD160)); assert!(morph203_p.contains(&addresses::BLAKE2F)); assert!(!morph203_p.contains(&addresses::P256_VERIFY)); - assert_eq!(morph203_p.precompiles().len(), viridian_p.precompiles().len()); + assert_eq!( + morph203_p.precompiles().len(), + viridian_p.precompiles().len() + ); // Emerald: full set with BLS12-381 + P256verify, no KZG assert!(emerald_p.contains(&addresses::P256_VERIFY)); diff --git a/crates/revm/src/tx.rs b/crates/revm/src/tx.rs index f4e9010..4f3b9b6 100644 --- a/crates/revm/src/tx.rs +++ b/crates/revm/src/tx.rs @@ -9,7 +9,6 @@ use alloy_eips::eip2718::Encodable2718; use alloy_eips::eip2930::AccessList; use alloy_eips::eip7702::RecoveredAuthority; use alloy_primitives::{Address, B256, Bytes, Signature, TxKind, U256}; -use alloy_rlp::Decodable; use morph_primitives::{L1_TX_TYPE_ID, MORPH_TX_TYPE_ID, MorphTxEnvelope, TxMorph}; use reth_evm::{FromRecoveredTx, FromTxWithEncoded, ToTxEnv, TransactionEnv}; use revm::context::{Transaction, TxEnv}; @@ -153,7 +152,7 @@ impl MorphTxEnv { // If version is missing (e.g. from an older transaction type), we fallback to V1 // to safely overestimate the L1 fee, ensuring we don't underprice the transaction. tracing::debug!( - target: "morph::revm", + target: "morph::evm", "MorphTx version not set, falling back to V1 for L1 fee calculation to safely overestimate" ); morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1 @@ -240,11 +239,20 @@ impl MorphTxEnv { fn from_tx_with_rlp_bytes(tx: &MorphTxEnvelope, signer: Address, rlp_bytes: Bytes) -> Self { let tx_type: u8 = tx.tx_type().into(); - // Extract MorphTx fields for TxMorph (type 0x7F) - let morph_tx_info = if tx_type == MORPH_TX_TYPE_ID { - extract_morph_tx_fields_from_rlp(&rlp_bytes) - } else { - None + // Extract MorphTx fields directly from the typed envelope — avoids an + // unnecessary encode→decode RLP roundtrip that the old helper performed. + let morph_tx_info = match tx { + MorphTxEnvelope::Morph(signed) => { + let morph = signed.tx(); + Some(DecodedMorphTxFields { + version: morph.version, + fee_token_id: morph.fee_token_id, + fee_limit: morph.fee_limit, + reference: morph.reference, + memo: morph.memo.clone(), + }) + } + _ => None, }; // Build TxEnv from the transaction @@ -309,28 +317,6 @@ struct DecodedMorphTxFields { memo: Option, } -/// Extract all MorphTx fields from RLP-encoded TxMorph bytes. -/// -/// The bytes should be EIP-2718 encoded (type byte + RLP payload). -/// Returns None if decoding fails. -fn extract_morph_tx_fields_from_rlp(rlp_bytes: &Bytes) -> Option { - if rlp_bytes.is_empty() { - return None; - } - - // Skip the type byte (0x7F) and decode the TxMorph - let payload = &rlp_bytes[1..]; - TxMorph::decode(&mut &payload[..]) - .map(|tx| DecodedMorphTxFields { - version: tx.version, - fee_token_id: tx.fee_token_id, - fee_limit: tx.fee_limit, - reference: tx.reference, - memo: tx.memo, - }) - .ok() -} - impl Deref for MorphTxEnv { type Target = TxEnv; diff --git a/crates/rpc/src/eth/transaction.rs b/crates/rpc/src/eth/transaction.rs index 5599089..650fe15 100644 --- a/crates/rpc/src/eth/transaction.rs +++ b/crates/rpc/src/eth/transaction.rs @@ -38,7 +38,7 @@ impl FromConsensusTx for MorphRpcTransaction { let fee_token_id = tx.fee_token_id().map(U64::from); let fee_limit = tx.fee_limit(); let reference = tx.reference(); - let memo = tx.memo(); + let memo = tx.memo().cloned(); let effective_gas_price = tx_info.base_fee.map(|base_fee| { tx.effective_tip_per_gas(base_fee) diff --git a/crates/txpool/src/maintain.rs b/crates/txpool/src/maintain.rs index 1e43c38..6fa1163 100644 --- a/crates/txpool/src/maintain.rs +++ b/crates/txpool/src/maintain.rs @@ -21,13 +21,12 @@ //! and `demoteUnexecutables` (tx_pool.go), but implemented as a separate //! maintenance task since we cannot modify reth's internal pool logic. +use crate::MorphPooledTransaction; use alloy_consensus::Transaction; -use alloy_eips::eip2718::Encodable2718; use alloy_consensus::Typed2718; use alloy_primitives::{Address, TxHash, U256}; use futures::StreamExt; use morph_chainspec::hardfork::MorphHardforks; -use morph_primitives::MorphTxEnvelope; use morph_revm::L1BlockInfo; use reth_chainspec::ChainSpecProvider; use reth_primitives_traits::AlloyBlockHeader; @@ -35,8 +34,8 @@ use reth_provider::CanonStateSubscriptions; use reth_revm::Database; use reth_revm::database::StateProviderDatabase; use reth_storage_api::StateProviderFactory; -use reth_transaction_pool::{EthPoolTransaction, PoolTransaction, TransactionPool}; -use std::collections::{HashMap, HashSet}; +use reth_transaction_pool::{PoolTransaction, TransactionPool}; +use std::collections::HashMap; /// Sender-level rolling affordability budget used during maintenance revalidation. #[derive(Debug, Clone, Default)] @@ -114,8 +113,7 @@ fn consume_token_budget( /// pub async fn maintain_morph_pool(pool: Pool, client: Client) where - Pool: TransactionPool + Clone, - Pool::Transaction: EthPoolTransaction, + Pool: TransactionPool + Clone, Client: ChainSpecProvider + StateProviderFactory + CanonStateSubscriptions @@ -124,12 +122,12 @@ where { let mut chain_events = client.canonical_state_stream(); - tracing::info!(target: "morph_txpool::maintain", "Starting MorphTx maintenance task"); + tracing::info!(target: "morph::txpool::maintain", "Starting MorphTx maintenance task"); loop { // Wait for the next canonical state change let Some(event) = chain_events.next().await else { - tracing::debug!(target: "morph_txpool::maintain", "Chain event stream ended"); + tracing::debug!(target: "morph::txpool::maintain", "Chain event stream ended"); break; }; @@ -138,7 +136,7 @@ where let block_timestamp = new_tip.timestamp(); tracing::trace!( - target: "morph_txpool::maintain", + target: "morph::txpool::maintain", block_number, "Processing new block for MorphTx validation" ); @@ -166,7 +164,7 @@ where Ok(provider) => provider, Err(err) => { tracing::warn!( - target: "morph_txpool::maintain", + target: "morph::txpool::maintain", %err, "Failed to get state provider for MorphTx revalidation" ); @@ -181,7 +179,7 @@ where Ok(info) => info, Err(err) => { tracing::warn!( - target: "morph_txpool::maintain", + target: "morph::txpool::maintain", ?err, "Failed to fetch L1 block info for MorphTx revalidation" ); @@ -190,7 +188,7 @@ where }; tracing::trace!( - target: "morph_txpool::maintain", + target: "morph::txpool::maintain", count = morph_txs.len(), "Revalidating MorphTx transactions" ); @@ -203,11 +201,10 @@ where } // Revalidate each sender's MorphTx set and collect invalid ones - let mut to_remove: HashSet = HashSet::new(); + let mut to_remove: Vec = Vec::new(); for (sender, mut sender_txs) in txs_by_sender { - sender_txs - .sort_by_key(|pooled_tx| pooled_tx.transaction.nonce()); + sender_txs.sort_by_key(|pooled_tx| pooled_tx.transaction.nonce()); // Initialize sender ETH budget once. let eth_balance = match db.basic(sender) { @@ -215,7 +212,7 @@ where Ok(None) => U256::ZERO, Err(err) => { tracing::warn!( - target: "morph_txpool::maintain", + target: "morph::txpool::maintain", ?sender, ?err, "Failed to get account balance" @@ -231,16 +228,15 @@ where for pooled_tx in sender_txs { let tx = &pooled_tx.transaction; - let consensus_tx = tx.clone_into_consensus(); + // Access the consensus tx by reference (via Deref chain) instead of + // cloning. Use the pool tx's cached EIP-2718 encoding for L1 fee. + let consensus_tx = tx.transaction(); - // Calculate L1 data fee for this transaction. - let mut encoded = Vec::with_capacity(consensus_tx.encode_2718_len()); - consensus_tx.encode_2718(&mut encoded); - let l1_data_fee = l1_block_info.calculate_tx_l1_cost(&encoded, hardfork); + let l1_data_fee = l1_block_info.calculate_tx_l1_cost(tx.encoded_2718(), hardfork); // Use shared validation logic first with current sender ETH budget. let input = crate::MorphTxValidationInput { - consensus_tx: &consensus_tx, + consensus_tx, sender, eth_balance: budget.eth_balance, l1_data_fee, @@ -251,13 +247,13 @@ where Ok(v) => v, Err(err) => { tracing::debug!( - target: "morph_txpool::maintain", + target: "morph::txpool::maintain", tx_hash = ?tx.hash(), ?sender, ?err, "Removing MorphTx: validation failed" ); - to_remove.insert(*tx.hash()); + to_remove.push(*tx.hash()); break; } }; @@ -287,7 +283,7 @@ where }; if !affordable { tracing::debug!( - target: "morph_txpool::maintain", + target: "morph::txpool::maintain", tx_hash = ?tx.hash(), ?sender, uses_token_fee = validation.uses_token_fee, @@ -295,7 +291,7 @@ where required_token_amount = ?validation.required_token_amount, "Removing MorphTx: insufficient cumulative sender budget" ); - to_remove.insert(*tx.hash()); + to_remove.push(*tx.hash()); break; } } @@ -304,10 +300,9 @@ where // Remove invalid transactions if !to_remove.is_empty() { let count = to_remove.len(); - let hashes: Vec<_> = to_remove.into_iter().collect(); - pool.remove_transactions(hashes); + pool.remove_transactions(to_remove); tracing::info!( - target: "morph_txpool::maintain", + target: "morph::txpool::maintain", count, block_number, "Removed invalid MorphTx transactions" diff --git a/crates/txpool/src/morph_tx_validation.rs b/crates/txpool/src/morph_tx_validation.rs index f1ae559..1dd9652 100644 --- a/crates/txpool/src/morph_tx_validation.rs +++ b/crates/txpool/src/morph_tx_validation.rs @@ -218,8 +218,11 @@ mod tests { memo: None, input: Default::default(), }; - let envelope = - MorphTxEnvelope::Morph(Signed::new_unchecked(tx, Signature::test_signature(), B256::ZERO)); + let envelope = MorphTxEnvelope::Morph(Signed::new_unchecked( + tx, + Signature::test_signature(), + B256::ZERO, + )); let input = MorphTxValidationInput { consensus_tx: &envelope, sender, @@ -234,8 +237,7 @@ mod tests { assert_eq!( err, MorphTxError::InvalidFormat { - reason: "version 1 MorphTx cannot have FeeLimit when FeeTokenID is 0" - .to_string(), + reason: "version 1 MorphTx cannot have FeeLimit when FeeTokenID is 0".to_string(), } ); } diff --git a/crates/txpool/src/validator.rs b/crates/txpool/src/validator.rs index d11c29c..7e27047 100644 --- a/crates/txpool/src/validator.rs +++ b/crates/txpool/src/validator.rs @@ -185,7 +185,7 @@ where { Ok(provider) => provider, Err(err) => { - tracing::warn!(target: "morph_txpool", %err, "Failed to get state provider for L1 block info update"); + tracing::warn!(target: "morph::txpool", %err, "Failed to get state provider for L1 block info update"); return; } }; @@ -200,7 +200,7 @@ where *self.block_info.l1_block_info.write() = l1_block_info; } Err(err) => { - tracing::warn!(target: "morph_txpool", ?err, "Failed to fetch L1 block info"); + tracing::warn!(target: "morph::txpool", ?err, "Failed to fetch L1 block info"); } } } @@ -240,7 +240,7 @@ where let outcome = self.inner.validate_one(origin, transaction); if outcome.is_invalid() || outcome.is_error() { - tracing::trace!(target: "morph_txpool", ?outcome, "tx pool validation failed"); + tracing::trace!(target: "morph::txpool", ?outcome, "tx pool validation failed"); return outcome; } @@ -383,7 +383,7 @@ where .unwrap_or_default(); tracing::trace!( - target: "morph_txpool", + target: "morph::txpool", fee_token_id = ?consensus_tx.fee_token_id(), fee_limit = ?consensus_tx.fee_limit(), uses_token_fee = result.uses_token_fee, From bae9aa4bbe4929826dbcea8eb7fbd397923a62fa Mon Sep 17 00:00:00 2001 From: panos Date: Sat, 7 Mar 2026 00:02:34 +0800 Subject: [PATCH 06/35] refactor: load precompile contracts once --- crates/evm/src/evm.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/evm/src/evm.rs b/crates/evm/src/evm.rs index 6e4fa65..2d645a2 100644 --- a/crates/evm/src/evm.rs +++ b/crates/evm/src/evm.rs @@ -9,9 +9,7 @@ use alloy_evm::{ }; use alloy_primitives::{Address, Bytes, Log}; use morph_chainspec::hardfork::MorphHardfork; -use morph_revm::{ - MorphHaltReason, MorphInvalidTransaction, MorphPrecompiles, MorphTxEnv, evm::MorphContext, -}; +use morph_revm::{MorphHaltReason, MorphInvalidTransaction, MorphTxEnv, evm::MorphContext}; use reth_revm::MainContext; use std::ops::{Deref, DerefMut}; @@ -66,7 +64,6 @@ pub struct MorphEvm { impl MorphEvm { /// Create a new [`MorphEvm`] instance. pub fn new(db: DB, input: EvmEnv) -> Self { - let spec = input.cfg_env.spec; let ctx = Context::mainnet() .with_db(db) .with_block(input.block_env) @@ -74,12 +71,14 @@ impl MorphEvm { .with_tx(Default::default()) .with_chain(morph_revm::L1BlockInfo::default()); - // Create precompiles for the hardfork and wrap in PrecompilesMap - let morph_precompiles = MorphPrecompiles::new_with_spec(spec); - let precompiles_map = PrecompilesMap::from_static(morph_precompiles.precompiles()); + // Build the inner MorphEvm which creates precompiles once. + // Derive the PrecompilesMap from the inner's precompiles to avoid + // a second MorphPrecompiles::new_with_spec call. + let inner = morph_revm::MorphEvm::new(ctx, NoOpInspector {}); + let precompiles_map = PrecompilesMap::from_static(inner.precompiles.precompiles()); Self { - inner: morph_revm::MorphEvm::new(ctx, NoOpInspector {}), + inner, precompiles_map, inspect: false, } From 71aba4c6c80810afdb9cca54ff03bb3d900fb8d0 Mon Sep 17 00:00:00 2001 From: panos Date: Sat, 7 Mar 2026 08:34:34 +0800 Subject: [PATCH 07/35] fix: align token transfer check with go-eth and clean up txpool removal - Remove from==to skip in fee token transfer validation to match go-ethereum's transferAltTokenByEVM (self-transfers should fail) - Use remove_transactions_and_descendants in txpool maintain to clean up nonce-dependent orphans immediately - Remove unnecessary RECEIPT_CAPACITY_HINT pre-allocation --- crates/evm/src/block/mod.rs | 5 +---- crates/revm/src/handler.rs | 35 +++++++++++++++++------------------ crates/txpool/src/maintain.rs | 6 ++++-- 3 files changed, 22 insertions(+), 24 deletions(-) diff --git a/crates/evm/src/block/mod.rs b/crates/evm/src/block/mod.rs index eb556a3..4c23d25 100644 --- a/crates/evm/src/block/mod.rs +++ b/crates/evm/src/block/mod.rs @@ -80,9 +80,6 @@ where DB: Database, I: Inspector>>, { - /// Default capacity hint for the receipts Vec — avoids reallocations for - /// typical L2 blocks (which usually contain tens to low-hundreds of txs). - const RECEIPT_CAPACITY_HINT: usize = 128; /// Creates a new [`MorphBlockExecutor`]. /// @@ -99,7 +96,7 @@ where evm, spec, receipt_builder, - receipts: Vec::with_capacity(Self::RECEIPT_CAPACITY_HINT), + receipts: Vec::new(), gas_used: 0, hardfork: MorphHardfork::default(), } diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 4634b32..cd11f1f 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -764,27 +764,26 @@ where // Restore the original transaction evm.tx = tx_origin; - // When from == to (self-transfer), the net balance change is zero because the - // ERC20 transfer subtracts then adds the same amount to the same account. - // Only check the balance decrease when sender and recipient are different. - if from != to { - let expected_balance = from_balance_before - .checked_sub(token_amount) - .ok_or_else(|| { - EVMError::Transaction(MorphInvalidTransaction::TokenTransferFailed { - reason: format!( - "sender balance {from_balance_before} less than token amount {token_amount}" - ), - }) - })?; - if from_balance_after != expected_balance { - return Err(MorphInvalidTransaction::TokenTransferFailed { + // Verify sender balance decreased by exactly the transfer amount. + // Matches go-ethereum's transferAltTokenByEVM which always checks this, + // even for self-transfers (from == to), where it would fail because the + // net balance change is zero but the expected decrease is `token_amount`. + let expected_balance = from_balance_before + .checked_sub(token_amount) + .ok_or_else(|| { + EVMError::Transaction(MorphInvalidTransaction::TokenTransferFailed { reason: format!( - "sender balance mismatch: expected {expected_balance}, got {from_balance_after}" + "sender balance {from_balance_before} less than token amount {token_amount}" ), - } - .into()); + }) + })?; + if from_balance_after != expected_balance { + return Err(MorphInvalidTransaction::TokenTransferFailed { + reason: format!( + "sender balance mismatch: expected {expected_balance}, got {from_balance_after}" + ), } + .into()); } Ok(()) diff --git a/crates/txpool/src/maintain.rs b/crates/txpool/src/maintain.rs index 6fa1163..1b469b4 100644 --- a/crates/txpool/src/maintain.rs +++ b/crates/txpool/src/maintain.rs @@ -297,10 +297,12 @@ where } } - // Remove invalid transactions + // Remove invalid transactions and all higher-nonce descendants from the same sender. + // Using remove_transactions_and_descendants ensures that nonce-dependent txs are cleaned + // up immediately rather than becoming orphans that are re-validated every block. if !to_remove.is_empty() { let count = to_remove.len(); - pool.remove_transactions(to_remove); + pool.remove_transactions_and_descendants(to_remove); tracing::info!( target: "morph::txpool::maintain", count, From fe6c146d2721d0c71ccf8ba9f08bae2cbbddd7e9 Mon Sep 17 00:00:00 2001 From: panos Date: Sat, 7 Mar 2026 09:02:51 +0800 Subject: [PATCH 08/35] perf: tune reth persistence threshold to reduce disk I/O contention Set --engine.persistence-threshold 256 and --engine.memory-block-buffer-target 16 to batch MDBX writes and avoid competing with Tendermint's LevelDB fsyncs. --- local-test/reth-start.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/local-test/reth-start.sh b/local-test/reth-start.sh index 186543e..9d04e20 100755 --- a/local-test/reth-start.sh +++ b/local-test/reth-start.sh @@ -40,6 +40,8 @@ args=( --morph.max-tx-payload-bytes "${MORPH_MAX_TX_PAYLOAD_BYTES}" --nat none --engine.legacy-state-root + --engine.persistence-threshold 256 + --engine.memory-block-buffer-target 16 ) # Add optional max-tx-per-block if configured From 64aa68ac9502d3502a8f962a1cba2d53c353b2df Mon Sep 17 00:00:00 2001 From: panos Date: Sat, 7 Mar 2026 09:04:09 +0800 Subject: [PATCH 09/35] refactor: add geth url --- local-test/common.sh | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/local-test/common.sh b/local-test/common.sh index 0777245..5bb7fd4 100755 --- a/local-test/common.sh +++ b/local-test/common.sh @@ -35,10 +35,7 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" : "${RETH_BOOTNODES:=}" : "${MORPH_MAX_TX_PAYLOAD_BYTES:=122880}" : "${MORPH_MAX_TX_PER_BLOCK:=}" -# Keep disabled by default for fair local benchmarks. -# Set MORPH_GETH_RPC_URL explicitly when cross-validation is needed. -: "${MORPH_GETH_RPC_URL:=}" - +: "${MORPH_GETH_RPC_URL:=http://localhost:8546}" check_binary() { local bin_path="$1" local build_hint="$2" From 397c0ff86e2b6a6d7debeec3c6b9cbb6b81449b9 Mon Sep 17 00:00:00 2001 From: panos Date: Sun, 8 Mar 2026 19:35:40 +0800 Subject: [PATCH 10/35] fix: use evm_call for ERC20 token fee to preserve Transfer logs in receipt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ERC20 Transfer event logs were lost during token fee deduction/reimbursement because revm's system_call pipeline drains logs from the journal via execution_result() → take_logs(). This caused receipt root mismatch at blocks with MorphTx (0x7F) using ERC20 token gas payment. Replace system_call_one_with_caller with evm_call() that only runs the handler's execution() phase, keeping logs in the journal naturally — matching go-ethereum's evm.Call() semantics. Also remove dead code: evm.logs field, take_logs(), take_revert_logs(). --- crates/evm/src/block/mod.rs | 1 - crates/evm/src/evm.rs | 14 +--- crates/revm/src/evm.rs | 12 +--- crates/revm/src/handler.rs | 129 ++++++++++++++++++++++++++---------- 4 files changed, 96 insertions(+), 60 deletions(-) diff --git a/crates/evm/src/block/mod.rs b/crates/evm/src/block/mod.rs index 4c23d25..71c1c79 100644 --- a/crates/evm/src/block/mod.rs +++ b/crates/evm/src/block/mod.rs @@ -80,7 +80,6 @@ where DB: Database, I: Inspector>>, { - /// Creates a new [`MorphBlockExecutor`]. /// /// # Arguments diff --git a/crates/evm/src/evm.rs b/crates/evm/src/evm.rs index 2d645a2..14b5f80 100644 --- a/crates/evm/src/evm.rs +++ b/crates/evm/src/evm.rs @@ -7,7 +7,7 @@ use alloy_evm::{ inspector::NoOpInspector, }, }; -use alloy_primitives::{Address, Bytes, Log}; +use alloy_primitives::{Address, Bytes}; use morph_chainspec::hardfork::MorphHardfork; use morph_revm::{MorphHaltReason, MorphInvalidTransaction, MorphTxEnv, evm::MorphContext}; use reth_revm::MainContext; @@ -107,18 +107,6 @@ impl MorphEvm { /// Takes the inner EVM's revert logs. /// - /// Morph requires logs from reverted transactions to be included in receipts - /// (matching go-ethereum's `morph-l2/go-ethereum` behavior). Standard revm - /// discards logs on revert, so we capture them from the EVM's internal log - /// buffer before they are cleared. - /// - /// This workaround is needed until revm natively supports emitting logs for - /// reverted transactions. Tracked in: - /// - pub fn take_revert_logs(&mut self) -> Vec { - std::mem::take(&mut self.inner.logs) - } - /// Returns the cached token fee info from the handler's validation phase. /// /// Avoids redundant DB reads when the block executor needs token fee diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index 6fb8e11..aa94eef 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -3,7 +3,7 @@ use crate::{ token_fee::TokenFeeInfo, }; use alloy_evm::Database; -use alloy_primitives::{Log, U256, keccak256}; +use alloy_primitives::{U256, keccak256}; use morph_chainspec::hardfork::MorphHardfork; use revm::{ Context, Inspector, @@ -173,8 +173,6 @@ pub struct MorphEvm { MorphPrecompiles, EthFrame, >, - /// Preserved logs from the last transaction - pub logs: Vec, /// Cached token fee info from the validation/deduction phase. /// Ensures consistent price_ratio/scale between deduct and reimburse, /// matching go-ethereum's `st.feeRate`/`st.tokenScale` caching pattern. @@ -224,7 +222,6 @@ impl MorphEvm { }) } - /// Inner helper function to create a new Morph EVM with empty logs. #[inline] #[expect(clippy::type_complexity)] fn new_inner( @@ -238,7 +235,6 @@ impl MorphEvm { ) -> Self { Self { inner, - logs: Vec::new(), cached_token_fee_info: None, cached_l1_data_fee: U256::ZERO, } @@ -261,12 +257,6 @@ impl MorphEvm { self.inner.into_inspector() } - /// Take logs from the EVM. - #[inline] - pub fn take_logs(&mut self) -> Vec { - std::mem::take(&mut self.logs) - } - /// Returns the cached token fee info set during handler validation. /// /// The cache is populated by `validate_and_deduct_token_fee` and persists diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index cd11f1f..60077b5 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -2,7 +2,7 @@ use alloy_primitives::{Address, Bytes, U256}; use revm::{ - ExecuteEvm, SystemCallEvm, + ExecuteEvm, context::{ Cfg, ContextTr, JournalTr, Transaction, result::{EVMError, ExecutionResult, InvalidTransaction}, @@ -14,10 +14,10 @@ use revm::{ }; use crate::{ - MorphEvm, MorphInvalidTransaction, + MorphEvm, MorphInvalidTransaction, MorphTxEnv, error::MorphHaltReason, evm::MorphContext, - token_fee::{TokenFeeInfo, compute_mapping_slot_for_address, query_erc20_balance}, + token_fee::{TokenFeeInfo, compute_mapping_slot_for_address, encode_balance_of_calldata}, tx::MorphTxExt, }; @@ -74,11 +74,6 @@ where evm: &mut Self::Evm, result: <::Frame as FrameTr>::FrameResult, ) -> Result, Self::Error> { - evm.logs.clear(); - if !result.instruction_result().is_ok() { - evm.logs = evm.journal_mut().take_logs(); - } - MainnetHandler::default() .execution_result(evm, result) .map(|result| result.map_haltreason(Into::into)) @@ -626,6 +621,10 @@ where Some(token_fee_info.balance), )?; + // Preserve logs emitted during the ERC20 transfer (e.g., Transfer events). + // go-ethereum keeps these in the receipt; finalize() would drop them. + let saved_logs = std::mem::take(&mut evm.ctx_mut().journal_mut().logs); + // State changes should be marked cold to avoid warm access in the main tx execution. let mut state = evm.finalize(); state.iter_mut().for_each(|(_, acc)| { @@ -635,6 +634,9 @@ where .for_each(|(_, slot)| slot.mark_cold()); }); evm.ctx_mut().journal_mut().state.extend(state); + + // Restore the logs so they appear in the transaction receipt. + evm.ctx_mut().journal_mut().logs = saved_logs; } // Bump nonce for calls (CREATE nonce is bumped in make_create_frame) @@ -695,11 +697,70 @@ where /// Transfers ERC20 tokens by executing a `transfer(address,uint256)` call via the EVM. /// +/// Gas limit for internal EVM calls (ERC20 transfer, balanceOf). +const EVM_CALL_GAS_LIMIT: u64 = 200_000; + +/// Execute an internal EVM call, matching go-ethereum's `evm.Call()` semantics. +/// +/// Unlike `system_call_one_with_caller`, this only runs the handler's `execution()` +/// phase — NOT `execution_result()`. This means: +/// - Logs emitted during the call (e.g., ERC20 Transfer events) remain in the journal +/// - State changes remain in the journal +/// +/// **Caller is responsible for saving/restoring `evm.tx` if needed.** +fn evm_call( + evm: &mut MorphEvm, + caller: Address, + target: Address, + calldata: Bytes, +) -> Result> +where + DB: alloy_evm::Database, +{ + evm.tx = MorphTxEnv { + inner: revm::context::TxEnv { + caller, + kind: target.into(), + data: calldata, + gas_limit: EVM_CALL_GAS_LIMIT, + ..Default::default() + }, + ..Default::default() + }; + let mut h = MorphEvmHandler::::new(); + h.execution(evm, &InitialAndFloorGas::new(0, 0)) +} + +/// Query ERC20 `balanceOf(address)` via an internal EVM call. +/// +/// Uses [`evm_call`] so that journal logs are not drained. +fn evm_call_balance_of(evm: &mut MorphEvm, token: Address, account: Address) -> U256 +where + DB: alloy_evm::Database, +{ + let calldata = encode_balance_of_calldata(account); + match evm_call(evm, Address::ZERO, token, calldata) { + Ok(ref result) if result.instruction_result().is_ok() => { + let output = &result.interpreter_result().output; + if output.len() >= 32 { + U256::from_be_slice(&output[..32]) + } else { + U256::ZERO + } + } + _ => U256::ZERO, + } +} + /// Matches go-ethereum's `transferAltTokenByEVM` validation: /// 1. Checks EVM call succeeded (no revert) /// 2. Validates ABI-decoded bool return value (supports old tokens with no return data) /// 3. Verifies sender balance changed by the expected amount /// +/// Uses [`evm_call`] instead of `system_call_one_with_caller` so that event logs +/// (e.g., ERC20 Transfer) naturally remain in the journal and appear in the +/// transaction receipt, matching go-ethereum's `evm.Call()` behavior. +/// /// `from_balance_before` is the sender's balance before the transfer. If `None`, /// the balance is queried via EVM call (matching go-eth's nil `userBalanceBefore`). fn transfer_erc20_with_evm( @@ -720,35 +781,12 @@ where // Read sender balance before transfer if not provided let from_balance_before = match from_balance_before { Some(b) => b, - None => query_erc20_balance(evm, token_address, from).unwrap_or(U256::ZERO), + None => evm_call_balance_of(evm, token_address, from), }; let calldata = build_transfer_calldata(to, token_amount); - match evm.system_call_one_with_caller(from, token_address, calldata) { - Ok(result) => { - if !result.is_success() { - evm.tx = tx_origin; - return Err(MorphInvalidTransaction::TokenTransferFailed { - reason: format!("{result:?}"), - } - .into()); - } - - // Validate ABI bool return value, matching go-ethereum behavior: - // - No return data: accepted (old tokens that don't return bool) - // - 32+ bytes with last byte == 1: accepted (standard ERC20) - // - Otherwise: rejected - if let Some(output) = result.output() - && !output.is_empty() - && (output.len() < 32 || output[31] != 1) - { - evm.tx = tx_origin; - return Err(MorphInvalidTransaction::TokenTransferFailed { - reason: "alt token transfer returned failure".to_string(), - } - .into()); - } - } + let frame_result = match evm_call(evm, from, token_address, calldata) { + Ok(result) => result, Err(e) => { evm.tx = tx_origin; return Err(MorphInvalidTransaction::TokenTransferFailed { @@ -758,8 +796,29 @@ where } }; + if !frame_result.instruction_result().is_ok() { + evm.tx = tx_origin; + return Err(MorphInvalidTransaction::TokenTransferFailed { + reason: format!("{:?}", frame_result.interpreter_result()), + } + .into()); + } + + // Validate ABI bool return value, matching go-ethereum behavior: + // - No return data: accepted (old tokens that don't return bool) + // - 32+ bytes with last byte == 1: accepted (standard ERC20) + // - Otherwise: rejected + let output = &frame_result.interpreter_result().output; + if !output.is_empty() && (output.len() < 32 || output[31] != 1) { + evm.tx = tx_origin; + return Err(MorphInvalidTransaction::TokenTransferFailed { + reason: "alt token transfer returned failure".to_string(), + } + .into()); + } + // Verify sender balance changed by the expected amount, matching go-ethereum. - let from_balance_after = query_erc20_balance(evm, token_address, from).unwrap_or(U256::ZERO); + let from_balance_after = evm_call_balance_of(evm, token_address, from); // Restore the original transaction evm.tx = tx_origin; From 8b04fea7f5bb13818f6f7bba3d2f391942b0eb97 Mon Sep 17 00:00:00 2001 From: panos Date: Sun, 8 Mar 2026 22:21:33 +0800 Subject: [PATCH 11/35] fix: decouple token fee logs from handler pipeline to survive tx revert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit go-ethereum's StateDB.logs is independent of the state snapshot/revert mechanism, so Transfer events from buyAltTokenGas() and refundAltTokenGas() always appear in receipts regardless of whether the main tx reverts. revm's ExecutionResult::Revert has no logs field — the handler pipeline's take_logs() discards all logs on revert. Previously, fee Transfer logs were kept in the journal and restored via a saved_logs save/restore hack, which only worked for the success path. This commit caches fee Transfer logs in MorphEvm (pre_fee_logs for deduction, post_fee_logs for reimbursement) separately from the journal. The receipt builder merges them in chronological order: [deduct Transfer] + [main tx logs] + [refund Transfer] This handles both success and revert uniformly without overriding execution_result, and removes the saved_logs save/restore workaround. --- crates/evm/src/block/mod.rs | 8 +++++++- crates/evm/src/block/receipt.rs | 22 +++++++++++++++++++--- crates/evm/src/evm.rs | 14 ++++++++++++-- crates/revm/src/evm.rs | 24 ++++++++++++++++++++++++ crates/revm/src/handler.rs | 27 +++++++++++++++++++-------- 5 files changed, 81 insertions(+), 14 deletions(-) diff --git a/crates/evm/src/block/mod.rs b/crates/evm/src/block/mod.rs index 71c1c79..d69ec58 100644 --- a/crates/evm/src/block/mod.rs +++ b/crates/evm/src/block/mod.rs @@ -343,13 +343,19 @@ where let gas_used = result.gas_used(); self.gas_used += gas_used; - // Build receipt + // Build receipt. + // Fee Transfer logs are cached separately by the handler (pre_fee_logs / + // post_fee_logs) so they survive main tx revert. + let pre_fee_logs = self.evm.take_pre_fee_logs(); + let post_fee_logs = self.evm.take_post_fee_logs(); let ctx: MorphReceiptBuilderCtx<'_, Self::Evm> = MorphReceiptBuilderCtx { tx: tx.tx(), result, cumulative_gas_used: self.gas_used, l1_fee, morph_tx_fields, + pre_fee_logs, + post_fee_logs, }; self.receipts.push(self.receipt_builder.build_receipt(ctx)); diff --git a/crates/evm/src/block/receipt.rs b/crates/evm/src/block/receipt.rs index 56ec664..24b21a5 100644 --- a/crates/evm/src/block/receipt.rs +++ b/crates/evm/src/block/receipt.rs @@ -28,7 +28,7 @@ use alloy_consensus::Receipt; use alloy_consensus::transaction::TxHashRef; use alloy_evm::Evm; -use alloy_primitives::{B256, Bytes, U256}; +use alloy_primitives::{B256, Bytes, Log, U256}; use morph_primitives::{MorphReceipt, MorphTransactionReceipt, MorphTxEnvelope, MorphTxType}; use revm::context::result::ExecutionResult; use tracing::warn; @@ -57,6 +57,11 @@ pub(crate) struct MorphReceiptBuilderCtx<'a, E: Evm> { pub l1_fee: U256, /// MorphTx-specific fields (token fee info, version, reference, memo) pub morph_tx_fields: Option, + /// Transfer event logs from token fee deduction (before main tx execution). + /// Managed separately from the handler pipeline to survive main tx revert. + pub pre_fee_logs: Vec, + /// Transfer event logs from token fee reimbursement (after main tx execution). + pub post_fee_logs: Vec, } /// MorphTx (0x7F) specific fields for receipts. @@ -148,12 +153,23 @@ impl MorphReceiptBuilder for DefaultMorphReceiptBuilder { cumulative_gas_used, l1_fee, morph_tx_fields, + pre_fee_logs, + post_fee_logs, } = ctx; + // Assemble logs in chronological order matching go-ethereum: + // [deduct Transfer] + [main tx logs] + [refund Transfer] + // Fee logs are cached separately from the journal so they survive + // main tx revert (revm's ExecutionResult::Revert carries no logs). + let is_success = result.is_success(); + let mut logs = pre_fee_logs; + logs.extend(result.into_logs()); + logs.extend(post_fee_logs); + let inner = Receipt { - status: result.is_success().into(), + status: is_success.into(), cumulative_gas_used, - logs: result.into_logs(), + logs, }; // Create the appropriate receipt variant based on transaction type diff --git a/crates/evm/src/evm.rs b/crates/evm/src/evm.rs index 14b5f80..04ee14f 100644 --- a/crates/evm/src/evm.rs +++ b/crates/evm/src/evm.rs @@ -105,8 +105,6 @@ impl MorphEvm { } } - /// Takes the inner EVM's revert logs. - /// /// Returns the cached token fee info from the handler's validation phase. /// /// Avoids redundant DB reads when the block executor needs token fee @@ -124,6 +122,18 @@ impl MorphEvm { pub fn cached_l1_data_fee(&self) -> alloy_primitives::U256 { self.inner.cached_l1_data_fee() } + + /// Takes the cached pre-execution fee logs (token fee deduction Transfer events). + #[inline] + pub fn take_pre_fee_logs(&mut self) -> Vec { + self.inner.take_pre_fee_logs() + } + + /// Takes the cached post-execution fee logs (token fee reimbursement Transfer events). + #[inline] + pub fn take_post_fee_logs(&mut self) -> Vec { + self.inner.take_post_fee_logs() + } } impl Deref for MorphEvm diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index aa94eef..98b9fa6 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -182,6 +182,16 @@ pub struct MorphEvm { /// receipt-building path (the handler already has the encoded bytes via /// `MorphTxEnv.rlp_bytes`). pub(crate) cached_l1_data_fee: U256, + /// Transfer event logs from token fee deduction (pre-execution phase). + /// + /// In go-ethereum, `buyAltTokenGas()` emits Transfer events into `StateDB.logs` + /// which is independent of the state snapshot/revert mechanism — logs survive + /// regardless of main tx result. revm's `ExecutionResult::Revert` has no `logs` + /// field, so we cache fee-related logs separately from the journal and merge + /// them into the receipt in the block executor. + pub(crate) pre_fee_logs: Vec, + /// Transfer event logs from token fee reimbursement (post-execution phase). + pub(crate) post_fee_logs: Vec, } impl MorphEvm { @@ -237,6 +247,8 @@ impl MorphEvm { inner, cached_token_fee_info: None, cached_l1_data_fee: U256::ZERO, + pre_fee_logs: Vec::new(), + post_fee_logs: Vec::new(), } } } @@ -276,6 +288,18 @@ impl MorphEvm { pub fn cached_l1_data_fee(&self) -> U256 { self.cached_l1_data_fee } + + /// Takes the cached pre-execution fee logs (token fee deduction Transfer events). + #[inline] + pub fn take_pre_fee_logs(&mut self) -> Vec { + std::mem::take(&mut self.pre_fee_logs) + } + + /// Takes the cached post-execution fee logs (token fee reimbursement Transfer events). + #[inline] + pub fn take_post_fee_logs(&mut self) -> Vec { + std::mem::take(&mut self.post_fee_logs) + } } impl EvmTr for MorphEvm diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 60077b5..8cd29ff 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -92,6 +92,8 @@ where // Reset per-transaction caches from the previous iteration. evm.cached_l1_data_fee = U256::ZERO; evm.cached_token_fee_info = None; + evm.pre_fee_logs.clear(); + evm.post_fee_logs.clear(); let (_, tx, _, journal, _, _) = evm.ctx().all_mut(); @@ -423,14 +425,21 @@ where ) .map(|_| ()) } else { - transfer_erc20_with_evm( + // Cache refund Transfer logs separately, matching the pre_fee_logs + // pattern from validate_and_deduct_token_fee. + let log_count_before = evm.ctx_mut().journal_mut().logs.len(); + let result = transfer_erc20_with_evm( evm, beneficiary, caller, token_fee_info.token_address, token_amount_required, None, - ) + ); + let refund_logs: Vec<_> = + evm.ctx_mut().journal_mut().logs.drain(log_count_before..).collect(); + evm.post_fee_logs.extend(refund_logs); + result }; if let Err(err) = refund_result { @@ -621,11 +630,16 @@ where Some(token_fee_info.balance), )?; - // Preserve logs emitted during the ERC20 transfer (e.g., Transfer events). - // go-ethereum keeps these in the receipt; finalize() would drop them. - let saved_logs = std::mem::take(&mut evm.ctx_mut().journal_mut().logs); + // Cache fee Transfer logs separately from the journal. + // + // go-ethereum's StateDB.logs is independent of the state snapshot/revert + // mechanism — fee logs survive regardless of main tx result. revm's + // ExecutionResult::Revert has no logs field, so we keep fee logs out of + // the handler pipeline entirely and merge them in the receipt builder. + evm.pre_fee_logs = std::mem::take(&mut evm.ctx_mut().journal_mut().logs); // State changes should be marked cold to avoid warm access in the main tx execution. + // finalize() clears journal state (including logs, which we already took above). let mut state = evm.finalize(); state.iter_mut().for_each(|(_, acc)| { acc.mark_cold(); @@ -634,9 +648,6 @@ where .for_each(|(_, slot)| slot.mark_cold()); }); evm.ctx_mut().journal_mut().state.extend(state); - - // Restore the logs so they appear in the transaction receipt. - evm.ctx_mut().journal_mut().logs = saved_logs; } // Bump nonce for calls (CREATE nonce is bumped in make_create_frame) From 58b3770a31207fb00ec1b8b65eb03e8761959913 Mon Sep 17 00:00:00 2001 From: panos Date: Sun, 8 Mar 2026 22:39:28 +0800 Subject: [PATCH 12/35] fix: discard logs from balanceOf evm_call to match go-eth StaticCall semantic go-ethereum uses evm.StaticCall() for balanceOf queries which cannot emit events. Our evm_call() runs a regular CALL, so a non-standard token could emit logs that leak into pre_fee_logs or post_fee_logs. Truncate journal logs after evm_call_balance_of to match the read-only semantic. Also fix a misplaced doc comment on EVM_CALL_GAS_LIMIT. --- crates/revm/src/handler.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 8cd29ff..545af2e 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -706,8 +706,6 @@ where Ok((from_storage_slot, to_storage_slot)) } -/// Transfers ERC20 tokens by executing a `transfer(address,uint256)` call via the EVM. -/// /// Gas limit for internal EVM calls (ERC20 transfer, balanceOf). const EVM_CALL_GAS_LIMIT: u64 = 200_000; @@ -749,8 +747,14 @@ fn evm_call_balance_of(evm: &mut MorphEvm, token: Address, account where DB: alloy_evm::Database, { + // Record log count so we can discard any logs emitted during the call. + // go-ethereum uses evm.StaticCall() for balanceOf which cannot emit events; + // we truncate to match that read-only semantic. + let log_count_before = evm.ctx_mut().journal_mut().logs.len(); let calldata = encode_balance_of_calldata(account); - match evm_call(evm, Address::ZERO, token, calldata) { + let result = evm_call(evm, Address::ZERO, token, calldata); + evm.ctx_mut().journal_mut().logs.truncate(log_count_before); + match result { Ok(ref result) if result.instruction_result().is_ok() => { let output = &result.interpreter_result().output; if output.len() >= 32 { From ff5bda51872ae22c46d49719fa5f357e047423d0 Mon Sep 17 00:00:00 2001 From: panos Date: Sun, 8 Mar 2026 22:47:32 +0800 Subject: [PATCH 13/35] refactor: minor efficiency improvements in token fee log handling - Pre-allocate receipt log Vec with exact capacity instead of growing through repeated extend calls - Use direct assignment for post_fee_logs instead of extend (the Vec is always empty at that point) --- crates/evm/src/block/receipt.rs | 6 ++++-- crates/revm/src/handler.rs | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/evm/src/block/receipt.rs b/crates/evm/src/block/receipt.rs index 24b21a5..e66ff45 100644 --- a/crates/evm/src/block/receipt.rs +++ b/crates/evm/src/block/receipt.rs @@ -162,8 +162,10 @@ impl MorphReceiptBuilder for DefaultMorphReceiptBuilder { // Fee logs are cached separately from the journal so they survive // main tx revert (revm's ExecutionResult::Revert carries no logs). let is_success = result.is_success(); - let mut logs = pre_fee_logs; - logs.extend(result.into_logs()); + let main_logs = result.into_logs(); + let mut logs = Vec::with_capacity(pre_fee_logs.len() + main_logs.len() + post_fee_logs.len()); + logs.extend(pre_fee_logs); + logs.extend(main_logs); logs.extend(post_fee_logs); let inner = Receipt { diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 545af2e..b8ec84f 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -438,7 +438,7 @@ where ); let refund_logs: Vec<_> = evm.ctx_mut().journal_mut().logs.drain(log_count_before..).collect(); - evm.post_fee_logs.extend(refund_logs); + evm.post_fee_logs = refund_logs; result }; From 14f962bfc99fdad8183a24ae52c37aa5f7747ccd Mon Sep 17 00:00:00 2001 From: panos Date: Sun, 8 Mar 2026 23:02:07 +0800 Subject: [PATCH 14/35] refactor: deduplicate MorphTx validation in consensus layer Replace inline version/field validation in validate_morph_txs() with a call to TxMorph::validate(), which already implements the same checks (version constraints, memo length, fee_limit rules, gas price ordering). Only the hardfork-gated V1 rejection remains in the consensus layer, as it requires chain_spec context unavailable in the primitive layer. --- crates/consensus/src/validation.rs | 94 +++++++----------------------- 1 file changed, 21 insertions(+), 73 deletions(-) diff --git a/crates/consensus/src/validation.rs b/crates/consensus/src/validation.rs index 49fc833..cdb388d 100644 --- a/crates/consensus/src/validation.rs +++ b/crates/consensus/src/validation.rs @@ -41,7 +41,7 @@ use alloy_primitives::{B256, Bloom}; use morph_chainspec::{MorphChainSpec, MorphHardforks}; use morph_primitives::{ Block, BlockBody, MorphHeader, MorphReceipt, MorphTxEnvelope, - transaction::morph_transaction::{MAX_MEMO_LENGTH, MORPH_TX_VERSION_0, MORPH_TX_VERSION_1}, + transaction::morph_transaction::{MAX_MEMO_LENGTH, MORPH_TX_VERSION_1}, }; use reth_consensus::{Consensus, ConsensusError, FullConsensus, HeaderValidator, ReceiptRootBloom}; use reth_consensus_common::validation::{ @@ -309,7 +309,7 @@ impl Consensus for MorphConsensus { let is_jade = self .chain_spec .is_jade_active_at_timestamp(block.header().timestamp()); - validate_morph_tx_versions(&block.body().transactions, is_jade)?; + validate_morph_txs(&block.body().transactions, is_jade)?; // Validate L1 messages ordering and internal consistency with header. // This is the body-level half of L1 validation; it verifies that the L1 @@ -596,31 +596,26 @@ fn validate_l1_messages_in_block( Ok(()) } -/// Validates MorphTx version and field constraints for all transactions in a block. +/// Validates all MorphTx (0x7F) transactions in a block. /// -/// Matches go-ethereum's `BlockValidator.ValidateBody()` which: -/// 1. Rejects MorphTx V1 before the Jade fork is active -/// 2. Validates version-specific field constraints via `ValidateMorphTxVersion()` +/// Performs two checks per MorphTx: +/// 1. **Hardfork gate**: rejects V1 transactions before the Jade fork is active +/// 2. **Field validation**: delegates to [`TxMorph::validate()`] for version-specific +/// field constraints, memo length, and gas price ordering /// -/// # Rules -/// -/// - **Version 0**: `fee_token_id` must be > 0; `reference` and `memo` must not be set -/// - **Version 1**: If `fee_token_id` is 0, `fee_limit` must be zero; `memo` must not -/// exceed [`MAX_MEMO_LENGTH`] bytes -/// - **Other versions**: rejected as unsupported -fn validate_morph_tx_versions( +/// See [`TxMorph::validate()`] for the detailed per-version rules. +fn validate_morph_txs( txs: &[MorphTxEnvelope], is_jade: bool, ) -> Result<(), ConsensusError> { for tx in txs { - if !tx.is_morph_tx() { - continue; - } - - let version = tx.version().unwrap_or(0); + let morph_tx = match tx { + MorphTxEnvelope::Morph(signed) => signed.tx(), + _ => continue, + }; - // Reject MorphTx V1 before Jade fork - if !is_jade && version == MORPH_TX_VERSION_1 { + // Reject MorphTx V1 before Jade fork (hardfork-gated, consensus-only check). + if !is_jade && morph_tx.version == MORPH_TX_VERSION_1 { return Err(ConsensusError::Other( MorphConsensusError::InvalidBody( "MorphTx version 1 is not yet active (jade fork not reached)".into(), @@ -629,59 +624,12 @@ fn validate_morph_tx_versions( )); } - match version { - MORPH_TX_VERSION_0 => { - // V0 requires fee_token_id > 0 and no reference/memo - let fee_token_id = tx.fee_token_id().unwrap_or(0); - let has_reference = tx - .reference() - .is_some_and(|r| r != alloy_primitives::B256::ZERO); - let has_memo = tx.memo().is_some_and(|m| !m.is_empty()); - - if fee_token_id == 0 || has_reference || has_memo { - return Err(ConsensusError::Other( - MorphConsensusError::InvalidBody( - "illegal extra parameters of version 0 MorphTx".into(), - ) - .to_string(), - )); - } - } - MORPH_TX_VERSION_1 => { - let fee_token_id = tx.fee_token_id().unwrap_or(0); - let fee_limit = tx.fee_limit().unwrap_or(alloy_primitives::U256::ZERO); - - // If fee_token_id is 0, fee_limit must not be set (must be zero) - if fee_token_id == 0 && !fee_limit.is_zero() { - return Err(ConsensusError::Other( - MorphConsensusError::InvalidBody( - "illegal extra parameters of version 1 MorphTx".into(), - ) - .to_string(), - )); - } - - // Validate memo length - if let Some(memo) = tx.memo() - && memo.len() > MAX_MEMO_LENGTH - { - return Err(ConsensusError::Other( - MorphConsensusError::InvalidBody(format!( - "memo exceeds maximum length of {MAX_MEMO_LENGTH} bytes, got {}", - memo.len() - )) - .to_string(), - )); - } - } - _ => { - return Err(ConsensusError::Other( - MorphConsensusError::InvalidBody(format!( - "unsupported MorphTx version: {version}" - )) - .to_string(), - )); - } + // Reuse primitive-layer validation (version, fee_token_id, reference, + // memo length, fee_limit constraints, gas price ordering). + if let Err(reason) = morph_tx.validate() { + return Err(ConsensusError::Other( + MorphConsensusError::InvalidBody(reason.to_string()).to_string(), + )); } } From afdcd4c804d16ea7e7a6545d3945fab9138e72ce Mon Sep 17 00:00:00 2001 From: panos Date: Mon, 9 Mar 2026 00:02:58 +0800 Subject: [PATCH 15/35] style: fmt all --- crates/consensus/src/validation.rs | 7 ++----- crates/evm/src/block/receipt.rs | 3 ++- crates/revm/src/handler.rs | 8 ++++++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/consensus/src/validation.rs b/crates/consensus/src/validation.rs index cdb388d..89f2a18 100644 --- a/crates/consensus/src/validation.rs +++ b/crates/consensus/src/validation.rs @@ -41,7 +41,7 @@ use alloy_primitives::{B256, Bloom}; use morph_chainspec::{MorphChainSpec, MorphHardforks}; use morph_primitives::{ Block, BlockBody, MorphHeader, MorphReceipt, MorphTxEnvelope, - transaction::morph_transaction::{MAX_MEMO_LENGTH, MORPH_TX_VERSION_1}, + transaction::morph_transaction::MORPH_TX_VERSION_1, }; use reth_consensus::{Consensus, ConsensusError, FullConsensus, HeaderValidator, ReceiptRootBloom}; use reth_consensus_common::validation::{ @@ -604,10 +604,7 @@ fn validate_l1_messages_in_block( /// field constraints, memo length, and gas price ordering /// /// See [`TxMorph::validate()`] for the detailed per-version rules. -fn validate_morph_txs( - txs: &[MorphTxEnvelope], - is_jade: bool, -) -> Result<(), ConsensusError> { +fn validate_morph_txs(txs: &[MorphTxEnvelope], is_jade: bool) -> Result<(), ConsensusError> { for tx in txs { let morph_tx = match tx { MorphTxEnvelope::Morph(signed) => signed.tx(), diff --git a/crates/evm/src/block/receipt.rs b/crates/evm/src/block/receipt.rs index e66ff45..65f816d 100644 --- a/crates/evm/src/block/receipt.rs +++ b/crates/evm/src/block/receipt.rs @@ -163,7 +163,8 @@ impl MorphReceiptBuilder for DefaultMorphReceiptBuilder { // main tx revert (revm's ExecutionResult::Revert carries no logs). let is_success = result.is_success(); let main_logs = result.into_logs(); - let mut logs = Vec::with_capacity(pre_fee_logs.len() + main_logs.len() + post_fee_logs.len()); + let mut logs = + Vec::with_capacity(pre_fee_logs.len() + main_logs.len() + post_fee_logs.len()); logs.extend(pre_fee_logs); logs.extend(main_logs); logs.extend(post_fee_logs); diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index b8ec84f..40f38ae 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -436,8 +436,12 @@ where token_amount_required, None, ); - let refund_logs: Vec<_> = - evm.ctx_mut().journal_mut().logs.drain(log_count_before..).collect(); + let refund_logs: Vec<_> = evm + .ctx_mut() + .journal_mut() + .logs + .drain(log_count_before..) + .collect(); evm.post_fee_logs = refund_logs; result }; From 603123640ec11e3b4608e60fffb29735839363c7 Mon Sep 17 00:00:00 2001 From: panos Date: Mon, 9 Mar 2026 00:07:14 +0800 Subject: [PATCH 16/35] fix: override SLOAD to restore original_value after token fee deduction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit revm's mark_warm_with_transaction_id() resets original_value = present_value on cold→warm transitions. After token fee deduction marks slots cold, the main tx's first SLOAD corrupts original_value, making EIP-2200 SSTORE see "clean" slots (2900 gas) instead of "dirty" (100 gas) — a 2800 gas mismatch vs go-eth at block 19720219. Fix: custom SLOAD reads the true committed value from DB (O(1) cache hit) on cold loads and restores original_value if corrupted. Only triggers on cold SLOADs; zero overhead on warm accesses. --- crates/revm/src/evm.rs | 67 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index 98b9fa6..a9fefcc 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -8,7 +8,10 @@ use morph_chainspec::hardfork::MorphHardfork; use revm::{ Context, Inspector, context::{CfgEnv, ContextError, Evm, FrameStack, Journal}, - context_interface::cfg::gas::BLOCKHASH, + context_interface::{ + cfg::gas::{BLOCKHASH, WARM_STORAGE_READ_COST}, + host::LoadError, + }, handler::{ EthFrame, EvmTr, FrameInitOrResult, FrameTr, ItemOrResult, instructions::EthInstructions, }, @@ -159,6 +162,65 @@ fn blockhash_morph( *number = morph_blockhash_result(chain_id_u64, current_number_u64, requested_number_u64); } +/// Morph custom SLOAD opcode. +/// +/// Fixes `original_value` corruption caused by revm's `mark_warm_with_transaction_id()`. +/// +/// When token fee deduction marks storage slots cold, the main tx's first SLOAD +/// triggers `mark_warm_with_transaction_id()` which resets `original_value = present_value`, +/// losing the true DB-committed original. This makes SSTORE see "clean" slots (2900 gas) +/// instead of "dirty" (100 gas), causing a 2800 gas mismatch vs go-eth. +/// +/// Fix: after the default SLOAD logic runs (which handles cold gas charging correctly), +/// read the true original from DB and restore it. The DB read hits the State cache (O(1)) +/// and only triggers on cold SLOADs. +fn sload_morph( + context: InstructionContext<'_, MorphContext, EthInterpreter>, +) { + let Some(([], index)) = StackTr::popn_top::<0>(&mut context.interpreter.stack) else { + context.interpreter.halt_underflow(); + return; + }; + + let target = context.interpreter.input.target_address; + let key = *index; // Save key before it gets overwritten with the loaded value + + let additional_cold_cost = context.host.gas_params().cold_storage_additional_cost(); + let skip_cold = context.interpreter.gas.remaining() < additional_cold_cost; + let res = context.host.sload_skip_cold_load(target, key, skip_cold); + + match res { + Ok(storage) => { + if storage.is_cold { + // Fix original_value corruption from mark_warm_with_transaction_id. + // Read the true committed value from DB (hits State cache, O(1)). + // This matches go-eth's GetCommittedState() which returns originStorage + // — the DB value unmodified by fee deduction. + let db_original = + context.host.journaled_state.database.storage(target, key); + if let Ok(db_original) = db_original + && let Some(acc) = + context.host.journaled_state.inner.state.get_mut(&target) + && let Some(slot) = acc.storage.get_mut(&key) + && slot.original_value != db_original + { + slot.original_value = db_original; + } + + // Charge cold SLOAD gas (same as default SLOAD) + if !context.interpreter.gas.record_cost(additional_cold_cost) { + context.interpreter.halt_oog(); + return; + } + } + + *index = storage.data; + } + Err(LoadError::ColdLoadSkipped) => context.interpreter.halt_oog(), + Err(LoadError::DBError) => context.interpreter.halt_fatal(), + } +} + /// MorphEvm extends the Evm with Morph specific types and logic. #[derive(Debug, derive_more::Deref, derive_more::DerefMut)] #[expect(clippy::type_complexity)] @@ -217,6 +279,9 @@ impl MorphEvm { ); // Morph custom BLOCKHASH implementation (matches Morph geth). instructions.insert_instruction(0x40, Instruction::new(blockhash_morph::, BLOCKHASH)); + // Morph custom SLOAD: fixes original_value corruption from token fee deduction. + instructions + .insert_instruction(0x54, Instruction::new(sload_morph::, WARM_STORAGE_READ_COST)); // SELFDESTRUCT is disabled in Morph instructions.insert_instruction(0xff, Instruction::unknown()); // BLOBHASH is disabled in Morph From 572e6ca39b6f4876400a5acb7df9bb1afab1df16 Mon Sep 17 00:00:00 2001 From: panos Date: Mon, 9 Mar 2026 09:53:52 +0800 Subject: [PATCH 17/35] fix: scope SLOAD original_value fix to token fee txs and use save-restore The previous SLOAD override (d71106e) unconditionally read database.storage() on every cold SLOAD to fix original_value corruption from token fee deduction's mark_cold(). This had two problems: 1. For non-token-fee transactions, it incorrectly overwrote the correct original_value set by mark_warm_with_transaction_id() during cross-tx slot access, causing SSTORE gas miscalculation in multi-tx blocks. 2. database.storage() returns the beginning-of-block value, which is wrong for the 2nd+ transaction in a block where a prior tx modified the same slot (go-eth's originStorage reflects the post-previous-tx value). Fix: add a had_token_fee_deduction flag on L1BlockInfo (the chain context field) to scope the fix to only token fee transactions. Replace the database.storage() read with a save-restore pattern: save the slot's original_value from journal state BEFORE sload_skip_cold_load corrupts it via mark_warm_with_transaction_id, then restore after. The journal state's original_value at that point correctly reflects the post-previous-tx committed value, matching go-eth's originStorage semantics. --- crates/revm/src/evm.rs | 60 ++++++++++++++++++++++++++++---------- crates/revm/src/handler.rs | 10 ++++++- crates/revm/src/l1block.rs | 9 ++++++ 3 files changed, 62 insertions(+), 17 deletions(-) diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index a9fefcc..503de11 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -166,14 +166,17 @@ fn blockhash_morph( /// /// Fixes `original_value` corruption caused by revm's `mark_warm_with_transaction_id()`. /// -/// When token fee deduction marks storage slots cold, the main tx's first SLOAD -/// triggers `mark_warm_with_transaction_id()` which resets `original_value = present_value`, -/// losing the true DB-committed original. This makes SSTORE see "clean" slots (2900 gas) -/// instead of "dirty" (100 gas), causing a 2800 gas mismatch vs go-eth. +/// When token fee deduction marks storage slots cold via `mark_cold()`, the main +/// tx's first SLOAD triggers `mark_warm_with_transaction_id()` which resets +/// `original_value = present_value` (the fee-modified value), losing the true +/// pre-fee original. This makes SSTORE see "clean" slots (2900 gas) instead of +/// "dirty" (100 gas), causing a 2800 gas mismatch vs go-eth. /// -/// Fix: after the default SLOAD logic runs (which handles cold gas charging correctly), -/// read the true original from DB and restore it. The DB read hits the State cache (O(1)) -/// and only triggers on cold SLOADs. +/// Fix: save the slot's `original_value` from journal state **before** +/// `sload_skip_cold_load` corrupts it, then restore after. This is correct for +/// multi-tx blocks because the journal's `original_value` at that point reflects +/// the post-previous-tx committed value (set by `mark_warm` during fee deduction's +/// own SLOAD), matching go-eth's `originStorage` semantics. fn sload_morph( context: InstructionContext<'_, MorphContext, EthInterpreter>, ) { @@ -185,6 +188,31 @@ fn sload_morph( let target = context.interpreter.input.target_address; let key = *index; // Save key before it gets overwritten with the loaded value + // When token fee deduction occurred, save original_value BEFORE sload_skip_cold_load. + // + // Token fee deduction calls mark_cold() on slots it touched. The subsequent + // sload_skip_cold_load → mark_warm_with_transaction_id() sees is_cold=true and + // resets original_value = present_value (the fee-modified value), losing the true + // pre-fee original. We save it here and restore after the call. + // + // This is superior to reading database.storage() because DB returns the beginning- + // of-block value, which is wrong when a previous transaction in the same block + // modified the same slot. The journal state's original_value at this point correctly + // reflects the post-previous-tx committed value (set by mark_warm during fee + // deduction's own SLOAD). + let saved_original = if context.host.chain.had_token_fee_deduction { + context + .host + .journaled_state + .inner + .state + .get(&target) + .and_then(|acc| acc.storage.get(&key)) + .map(|slot| slot.original_value) + } else { + None + }; + let additional_cold_cost = context.host.gas_params().cold_storage_additional_cost(); let skip_cold = context.interpreter.gas.remaining() < additional_cold_cost; let res = context.host.sload_skip_cold_load(target, key, skip_cold); @@ -192,19 +220,19 @@ fn sload_morph( match res { Ok(storage) => { if storage.is_cold { - // Fix original_value corruption from mark_warm_with_transaction_id. - // Read the true committed value from DB (hits State cache, O(1)). - // This matches go-eth's GetCommittedState() which returns originStorage - // — the DB value unmodified by fee deduction. - let db_original = - context.host.journaled_state.database.storage(target, key); - if let Ok(db_original) = db_original + // Restore original_value that mark_warm_with_transaction_id corrupted. + // saved_original is Some only when: + // 1. Token fee deduction occurred (had_token_fee_deduction = true) + // 2. The slot was already in journal state (touched by fee deduction) + // For slots first loaded here (not in state before), saved_original is + // None and sload_skip_cold_load already set original_value correctly + // from the DB load. + if let Some(saved) = saved_original && let Some(acc) = context.host.journaled_state.inner.state.get_mut(&target) && let Some(slot) = acc.storage.get_mut(&key) - && slot.original_value != db_original { - slot.original_value = db_original; + slot.original_value = saved; } // Charge cold SLOAD gas (same as default SLOAD) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 40f38ae..67b4dba 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -95,7 +95,8 @@ where evm.pre_fee_logs.clear(); evm.post_fee_logs.clear(); - let (_, tx, _, journal, _, _) = evm.ctx().all_mut(); + let (_, tx, _, journal, chain, _) = evm.ctx().all_mut(); + chain.had_token_fee_deduction = false; // L1 message - skip fee validation if tx.is_l1_msg() { @@ -666,6 +667,13 @@ where evm.cached_token_fee_info = Some(token_fee_info); evm.cached_l1_data_fee = l1_data_fee; + // Signal to sload_morph that token fee deduction with mark_cold() occurred, + // so it should fix original_value corruption from mark_warm_with_transaction_id. + { + let (_, _, _, _, chain, _) = evm.ctx().all_mut(); + chain.had_token_fee_deduction = true; + } + Ok(()) } } diff --git a/crates/revm/src/l1block.rs b/crates/revm/src/l1block.rs index cbe5b6a..f38aed6 100644 --- a/crates/revm/src/l1block.rs +++ b/crates/revm/src/l1block.rs @@ -145,6 +145,14 @@ pub struct L1BlockInfo { pub l1_blob_scalar: U256, /// The current call data gas: `l1_commit_scalar * l1_base_fee` (zero if before Curie). pub calldata_gas: U256, + /// Set when token fee deduction occurred in the current transaction. + /// + /// Token fee deduction calls `mark_cold()` on modified storage slots. The + /// main tx's first SLOAD then triggers `mark_warm_with_transaction_id()` + /// which incorrectly resets `original_value = present_value`. This flag + /// tells `sload_morph` to restore the true DB-committed original value, + /// scoping the fix to only transactions that need it. + pub had_token_fee_deduction: bool, } impl L1BlockInfo { @@ -185,6 +193,7 @@ impl L1BlockInfo { l1_commit_scalar, l1_blob_scalar, calldata_gas, + had_token_fee_deduction: false, }) } } From c6d18f219143bf66805ade7d72d909eb6655c3de Mon Sep 17 00:00:00 2001 From: panos Date: Mon, 9 Mar 2026 11:49:32 +0800 Subject: [PATCH 18/35] fix: rewrite TxMorph Compact codec using derive helper struct pattern The manual impl Compact for TxMorph was fundamentally broken: - to_compact wrote variable-length fields without flags bytes - from_compact passed total buffer length to each field's decoder, causing u64::from_compact(buf, huge_len) to panic on arr[8-len] This made stage unwind and any static file read of MorphTx crash. Fix: follow reth's standard pattern (same as TxEip1559 in reth-codecs): - TxMorphCompact helper struct with #[derive(Compact)] for automatic bitfield/flags generation - u8/u16 fields stored as u64 (reth_codecs doesn't support u8/u16) - memo + input packed into single Bytes field (derive allows only one, must be last): [memo_len: u8][memo_bytes][input_bytes] Note: new encoding is incompatible with old broken data in static files. Existing reth databases with MorphTx must be rebuilt. --- .../src/transaction/morph_transaction.rs | 216 +++++++++++++----- 1 file changed, 153 insertions(+), 63 deletions(-) diff --git a/crates/primitives/src/transaction/morph_transaction.rs b/crates/primitives/src/transaction/morph_transaction.rs index 63c241c..c1e767f 100644 --- a/crates/primitives/src/transaction/morph_transaction.rs +++ b/crates/primitives/src/transaction/morph_transaction.rs @@ -867,73 +867,103 @@ impl reth_primitives_traits::InMemorySize for TxMorph { } #[cfg(feature = "reth-codec")] -impl reth_codecs::Compact for TxMorph { - fn to_compact(&self, buf: &mut B) -> usize - where - B: BufMut + AsMut<[u8]>, - { - let mut len = 0; - len += self.chain_id.to_compact(buf); - len += self.nonce.to_compact(buf); - len += self.gas_limit.to_compact(buf); - len += self.max_fee_per_gas.to_compact(buf); - len += self.max_priority_fee_per_gas.to_compact(buf); - len += self.to.to_compact(buf); - len += self.value.to_compact(buf); - len += self.access_list.to_compact(buf); - len += (self.version as u64).to_compact(buf); - len += (self.fee_token_id as u64).to_compact(buf); - len += self.fee_limit.to_compact(buf); - len += self.reference.to_compact(buf); - // Memo is Option, convert to Bytes for Compact - let memo_bytes = self.memo.clone().unwrap_or_default(); - len += memo_bytes.to_compact(buf); - len += self.input.to_compact(buf); - len - } +mod compact_txmorph { + use super::*; + use alloy_eips::eip2930::AccessList; + use alloy_primitives::{Bytes, ChainId, TxKind, U256}; + use reth_codecs::Compact; - fn from_compact(buf: &[u8], len: usize) -> (Self, &[u8]) { - let (chain_id, buf) = ChainId::from_compact(buf, len); - let (nonce, buf) = u64::from_compact(buf, len); - let (gas_limit, buf) = u64::from_compact(buf, len); - let (max_fee_per_gas, buf) = u128::from_compact(buf, len); - let (max_priority_fee_per_gas, buf) = u128::from_compact(buf, len); - let (to, buf) = TxKind::from_compact(buf, len); - let (value, buf) = U256::from_compact(buf, len); - let (access_list, buf) = AccessList::from_compact(buf, len); - let (version, buf) = u64::from_compact(buf, len); - let (fee_token_id, buf) = u64::from_compact(buf, len); - let (fee_limit, buf) = U256::from_compact(buf, len); - let (reference, buf) = Option::::from_compact(buf, len); - let (memo_bytes, buf) = Bytes::from_compact(buf, len); - let (input, buf) = Bytes::from_compact(buf, len); - - // Convert Bytes to Option (empty = None) - let memo = if memo_bytes.is_empty() { - None - } else { - Some(memo_bytes) - }; + /// Helper struct for deriving `Compact` instead of manually managing bitfields. + /// + /// Follows the same pattern as reth's `TxEip1559` compact helper + /// (see `reth-codecs/src/alloy/transaction/eip1559.rs`). + /// + /// - `version` and `fee_token_id` are stored as `u64` because `u8`/`u16` don't + /// implement `Compact` in reth_codecs. The conversion is lossless. + /// - `memo` and `input` are packed into a single `Bytes` field (`data`) because + /// the derive macro only allows one `Bytes` field and it must be last. + /// Format: `[memo_len: u8][memo_bytes][input_bytes]`. + #[derive(Debug, Clone, PartialEq, Eq, Hash, Compact)] + #[reth_codecs(crate = "reth_codecs")] + struct TxMorphCompact { + chain_id: ChainId, + nonce: u64, + gas_limit: u64, + max_fee_per_gas: u128, + max_priority_fee_per_gas: u128, + to: TxKind, + value: U256, + access_list: AccessList, + /// Stored as u64 for Compact compatibility (u8 doesn't implement Compact) + version: u64, + /// Stored as u64 for Compact compatibility (u16 doesn't implement Compact) + fee_token_id: u64, + fee_limit: U256, + reference: Option, + /// Packed: `[memo_len: u8][memo_bytes][input_bytes]` (must be last) + data: Bytes, + } + + impl Compact for TxMorph { + fn to_compact(&self, buf: &mut B) -> usize + where + B: bytes::BufMut + AsMut<[u8]>, + { + // Pack memo + input into a single Bytes field + let memo_slice = self.memo.as_deref().map_or(&[] as &[u8], |v| v); + let mut data = Vec::with_capacity(1 + memo_slice.len() + self.input.len()); + data.push(memo_slice.len() as u8); // memo max 64 bytes, fits in u8 + data.extend_from_slice(memo_slice); + data.extend_from_slice(&self.input); + + let helper = TxMorphCompact { + chain_id: self.chain_id, + nonce: self.nonce, + gas_limit: self.gas_limit, + max_fee_per_gas: self.max_fee_per_gas, + max_priority_fee_per_gas: self.max_priority_fee_per_gas, + to: self.to, + value: self.value, + access_list: self.access_list.clone(), + version: u64::from(self.version), + fee_token_id: u64::from(self.fee_token_id), + fee_limit: self.fee_limit, + reference: self.reference, + data: data.into(), + }; + helper.to_compact(buf) + } + + fn from_compact(buf: &[u8], len: usize) -> (Self, &[u8]) { + let (helper, remaining) = TxMorphCompact::from_compact(buf, len); - ( - Self { - chain_id, - nonce, - gas_limit, - max_fee_per_gas, - max_priority_fee_per_gas, - to, - value, - access_list, - version: version as u8, - fee_token_id: fee_token_id as u16, - fee_limit, - reference, + // Unpack memo + input from the combined data field + let memo_len = helper.data[0] as usize; + let memo = if memo_len == 0 { + None + } else { + Some(Bytes::copy_from_slice(&helper.data[1..1 + memo_len])) + }; + let input = Bytes::copy_from_slice(&helper.data[1 + memo_len..]); + + let tx = Self { + chain_id: helper.chain_id, + nonce: helper.nonce, + gas_limit: helper.gas_limit, + max_fee_per_gas: helper.max_fee_per_gas, + max_priority_fee_per_gas: helper.max_priority_fee_per_gas, + to: helper.to, + value: helper.value, + access_list: helper.access_list, + version: helper.version as u8, + fee_token_id: helper.fee_token_id as u16, + fee_limit: helper.fee_limit, + reference: helper.reference, memo, input, - }, - buf, - ) + }; + (tx, remaining) + } } } @@ -2071,4 +2101,64 @@ mod tests { assert_eq!(decoded_signed.tx().memo, None); assert!(decoded_signed.tx().is_v0()); } + + #[cfg(feature = "reth-codec")] + #[test] + fn test_compact_roundtrip_v1_with_memo() { + use reth_codecs::Compact; + + let tx = TxMorph { + chain_id: 2818, + nonce: 42, + gas_limit: 21_000, + max_fee_per_gas: 100_000_000_000, + max_priority_fee_per_gas: 2_000_000_000, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + value: U256::from(1_000_000_000_000_000_000u128), + access_list: AccessList::default(), + version: 1, + fee_token_id: 7, + fee_limit: U256::from(999u64), + reference: Some(B256::from([0xab; 32])), + memo: Some(Bytes::from(vec![0xca, 0xfe, 0xba, 0xbe])), + input: Bytes::from(vec![0x12, 0x34, 0x56]), + }; + + let mut buf = Vec::new(); + tx.to_compact(&mut buf); + let (decoded, remaining) = TxMorph::from_compact(&buf, buf.len()); + + assert!(remaining.is_empty()); + assert_eq!(tx, decoded); + } + + #[cfg(feature = "reth-codec")] + #[test] + fn test_compact_roundtrip_v0_no_memo() { + use reth_codecs::Compact; + + let tx = TxMorph { + chain_id: 2818, + nonce: 0, + gas_limit: 100_000, + max_fee_per_gas: 50_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + to: TxKind::Create, + value: U256::ZERO, + access_list: AccessList::default(), + version: 0, + fee_token_id: 1, + fee_limit: U256::from(500u64), + reference: None, + memo: None, + input: Bytes::from(vec![0x60, 0x80, 0x60, 0x40]), + }; + + let mut buf = Vec::new(); + tx.to_compact(&mut buf); + let (decoded, remaining) = TxMorph::from_compact(&buf, buf.len()); + + assert!(remaining.is_empty()); + assert_eq!(tx, decoded); + } } From 4bf7b9d87324aff10a20b75009357d0006de4bff Mon Sep 17 00:00:00 2001 From: panos Date: Mon, 9 Mar 2026 12:45:57 +0800 Subject: [PATCH 19/35] chore: bump reth fork to f137577 (unwind state root warning) Update reth dependency to include the MerkleUnwind patch that downgrades state root validation errors to warnings during unwind, allowing unwind past pre-MPTFork blocks with ZK-trie roots. --- Cargo.lock | 214 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 104 +++++++++++++------------- 2 files changed, 159 insertions(+), 159 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 215866c..1997598 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6336,7 +6336,7 @@ checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" [[package]] name = "reth-basic-payload-builder" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -6360,7 +6360,7 @@ dependencies = [ [[package]] name = "reth-chain-state" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -6391,7 +6391,7 @@ dependencies = [ [[package]] name = "reth-chainspec" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-chains", "alloy-consensus", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "reth-cli" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-genesis", "clap", @@ -6425,7 +6425,7 @@ dependencies = [ [[package]] name = "reth-cli-commands" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-chains", "alloy-consensus", @@ -6503,7 +6503,7 @@ dependencies = [ [[package]] name = "reth-cli-runner" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "reth-tasks", "tokio", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "reth-cli-util" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-eips", "alloy-primitives", @@ -6530,7 +6530,7 @@ dependencies = [ [[package]] name = "reth-codecs" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -6550,7 +6550,7 @@ dependencies = [ [[package]] name = "reth-codecs-derive" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "proc-macro2", "quote", @@ -6560,7 +6560,7 @@ dependencies = [ [[package]] name = "reth-config" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "eyre", "humantime-serde", @@ -6576,7 +6576,7 @@ dependencies = [ [[package]] name = "reth-consensus" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -6589,7 +6589,7 @@ dependencies = [ [[package]] name = "reth-consensus-common" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -6601,7 +6601,7 @@ dependencies = [ [[package]] name = "reth-consensus-debug-client" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -6627,7 +6627,7 @@ dependencies = [ [[package]] name = "reth-db" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-primitives", "derive_more", @@ -6653,7 +6653,7 @@ dependencies = [ [[package]] name = "reth-db-api" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-genesis", @@ -6681,7 +6681,7 @@ dependencies = [ [[package]] name = "reth-db-common" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-genesis", @@ -6711,7 +6711,7 @@ dependencies = [ [[package]] name = "reth-db-models" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-eips", "alloy-primitives", @@ -6726,7 +6726,7 @@ dependencies = [ [[package]] name = "reth-discv4" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -6751,7 +6751,7 @@ dependencies = [ [[package]] name = "reth-discv5" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -6775,7 +6775,7 @@ dependencies = [ [[package]] name = "reth-dns-discovery" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-primitives", "data-encoding", @@ -6799,7 +6799,7 @@ dependencies = [ [[package]] name = "reth-downloaders" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -6830,7 +6830,7 @@ dependencies = [ [[package]] name = "reth-ecies" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "aes", "alloy-primitives", @@ -6858,7 +6858,7 @@ dependencies = [ [[package]] name = "reth-engine-local" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -6881,7 +6881,7 @@ dependencies = [ [[package]] name = "reth-engine-primitives" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -6906,7 +6906,7 @@ dependencies = [ [[package]] name = "reth-engine-service" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "futures", "pin-project", @@ -6929,7 +6929,7 @@ dependencies = [ [[package]] name = "reth-engine-tree" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eip7928", @@ -6983,7 +6983,7 @@ dependencies = [ [[package]] name = "reth-engine-util" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-rpc-types-engine", @@ -7011,7 +7011,7 @@ dependencies = [ [[package]] name = "reth-era" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -7026,7 +7026,7 @@ dependencies = [ [[package]] name = "reth-era-downloader" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-primitives", "bytes", @@ -7042,7 +7042,7 @@ dependencies = [ [[package]] name = "reth-era-utils" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -7064,7 +7064,7 @@ dependencies = [ [[package]] name = "reth-errors" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "reth-consensus", "reth-execution-errors", @@ -7075,7 +7075,7 @@ dependencies = [ [[package]] name = "reth-eth-wire" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-chains", "alloy-primitives", @@ -7103,7 +7103,7 @@ dependencies = [ [[package]] name = "reth-eth-wire-types" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-chains", "alloy-consensus", @@ -7124,7 +7124,7 @@ dependencies = [ [[package]] name = "reth-ethereum-cli" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "clap", "eyre", @@ -7146,7 +7146,7 @@ dependencies = [ [[package]] name = "reth-ethereum-consensus" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -7162,7 +7162,7 @@ dependencies = [ [[package]] name = "reth-ethereum-engine-primitives" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-eips", "alloy-primitives", @@ -7180,7 +7180,7 @@ dependencies = [ [[package]] name = "reth-ethereum-forks" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-eip2124", "alloy-hardforks", @@ -7193,7 +7193,7 @@ dependencies = [ [[package]] name = "reth-ethereum-payload-builder" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -7222,7 +7222,7 @@ dependencies = [ [[package]] name = "reth-ethereum-primitives" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -7242,7 +7242,7 @@ dependencies = [ [[package]] name = "reth-etl" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "rayon", "reth-db-api", @@ -7252,7 +7252,7 @@ dependencies = [ [[package]] name = "reth-evm" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -7276,7 +7276,7 @@ dependencies = [ [[package]] name = "reth-evm-ethereum" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -7297,7 +7297,7 @@ dependencies = [ [[package]] name = "reth-execution-errors" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-evm", "alloy-primitives", @@ -7310,7 +7310,7 @@ dependencies = [ [[package]] name = "reth-execution-types" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -7328,7 +7328,7 @@ dependencies = [ [[package]] name = "reth-exex" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -7366,7 +7366,7 @@ dependencies = [ [[package]] name = "reth-exex-types" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-eips", "alloy-primitives", @@ -7380,7 +7380,7 @@ dependencies = [ [[package]] name = "reth-fs-util" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "serde", "serde_json", @@ -7390,7 +7390,7 @@ dependencies = [ [[package]] name = "reth-invalid-block-hooks" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -7418,7 +7418,7 @@ dependencies = [ [[package]] name = "reth-ipc" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "bytes", "futures", @@ -7438,7 +7438,7 @@ dependencies = [ [[package]] name = "reth-libmdbx" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "bitflags 2.10.0", "byteorder", @@ -7454,7 +7454,7 @@ dependencies = [ [[package]] name = "reth-mdbx-sys" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "bindgen 0.71.1", "cc", @@ -7463,7 +7463,7 @@ dependencies = [ [[package]] name = "reth-metrics" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "futures", "metrics", @@ -7475,7 +7475,7 @@ dependencies = [ [[package]] name = "reth-net-banlist" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-primitives", "ipnet", @@ -7484,7 +7484,7 @@ dependencies = [ [[package]] name = "reth-net-nat" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "futures-util", "if-addrs", @@ -7498,7 +7498,7 @@ dependencies = [ [[package]] name = "reth-network" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -7554,7 +7554,7 @@ dependencies = [ [[package]] name = "reth-network-api" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -7579,7 +7579,7 @@ dependencies = [ [[package]] name = "reth-network-p2p" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -7601,7 +7601,7 @@ dependencies = [ [[package]] name = "reth-network-peers" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -7616,7 +7616,7 @@ dependencies = [ [[package]] name = "reth-network-types" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-eip2124", "humantime-serde", @@ -7630,7 +7630,7 @@ dependencies = [ [[package]] name = "reth-nippy-jar" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "anyhow", "bincode", @@ -7647,7 +7647,7 @@ dependencies = [ [[package]] name = "reth-node-api" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-rpc-types-engine", "eyre", @@ -7671,7 +7671,7 @@ dependencies = [ [[package]] name = "reth-node-builder" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -7740,7 +7740,7 @@ dependencies = [ [[package]] name = "reth-node-core" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -7796,7 +7796,7 @@ dependencies = [ [[package]] name = "reth-node-ethereum" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-eips", "alloy-network", @@ -7834,7 +7834,7 @@ dependencies = [ [[package]] name = "reth-node-ethstats" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -7858,7 +7858,7 @@ dependencies = [ [[package]] name = "reth-node-events" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -7882,7 +7882,7 @@ dependencies = [ [[package]] name = "reth-node-metrics" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "bytes", "eyre", @@ -7905,7 +7905,7 @@ dependencies = [ [[package]] name = "reth-node-types" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "reth-chainspec", "reth-db-api", @@ -7917,7 +7917,7 @@ dependencies = [ [[package]] name = "reth-optimism-primitives" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -7932,7 +7932,7 @@ dependencies = [ [[package]] name = "reth-payload-builder" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -7953,7 +7953,7 @@ dependencies = [ [[package]] name = "reth-payload-builder-primitives" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "pin-project", "reth-payload-primitives", @@ -7965,7 +7965,7 @@ dependencies = [ [[package]] name = "reth-payload-primitives" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -7988,7 +7988,7 @@ dependencies = [ [[package]] name = "reth-payload-util" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -7998,7 +7998,7 @@ dependencies = [ [[package]] name = "reth-payload-validator" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-rpc-types-engine", @@ -8008,7 +8008,7 @@ dependencies = [ [[package]] name = "reth-primitives-traits" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -8041,7 +8041,7 @@ dependencies = [ [[package]] name = "reth-provider" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -8084,7 +8084,7 @@ dependencies = [ [[package]] name = "reth-prune" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -8112,7 +8112,7 @@ dependencies = [ [[package]] name = "reth-prune-types" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-primitives", "arbitrary", @@ -8127,7 +8127,7 @@ dependencies = [ [[package]] name = "reth-revm" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-primitives", "reth-primitives-traits", @@ -8140,7 +8140,7 @@ dependencies = [ [[package]] name = "reth-rpc" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-dyn-abi", @@ -8222,7 +8222,7 @@ dependencies = [ [[package]] name = "reth-rpc-api" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-eip7928", "alloy-eips", @@ -8252,7 +8252,7 @@ dependencies = [ [[package]] name = "reth-rpc-builder" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-network", "alloy-provider", @@ -8293,7 +8293,7 @@ dependencies = [ [[package]] name = "reth-rpc-convert" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-evm", @@ -8314,7 +8314,7 @@ dependencies = [ [[package]] name = "reth-rpc-engine-api" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-eips", "alloy-primitives", @@ -8344,7 +8344,7 @@ dependencies = [ [[package]] name = "reth-rpc-eth-api" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-dyn-abi", @@ -8388,7 +8388,7 @@ dependencies = [ [[package]] name = "reth-rpc-eth-types" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -8436,7 +8436,7 @@ dependencies = [ [[package]] name = "reth-rpc-layer" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-rpc-types-engine", "http", @@ -8450,7 +8450,7 @@ dependencies = [ [[package]] name = "reth-rpc-server-types" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-eips", "alloy-primitives", @@ -8466,7 +8466,7 @@ dependencies = [ [[package]] name = "reth-stages" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -8511,7 +8511,7 @@ dependencies = [ [[package]] name = "reth-stages-api" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-eips", "alloy-primitives", @@ -8538,7 +8538,7 @@ dependencies = [ [[package]] name = "reth-stages-types" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-primitives", "arbitrary", @@ -8552,7 +8552,7 @@ dependencies = [ [[package]] name = "reth-static-file" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-primitives", "parking_lot", @@ -8572,7 +8572,7 @@ dependencies = [ [[package]] name = "reth-static-file-types" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-primitives", "clap", @@ -8585,7 +8585,7 @@ dependencies = [ [[package]] name = "reth-storage-api" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -8609,7 +8609,7 @@ dependencies = [ [[package]] name = "reth-storage-errors" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-eips", "alloy-primitives", @@ -8626,7 +8626,7 @@ dependencies = [ [[package]] name = "reth-tasks" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "auto_impl", "dyn-clone", @@ -8644,7 +8644,7 @@ dependencies = [ [[package]] name = "reth-tokio-util" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "tokio", "tokio-stream", @@ -8654,7 +8654,7 @@ dependencies = [ [[package]] name = "reth-tracing" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "clap", "eyre", @@ -8671,7 +8671,7 @@ dependencies = [ [[package]] name = "reth-tracing-otlp" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "clap", "eyre", @@ -8688,7 +8688,7 @@ dependencies = [ [[package]] name = "reth-transaction-pool" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -8728,7 +8728,7 @@ dependencies = [ [[package]] name = "reth-trie" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -8754,7 +8754,7 @@ dependencies = [ [[package]] name = "reth-trie-common" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -8781,7 +8781,7 @@ dependencies = [ [[package]] name = "reth-trie-db" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-primitives", "metrics", @@ -8801,7 +8801,7 @@ dependencies = [ [[package]] name = "reth-trie-parallel" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -8826,7 +8826,7 @@ dependencies = [ [[package]] name = "reth-trie-sparse" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -8845,7 +8845,7 @@ dependencies = [ [[package]] name = "reth-trie-sparse-parallel" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -8863,7 +8863,7 @@ dependencies = [ [[package]] name = "reth-zstd-compressors" version = "1.10.2" -source = "git+https://github.com/morph-l2/reth?rev=8057627ac9f169a0da4de44b616006a9e30382c1#8057627ac9f169a0da4de44b616006a9e30382c1" +source = "git+https://github.com/morph-l2/reth?rev=f13757781d2f6b0c1ec1c3e38f3ac32004bff24e#f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" dependencies = [ "zstd", ] diff --git a/Cargo.toml b/Cargo.toml index b49f43c..6920ce1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,59 +54,59 @@ morph-rpc = { path = "crates/rpc" } morph-revm = { path = "crates/revm", default-features = false } morph-txpool = { path = "crates/txpool", default-features = false } -reth-basic-payload-builder = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-chain-state = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-chainspec = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-cli = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-cli-commands = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-cli-util = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-codecs = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-codecs-derive = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-consensus = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-consensus-common = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-db = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-db-api = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-e2e-test-utils = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-engine-local = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-engine-primitives = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-engine-tree = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-errors = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-eth-wire-types = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-ethereum = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-ethereum-cli = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-ethereum-consensus = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-ethereum-engine-primitives = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-ethereum-primitives = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1", default-features = false } -reth-evm = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-evm-ethereum = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-execution-types = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-metrics = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-network-peers = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-node-api = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-node-builder = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-node-core = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-node-ethereum = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-node-metrics = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-payload-builder = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-payload-primitives = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-payload-util = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-primitives-traits = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1", default-features = false } -reth-provider = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-rpc = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-rpc-api = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-rpc-builder = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-rpc-convert = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-rpc-eth-api = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-rpc-eth-types = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-rpc-server-types = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-storage-api = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-tasks = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-tracing = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-trie = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-transaction-pool = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1" } -reth-zstd-compressors = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1", default-features = false } +reth-basic-payload-builder = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-chain-state = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-chainspec = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-cli = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-cli-commands = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-cli-util = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-codecs = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-codecs-derive = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-consensus = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-consensus-common = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-db = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-db-api = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-e2e-test-utils = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-engine-local = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-engine-primitives = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-engine-tree = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-errors = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-eth-wire-types = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-ethereum = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-ethereum-cli = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-ethereum-consensus = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-ethereum-engine-primitives = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-ethereum-primitives = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e", default-features = false } +reth-evm = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-evm-ethereum = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-execution-types = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-metrics = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-network-peers = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-node-api = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-node-builder = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-node-core = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-node-ethereum = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-node-metrics = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-payload-builder = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-payload-primitives = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-payload-util = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-primitives-traits = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e", default-features = false } +reth-provider = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-rpc = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-rpc-api = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-rpc-builder = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-rpc-convert = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-rpc-eth-api = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-rpc-eth-types = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-rpc-server-types = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-storage-api = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-tasks = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-tracing = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-trie = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-transaction-pool = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-zstd-compressors = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e", default-features = false } -reth-revm = { git = "https://github.com/morph-l2/reth", rev = "8057627ac9f169a0da4de44b616006a9e30382c1", features = [ +reth-revm = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e", features = [ "std", "optional-checks", ] } From 1a089ee890fbdf3d672de80a74124461e1702c7b Mon Sep 17 00:00:00 2001 From: panos Date: Mon, 9 Mar 2026 15:01:25 +0800 Subject: [PATCH 20/35] chore: add local-test-hoodi scripts for Hoodi testnet sync Adapted from local-test (mainnet) with Hoodi-specific config: - chain: hoodi (chain ID 2910) - L1 RPC: ethereum-hoodi-rpc.publicnode.com - deposit contract: 0xd7f39d837f... - rollup contract: 0x57e0e6dde8... - config bundle from morph-l2/run-morph-node hoodi/ --- local-test-hoodi/.gitignore | 18 ++ local-test-hoodi/common.sh | 84 ++++++++ local-test-hoodi/geth-start.sh | 47 +++++ local-test-hoodi/geth-stop.sh | 9 + local-test-hoodi/node-start.sh | 46 +++++ local-test-hoodi/node-stop.sh | 9 + local-test-hoodi/prepare.sh | 110 +++++++++++ local-test-hoodi/reset.sh | 53 +++++ local-test-hoodi/reth-start.sh | 65 +++++++ local-test-hoodi/reth-stop.sh | 9 + local-test-hoodi/start-all.sh | 67 +++++++ local-test-hoodi/status.sh | 110 +++++++++++ local-test-hoodi/stop-all.sh | 13 ++ local-test-hoodi/sync-test.sh | 341 +++++++++++++++++++++++++++++++++ 14 files changed, 981 insertions(+) create mode 100644 local-test-hoodi/.gitignore create mode 100755 local-test-hoodi/common.sh create mode 100755 local-test-hoodi/geth-start.sh create mode 100755 local-test-hoodi/geth-stop.sh create mode 100755 local-test-hoodi/node-start.sh create mode 100755 local-test-hoodi/node-stop.sh create mode 100755 local-test-hoodi/prepare.sh create mode 100755 local-test-hoodi/reset.sh create mode 100755 local-test-hoodi/reth-start.sh create mode 100755 local-test-hoodi/reth-stop.sh create mode 100755 local-test-hoodi/start-all.sh create mode 100755 local-test-hoodi/status.sh create mode 100755 local-test-hoodi/stop-all.sh create mode 100755 local-test-hoodi/sync-test.sh diff --git a/local-test-hoodi/.gitignore b/local-test-hoodi/.gitignore new file mode 100644 index 0000000..30820c7 --- /dev/null +++ b/local-test-hoodi/.gitignore @@ -0,0 +1,18 @@ +# Data directories +reth-data/ +geth-data/ +node-data/ + +# Runtime files +jwt-secret.txt +*.pid +*.log + +# Log rotation directories (numeric names like 2910/) +[0-9]*/ + +# Downloaded artifacts +hoodi-data.zip +hoodi-data/ +.config-extract/ +config-prep.*/ diff --git a/local-test-hoodi/common.sh b/local-test-hoodi/common.sh new file mode 100755 index 0000000..87eaf57 --- /dev/null +++ b/local-test-hoodi/common.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# Morphnode configuration (binary is in ../morph/node, data is in local-test-hoodi) +: "${MORPHNODE_BIN:=../morph/node/build/bin/morphnode}" +: "${NODE_HOME:=./local-test-hoodi/node-data}" +: "${JWT_SECRET:=./local-test-hoodi/jwt-secret.txt}" +: "${NODE_LOG_FILE:=./local-test-hoodi/node.log}" +: "${MORPH_NODE_L1_RPC:=${MORPH_NODE_L1_ETH_RPC:-https://ethereum-hoodi-rpc.publicnode.com}}" +: "${MORPH_NODE_DEPOSIT_CONTRACT:=${MORPH_NODE_SYNC_DEPOSIT_CONTRACT_ADDRESS:-0xd7f39d837f4790b215ba67e0ab63665912648dbe}}" +: "${MORPH_NODE_ROLLUP_CONTRACT:=0x57e0e6dde89dc52c01fe785774271504b1e04664}" +: "${MORPH_NODE_EXTRA_FLAGS:=}" +: "${DOWNLOAD_CONFIG_IF_MISSING:=1}" +: "${HOODI_CONFIG_ZIP_URL:=https://raw.githubusercontent.com/morph-l2/run-morph-node/main/hoodi/data.zip}" +: "${CONFIG_ZIP_PATH:=./local-test-hoodi/hoodi-data.zip}" +: "${KEEP_CONFIG_ARTIFACTS:=0}" +: "${AUTO_RESET_ON_WRONG_BLOCK:=0}" + +# Morph Geth configuration +: "${GETH_BIN:=../morph/go-ethereum/build/bin/geth}" +: "${GETH_DATA_DIR:=./local-test-hoodi/geth-data}" +: "${GETH_LOG_FILE:=./local-test-hoodi/geth.log}" + +# Morph-Reth configuration +: "${RETH_BIN:=./target/release/morph-reth}" +: "${RETH_DATA_DIR:=./local-test-hoodi/reth-data}" +: "${RETH_LOG_FILE:=./local-test-hoodi/reth.log}" +: "${RETH_HTTP_ADDR:=0.0.0.0}" +: "${RETH_HTTP_PORT:=8545}" +: "${RETH_AUTHRPC_ADDR:=127.0.0.1}" +: "${RETH_AUTHRPC_PORT:=8551}" +: "${RETH_BOOTNODES:=}" +: "${MORPH_MAX_TX_PAYLOAD_BYTES:=122880}" +: "${MORPH_MAX_TX_PER_BLOCK:=}" +: "${MORPH_GETH_RPC_URL:=}" +check_binary() { + local bin_path="$1" + local build_hint="$2" + if [[ ! -x "${bin_path}" ]]; then + echo "Missing executable: ${bin_path}" + echo "Build hint: ${build_hint}" + return 1 + fi +} + +cleanup_runtime_logs() { + rm -f "${NODE_LOG_FILE}" "${RETH_LOG_FILE}" + rm -rf "$(dirname "${RETH_LOG_FILE}")"/{[0-9]*,*.log*} +} + +# pm2 helper functions +pm2_check() { + if ! command -v pm2 &> /dev/null; then + echo "ERROR: pm2 is not installed" + echo "Install with: npm install -g pm2" + return 1 + fi +} + +pm2_is_running() { + local name="$1" + pm2 describe "${name}" &>/dev/null && \ + [[ "$(pm2 jlist 2>/dev/null | jq -r ".[] | select(.name==\"${name}\") | .pm2_env.status")" == "online" ]] +} + +pm2_stop() { + local name="$1" + if pm2 describe "${name}" &>/dev/null; then + pm2 stop "${name}" 2>/dev/null || true + pm2 delete "${name}" 2>/dev/null || true + echo "${name}: stopped" + else + echo "${name}: not running" + fi +} + +rel_path() { + local path="$1" + echo "${path#./}" +} diff --git a/local-test-hoodi/geth-start.sh b/local-test-hoodi/geth-start.sh new file mode 100755 index 0000000..6c04721 --- /dev/null +++ b/local-test-hoodi/geth-start.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" +cd "${REPO_ROOT}" + +echo "Starting morph-geth (hoodi)..." + +# Check prerequisites +pm2_check +check_binary "${GETH_BIN}" "cd ../morph/go-ethereum && make geth" + +# Check if already running +if pm2_is_running "morph-geth"; then + echo "morph-geth already running" + pm2 describe morph-geth + exit 0 +fi + +# Ensure data directory exists +mkdir -p "${GETH_DATA_DIR}" +mkdir -p "$(dirname "${GETH_LOG_FILE}")" + +# Start morph-geth with pm2 +pm2 start "${GETH_BIN}" --name morph-geth -- \ + --morph-hoodi \ + --datadir "${GETH_DATA_DIR}" \ + --gcmode archive \ + --syncmode full \ + --http \ + --http.addr "${RETH_HTTP_ADDR}" \ + --http.port "${RETH_HTTP_PORT}" \ + --http.corsdomain "*" \ + --http.vhosts "*" \ + --http.api "web3,eth,debug,txpool,net,morph,engine" \ + --authrpc.addr "${RETH_AUTHRPC_ADDR}" \ + --authrpc.port "${RETH_AUTHRPC_PORT}" \ + --authrpc.vhosts "*" \ + --authrpc.jwtsecret "${JWT_SECRET}" \ + --nodiscover \ + --maxpeers 0 \ + --verbosity 3 \ + --log.filename "${GETH_LOG_FILE}" + +echo "Logs: pm2 logs morph-geth" diff --git a/local-test-hoodi/geth-stop.sh b/local-test-hoodi/geth-stop.sh new file mode 100755 index 0000000..72df6ab --- /dev/null +++ b/local-test-hoodi/geth-stop.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" +cd "${REPO_ROOT}" + +pm2_stop "morph-geth" diff --git a/local-test-hoodi/node-start.sh b/local-test-hoodi/node-start.sh new file mode 100755 index 0000000..cb31877 --- /dev/null +++ b/local-test-hoodi/node-start.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" +cd "${REPO_ROOT}" + +echo "Starting morphnode (hoodi)..." + +# Check prerequisites +pm2_check +check_binary "${MORPHNODE_BIN}" "cd ../morph/node && make build" + +# Check if already running +if pm2_is_running "morph-node"; then + echo "morphnode-hoodi already running" + pm2 describe morph-node + exit 0 +fi + +# Ensure log directory exists +mkdir -p "$(dirname "${NODE_LOG_FILE}")" + +# Build node args +args=( + --home "${NODE_HOME}" \ + --l2.jwt-secret "${JWT_SECRET}" \ + --l2.eth "http://${RETH_HTTP_ADDR}:${RETH_HTTP_PORT}" \ + --l2.engine "http://${RETH_AUTHRPC_ADDR}:${RETH_AUTHRPC_PORT}" \ + --l1.rpc "${MORPH_NODE_L1_RPC}" \ + --sync.depositContractAddr "${MORPH_NODE_DEPOSIT_CONTRACT}" \ + --derivation.rollupAddress "${MORPH_NODE_ROLLUP_CONTRACT}" \ + --log.filename "${NODE_LOG_FILE}" +) + +if [[ -n "${MORPH_NODE_EXTRA_FLAGS}" ]]; then + # shellcheck disable=SC2206 + extra_flags=(${MORPH_NODE_EXTRA_FLAGS}) + args+=("${extra_flags[@]}") +fi + +# Start morphnode with pm2 +pm2 start "${MORPHNODE_BIN}" --name morph-node -- "${args[@]}" + +echo "Logs: pm2 logs morph-node" diff --git a/local-test-hoodi/node-stop.sh b/local-test-hoodi/node-stop.sh new file mode 100755 index 0000000..4c01909 --- /dev/null +++ b/local-test-hoodi/node-stop.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" +cd "${REPO_ROOT}" + +pm2_stop "morph-node" diff --git a/local-test-hoodi/prepare.sh b/local-test-hoodi/prepare.sh new file mode 100755 index 0000000..31f3980 --- /dev/null +++ b/local-test-hoodi/prepare.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" +cd "${REPO_ROOT}" + +check_binary "${RETH_BIN}" "cargo build --release --bin morph-reth" +check_binary "${MORPHNODE_BIN}" "run: cd node && make build" + +mkdir -p "${RETH_DATA_DIR}" +mkdir -p "${NODE_HOME}" +mkdir -p "${NODE_HOME}/config" +mkdir -p "${NODE_HOME}/data" +mkdir -p "$(dirname "${RETH_LOG_FILE}")" +mkdir -p "$(dirname "${NODE_LOG_FILE}")" + +if [[ ! -f "${JWT_SECRET}" ]]; then + openssl rand -hex 32 > "${JWT_SECRET}" + chmod 600 "${JWT_SECRET}" +fi + +if [[ ! -f "${NODE_HOME}/config/config.toml" || ! -f "${NODE_HOME}/config/genesis.json" || ! -f "${NODE_HOME}/data/priv_validator_state.json" ]]; then + if [[ "${DOWNLOAD_CONFIG_IF_MISSING}" == "1" ]]; then + if ! command -v curl >/dev/null 2>&1; then + echo "curl is required to download Morph config bundle." + exit 1 + fi + if ! command -v unzip >/dev/null 2>&1; then + echo "unzip is required to extract Morph config bundle." + exit 1 + fi + + # Clean legacy extraction directories from previous runs. + rm -rf "./local-test-hoodi/.config-extract" "./local-test-hoodi/hoodi-data" + mkdir -p "$(dirname "${CONFIG_ZIP_PATH}")" + + temp_extract_dir="$(mktemp -d "${SCRIPT_DIR}/config-prep.XXXXXX")" + cleanup_temp() { + if [[ "${KEEP_CONFIG_ARTIFACTS}" != "1" ]]; then + rm -rf "${temp_extract_dir}" + rm -f "${CONFIG_ZIP_PATH}" + fi + } + trap cleanup_temp EXIT + + echo "Downloading hoodi config bundle..." + curl -fL "${HOODI_CONFIG_ZIP_URL}" -o "${CONFIG_ZIP_PATH}" + unzip -oq "${CONFIG_ZIP_PATH}" -d "${temp_extract_dir}" + + bundle_root="" + if [[ -f "${temp_extract_dir}/data/node-data/config/config.toml" && -f "${temp_extract_dir}/data/node-data/config/genesis.json" ]]; then + bundle_root="${temp_extract_dir}/data" + elif [[ -f "${temp_extract_dir}/hoodi-data/node-data/config/config.toml" && -f "${temp_extract_dir}/hoodi-data/node-data/config/genesis.json" ]]; then + bundle_root="${temp_extract_dir}/hoodi-data" + elif [[ -f "${temp_extract_dir}/node-data/config/config.toml" && -f "${temp_extract_dir}/node-data/config/genesis.json" ]]; then + bundle_root="${temp_extract_dir}" + fi + + if [[ -z "${bundle_root}" ]]; then + echo "Downloaded zip does not contain expected node-data config files." + echo "Checked bundle roots: ${temp_extract_dir}/data, ${temp_extract_dir}/hoodi-data, ${temp_extract_dir}" + exit 1 + fi + + cp -f "${bundle_root}/node-data/config/config.toml" "${NODE_HOME}/config/config.toml" + cp -f "${bundle_root}/node-data/config/genesis.json" "${NODE_HOME}/config/genesis.json" + if [[ -f "${bundle_root}/node-data/config/addrbook.json" ]]; then + cp -f "${bundle_root}/node-data/config/addrbook.json" "${NODE_HOME}/config/addrbook.json" + fi + if [[ -f "${bundle_root}/node-data/config/node_key.json" && ! -f "${NODE_HOME}/config/node_key.json" ]]; then + cp -f "${bundle_root}/node-data/config/node_key.json" "${NODE_HOME}/config/node_key.json" + fi + if [[ -f "${bundle_root}/node-data/config/priv_validator_key.json" && ! -f "${NODE_HOME}/config/priv_validator_key.json" ]]; then + cp -f "${bundle_root}/node-data/config/priv_validator_key.json" "${NODE_HOME}/config/priv_validator_key.json" + fi + if [[ -f "${bundle_root}/node-data/data/priv_validator_state.json" ]]; then + cp -f "${bundle_root}/node-data/data/priv_validator_state.json" "${NODE_HOME}/data/priv_validator_state.json" + fi + echo "Config prepared at ${NODE_HOME} from ${HOODI_CONFIG_ZIP_URL}" + else + echo "Warning: node-data is incomplete under ${NODE_HOME}." + echo "Set DOWNLOAD_CONFIG_IF_MISSING=1 or prepare config files manually." + fi +fi + +# Tendermint needs this state file. Some published bundles do not include it. +if [[ ! -f "${NODE_HOME}/data/priv_validator_state.json" ]]; then + cat > "${NODE_HOME}/data/priv_validator_state.json" <<'EOF' +{"height":"0","round":0,"step":0} +EOF +fi + +# If the previous run failed with replay "wrong block number", suggest or trigger a clean reset. +if [[ -f "${NODE_LOG_FILE}" ]] && grep -q "wrong block number" "${NODE_LOG_FILE}"; then + echo + echo "Detected historical replay failure in ${NODE_LOG_FILE}: wrong block number" + if [[ "${AUTO_RESET_ON_WRONG_BLOCK}" == "1" ]]; then + echo "AUTO_RESET_ON_WRONG_BLOCK=1, resetting local sync state..." + "${SCRIPT_DIR}/reset.sh" --yes + else + echo "If replay fails again, run: ./local-test-hoodi/reset.sh --yes" + fi +fi + +echo "Preparation finished." +echo "RETH_DATA_DIR=$(rel_path "${RETH_DATA_DIR}")" +echo "NODE_HOME=$(rel_path "${NODE_HOME}")" +echo "JWT_SECRET=$(rel_path "${JWT_SECRET}")" diff --git a/local-test-hoodi/reset.sh b/local-test-hoodi/reset.sh new file mode 100755 index 0000000..48bf6f6 --- /dev/null +++ b/local-test-hoodi/reset.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" +cd "${REPO_ROOT}" + +assume_yes=0 +if [[ "${1:-}" == "--yes" ]]; then + assume_yes=1 +fi + +echo "==========================================" +echo "Reset local sync state (hoodi: morph-reth + node)" +echo "==========================================" +echo +echo "This will remove:" +echo " - ${RETH_DATA_DIR}/db" +echo " - ${RETH_DATA_DIR}/static_files" +echo " - ${GETH_DATA_DIR}/geth" +echo " - ${NODE_HOME}/data" +echo +echo "This keeps:" +echo " - ${NODE_HOME}/config (genesis/keys)" +echo " - ${GETH_DATA_DIR}/keystore" +echo " - log files" +echo + +if [[ ${assume_yes} -ne 1 ]]; then + read -r -p "Continue? [y/N] " confirm + if [[ "${confirm}" != "y" && "${confirm}" != "Y" ]]; then + echo "Cancelled." + exit 0 + fi +fi + +"${SCRIPT_DIR}/stop-all.sh" || true +pm2_stop "morph-geth" 2>/dev/null || true + +rm -rf "${RETH_DATA_DIR}/db" "${RETH_DATA_DIR}/static_files" "${GETH_DATA_DIR}/geth" "${NODE_HOME}/data" +mkdir -p "${RETH_DATA_DIR}" "${GETH_DATA_DIR}" "${NODE_HOME}/data" + +cat > "${NODE_HOME}/data/priv_validator_state.json" <<'EOF' +{"height":"0","round":0,"step":0} +EOF +cleanup_runtime_logs + +echo +echo "Reset finished." +echo "Next steps:" +echo " 1) $(rel_path "${SCRIPT_DIR}")/prepare.sh" +echo " 2) $(rel_path "${SCRIPT_DIR}")/start-all.sh" diff --git a/local-test-hoodi/reth-start.sh b/local-test-hoodi/reth-start.sh new file mode 100755 index 0000000..c87ae25 --- /dev/null +++ b/local-test-hoodi/reth-start.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" +cd "${REPO_ROOT}" + +echo "Starting morph-reth (hoodi)..." + +# Check prerequisites +pm2_check +check_binary "${RETH_BIN}" "cargo build --release --bin morph-reth" + +# Check if already running +if pm2_is_running "morph-reth"; then + echo "morph-reth already running" + pm2 describe morph-reth + exit 0 +fi + +# Ensure data directory exists +mkdir -p "${RETH_DATA_DIR}" +mkdir -p "$(dirname "${RETH_LOG_FILE}")" + +# Build command arguments +args=( + node + --chain hoodi + --datadir "${RETH_DATA_DIR}" + --http + --http.addr "${RETH_HTTP_ADDR}" + --http.port "${RETH_HTTP_PORT}" + --http.api "web3,debug,eth,txpool,net,trace" + --authrpc.addr "${RETH_AUTHRPC_ADDR}" + --authrpc.port "${RETH_AUTHRPC_PORT}" + --authrpc.jwtsecret "${JWT_SECRET}" + --log.file.directory "$(dirname "${RETH_LOG_FILE}")" + --log.file.filter info + --morph.max-tx-payload-bytes "${MORPH_MAX_TX_PAYLOAD_BYTES}" + --nat none + --engine.legacy-state-root + --engine.persistence-threshold 256 + --engine.memory-block-buffer-target 16 +) + +# Add optional max-tx-per-block if configured +if [[ -n "${MORPH_MAX_TX_PER_BLOCK}" ]]; then + args+=(--morph.max-tx-per-block "${MORPH_MAX_TX_PER_BLOCK}") +fi + +# Add optional geth RPC URL for state root cross-validation +if [[ -n "${MORPH_GETH_RPC_URL}" ]]; then + args+=(--morph.geth-rpc-url "${MORPH_GETH_RPC_URL}") +fi + +# Add bootnodes if configured +if [[ -n "${RETH_BOOTNODES}" ]]; then + args+=(--bootnodes "${RETH_BOOTNODES}") +fi + +# Start morph-reth with pm2 +pm2 start "${RETH_BIN}" --name morph-reth -- "${args[@]}" + +echo "Logs: pm2 logs morph-reth" diff --git a/local-test-hoodi/reth-stop.sh b/local-test-hoodi/reth-stop.sh new file mode 100755 index 0000000..e99d8fb --- /dev/null +++ b/local-test-hoodi/reth-stop.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" +cd "${REPO_ROOT}" + +pm2_stop "morph-reth" diff --git a/local-test-hoodi/start-all.sh b/local-test-hoodi/start-all.sh new file mode 100755 index 0000000..ef93364 --- /dev/null +++ b/local-test-hoodi/start-all.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" +cd "${REPO_ROOT}" + +echo "==========================================" +echo "Starting Morph Hoodi full node (pm2)" +echo "==========================================" + +# Step 1: Check pm2 +echo "[1/4] Checking pm2..." +pm2_check + +# Step 2: Prepare configuration +echo "[2/4] Preparing configuration..." +"${SCRIPT_DIR}/prepare.sh" + +# Step 3: Start morph-reth +echo "[3/4] Starting morph-reth..." +"${SCRIPT_DIR}/reth-start.sh" + +# Wait for RPC to be ready +echo "Waiting for RPC..." +max_retries=60 +retry_count=0 +while [[ ${retry_count} -lt ${max_retries} ]]; do + if curl -s -X POST \ + -H "Content-Type: application/json" \ + --data '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}' \ + "http://${RETH_HTTP_ADDR}:${RETH_HTTP_PORT}" >/dev/null 2>&1; then + echo "RPC ready" + break + fi + + retry_count=$((retry_count + 1)) + if [[ $((retry_count % 10)) -eq 0 ]]; then + echo "Still waiting... (${retry_count}/${max_retries})" + fi + sleep 1 +done + +if [[ ${retry_count} -eq ${max_retries} ]]; then + echo "ERROR: RPC did not become ready after ${max_retries} seconds" + echo "Check logs: pm2 logs morph-reth" + exit 1 +fi + +# Step 4: Start morphnode +echo "[4/4] Starting morphnode..." +"${SCRIPT_DIR}/node-start.sh" + +echo +echo "Hoodi full node started" +echo "RPC: http://${RETH_HTTP_ADDR}:${RETH_HTTP_PORT}" +echo +echo "Useful commands:" +echo " pm2 list - view process status" +echo " pm2 logs - view all logs" +echo " pm2 logs morph-reth - view morph-reth logs" +echo " pm2 logs morph-node - view morphnode logs" +echo " pm2 monit - real-time monitoring" +echo " pm2 save - save process list for restart" +echo +echo "Check status: $(rel_path "${SCRIPT_DIR}")/status.sh" diff --git a/local-test-hoodi/status.sh b/local-test-hoodi/status.sh new file mode 100755 index 0000000..dcd3d9e --- /dev/null +++ b/local-test-hoodi/status.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" +cd "${REPO_ROOT}" + +echo "==========================================" +echo "Morph Hoodi Node Status (with morph-reth)" +echo "==========================================" +echo + +# Process status via pm2 +echo "--- Process Status (pm2) ---" +pm2 list --no-color 2>/dev/null | grep -E "morph-reth|morph-node|name" || echo "No pm2 processes found" +echo + +# morph-reth RPC status +echo "--- morph-reth RPC ---" + +# Chain ID +echo -n "Chain ID: " +chain_id=$(curl -s -X POST \ + -H "Content-Type: application/json" \ + --data '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}' \ + "http://${RETH_HTTP_ADDR}:${RETH_HTTP_PORT}" 2>/dev/null | jq -r '.result // "error"') +if [[ "${chain_id}" != "error" && "${chain_id}" != "null" ]]; then + # Convert hex to decimal + chain_id_dec=$((chain_id)) + echo "${chain_id} (${chain_id_dec})" +else + echo "unavailable" +fi + +# Block number +echo -n "Block Number: " +block_num=$(curl -s -X POST \ + -H "Content-Type: application/json" \ + --data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \ + "http://${RETH_HTTP_ADDR}:${RETH_HTTP_PORT}" 2>/dev/null | jq -r '.result // "error"') +if [[ "${block_num}" != "error" && "${block_num}" != "null" ]]; then + block_num_dec=$((block_num)) + echo "${block_num} (${block_num_dec})" +else + echo "unavailable" +fi + +# Peer count +echo -n "Peer Count: " +peer_count=$(curl -s -X POST \ + -H "Content-Type: application/json" \ + --data '{"jsonrpc":"2.0","method":"net_peerCount","params":[],"id":1}' \ + "http://${RETH_HTTP_ADDR}:${RETH_HTTP_PORT}" 2>/dev/null | jq -r '.result // "error"') +if [[ "${peer_count}" != "error" && "${peer_count}" != "null" ]]; then + peer_count_dec=$((peer_count)) + echo "${peer_count} (${peer_count_dec})" +else + echo "unavailable" +fi + +# Syncing status +echo -n "Syncing: " +syncing=$(curl -s -X POST \ + -H "Content-Type: application/json" \ + --data '{"jsonrpc":"2.0","method":"eth_syncing","params":[],"id":1}' \ + "http://${RETH_HTTP_ADDR}:${RETH_HTTP_PORT}" 2>/dev/null | jq -r '.result // "error"') +if [[ "${syncing}" == "false" ]]; then + echo "false (synced)" +elif [[ "${syncing}" != "error" && "${syncing}" != "null" ]]; then + echo "true (in progress)" +else + echo "unavailable" +fi + +echo + +# morphnode status +echo "--- morphnode Status ---" +morphnode_status=$(curl -s "http://127.0.0.1:26657/status" 2>/dev/null) +if [[ -n "${morphnode_status}" ]]; then + echo -n "Latest Block Height: " + echo "${morphnode_status}" | jq -r '.result.sync_info.latest_block_height // "unknown"' + echo -n "Latest Block Time: " + echo "${morphnode_status}" | jq -r '.result.sync_info.latest_block_time // "unknown"' + echo -n "Catching Up: " + echo "${morphnode_status}" | jq -r '.result.sync_info.catching_up // "unknown"' +else + echo "morphnode RPC not available" +fi + +echo + +# morphnode net_info +echo "--- morphnode Network ---" +morphnode_netinfo=$(curl -s "http://127.0.0.1:26657/net_info" 2>/dev/null) +if [[ -n "${morphnode_netinfo}" ]]; then + echo -n "Peers: " + echo "${morphnode_netinfo}" | jq -r '.result.n_peers // "unknown"' +else + echo "morphnode RPC not available" +fi + +echo +echo "==========================================" +echo "Logs:" +echo " - pm2 logs morph-reth" +echo " - pm2 logs morph-node" +echo " - pm2 monit (real-time monitoring)" +echo "==========================================" diff --git a/local-test-hoodi/stop-all.sh b/local-test-hoodi/stop-all.sh new file mode 100755 index 0000000..33b768f --- /dev/null +++ b/local-test-hoodi/stop-all.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" +cd "${REPO_ROOT}" + +# Stop in reverse order: morphnode first, then morph-reth +pm2_stop "morph-node" +pm2_stop "morph-reth" + +echo "All services stopped" diff --git a/local-test-hoodi/sync-test.sh b/local-test-hoodi/sync-test.sh new file mode 100755 index 0000000..7859a93 --- /dev/null +++ b/local-test-hoodi/sync-test.sh @@ -0,0 +1,341 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" +cd "${REPO_ROOT}" + +# ─── Configuration ──────────────────────────────────────────────────────────── +: "${TEST_DURATION:=120}" # seconds to run each node +: "${RPC_WAIT_TIMEOUT:=60}" # seconds to wait for RPC readiness +: "${SAMPLE_INTERVAL:=10}" # seconds between BPS samples +: "${SKIP_GETH:=0}" # set to 1 to skip geth test +: "${SKIP_RETH:=0}" # set to 1 to skip reth test +: "${RETH_DISABLE_GETH_RPC_COMPARE:=1}" # 1: do NOT pass --morph.geth-rpc-url during reth benchmark +: "${MAINNET_TIP:=21100000}" # approximate current mainnet tip for ETA calc + +# ─── Helpers ────────────────────────────────────────────────────────────────── + +get_block_number() { + local result + result=$(curl -s -X POST \ + -H "Content-Type: application/json" \ + --data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \ + "http://${RETH_HTTP_ADDR}:${RETH_HTTP_PORT}" 2>/dev/null | jq -r '.result // ""') + if [[ -n "${result}" && "${result}" != "null" ]]; then + printf "%d" "${result}" + else + echo "0" + fi +} + +wait_for_rpc() { + local name="$1" + local retries=0 + echo -n " Waiting for ${name} RPC..." + while [[ ${retries} -lt ${RPC_WAIT_TIMEOUT} ]]; do + if curl -s -X POST \ + -H "Content-Type: application/json" \ + --data '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}' \ + "http://${RETH_HTTP_ADDR}:${RETH_HTTP_PORT}" >/dev/null 2>&1; then + echo " ready" + return 0 + fi + retries=$((retries + 1)) + sleep 1 + done + echo " TIMEOUT" + return 1 +} + +# Collect BPS samples over the test duration by polling eth_blockNumber. +# Outputs: start_block end_block elapsed_seconds avg_bps peak_bps +run_bps_sampling() { + local name="$1" + local duration="$2" + local interval="${SAMPLE_INTERVAL}" + + local start_block end_block prev_block + local elapsed=0 sample_count=0 + local total_bps=0 peak_bps=0 + + start_block=$(get_block_number) + prev_block=${start_block} + + echo " Sampling BPS for ${name} (${duration}s, every ${interval}s)..." + + while [[ ${elapsed} -lt ${duration} ]]; do + sleep "${interval}" + elapsed=$((elapsed + interval)) + + local current_block + current_block=$(get_block_number) + local delta=$((current_block - prev_block)) + local bps + bps=$(echo "scale=2; ${delta} / ${interval}" | bc) + + sample_count=$((sample_count + 1)) + total_bps=$(echo "${total_bps} + ${bps}" | bc) + + # Track peak + if [[ $(echo "${bps} > ${peak_bps}" | bc -l) -eq 1 ]]; then + peak_bps=${bps} + fi + + printf " [%3ds] block=%d delta=+%d bps=%.2f\n" "${elapsed}" "${current_block}" "${delta}" "${bps}" + prev_block=${current_block} + done + + end_block=$(get_block_number) + local total_blocks=$((end_block - start_block)) + local avg_bps + avg_bps=$(echo "scale=2; ${total_blocks} / ${duration}" | bc) + + echo " ${name} sampling complete: ${start_block} -> ${end_block} (+${total_blocks} blocks)" + + # Export results via global vars (bash doesn't have return values for multiple) + RESULT_START_BLOCK=${start_block} + RESULT_END_BLOCK=${end_block} + RESULT_TOTAL_BLOCKS=${total_blocks} + RESULT_AVG_BPS=${avg_bps} + RESULT_PEAK_BPS=${peak_bps} +} + +# Full reset: stop everything, clean EL + node data, re-prepare config. +full_reset() { + echo " Resetting all data..." + pm2_stop "morph-geth" 2>/dev/null || true + pm2_stop "morph-reth" 2>/dev/null || true + pm2_stop "morph-node" 2>/dev/null || true + + rm -rf "${RETH_DATA_DIR}/db" "${RETH_DATA_DIR}/static_files" + rm -rf "${GETH_DATA_DIR}/geth" + rm -rf "${NODE_HOME}/data" + mkdir -p "${RETH_DATA_DIR}" "${GETH_DATA_DIR}" "${NODE_HOME}/data" + + cat > "${NODE_HOME}/data/priv_validator_state.json" <<'EOF' +{"height":"0","round":0,"step":0} +EOF + + cleanup_runtime_logs + echo " Reset complete" +} + +stop_all() { + pm2_stop "morph-geth" 2>/dev/null || true + pm2_stop "morph-reth" 2>/dev/null || true + pm2_stop "morph-node" 2>/dev/null || true +} + +format_duration() { + local total_seconds=$1 + local days=$((total_seconds / 86400)) + local hours=$(( (total_seconds % 86400) / 3600 )) + local minutes=$(( (total_seconds % 3600) / 60 )) + if [[ ${days} -gt 0 ]]; then + printf "%dd %dh %dm" "${days}" "${hours}" "${minutes}" + elif [[ ${hours} -gt 0 ]]; then + printf "%dh %dm" "${hours}" "${minutes}" + else + printf "%dm" "${minutes}" + fi +} + +# ─── Main ───────────────────────────────────────────────────────────────────── + +echo "==========================================" +echo " Morph Sync Speed Test: Geth vs Reth" +echo "==========================================" +echo " Duration per node: ${TEST_DURATION}s" +echo " Sample interval: ${SAMPLE_INTERVAL}s" +echo " Mainnet tip (est): ${MAINNET_TIP}" +echo "==========================================" +echo + +# Check prerequisites +pm2_check +check_binary "${MORPHNODE_BIN}" "cd ../morph/node && make build" + +# Results storage +GETH_AVG_BPS=0 +GETH_PEAK_BPS=0 +GETH_START=0 +GETH_END=0 +GETH_TOTAL=0 + +RETH_AVG_BPS=0 +RETH_PEAK_BPS=0 +RETH_START=0 +RETH_END=0 +RETH_TOTAL=0 + +# ─── Phase 1: Test Geth ────────────────────────────────────────────────────── + +if [[ "${SKIP_GETH}" != "1" ]]; then + check_binary "${GETH_BIN}" "cd ../morph/go-ethereum && make geth" + + echo "=== Phase 1: Testing Geth ===" + echo + + # Reset + full_reset + + # Prepare config (need jwt-secret and node config) + "${SCRIPT_DIR}/prepare.sh" 2>/dev/null + + # Start geth + echo " Starting morph-geth..." + "${SCRIPT_DIR}/geth-start.sh" + + # Wait for geth RPC + wait_for_rpc "geth" + + # Start morphnode + echo " Starting morphnode..." + "${SCRIPT_DIR}/node-start.sh" + + # Brief warmup to let morphnode establish connection + echo " Warming up (10s)..." + sleep 10 + + # Run BPS sampling + run_bps_sampling "geth" "${TEST_DURATION}" + GETH_AVG_BPS=${RESULT_AVG_BPS} + GETH_PEAK_BPS=${RESULT_PEAK_BPS} + GETH_START=${RESULT_START_BLOCK} + GETH_END=${RESULT_END_BLOCK} + GETH_TOTAL=${RESULT_TOTAL_BLOCKS} + + # Collect morphnode BPS logs + echo + echo " morphnode Block Sync Rate samples (geth):" + grep "Block Sync Rate" "${NODE_LOG_FILE}" 2>/dev/null | tail -5 | while read -r line; do + echo " ${line}" + done + + # Stop everything + echo + echo " Stopping geth test..." + stop_all + + echo + echo "=== Geth test complete ===" + echo +else + echo "=== Skipping Geth test (SKIP_GETH=1) ===" + echo +fi + +# ─── Phase 2: Test Reth ────────────────────────────────────────────────────── + +if [[ "${SKIP_RETH}" != "1" ]]; then + check_binary "${RETH_BIN}" "cargo build --release --bin morph-reth" + + echo "=== Phase 2: Testing Reth ===" + echo + + # Reset + full_reset + + # Prepare config + "${SCRIPT_DIR}/prepare.sh" 2>/dev/null + + # Start reth + echo " Starting morph-reth..." + if [[ "${RETH_DISABLE_GETH_RPC_COMPARE}" == "1" ]]; then + echo " Reth benchmark mode: disabling morph.geth-rpc-url" + MORPH_GETH_RPC_URL="" "${SCRIPT_DIR}/reth-start.sh" + else + "${SCRIPT_DIR}/reth-start.sh" + fi + + # Wait for reth RPC + wait_for_rpc "reth" + + # Start morphnode + echo " Starting morphnode..." + "${SCRIPT_DIR}/node-start.sh" + + # Brief warmup + echo " Warming up (10s)..." + sleep 10 + + # Run BPS sampling + run_bps_sampling "reth" "${TEST_DURATION}" + RETH_AVG_BPS=${RESULT_AVG_BPS} + RETH_PEAK_BPS=${RESULT_PEAK_BPS} + RETH_START=${RESULT_START_BLOCK} + RETH_END=${RESULT_END_BLOCK} + RETH_TOTAL=${RESULT_TOTAL_BLOCKS} + + # Collect morphnode BPS logs + echo + echo " morphnode Block Sync Rate samples (reth):" + grep "Block Sync Rate" "${NODE_LOG_FILE}" 2>/dev/null | tail -5 | while read -r line; do + echo " ${line}" + done + + # Stop everything + echo + echo " Stopping reth test..." + stop_all + + echo + echo "=== Reth test complete ===" + echo +else + echo "=== Skipping Reth test (SKIP_RETH=1) ===" + echo +fi + +# ─── Results ────────────────────────────────────────────────────────────────── + +echo "==========================================" +echo " RESULTS" +echo "==========================================" +echo + +printf "%-20s %12s %12s\n" "" "Geth" "Reth" +printf "%-20s %12s %12s\n" "---" "---" "---" +printf "%-20s %12d %12d\n" "Start Block" "${GETH_START}" "${RETH_START}" +printf "%-20s %12d %12d\n" "End Block" "${GETH_END}" "${RETH_END}" +printf "%-20s %12d %12d\n" "Total Blocks" "${GETH_TOTAL}" "${RETH_TOTAL}" +printf "%-20s %12s %12s\n" "Avg BPS" "${GETH_AVG_BPS}" "${RETH_AVG_BPS}" +printf "%-20s %12s %12s\n" "Peak BPS" "${GETH_PEAK_BPS}" "${RETH_PEAK_BPS}" + +# ETA calculation +echo +echo "--- Estimated Full Sync Time (to block ${MAINNET_TIP}) ---" +if [[ $(echo "${GETH_AVG_BPS} > 0" | bc -l) -eq 1 ]]; then + geth_eta_seconds=$(echo "scale=0; ${MAINNET_TIP} / ${GETH_AVG_BPS}" | bc) + printf "Geth: %s (at %.2f bps)\n" "$(format_duration "${geth_eta_seconds}")" "${GETH_AVG_BPS}" +else + echo "Geth: N/A (no data)" +fi + +if [[ $(echo "${RETH_AVG_BPS} > 0" | bc -l) -eq 1 ]]; then + reth_eta_seconds=$(echo "scale=0; ${MAINNET_TIP} / ${RETH_AVG_BPS}" | bc) + printf "Reth: %s (at %.2f bps)\n" "$(format_duration "${reth_eta_seconds}")" "${RETH_AVG_BPS}" +else + echo "Reth: N/A (no data)" +fi + +# Winner +echo +if [[ $(echo "${GETH_AVG_BPS} > 0 && ${RETH_AVG_BPS} > 0" | bc -l) -eq 1 ]]; then + if [[ $(echo "${RETH_AVG_BPS} > ${GETH_AVG_BPS}" | bc -l) -eq 1 ]]; then + speedup=$(echo "scale=2; ${RETH_AVG_BPS} / ${GETH_AVG_BPS}" | bc) + echo "Winner: Reth (${speedup}x faster)" + elif [[ $(echo "${GETH_AVG_BPS} > ${RETH_AVG_BPS}" | bc -l) -eq 1 ]]; then + speedup=$(echo "scale=2; ${GETH_AVG_BPS} / ${RETH_AVG_BPS}" | bc) + echo "Winner: Geth (${speedup}x faster)" + else + echo "Result: Tie" + fi +fi + +echo +echo "==========================================" +echo " Test complete" +echo "==========================================" From 515db5c87a19aac345fad7d8377c8df6b6a67617 Mon Sep 17 00:00:00 2001 From: panos Date: Mon, 9 Mar 2026 16:42:45 +0800 Subject: [PATCH 21/35] fix: read L1BlockInfo per-tx instead of per-block cache The L1 Gas Price Oracle can be updated by a regular L2 transaction (from the external gas-oracle service) within the same block. Caching L1BlockInfo once per block in apply_pre_execution_changes caused subsequent user txs to use stale fee parameters, leading to "lack of funds" errors (observed at mainnet block 152685). Restore per-tx L1BlockInfo::try_fetch() in the handler's validate_and_deduct_eth_fee / validate_and_deduct_token_fee, matching go-ethereum's per-tx read behavior. --- crates/evm/src/block/mod.rs | 14 ++++++-------- crates/revm/src/evm.rs | 8 +++++--- crates/revm/src/handler.rs | 22 ++++++++++++++-------- 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/crates/evm/src/block/mod.rs b/crates/evm/src/block/mod.rs index d69ec58..f3b8841 100644 --- a/crates/evm/src/block/mod.rs +++ b/crates/evm/src/block/mod.rs @@ -27,7 +27,7 @@ use curie::apply_curie_hard_fork; use morph_chainspec::{MorphChainSpec, MorphHardfork, MorphHardforks}; use morph_primitives::{MorphReceipt, MorphTxEnvelope}; use morph_revm::{ - L1_GAS_PRICE_ORACLE_ADDRESS, L1BlockInfo, MorphHaltReason, TokenFeeInfo, evm::MorphContext, + L1_GAS_PRICE_ORACLE_ADDRESS, MorphHaltReason, TokenFeeInfo, evm::MorphContext, }; use reth_chainspec::EthereumHardforks; use reth_revm::{DatabaseCommit, Inspector, State, context::result::ResultAndState}; @@ -231,9 +231,11 @@ where let state_clear_flag = self.spec.is_spurious_dragon_active_at_block(block_number); self.evm.db_mut().set_state_clear_flag(state_clear_flag); - // 2. Load L1 gas oracle contract into cache and fetch L1BlockInfo once per block. - // The fetched L1BlockInfo is stored in the context's chain field so that - // all transactions in this block can access it without repeated DB reads. + // 2. Load L1 gas oracle contract into cache so that subsequent per-tx + // L1BlockInfo reads in the handler are fast (avoid cold DB hits). + // NOTE: We do NOT cache L1BlockInfo here because the oracle can be + // updated by a regular transaction (from the external gas-oracle service) + // within the same block. The handler reads it per-tx instead. let _ = self .evm .db_mut() @@ -244,10 +246,6 @@ where .spec .morph_hardfork_at(block_number, self.evm.block().timestamp.to::()); self.hardfork = hardfork; - let l1_block_info = L1BlockInfo::try_fetch(self.evm.db_mut(), hardfork).map_err(|e| { - BlockExecutionError::msg(format!("Failed to fetch L1 block info: {e:?}")) - })?; - self.evm.ctx_mut().chain = l1_block_info; // 3. Apply Curie hardfork at the transition block // Only executes once at the exact block where Curie activates diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index 503de11..0b00099 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -25,9 +25,11 @@ use revm::{ /// The Morph EVM context type. /// -/// Uses [`L1BlockInfo`] as the `CHAIN` parameter so that L1 fee parameters -/// are fetched once per block and shared across all transactions, avoiding -/// repeated storage reads in the handler hot path. +/// Uses [`L1BlockInfo`] as the `CHAIN` parameter. Only the +/// `had_token_fee_deduction` flag is used from the chain field (to signal +/// `sload_morph` when original_value restoration is needed). L1 fee +/// parameters are fetched per-tx in the handler to reflect mid-block +/// oracle updates from the external gas-oracle service. pub type MorphContext = Context, DB, Journal, L1BlockInfo>; diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 67b4dba..c4a8489 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -17,6 +17,7 @@ use crate::{ MorphEvm, MorphInvalidTransaction, MorphTxEnv, error::MorphHaltReason, evm::MorphContext, + l1block::L1BlockInfo, token_fee::{TokenFeeInfo, compute_mapping_slot_for_address, encode_balance_of_calldata}, tx::MorphTxExt, }; @@ -324,6 +325,13 @@ where ) -> Result<(), EVMError> { let hardfork = *evm.ctx_ref().cfg().spec(); + // Fetch L1 block info from the L1 Gas Price Oracle contract per-tx. + // Must NOT use a per-block cache because the oracle can be updated by a + // regular transaction (from the external gas-oracle service) within the + // same block. Subsequent user txs must see the updated fee parameters, + // matching go-ethereum's per-tx L1BlockInfo read. + let l1_block_info = L1BlockInfo::try_fetch(evm.ctx_mut().db_mut(), hardfork)?; + // Get RLP-encoded transaction bytes for L1 fee calculation let rlp_bytes = evm .ctx_ref() @@ -333,11 +341,8 @@ where .map(|b| b.as_ref()) .unwrap_or_default(); - // Calculate L1 data fee using the cached L1BlockInfo from context chain field. - let l1_data_fee = evm - .ctx_ref() - .chain - .calculate_tx_l1_cost(rlp_bytes, hardfork); + // Calculate L1 data fee based on full RLP-encoded transaction + let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes, hardfork); evm.cached_l1_data_fee = l1_data_fee; // Get mutable access to context components @@ -505,7 +510,7 @@ where return Ok(()); } - let (block, tx, cfg, journal, chain, _) = evm.ctx_mut().all_mut(); + let (block, tx, cfg, journal, _chain, _) = evm.ctx_mut().all_mut(); let caller_addr = tx.caller(); let beneficiary = block.beneficiary(); let hardfork = *cfg.spec(); @@ -548,8 +553,9 @@ where .map(|b| b.as_ref()) .unwrap_or_default(); - // Calculate L1 data fee using the cached L1BlockInfo from context chain field. - let l1_data_fee = chain.calculate_tx_l1_cost(rlp_bytes, hardfork); + // Fetch L1 block info per-tx (same rationale as validate_and_deduct_eth_fee). + let l1_block_info = L1BlockInfo::try_fetch(journal.db_mut(), hardfork)?; + let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes, hardfork); // Calculate L2 gas fee using effective_gas_price (= min(gasTipCap + baseFee, gasFeeCap)), // matching go-ethereum's buyAltTokenGas() which uses st.gasPrice (effective gas price). From 68a1bdccb0647e1158f0dc68be78a7c243cb5477 Mon Sep 17 00:00:00 2001 From: panos Date: Mon, 9 Mar 2026 18:37:52 +0800 Subject: [PATCH 22/35] fix: remove PR#39 fee_slot_saves residual code after rebase During rebase onto main, PR#39's fee_slot_saves SLOAD fix was superseded by the branch's save-restore SLOAD approach (had_token_fee_deduction flag). This commit removes the leftover code from PR#39: - Duplicate sload_morph function and SLOAD registration in evm.rs - fee_slot_saves collection in handler.rs slot-based transfer path - fee_slot_original_values field on MorphTxEnv in tx.rs --- crates/revm/src/evm.rs | 91 -------------------------------------- crates/revm/src/handler.rs | 19 -------- crates/revm/src/tx.rs | 12 ----- 3 files changed, 122 deletions(-) diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index 0b00099..9795949 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -63,87 +63,6 @@ fn morph_blockhash_result(chain_id: u64, current_number: u64, requested_number: } } -/// Morph custom SLOAD opcode. -/// -/// This wraps the standard SLOAD logic and adds a fix for the revm-state 9.0.0 -/// `mark_warm_with_transaction_id` behavior change. -/// -/// ## Background -/// -/// In revm-state 9.0.0, `EvmStorageSlot::mark_warm_with_transaction_id` resets -/// `original_value = present_value` when a cold slot becomes warm. This is -/// semantically correct for standard per-transaction state, but breaks Morph's -/// token fee mechanism where storage slots are modified *before* the main -/// transaction and then marked cold. -/// -/// When the main transaction SLOADs such a slot, the cold→warm transition -/// resets `original_value`, making the slot appear "clean" (original == present). -/// A subsequent SSTORE then uses EIP-2200 "clean" pricing (2900 gas) instead of -/// "dirty" pricing (100 gas), creating a **2800 gas** discrepancy vs go-ethereum. -/// -/// ## Fix -/// -/// After the standard SLOAD completes (including `mark_warm_with_transaction_id`), -/// this instruction checks whether the loaded slot is one of the fee-deducted -/// slots whose original DB value was saved in `MorphTxEnv::fee_slot_original_values`. -/// If so, it restores `original_value` on the `EvmStorageSlot` in journal state -/// to the true DB value, so that SSTORE gas calculation sees the correct -/// dirty/clean status. -fn sload_morph(context: InstructionContext<'_, MorphContext, EthInterpreter>) { - let Some(([], index)) = StackTr::popn_top::<0>(&mut context.interpreter.stack) else { - context.interpreter.halt_underflow(); - return; - }; - - let target = context.interpreter.input.target_address; - let key = *index; - - // Berlin+ path (Morph is always post-Berlin). - // Charge WARM_STORAGE_READ_COST (100) as static gas via the Instruction wrapper, - // then charge the additional cold cost (2000) here if the slot is cold. - let additional_cold_cost = context.host.gas_params().cold_storage_additional_cost(); - let skip_cold = context.interpreter.gas.remaining() < additional_cold_cost; - - match context.host.sload_skip_cold_load(target, key, skip_cold) { - Ok(storage) => { - if storage.is_cold && !context.interpreter.gas.record_cost(additional_cold_cost) { - context.interpreter.halt_oog(); - return; - } - *index = storage.data; - } - Err(LoadError::ColdLoadSkipped) => { - context.interpreter.halt_oog(); - return; - } - Err(LoadError::DBError) => { - context.interpreter.halt_fatal(); - return; - } - } - - // Morph fix: restore original_value for slots modified by token fee deduction. - // After mark_warm_with_transaction_id reset original_value = present_value, - // we set it back to the true DB value so SSTORE sees the slot as dirty. - // - // We use `.iter().find()` instead of `.remove()` so the entry is kept for the - // lifetime of the transaction. This is revert-safe: if a sub-call REVERTs, - // the journal rolls the slot back to cold, and a later SLOAD to the same slot - // would corrupt original_value again — the kept entry ensures every cold→warm - // transition is corrected, not just the first one. - if let Some(&(_, _, original_db_value)) = context - .host - .tx - .fee_slot_original_values - .iter() - .find(|(addr, slot_key, _)| *addr == target && *slot_key == key) - && let Some(acc) = context.host.journaled_state.state.get_mut(&target) - && let Some(slot) = acc.storage.get_mut(&key) - { - slot.original_value = original_db_value; - } -} - /// Morph custom BLOCKHASH opcode. /// /// Morph geth does not read historical header hashes for BLOCKHASH. Instead it returns: @@ -297,16 +216,6 @@ impl MorphEvm { let precompiles = MorphPrecompiles::new_with_spec(spec); let mut instructions = EthInstructions::new_mainnet(); - // Morph custom SLOAD: restores original_value after revm-state 9.0.0's - // mark_warm_with_transaction_id resets it, fixing EIP-2200 gas for - // token fee deducted slots. Static gas = WARM_STORAGE_READ_COST (100). - instructions.insert_instruction( - 0x54, // SLOAD - Instruction::new( - sload_morph::, - revm::context_interface::cfg::gas::WARM_STORAGE_READ_COST, - ), - ); // Morph custom BLOCKHASH implementation (matches Morph geth). instructions.insert_instruction(0x40, Instruction::new(blockhash_morph::, BLOCKHASH)); // Morph custom SLOAD: fixes original_value corruption from token fee deduction. diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index c4a8489..c243c81 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -586,15 +586,6 @@ where .into()); } - // Collect original DB values for fee-deducted slots (revm-state 9.0.0 workaround). - // - // revm-state 9.0.0's `mark_warm_with_transaction_id` resets - // `original_value = present_value` when a cold slot becomes warm. This - // breaks EIP-2200 gas for slots modified during pre-tx fee deduction. - // We save the true DB values here; the custom SLOAD instruction in - // `sload_morph` restores them after the cold→warm transition. - let mut fee_slot_saves: Vec<(Address, U256, U256)> = Vec::new(); - if let Some(balance_slot) = token_fee_info.balance_slot { // Transfer with token slot. // Ensure token account is loaded into the journal state, because `sload`/`sstore` @@ -614,19 +605,9 @@ where if let Some(token_acc) = journal.state.get_mut(&token_fee_info.token_address) { token_acc.mark_cold(); if let Some(slot) = token_acc.storage.get_mut(&from_storage_slot) { - fee_slot_saves.push(( - token_fee_info.token_address, - from_storage_slot, - slot.original_value, - )); slot.mark_cold(); } if let Some(slot) = token_acc.storage.get_mut(&to_storage_slot) { - fee_slot_saves.push(( - token_fee_info.token_address, - to_storage_slot, - slot.original_value, - )); slot.mark_cold(); } } diff --git a/crates/revm/src/tx.rs b/crates/revm/src/tx.rs index 4f3b9b6..60753e3 100644 --- a/crates/revm/src/tx.rs +++ b/crates/revm/src/tx.rs @@ -48,17 +48,6 @@ pub struct MorphTxEnv { pub reference: Option, /// Memo field for arbitrary data. pub memo: Option, - /// Saved original DB values for storage slots modified by token fee deduction. - /// - /// In revm-state 9.0.0, `EvmStorageSlot::mark_warm_with_transaction_id` resets - /// `original_value = present_value` when a cold slot becomes warm. This breaks - /// EIP-2200 gas calculation for slots that were modified during pre-transaction - /// fee deduction and then marked cold. - /// - /// We save `(contract_address, storage_key, original_db_value)` here so that - /// the custom SLOAD instruction can restore `original_value` after the warm - /// transition, preserving the correct dirty/clean determination for SSTORE gas. - pub fee_slot_original_values: Vec<(Address, U256, U256)>, } impl MorphTxEnv { @@ -72,7 +61,6 @@ impl MorphTxEnv { fee_limit: None, reference: None, memo: None, - fee_slot_original_values: Vec::new(), } } From aba9c71d5f96d9db7cdebc53a6fc13b29b437462 Mon Sep 17 00:00:00 2001 From: panos Date: Mon, 9 Mar 2026 18:43:08 +0800 Subject: [PATCH 23/35] style: fmt all --- crates/evm/src/block/mod.rs | 4 +--- crates/revm/src/evm.rs | 13 ++++++------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/crates/evm/src/block/mod.rs b/crates/evm/src/block/mod.rs index f3b8841..eeab834 100644 --- a/crates/evm/src/block/mod.rs +++ b/crates/evm/src/block/mod.rs @@ -26,9 +26,7 @@ use alloy_primitives::{Address, U256}; use curie::apply_curie_hard_fork; use morph_chainspec::{MorphChainSpec, MorphHardfork, MorphHardforks}; use morph_primitives::{MorphReceipt, MorphTxEnvelope}; -use morph_revm::{ - L1_GAS_PRICE_ORACLE_ADDRESS, MorphHaltReason, TokenFeeInfo, evm::MorphContext, -}; +use morph_revm::{L1_GAS_PRICE_ORACLE_ADDRESS, MorphHaltReason, TokenFeeInfo, evm::MorphContext}; use reth_chainspec::EthereumHardforks; use reth_revm::{DatabaseCommit, Inspector, State, context::result::ResultAndState}; use revm::context::Block; diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index 9795949..0e8c9e2 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -98,9 +98,7 @@ fn blockhash_morph( /// multi-tx blocks because the journal's `original_value` at that point reflects /// the post-previous-tx committed value (set by `mark_warm` during fee deduction's /// own SLOAD), matching go-eth's `originStorage` semantics. -fn sload_morph( - context: InstructionContext<'_, MorphContext, EthInterpreter>, -) { +fn sload_morph(context: InstructionContext<'_, MorphContext, EthInterpreter>) { let Some(([], index)) = StackTr::popn_top::<0>(&mut context.interpreter.stack) else { context.interpreter.halt_underflow(); return; @@ -149,8 +147,7 @@ fn sload_morph( // None and sload_skip_cold_load already set original_value correctly // from the DB load. if let Some(saved) = saved_original - && let Some(acc) = - context.host.journaled_state.inner.state.get_mut(&target) + && let Some(acc) = context.host.journaled_state.inner.state.get_mut(&target) && let Some(slot) = acc.storage.get_mut(&key) { slot.original_value = saved; @@ -219,8 +216,10 @@ impl MorphEvm { // Morph custom BLOCKHASH implementation (matches Morph geth). instructions.insert_instruction(0x40, Instruction::new(blockhash_morph::, BLOCKHASH)); // Morph custom SLOAD: fixes original_value corruption from token fee deduction. - instructions - .insert_instruction(0x54, Instruction::new(sload_morph::, WARM_STORAGE_READ_COST)); + instructions.insert_instruction( + 0x54, + Instruction::new(sload_morph::, WARM_STORAGE_READ_COST), + ); // SELFDESTRUCT is disabled in Morph instructions.insert_instruction(0xff, Instruction::unknown()); // BLOBHASH is disabled in Morph From 96eb175f1eaa76c9342cf13d1af6683ec8c8f019 Mon Sep 17 00:00:00 2001 From: panos Date: Tue, 10 Mar 2026 11:27:09 +0800 Subject: [PATCH 24/35] fix: allow skipped L1 messages in block validation The validate_l1_messages_in_block header consistency check used strict equality (header.next_l1_msg_index == last_queue_index + 1), which rejected blocks where the sequencer skipped L1 messages. Morph allows L1 messages to be skipped (not included in block body but counted as processed), as documented by go-eth's NumL1MessagesProcessed(): "This count includes both skipped and included messages." Block 628697 (mainnet) triggered this with queue_index 2572, 2573 and header next_l1_msg_index=2575, meaning queue_index 2574 was skipped. Change the check from != to < so header.next_l1_msg_index must be at least last_queue_index + 1 but may be greater when messages are skipped. --- crates/consensus/src/validation.rs | 44 +++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/crates/consensus/src/validation.rs b/crates/consensus/src/validation.rs index 89f2a18..1a0fa8f 100644 --- a/crates/consensus/src/validation.rs +++ b/crates/consensus/src/validation.rs @@ -563,8 +563,13 @@ fn validate_l1_messages_in_block( } } - // Validate header consistency: header.next_l1_msg_index must equal - // last_queue_index + 1 (i.e., first_queue_index + l1_msg_count). + // Validate header consistency: header.next_l1_msg_index must be at least + // last_queue_index + 1 (cannot go backwards relative to included messages). + // It may be strictly greater because Morph allows L1 messages to be + // "skipped" — the sequencer can advance past queue indices that are not + // included in the block body (e.g., messages that failed on L1 relay). + // go-eth's NumL1MessagesProcessed() comment: "This count includes both + // skipped and included messages." // For blocks with no L1 messages, this check is skipped — the cross-block // monotonicity check in validate_header_against_parent handles that case. if l1_msg_count > 0 { @@ -573,7 +578,7 @@ fn validate_l1_messages_in_block( "internal error: l1_msg_count > 0 but prev_queue_index is None".to_string(), ) })?; - let expected_next = last_queue_index.checked_add(1).ok_or_else(|| { + let min_expected = last_queue_index.checked_add(1).ok_or_else(|| { ConsensusError::Other( MorphConsensusError::InvalidNextL1MessageIndex { expected: u64::MAX, @@ -582,10 +587,10 @@ fn validate_l1_messages_in_block( .to_string(), ) })?; - if header_next_l1_msg_index != expected_next { + if header_next_l1_msg_index < min_expected { return Err(ConsensusError::Other( MorphConsensusError::InvalidNextL1MessageIndex { - expected: expected_next, + expected: min_expected, actual: header_next_l1_msg_index, } .to_string(), @@ -978,8 +983,8 @@ mod tests { } #[test] - fn test_validate_l1_messages_in_block_wrong_next_l1_msg_index() { - // Valid sequential L1 messages (0, 1, 2) but wrong next_l1_msg_index in header + fn test_validate_l1_messages_in_block_next_index_too_low() { + // Valid sequential L1 messages (0, 1, 2) but header.next_l1_msg_index < last+1 let txs = [ create_l1_msg_tx(0), create_l1_msg_tx(1), @@ -987,12 +992,31 @@ mod tests { create_regular_tx(), ]; - // Header says 100 but should be 3 (last=2, 2+1=3) - let result = validate_l1_messages_in_block(&txs, 100); + // Header says 2 but minimum is 3 (last=2, 2+1=3) — INVALID + let result = validate_l1_messages_in_block(&txs, 2); assert!(result.is_err()); let err_str = result.unwrap_err().to_string(); assert!(err_str.contains("expected 3")); - assert!(err_str.contains("got 100")); + assert!(err_str.contains("got 2")); + } + + #[test] + fn test_validate_l1_messages_in_block_skipped_messages_allowed() { + // L1 messages 0, 1, 2 but header says next=5 (messages 3, 4 were skipped). + // This is valid — Morph allows the sequencer to skip L1 messages. + let txs = [ + create_l1_msg_tx(0), + create_l1_msg_tx(1), + create_l1_msg_tx(2), + create_regular_tx(), + ]; + + // header_next=5 > last+1=3 — valid (2 messages skipped) + assert!(validate_l1_messages_in_block(&txs, 5).is_ok()); + // header_next=3 == last+1=3 — valid (no messages skipped) + assert!(validate_l1_messages_in_block(&txs, 3).is_ok()); + // header_next=100 > last+1=3 — valid (many messages skipped) + assert!(validate_l1_messages_in_block(&txs, 100).is_ok()); } #[test] From 6c6a33be2431b89bae455d8e89a7b9c2b3fdcbe4 Mon Sep 17 00:00:00 2001 From: panos Date: Tue, 10 Mar 2026 17:59:13 +0800 Subject: [PATCH 25/35] docs: fix stale comments, remove redundant annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix base fee doc: "after Curie" → "always active" (matches code) - Fix receipt doc: token_fee_info → morph_tx_fields, add missing fields - Fix get_morph_tx_fields doc: add missing sender parameter - Fix transfer ABI: transfer(address,amount) → transfer(address,uint256) - Fix checked_add comment: clarify difference from go-eth unbounded Add - Fix reimburse comment: remove misleading "system transactions" mention - Fix L1 message validation doc: equality → >= (skipped messages) - Fix evm.rs typo: "Consumed" → "Consumes" - Remove ~12 redundant "restates the code" comments in handler.rs --- crates/consensus/src/validation.rs | 14 ++++++------ crates/evm/src/block/mod.rs | 1 + crates/evm/src/block/receipt.rs | 4 +++- crates/revm/src/evm.rs | 2 +- crates/revm/src/handler.rs | 34 ++++++++---------------------- 5 files changed, 22 insertions(+), 33 deletions(-) diff --git a/crates/consensus/src/validation.rs b/crates/consensus/src/validation.rs index 1a0fa8f..5c8b914 100644 --- a/crates/consensus/src/validation.rs +++ b/crates/consensus/src/validation.rs @@ -14,7 +14,7 @@ //! - Coinbase must be zero when FeeVault is enabled //! - Timestamp cannot be in the future //! - Gas limit must be within bounds -//! - Base fee must be set after Curie hardfork +//! - Base fee must always be set (EIP-1559 is always active) //! //! ## L1 Message Rules //! @@ -131,7 +131,7 @@ impl HeaderValidator for MorphConsensus { /// 6. **Timestamp**: Must not be in the future /// 7. **Gas Limit**: Must be <= MAX_GAS_LIMIT /// 8. **Gas Used**: Must be <= gas limit - /// 9. **Base Fee**: Must be set after Curie hardfork and <= 10 Gwei + /// 9. **Base Fee**: Must always be set (EIP-1559 is always active) and <= 10 Gwei fn validate_header(&self, header: &SealedHeader) -> Result<(), ConsensusError> { // Extra data must be empty (Morph L2 specific - stricter than max length) if !header.extra_data().is_empty() { @@ -489,9 +489,10 @@ fn validate_against_parent_gas_limit( /// 2. **Sequential Queue Index**: L1 messages must have strictly sequential /// `queue_index` values (each = previous + 1). /// -/// 3. **Header Consistency**: If L1 messages are present, the last L1 message's -/// `queue_index + 1` must equal `header.next_l1_msg_index`. This ensures the -/// header correctly reflects the transactions in the body. +/// 3. **Header Consistency**: If L1 messages are present, +/// `header.next_l1_msg_index` must be >= `last_queue_index + 1`. It may be +/// strictly greater because Morph allows L1 messages to be "skipped" — the +/// sequencer can advance past queue indices not included in the block body. /// /// # Cross-Block Validation /// @@ -503,7 +504,8 @@ fn validate_against_parent_gas_limit( /// /// ```text /// [L1Msg(queue=5), L1Msg(queue=6), L1Msg(queue=7), RegularTx] -/// // header.next_l1_msg_index = 8 ✓ +/// // header.next_l1_msg_index = 8 ✓ (exact match) +/// // header.next_l1_msg_index = 10 ✓ (skipped queue indices 8, 9) /// ``` /// /// # Example (Invalid - L1 after L2) diff --git a/crates/evm/src/block/mod.rs b/crates/evm/src/block/mod.rs index eeab834..69a97c9 100644 --- a/crates/evm/src/block/mod.rs +++ b/crates/evm/src/block/mod.rs @@ -125,6 +125,7 @@ where /// /// # Arguments /// * `tx` - The transaction to extract fields from + /// * `sender` - Transaction sender (used for token registry balance queries) /// * `hardfork` - The current Morph hardfork (affects token registry behavior) /// /// # Returns diff --git a/crates/evm/src/block/receipt.rs b/crates/evm/src/block/receipt.rs index 65f816d..2b5f52c 100644 --- a/crates/evm/src/block/receipt.rs +++ b/crates/evm/src/block/receipt.rs @@ -44,7 +44,9 @@ use tracing::warn; /// - `result`: EVM execution result (success/failure, logs, gas used) /// - `cumulative_gas_used`: Running total of gas used in the block /// - `l1_fee`: Pre-calculated L1 data fee for this transaction -/// - `token_fee_info`: Token fee details for MorphTx transactions +/// - `morph_tx_fields`: MorphTx-specific fields (token fee info, version, reference, memo) +/// - `pre_fee_logs`: Transfer event logs from token fee deduction (survives tx revert) +/// - `post_fee_logs`: Transfer event logs from token fee reimbursement #[derive(Debug)] pub(crate) struct MorphReceiptBuilderCtx<'a, E: Evm> { /// The executed transaction diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index 0e8c9e2..5ea19e2 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -257,7 +257,7 @@ impl MorphEvm { } impl MorphEvm { - /// Consumed self and returns a new Evm type with given Inspector. + /// Consumes self and returns a new Evm type with given Inspector. pub fn with_inspector(self, inspector: OINSP) -> MorphEvm { MorphEvm::new_inner(self.inner.with_inspector(inspector)) } diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index c243c81..40f85b7 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -99,12 +99,10 @@ where let (_, tx, _, journal, chain, _) = evm.ctx().all_mut(); chain.had_token_fee_deduction = false; - // L1 message - skip fee validation if tx.is_l1_msg() { - // Load caller's account let mut caller = journal.load_account_with_code_mut(tx.caller())?.data; - // Bump nonce for calls (CREATE nonce is bumped in make_create_frame) + // CREATE nonce is bumped later in make_create_frame if tx.kind().is_call() { caller.bump_nonce(); } @@ -131,7 +129,7 @@ where ) -> Result<(), Self::Error> { let (_, tx, _, _, _, _) = evm.ctx().all_mut(); - // For L1 message transactions & system transactions, no reimbursement is needed + // L1 message gas is prepaid on L1, no reimbursement needed. if tx.is_l1_msg() { return Ok(()); } @@ -204,7 +202,6 @@ where let execution_fee = U256::from(effective_gas_price).saturating_mul(U256::from(gas_used)); - // reward beneficiary let mut beneficiary_account = journal.load_account_mut(beneficiary)?; beneficiary_account .data @@ -332,7 +329,6 @@ where // matching go-ethereum's per-tx L1BlockInfo read. let l1_block_info = L1BlockInfo::try_fetch(evm.ctx_mut().db_mut(), hardfork)?; - // Get RLP-encoded transaction bytes for L1 fee calculation let rlp_bytes = evm .ctx_ref() .tx() @@ -341,17 +337,13 @@ where .map(|b| b.as_ref()) .unwrap_or_default(); - // Calculate L1 data fee based on full RLP-encoded transaction let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes, hardfork); evm.cached_l1_data_fee = l1_data_fee; - // Get mutable access to context components let (block, tx, cfg, journal, _, _) = evm.ctx().all_mut(); - // Load caller's account let mut caller = journal.load_account_with_code_mut(tx.caller())?.data; - // Validate account nonce and code (EIP-3607) pre_execution::validate_account_nonce_and_code( &caller.account().info, tx.nonce(), @@ -359,15 +351,12 @@ where cfg.is_nonce_check_disabled(), )?; - // Calculate L2 fee and validate balance - // This includes: gas_limit * gas_price + value + blob_fee let new_balance_after_l2_fee = calculate_caller_fee_with_l1_cost(*caller.balance(), tx, block, cfg, l1_data_fee)?; - // Set the new balance (deducting L2 fee + L1 data fee) caller.set_balance(new_balance_after_l2_fee); - // Bump nonce for calls (CREATE nonce is bumped in make_create_frame) + // CREATE nonce is bumped later in make_create_frame if tx.kind().is_call() { caller.bump_nonce(); } @@ -385,9 +374,7 @@ where evm: &mut MorphEvm, gas: &Gas, ) -> Result<(), EVMError> { - // Get caller address let caller = evm.ctx_ref().tx().caller(); - // Get coinbase address let beneficiary = evm.ctx_ref().block().beneficiary(); let basefee = evm.ctx.block().basefee() as u128; let effective_gas_price = evm.ctx.tx().effective_gas_price(basefee); @@ -472,7 +459,7 @@ where evm: &mut MorphEvm, token_id: u16, ) -> Result<(), EVMError> { - // Token ID 0 not supported for gas payment. + // Token ID 0 means ETH — routed through validate_and_deduct_eth_fee instead. if token_id == 0 { return Err(MorphInvalidTransaction::TokenIdZeroNotSupported.into()); } @@ -536,17 +523,14 @@ where } } - // Fetch token fee info from Token Registry let token_fee_info = TokenFeeInfo::load_for_caller(journal.db_mut(), token_id, caller_addr, hardfork)? .ok_or(MorphInvalidTransaction::TokenNotRegistered(token_id))?; - // Check if token is active if !token_fee_info.is_active { return Err(MorphInvalidTransaction::TokenNotActive(token_id).into()); } - // Get RLP-encoded transaction bytes for L1 fee calculation let rlp_bytes = tx .rlp_bytes .as_ref() @@ -642,7 +626,7 @@ where evm.ctx_mut().journal_mut().state.extend(state); } - // Bump nonce for calls (CREATE nonce is bumped in make_create_frame) + // CREATE nonce is bumped later in make_create_frame if is_call { let (_, _, _, journal, _, _) = evm.ctx().all_mut(); let mut caller = journal.load_account_with_code_mut(caller_addr)?.data; @@ -691,8 +675,8 @@ where )?; journal.sstore(token, from_storage_slot, new_balance)?; - // Add amount (checked: reject on overflow to maintain token conservation, - // matching go-ethereum's big.Int Add which is unbounded) + // Add amount (checked: unlike go-ethereum's unbounded big.Int Add, + // we reject on overflow to maintain token conservation) let to_storage_slot = compute_mapping_slot_for_address(token_balance_slot, to); let balance = journal.sload(token, to_storage_slot)?; let new_to_balance = @@ -862,9 +846,9 @@ where Ok(()) } -/// Build the calldata for ERC20 transfer(address,amount) call. +/// Build the calldata for ERC20 `transfer(address,uint256)` call. /// -/// Method signature: `transfer(address,amount) -> 0xa9059cbb` +/// Method selector: `0xa9059cbb` #[inline] fn build_transfer_calldata(to: Address, token_amount: alloy_primitives::Uint<256, 4>) -> Bytes { let method_id = [0xa9u8, 0x05, 0x9c, 0xbb]; From b66c8906dd71bbdab8d6fbda085777a13ef10c9c Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 11 Mar 2026 16:19:00 +0800 Subject: [PATCH 26/35] fix: set finalized_block_hash in FCU to prevent unbounded memory growth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ForkchoiceState sent to reth's engine tree always had finalized_block_hash = B256::ZERO. Because reth's changeset-cache eviction, sidechain pruning, and block-buffer cleanup all depend on a non-zero finalized hash, in-memory state grew without bound during historical sync, eventually causing OOM on 32 GiB servers. Morph uses Tendermint instant finality so every committed block is final. During historical sync — before BlockTagService can provide L1-based values (all batches already finalized, state roots may be deleted) — FCU now falls back to head as finalized, enabling memory cleanup. Once BlockTagService starts providing L1 finalized hashes (via set_block_tags), FCU forwards those instead, keeping RPC-visible tags consistent with actual L1 finalization status. EngineStateTracker is extended with a BlockTagCache so set_block_tags values survive across FCU calls without race conditions. --- crates/engine-api/src/builder.rs | 75 ++++++++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 4 deletions(-) diff --git a/crates/engine-api/src/builder.rs b/crates/engine-api/src/builder.rs index 6f0c703..e09df2b 100644 --- a/crates/engine-api/src/builder.rs +++ b/crates/engine-api/src/builder.rs @@ -64,9 +64,24 @@ struct InMemoryHead { /// /// Updated from `CanonicalChainCommitted` consensus engine events and optimistically /// on successful local FCU calls to reduce latency before event delivery. +/// +/// Also caches L1-based safe/finalized block hashes from `set_block_tags` so that +/// the FCU can pass them to the engine tree, keeping both memory cleanup and +/// RPC-visible tags consistent. #[derive(Debug, Default)] pub struct EngineStateTracker { head: RwLock>, + /// Last L1-based safe/finalized hashes from `set_block_tags`. + /// `None` means `set_block_tags` has not yet provided a value (e.g. during + /// historical sync when all batches are already finalized on L1). + block_tags: RwLock, +} + +/// Cached L1-based block tag hashes from `set_block_tags`. +#[derive(Debug, Default, Clone, Copy)] +struct BlockTagCache { + safe_hash: Option, + finalized_hash: Option, } impl EngineStateTracker { @@ -94,6 +109,27 @@ impl EngineStateTracker { fn current_head(&self) -> Option { *self.head.read() } + + /// Caches L1-based block tag hashes from a successful `set_block_tags` call. + pub fn record_block_tags(&self, safe_hash: Option, finalized_hash: Option) { + let mut tags = self.block_tags.write(); + if let Some(h) = safe_hash { + tags.safe_hash = Some(h); + } + if let Some(h) = finalized_hash { + tags.finalized_hash = Some(h); + } + } + + /// Returns the last L1-based finalized hash, or `None` if not yet set. + fn l1_finalized_hash(&self) -> Option { + self.block_tags.read().finalized_hash + } + + /// Returns the last L1-based safe hash, or `None` if not yet set. + fn l1_safe_hash(&self) -> Option { + self.block_tags.read().safe_hash + } } impl RealMorphL2EngineApi { @@ -425,6 +461,18 @@ where ); } + // Cache the L1-based hashes so subsequent FCU calls use them instead of + // falling back to head. This keeps engine-tree finalization and + // RPC-visible tags aligned with the actual L1 finalization status. + self.engine_state_tracker.record_block_tags( + if safe_block_hash != B256::ZERO { Some(safe_block_hash) } else { None }, + if finalized_block_hash != B256::ZERO { + Some(finalized_block_hash) + } else { + None + }, + ); + Ok(()) } } @@ -547,12 +595,31 @@ impl RealMorphL2EngineApi { let new_payload_elapsed = new_payload_started.elapsed(); self.ensure_payload_status_acceptable(&payload_status, "newPayload")?; - // FCU only advances canonical head. Safe/finalized tags are managed - // separately via set_block_tags, matching geth's engine_setBlockTags design. + // Morph uses Tendermint consensus with instant finality — every committed + // block is final and no reorgs are possible. + // + // The safe/finalized hashes passed here serve two purposes in reth's engine + // tree: (1) driving changeset-cache eviction and sidechain pruning (memory + // management), and (2) setting the RPC-visible "safe"/"finalized" block tags. + // + // When BlockTagService has provided L1-based tags via set_block_tags, we + // forward those so the engine tree and RPC layer stay consistent with the + // actual L1 finalization status. During historical sync — before + // BlockTagService can provide values (all batches are already finalized on + // L1, state roots may be deleted) — we fall back to head so the engine tree + // still performs memory cleanup. + let finalized_hash = self + .engine_state_tracker + .l1_finalized_hash() + .unwrap_or(data.hash); + let safe_hash = self + .engine_state_tracker + .l1_safe_hash() + .unwrap_or(data.hash); let forkchoice = alloy_rpc_types_engine::ForkchoiceState { head_block_hash: data.hash, - safe_block_hash: B256::ZERO, - finalized_block_hash: B256::ZERO, + safe_block_hash: safe_hash, + finalized_block_hash: finalized_hash, }; self.provider.on_forkchoice_update_received(&forkchoice); From a4c395a751a223e571cda725cebc53dd51026b96 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 11 Mar 2026 16:59:45 +0800 Subject: [PATCH 27/35] fix: limit FCU tag fallback to historical sync --- crates/engine-api/src/builder.rs | 92 +++++++++++++++++++++++++++----- 1 file changed, 79 insertions(+), 13 deletions(-) diff --git a/crates/engine-api/src/builder.rs b/crates/engine-api/src/builder.rs index e09df2b..4fbf51f 100644 --- a/crates/engine-api/src/builder.rs +++ b/crates/engine-api/src/builder.rs @@ -60,6 +60,12 @@ struct InMemoryHead { timestamp: u64, } +/// Allow FCU tag fallback to head only while the imported block is clearly historical. +/// +/// Once imported blocks are close to wall-clock time, we stop synthesizing safe/finalized and +/// wait for Morph node's real `set_block_tags` updates instead. +const FCU_TAG_FALLBACK_MAX_AGE_SECS: u64 = 60; + /// Tracks engine-visible canonical head for the custom Morph engine API. /// /// Updated from `CanonicalChainCommitted` consensus engine events and optimistically @@ -465,7 +471,11 @@ where // falling back to head. This keeps engine-tree finalization and // RPC-visible tags aligned with the actual L1 finalization status. self.engine_state_tracker.record_block_tags( - if safe_block_hash != B256::ZERO { Some(safe_block_hash) } else { None }, + if safe_block_hash != B256::ZERO { + Some(safe_block_hash) + } else { + None + }, if finalized_block_hash != B256::ZERO { Some(finalized_block_hash) } else { @@ -604,18 +614,31 @@ impl RealMorphL2EngineApi { // // When BlockTagService has provided L1-based tags via set_block_tags, we // forward those so the engine tree and RPC layer stay consistent with the - // actual L1 finalization status. During historical sync — before - // BlockTagService can provide values (all batches are already finalized on - // L1, state roots may be deleted) — we fall back to head so the engine tree - // still performs memory cleanup. - let finalized_hash = self - .engine_state_tracker - .l1_finalized_hash() - .unwrap_or(data.hash); - let safe_hash = self - .engine_state_tracker - .l1_safe_hash() - .unwrap_or(data.hash); + // actual L1 finalization status. + // + // During deep historical sync, BlockTagService may be unable to provide + // tags for already-finalized batches. In that case we temporarily fall back + // to head so the engine tree can continue evicting old changesets. + // + // Once imported blocks are close to wall-clock time, we stop synthesizing + // safe/finalized and wait for real L1-derived tags to avoid falsely + // advertising live blocks as finalized in the catch-up window. + let now_timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let finalized_hash = resolve_fcu_block_tag_hash( + self.engine_state_tracker.l1_finalized_hash(), + data.hash, + data.timestamp, + now_timestamp, + ); + let safe_hash = resolve_fcu_block_tag_hash( + self.engine_state_tracker.l1_safe_hash(), + data.hash, + data.timestamp, + now_timestamp, + ); let forkchoice = alloy_rpc_types_engine::ForkchoiceState { head_block_hash: data.hash, safe_block_hash: safe_hash, @@ -867,6 +890,21 @@ fn apply_executable_data_overrides( Ok(RecoveredBlock::new_unhashed(block, senders)) } +fn resolve_fcu_block_tag_hash( + l1_tag_hash: Option, + head_hash: B256, + block_timestamp: u64, + now_timestamp: u64, +) -> B256 { + match l1_tag_hash { + Some(hash) => hash, + None if now_timestamp.saturating_sub(block_timestamp) > FCU_TAG_FALLBACK_MAX_AGE_SECS => { + head_hash + } + None => B256::ZERO, + } +} + #[cfg(test)] mod tests { use super::*; @@ -907,6 +945,34 @@ mod tests { assert_eq!(current_head.timestamp, sealed_header.timestamp()); } + #[test] + fn test_resolve_fcu_block_tag_hash_uses_l1_tag_when_available() { + let l1_tag = B256::from([0x11; 32]); + let head = B256::from([0x22; 32]); + + let resolved = resolve_fcu_block_tag_hash(Some(l1_tag), head, 1_700_000_000, 1_700_000_030); + + assert_eq!(resolved, l1_tag); + } + + #[test] + fn test_resolve_fcu_block_tag_hash_falls_back_to_head_for_historical_blocks() { + let head = B256::from([0x33; 32]); + + let resolved = resolve_fcu_block_tag_hash(None, head, 1_700_000_000, 1_700_000_000 + 300); + + assert_eq!(resolved, head); + } + + #[test] + fn test_resolve_fcu_block_tag_hash_returns_zero_near_live_without_l1_tag() { + let head = B256::from([0x44; 32]); + + let resolved = resolve_fcu_block_tag_hash(None, head, 1_700_000_000, 1_700_000_000 + 5); + + assert_eq!(resolved, B256::ZERO); + } + #[test] fn test_apply_executable_data_overrides_aligns_hash_with_engine_data() { let source_header: MorphHeader = Header::default().into(); From 1df0cd089b1d27261d566f366b1c6c3e028a17c2 Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 11 Mar 2026 17:21:39 +0800 Subject: [PATCH 28/35] fix: seed finalized tag in new_safe_l2_block for validator memory cleanup Validator / derivation nodes do not run BlockTagService, so set_block_tags is never called externally. With the new resolve_fcu_block_tag_hash fallback, once a derived block's timestamp is within 60 s of wall-clock time the FCU would send finalized_block_hash = B256::ZERO indefinitely, stopping changeset-cache eviction and causing a slow memory leak. Pass block_hash as the finalized hint in set_block_tags so the EngineStateTracker cache is seeded and the FCU always finds a non-zero fallback. When validators adopt BlockTagService the L1-derived finalized value will naturally supersede this hint. --- crates/engine-api/src/builder.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/engine-api/src/builder.rs b/crates/engine-api/src/builder.rs index 4fbf51f..74a588e 100644 --- a/crates/engine-api/src/builder.rs +++ b/crates/engine-api/src/builder.rs @@ -402,11 +402,21 @@ where // (do not rely on immediate DB visibility after FCU). let header = self.import_l2_block_via_engine(executable_data).await?; - // Update safe block tag separately, matching geth's decoupled design. - // Best-effort: block import already succeeded, so don't fail the whole - // call if only the tag update encounters an issue. The tag can be - // corrected later via engine_setBlockTags. - if let Err(e) = self.set_block_tags(block_hash, B256::ZERO).await { + // Update safe block tag and seed finalized for memory cleanup. + // + // Validator / derivation mode does not run BlockTagService, so + // set_block_tags is never called externally. Without a cached + // finalized hash the FCU falls back to B256::ZERO once blocks are + // near wall-clock time, disabling changeset-cache eviction. + // + // Passing block_hash as finalized here seeds the tracker so the + // engine tree can keep evicting. Once validators adopt + // BlockTagService the L1-derived finalized value will naturally + // supersede this hint. + // + // Best-effort: block import already succeeded, so don't fail the + // whole call if only the tag update encounters an issue. + if let Err(e) = self.set_block_tags(block_hash, block_hash).await { tracing::warn!( target: "morph::engine", block_hash = %block_hash, From a24e7ffa0ba75e43072ad7312b2a4f7449f12c1c Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 11 Mar 2026 17:51:34 +0800 Subject: [PATCH 29/35] fix: use journal checkpoint/revert for EVM internal calls Replace logs.truncate with checkpoint_revert in evm_call_balance_of and transfer_erc20_with_evm to properly isolate side effects (storage writes, logs, account touches) from helper EVM calls, matching go-ethereum's StaticCall semantics. Remove unused track_forced_cold_slot_from_state. --- crates/evm/src/evm.rs | 2 +- crates/revm/src/evm.rs | 189 +++++++++------ crates/revm/src/handler.rs | 480 +++++++++++++++++++++++++++++++++---- crates/revm/src/l1block.rs | 9 - crates/revm/src/lib.rs | 2 + crates/revm/src/runtime.rs | 58 +++++ 6 files changed, 610 insertions(+), 130 deletions(-) create mode 100644 crates/revm/src/runtime.rs diff --git a/crates/evm/src/evm.rs b/crates/evm/src/evm.rs index 04ee14f..4c64400 100644 --- a/crates/evm/src/evm.rs +++ b/crates/evm/src/evm.rs @@ -69,7 +69,7 @@ impl MorphEvm { .with_block(input.block_env) .with_cfg(input.cfg_env) .with_tx(Default::default()) - .with_chain(morph_revm::L1BlockInfo::default()); + .with_chain(morph_revm::MorphTxRuntime::default()); // Build the inner MorphEvm which creates precompiles once. // Derive the PrecompilesMap from the inner's precompiles to avoid diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index 5ea19e2..340224f 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -1,5 +1,5 @@ use crate::{ - MorphBlockEnv, MorphTxEnv, l1block::L1BlockInfo, precompiles::MorphPrecompiles, + MorphBlockEnv, MorphTxEnv, precompiles::MorphPrecompiles, runtime::MorphTxRuntime, token_fee::TokenFeeInfo, }; use alloy_evm::Database; @@ -17,21 +17,21 @@ use revm::{ }, inspector::InspectorEvmTr, interpreter::{ - Host, Instruction, InstructionContext, interpreter::EthInterpreter, - interpreter_types::StackTr, + Host, Instruction, InstructionContext, InstructionResult, + interpreter::EthInterpreter, + interpreter_types::{RuntimeFlag, StackTr}, + }, + primitives::{ + BLOCK_HASH_HISTORY, + hardfork::SpecId::{BERLIN, ISTANBUL}, }, - primitives::BLOCK_HASH_HISTORY, }; /// The Morph EVM context type. /// -/// Uses [`L1BlockInfo`] as the `CHAIN` parameter. Only the -/// `had_token_fee_deduction` flag is used from the chain field (to signal -/// `sload_morph` when original_value restoration is needed). L1 fee -/// parameters are fetched per-tx in the handler to reflect mid-block -/// oracle updates from the external gas-oracle service. +/// Uses [`MorphTxRuntime`] as the extra per-transaction runtime state payload. pub type MorphContext = - Context, DB, Journal, L1BlockInfo>; + Context, DB, Journal, MorphTxRuntime>; #[inline] fn as_u64_saturated(value: U256) -> u64 { @@ -83,54 +83,31 @@ fn blockhash_morph( *number = morph_blockhash_result(chain_id_u64, current_number_u64, requested_number_u64); } +#[inline] +fn restore_tracked_original_value( + context: &mut InstructionContext<'_, MorphContext, EthInterpreter>, + address: alloy_primitives::Address, + slot: U256, +) -> Option { + context.host.chain.restore_tracked_original_value( + &mut context.host.journaled_state.inner.state, + address, + slot, + ) +} + /// Morph custom SLOAD opcode. /// -/// Fixes `original_value` corruption caused by revm's `mark_warm_with_transaction_id()`. -/// -/// When token fee deduction marks storage slots cold via `mark_cold()`, the main -/// tx's first SLOAD triggers `mark_warm_with_transaction_id()` which resets -/// `original_value = present_value` (the fee-modified value), losing the true -/// pre-fee original. This makes SSTORE see "clean" slots (2900 gas) instead of -/// "dirty" (100 gas), causing a 2800 gas mismatch vs go-eth. -/// -/// Fix: save the slot's `original_value` from journal state **before** -/// `sload_skip_cold_load` corrupts it, then restore after. This is correct for -/// multi-tx blocks because the journal's `original_value` at that point reflects -/// the post-previous-tx committed value (set by `mark_warm` during fee deduction's -/// own SLOAD), matching go-eth's `originStorage` semantics. -fn sload_morph(context: InstructionContext<'_, MorphContext, EthInterpreter>) { - let Some(([], index)) = StackTr::popn_top::<0>(&mut context.interpreter.stack) else { +/// Restores tx-original values for forced-cold slots after revm warms them. +fn sload_morph( + mut context: InstructionContext<'_, MorphContext, EthInterpreter>, +) { + let Some([key]) = StackTr::popn::<1>(&mut context.interpreter.stack) else { context.interpreter.halt_underflow(); return; }; let target = context.interpreter.input.target_address; - let key = *index; // Save key before it gets overwritten with the loaded value - - // When token fee deduction occurred, save original_value BEFORE sload_skip_cold_load. - // - // Token fee deduction calls mark_cold() on slots it touched. The subsequent - // sload_skip_cold_load → mark_warm_with_transaction_id() sees is_cold=true and - // resets original_value = present_value (the fee-modified value), losing the true - // pre-fee original. We save it here and restore after the call. - // - // This is superior to reading database.storage() because DB returns the beginning- - // of-block value, which is wrong when a previous transaction in the same block - // modified the same slot. The journal state's original_value at this point correctly - // reflects the post-previous-tx committed value (set by mark_warm during fee - // deduction's own SLOAD). - let saved_original = if context.host.chain.had_token_fee_deduction { - context - .host - .journaled_state - .inner - .state - .get(&target) - .and_then(|acc| acc.storage.get(&key)) - .map(|slot| slot.original_value) - } else { - None - }; let additional_cold_cost = context.host.gas_params().cold_storage_additional_cost(); let skip_cold = context.interpreter.gas.remaining() < additional_cold_cost; @@ -139,34 +116,111 @@ fn sload_morph(context: InstructionContext<'_, MorphContext, E match res { Ok(storage) => { if storage.is_cold { - // Restore original_value that mark_warm_with_transaction_id corrupted. - // saved_original is Some only when: - // 1. Token fee deduction occurred (had_token_fee_deduction = true) - // 2. The slot was already in journal state (touched by fee deduction) - // For slots first loaded here (not in state before), saved_original is - // None and sload_skip_cold_load already set original_value correctly - // from the DB load. - if let Some(saved) = saved_original - && let Some(acc) = context.host.journaled_state.inner.state.get_mut(&target) - && let Some(slot) = acc.storage.get_mut(&key) - { - slot.original_value = saved; - } - - // Charge cold SLOAD gas (same as default SLOAD) + let _ = restore_tracked_original_value(&mut context, target, key); if !context.interpreter.gas.record_cost(additional_cold_cost) { context.interpreter.halt_oog(); return; } } - *index = storage.data; + let _ = context.interpreter.stack.push(storage.data); } Err(LoadError::ColdLoadSkipped) => context.interpreter.halt_oog(), Err(LoadError::DBError) => context.interpreter.halt_fatal(), } } +/// Morph custom SSTORE opcode. +/// +/// revm's standard SSTORE warms a cold slot through the same path as SLOAD, so +/// forced-cold token-fee slots need the same tx-original restoration before gas +/// accounting uses `SStoreResult::original_value`. +fn sstore_morph( + mut context: InstructionContext<'_, MorphContext, EthInterpreter>, +) { + if context.interpreter.runtime_flag.is_static() { + context + .interpreter + .halt(InstructionResult::StateChangeDuringStaticCall); + return; + } + + let Some([index, value]) = StackTr::popn::<2>(&mut context.interpreter.stack) else { + context.interpreter.halt_underflow(); + return; + }; + + let target = context.interpreter.input.target_address; + let spec_id = context.interpreter.runtime_flag.spec_id(); + + if spec_id.is_enabled_in(ISTANBUL) + && context.interpreter.gas.remaining() <= context.host.gas_params().call_stipend() + { + context + .interpreter + .halt(InstructionResult::ReentrancySentryOOG); + return; + } + + if !context + .interpreter + .gas + .record_cost(context.host.gas_params().sstore_static_gas()) + { + context.interpreter.halt_oog(); + return; + } + + let mut state_load = if spec_id.is_enabled_in(BERLIN) { + let additional_cold_cost = context.host.gas_params().cold_storage_additional_cost(); + let skip_cold = context.interpreter.gas.remaining() < additional_cold_cost; + match context + .host + .sstore_skip_cold_load(target, index, value, skip_cold) + { + Ok(load) => load, + Err(LoadError::ColdLoadSkipped) => { + context.interpreter.halt_oog(); + return; + } + Err(LoadError::DBError) => { + context.interpreter.halt_fatal(); + return; + } + } + } else { + let Some(load) = context.host.sstore(target, index, value) else { + context.interpreter.halt_fatal(); + return; + }; + load + }; + + if state_load.is_cold + && let Some(original_value) = restore_tracked_original_value(&mut context, target, index) + { + state_load.data.original_value = original_value; + } + + let is_istanbul = spec_id.is_enabled_in(ISTANBUL); + let dynamic_gas = context.host.gas_params().sstore_dynamic_gas( + is_istanbul, + &state_load.data, + state_load.is_cold, + ); + if !context.interpreter.gas.record_cost(dynamic_gas) { + context.interpreter.halt_oog(); + return; + } + + context.interpreter.gas.record_refund( + context + .host + .gas_params() + .sstore_refund(is_istanbul, &state_load.data), + ); +} + /// MorphEvm extends the Evm with Morph specific types and logic. #[derive(Debug, derive_more::Deref, derive_more::DerefMut)] #[expect(clippy::type_complexity)] @@ -220,6 +274,7 @@ impl MorphEvm { 0x54, Instruction::new(sload_morph::, WARM_STORAGE_READ_COST), ); + instructions.insert_instruction(0x55, Instruction::new(sstore_morph::, 0)); // SELFDESTRUCT is disabled in Morph instructions.insert_instruction(0xff, Instruction::unknown()); // BLOBHASH is disabled in Morph diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 40f85b7..5ce44b0 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -97,7 +97,7 @@ where evm.post_fee_logs.clear(); let (_, tx, _, journal, chain, _) = evm.ctx().all_mut(); - chain.had_token_fee_deduction = false; + chain.reset(); if tx.is_l1_msg() { let mut caller = journal.load_account_with_code_mut(tx.caller())?.data; @@ -401,14 +401,14 @@ where // Calculate token amount required for total fee let token_amount_required = token_fee_info.eth_to_token_amount(reimburse_eth); - // Get mutable access to journal components - let journal = evm.ctx().journal_mut(); - // Attempt token refund. Matches go-ethereum's refundGas() which silently logs // and continues on failure: "Continue execution even if refund fails - refund // should not cause transaction to fail" (state_transition.go:698). let refund_result = if let Some(balance_slot) = token_fee_info.balance_slot { - transfer_erc20_with_slot( + let (_, _, _, journal, runtime, _) = evm.ctx().all_mut(); + let from_storage_slot = compute_mapping_slot_for_address(balance_slot, beneficiary); + let to_storage_slot = compute_mapping_slot_for_address(balance_slot, caller); + let result = transfer_erc20_with_slot( journal, beneficiary, caller, @@ -416,7 +416,14 @@ where token_amount_required, balance_slot, ) - .map(|_| ()) + .map(|_| ()); + restore_tracked_original_values( + runtime, + &mut journal.state, + token_fee_info.token_address, + [from_storage_slot, to_storage_slot], + ); + result } else { // Cache refund Transfer logs separately, matching the pre_fee_logs // pattern from validate_and_deduct_token_fee. @@ -480,28 +487,34 @@ where )?; } - let (_, tx, cfg, journal, _, _) = evm.ctx_mut().all_mut(); - let caller_addr = tx.caller(); - let is_call = tx.kind().is_call(); + let caller_addr = evm.ctx_ref().tx().caller(); + let is_call = evm.ctx_ref().tx().kind().is_call(); // eth_call (disable_fee_charge): skip token fee deduction entirely. // Only nonce/code validation (above) and nonce bump are needed. // This matches the ETH path's disable_fee_charge semantics and ensures // eth_call is a pure simulation without token registry lookups, balance // checks, or ERC20 transfers. - if cfg.is_fee_charge_disabled() { + if evm.ctx_ref().cfg().is_fee_charge_disabled() { if is_call { - let mut caller = journal.load_account_with_code_mut(caller_addr)?.data; + let mut caller = evm + .ctx_mut() + .journal_mut() + .load_account_with_code_mut(caller_addr)? + .data; caller.bump_nonce(); } return Ok(()); } - let (block, tx, cfg, journal, _chain, _) = evm.ctx_mut().all_mut(); - let caller_addr = tx.caller(); - let beneficiary = block.beneficiary(); - let hardfork = *cfg.spec(); - let is_call = tx.kind().is_call(); + let beneficiary = evm.ctx_ref().block().beneficiary(); + let hardfork = *evm.ctx_ref().cfg().spec(); + let tx_value = evm.ctx_ref().tx().value(); + let rlp_bytes = evm.ctx_ref().tx().rlp_bytes.clone().unwrap_or_default(); + let gas_limit = evm.ctx_ref().tx().gas_limit(); + let fee_limit_from_tx = evm.ctx_ref().tx().fee_limit.unwrap_or_default(); + let basefee = evm.ctx_ref().block().basefee() as u128; + let effective_gas_price = evm.ctx_ref().tx().effective_gas_price(basefee); // Check that caller has enough ETH to cover the value transfer. // This matches go-ethereum's buyAltTokenGas() which checks @@ -509,9 +522,13 @@ where // Without this, the tx would proceed to EVM execution and fail there // (consuming gas), whereas go-ethereum rejects at the preCheck stage // (not consuming gas). - let tx_value = tx.value(); if !tx_value.is_zero() { - let caller_eth_balance = *journal.load_account_mut(caller_addr)?.data.balance(); + let caller_eth_balance = *evm + .ctx_mut() + .journal_mut() + .load_account_mut(caller_addr)? + .data + .balance(); if caller_eth_balance < tx_value { return Err(MorphInvalidTransaction::EthInvalidTransaction( InvalidTransaction::LackOfFundForMaxFee { @@ -523,30 +540,27 @@ where } } - let token_fee_info = - TokenFeeInfo::load_for_caller(journal.db_mut(), token_id, caller_addr, hardfork)? - .ok_or(MorphInvalidTransaction::TokenNotRegistered(token_id))?; + // Fetch token fee info from Token Registry + let token_fee_info = TokenFeeInfo::load_for_caller( + evm.ctx_mut().journal_mut().db_mut(), + token_id, + caller_addr, + hardfork, + )? + .ok_or(MorphInvalidTransaction::TokenNotRegistered(token_id))?; if !token_fee_info.is_active { return Err(MorphInvalidTransaction::TokenNotActive(token_id).into()); } - let rlp_bytes = tx - .rlp_bytes - .as_ref() - .map(|b| b.as_ref()) - .unwrap_or_default(); - + // Get RLP-encoded transaction bytes for L1 fee calculation // Fetch L1 block info per-tx (same rationale as validate_and_deduct_eth_fee). - let l1_block_info = L1BlockInfo::try_fetch(journal.db_mut(), hardfork)?; - let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes, hardfork); + let l1_block_info = L1BlockInfo::try_fetch(evm.ctx_mut().journal_mut().db_mut(), hardfork)?; + let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes.as_ref(), hardfork); // Calculate L2 gas fee using effective_gas_price (= min(gasTipCap + baseFee, gasFeeCap)), // matching go-ethereum's buyAltTokenGas() which uses st.gasPrice (effective gas price). // tx.gas_price() returns max_fee_per_gas and would overcharge when tip + basefee < feeCap. - let gas_limit = tx.gas_limit(); - let basefee = block.basefee() as u128; - let effective_gas_price = tx.effective_gas_price(basefee); let l2_gas_fee = U256::from(gas_limit).saturating_mul(U256::from(effective_gas_price)); // Total fee in ETH @@ -556,7 +570,7 @@ where let token_amount_required = token_fee_info.eth_to_token_amount(total_eth_fee); // Determine fee limit - let mut fee_limit = tx.fee_limit.unwrap_or_default(); + let mut fee_limit = fee_limit_from_tx; if fee_limit.is_zero() || fee_limit > token_fee_info.balance { fee_limit = token_fee_info.balance } @@ -574,6 +588,7 @@ where // Transfer with token slot. // Ensure token account is loaded into the journal state, because `sload`/`sstore` // assume the account is present. + let (_, _, _, journal, chain, _) = evm.ctx_mut().all_mut(); let _ = journal.load_account_mut(token_fee_info.token_address)?; journal.touch(token_fee_info.token_address); let (from_storage_slot, to_storage_slot) = transfer_erc20_with_slot( @@ -589,9 +604,19 @@ where if let Some(token_acc) = journal.state.get_mut(&token_fee_info.token_address) { token_acc.mark_cold(); if let Some(slot) = token_acc.storage.get_mut(&from_storage_slot) { + chain.track_forced_cold_slot( + token_fee_info.token_address, + from_storage_slot, + slot.original_value, + ); slot.mark_cold(); } if let Some(slot) = token_acc.storage.get_mut(&to_storage_slot) { + chain.track_forced_cold_slot( + token_fee_info.token_address, + to_storage_slot, + slot.original_value, + ); slot.mark_cold(); } } @@ -617,19 +642,26 @@ where // State changes should be marked cold to avoid warm access in the main tx execution. // finalize() clears journal state (including logs, which we already took above). let mut state = evm.finalize(); - state.iter_mut().for_each(|(_, acc)| { + let (_, _, _, _, chain, _) = evm.ctx_mut().all_mut(); + state.iter_mut().for_each(|(addr, acc)| { acc.mark_cold(); - acc.storage - .iter_mut() - .for_each(|(_, slot)| slot.mark_cold()); + acc.storage.iter_mut().for_each(|(key, slot)| { + if slot.original_value != slot.present_value { + chain.track_forced_cold_slot(*addr, *key, slot.original_value); + } + slot.mark_cold(); + }); }); evm.ctx_mut().journal_mut().state.extend(state); } // CREATE nonce is bumped later in make_create_frame if is_call { - let (_, _, _, journal, _, _) = evm.ctx().all_mut(); - let mut caller = journal.load_account_with_code_mut(caller_addr)?.data; + let mut caller = evm + .ctx_mut() + .journal_mut() + .load_account_with_code_mut(caller_addr)? + .data; caller.bump_nonce(); } @@ -638,13 +670,6 @@ where evm.cached_token_fee_info = Some(token_fee_info); evm.cached_l1_data_fee = l1_data_fee; - // Signal to sload_morph that token fee deduction with mark_cold() occurred, - // so it should fix original_value corruption from mark_warm_with_transaction_id. - { - let (_, _, _, _, chain, _) = evm.ctx().all_mut(); - chain.had_token_fee_deduction = true; - } - Ok(()) } } @@ -689,6 +714,19 @@ where Ok((from_storage_slot, to_storage_slot)) } +/// Restores tx-original values for tracked slots after direct journal access. +#[inline] +fn restore_tracked_original_values( + runtime: &crate::MorphTxRuntime, + state: &mut revm::state::EvmState, + address: Address, + slots: impl IntoIterator, +) { + slots.into_iter().for_each(|slot| { + let _ = runtime.restore_tracked_original_value(state, address, slot); + }); +} + /// Gas limit for internal EVM calls (ERC20 transfer, balanceOf). const EVM_CALL_GAS_LIMIT: u64 = 200_000; @@ -730,14 +768,12 @@ fn evm_call_balance_of(evm: &mut MorphEvm, token: Address, account where DB: alloy_evm::Database, { - // Record log count so we can discard any logs emitted during the call. - // go-ethereum uses evm.StaticCall() for balanceOf which cannot emit events; - // we truncate to match that read-only semantic. - let log_count_before = evm.ctx_mut().journal_mut().logs.len(); + // Snapshot the journal so this helper matches go-ethereum's StaticCall + // semantics even though we route through the normal CALL machinery. + let checkpoint = evm.ctx_mut().journal_mut().checkpoint(); let calldata = encode_balance_of_calldata(account); let result = evm_call(evm, Address::ZERO, token, calldata); - evm.ctx_mut().journal_mut().logs.truncate(log_count_before); - match result { + let balance = match result { Ok(ref result) if result.instruction_result().is_ok() => { let output = &result.interpreter_result().output; if output.len() >= 32 { @@ -747,7 +783,9 @@ where } } _ => U256::ZERO, - } + }; + evm.ctx_mut().journal_mut().checkpoint_revert(checkpoint); + balance } /// Matches go-ethereum's `transferAltTokenByEVM` validation: @@ -781,11 +819,13 @@ where Some(b) => b, None => evm_call_balance_of(evm, token_address, from), }; + let checkpoint = evm.ctx_mut().journal_mut().checkpoint(); let calldata = build_transfer_calldata(to, token_amount); let frame_result = match evm_call(evm, from, token_address, calldata) { Ok(result) => result, Err(e) => { + evm.ctx_mut().journal_mut().checkpoint_revert(checkpoint); evm.tx = tx_origin; return Err(MorphInvalidTransaction::TokenTransferFailed { reason: format!("Error: {e:?}"), @@ -795,6 +835,7 @@ where }; if !frame_result.instruction_result().is_ok() { + evm.ctx_mut().journal_mut().checkpoint_revert(checkpoint); evm.tx = tx_origin; return Err(MorphInvalidTransaction::TokenTransferFailed { reason: format!("{:?}", frame_result.interpreter_result()), @@ -808,6 +849,7 @@ where // - Otherwise: rejected let output = &frame_result.interpreter_result().output; if !output.is_empty() && (output.len() < 32 || output[31] != 1) { + evm.ctx_mut().journal_mut().checkpoint_revert(checkpoint); evm.tx = tx_origin; return Err(MorphInvalidTransaction::TokenTransferFailed { reason: "alt token transfer returned failure".to_string(), @@ -835,6 +877,7 @@ where }) })?; if from_balance_after != expected_balance { + evm.ctx_mut().journal_mut().checkpoint_revert(checkpoint); return Err(MorphInvalidTransaction::TokenTransferFailed { reason: format!( "sender balance mismatch: expected {expected_balance}, got {from_balance_after}" @@ -843,6 +886,7 @@ where .into()); } + evm.ctx_mut().journal_mut().checkpoint_commit(); Ok(()) } @@ -925,3 +969,333 @@ fn calculate_caller_fee_with_l1_cost( Ok(new_balance) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::MorphBlockEnv; + use alloy_primitives::{Bytes, address, keccak256}; + use morph_chainspec::hardfork::MorphHardfork; + use revm::{ + context::{BlockEnv, TxEnv}, + database::{CacheDB, EmptyDB}, + inspector::NoOpInspector, + state::{AccountInfo, Bytecode}, + }; + + fn sstore_code(slot: U256, value: u8) -> Bytes { + let mut code = Vec::with_capacity(1 + 1 + 1 + 32 + 2); + code.push(0x60); // PUSH1 value + code.push(value); + code.push(0x7f); // PUSH32 slot + code.extend_from_slice(&slot.to_be_bytes::<32>()); + code.push(0x55); // SSTORE + code.push(0x00); // STOP + Bytes::from(code) + } + + fn mutating_return_code(write_value: u8, return_value: u8) -> Bytes { + Bytes::from(vec![ + 0x60, + write_value, // PUSH1 write_value + 0x60, + 0x00, // PUSH1 slot 0 + 0x55, // SSTORE + 0x60, + return_value, // PUSH1 return_value + 0x60, + 0x00, // PUSH1 offset 0 + 0x52, // MSTORE + 0x60, + 0x20, // PUSH1 size 32 + 0x60, + 0x00, // PUSH1 offset 0 + 0xf3, // RETURN + ]) + } + + #[test] + fn forced_cold_slot_sstore_preserves_original_value() { + let caller = address!("1000000000000000000000000000000000000001"); + let token = address!("3000000000000000000000000000000000000003"); + let balance_slot = U256::from(7); + let caller_slot = compute_mapping_slot_for_address(balance_slot, caller); + + let original_balance = U256::from(100); + let deducted_balance = U256::from(90); + let final_balance = U256::from(80); + let contract_code = sstore_code(caller_slot, 80); + + let mut db = CacheDB::new(EmptyDB::default()); + db.insert_account_info( + caller, + AccountInfo { + balance: U256::from(1_000_000), + ..Default::default() + }, + ); + db.insert_account_info( + token, + AccountInfo { + code_hash: keccak256(contract_code.as_ref()), + code: Some(Bytecode::new_raw(contract_code)), + ..Default::default() + }, + ); + db.insert_account_storage(token, caller_slot, original_balance) + .unwrap(); + + let mut evm = MorphEvm::new( + MorphContext::new(db, MorphHardfork::default()), + NoOpInspector, + ); + evm.tx = MorphTxEnv { + inner: TxEnv { + caller, + kind: token.into(), + gas_limit: 100_000, + ..Default::default() + }, + ..Default::default() + }; + evm.block = MorphBlockEnv { + inner: BlockEnv::default(), + }; + + { + let (_, _, _, journal, chain, _) = evm.ctx().all_mut(); + let _ = journal.load_account_mut(token).unwrap(); + journal.touch(token); + journal + .sstore(token, caller_slot, deducted_balance) + .unwrap(); + + let token_account = journal.state.get_mut(&token).unwrap(); + token_account.mark_cold(); + token_account + .storage + .get_mut(&caller_slot) + .unwrap() + .mark_cold(); + + chain.track_forced_cold_slot(token, caller_slot, original_balance); + } + + let frame_result = MorphEvmHandler::, NoOpInspector>::new() + .execution(&mut evm, &InitialAndFloorGas::new(0, 0)) + .unwrap(); + assert!(frame_result.instruction_result().is_ok()); + + let slot_state = evm + .ctx_ref() + .journal() + .state + .get(&token) + .and_then(|account| account.storage.get(&caller_slot)) + .unwrap(); + + assert_eq!(slot_state.present_value, final_balance); + assert_eq!(slot_state.original_value, original_balance); + } + + #[test] + fn reimburse_token_fee_preserves_original_values_for_touched_slots() { + let caller = address!("1000000000000000000000000000000000000001"); + let beneficiary = address!("2000000000000000000000000000000000000002"); + let token = address!("3000000000000000000000000000000000000003"); + let balance_slot = U256::from(7); + let caller_slot = compute_mapping_slot_for_address(balance_slot, caller); + let beneficiary_slot = compute_mapping_slot_for_address(balance_slot, beneficiary); + + let caller_balance = U256::from(100); + let beneficiary_balance = U256::from(50); + let deducted = U256::from(10); + let refunded = U256::from(4); + + let mut db = CacheDB::new(EmptyDB::default()); + db.insert_account_info(token, AccountInfo::default()); + db.insert_account_storage(token, caller_slot, caller_balance) + .unwrap(); + db.insert_account_storage(token, beneficiary_slot, beneficiary_balance) + .unwrap(); + + let mut evm = MorphEvm::new( + MorphContext::new(db, MorphHardfork::default()), + NoOpInspector, + ); + evm.tx = MorphTxEnv { + inner: revm::context::TxEnv { + caller, + gas_price: 1, + gas_limit: 100_000, + ..Default::default() + }, + fee_token_id: Some(5), + ..Default::default() + }; + evm.block = MorphBlockEnv { + inner: BlockEnv { + beneficiary, + ..Default::default() + }, + }; + evm.cached_token_fee_info = Some(TokenFeeInfo { + token_address: token, + price_ratio: U256::from(1), + scale: U256::from(1), + balance_slot: Some(balance_slot), + ..Default::default() + }); + + { + let (_, _, _, journal, chain, _) = evm.ctx().all_mut(); + let _ = journal.load_account_mut(token).unwrap(); + journal.touch(token); + let (from_storage_slot, to_storage_slot) = transfer_erc20_with_slot( + journal, + caller, + beneficiary, + token, + deducted, + balance_slot, + ) + .unwrap(); + + let token_account = journal.state.get_mut(&token).unwrap(); + token_account.mark_cold(); + token_account + .storage + .get_mut(&from_storage_slot) + .unwrap() + .mark_cold(); + chain.track_forced_cold_slot(token, from_storage_slot, caller_balance); + token_account + .storage + .get_mut(&to_storage_slot) + .unwrap() + .mark_cold(); + chain.track_forced_cold_slot(token, to_storage_slot, beneficiary_balance); + } + + let mut gas = Gas::new(10); + gas.set_spent(6); + + MorphEvmHandler::, NoOpInspector>::new() + .reimburse_caller_token_fee(&mut evm, &gas) + .unwrap(); + + let token_account = evm.ctx_ref().journal().state.get(&token).unwrap(); + let caller_slot_state = token_account.storage.get(&caller_slot).unwrap(); + let beneficiary_slot_state = token_account.storage.get(&beneficiary_slot).unwrap(); + + assert_eq!(caller_slot_state.original_value, caller_balance); + assert_eq!( + caller_slot_state.present_value, + caller_balance - deducted + refunded + ); + assert_eq!(beneficiary_slot_state.original_value, beneficiary_balance); + assert_eq!( + beneficiary_slot_state.present_value, + beneficiary_balance + deducted - refunded + ); + } + + #[test] + fn transfer_erc20_with_evm_reverts_state_on_validation_failure() { + let from = address!("1000000000000000000000000000000000000001"); + let to = address!("2000000000000000000000000000000000000002"); + let token = address!("3000000000000000000000000000000000000003"); + let original_balance = U256::from(50); + let contract_code = mutating_return_code(1, 0); + + let mut db = CacheDB::new(EmptyDB::default()); + db.insert_account_info( + from, + AccountInfo { + balance: U256::from(1_000_000), + ..Default::default() + }, + ); + db.insert_account_info( + token, + AccountInfo { + code_hash: keccak256(contract_code.as_ref()), + code: Some(Bytecode::new_raw(contract_code)), + ..Default::default() + }, + ); + db.insert_account_storage(token, U256::ZERO, original_balance) + .unwrap(); + + let mut evm = MorphEvm::new( + MorphContext::new(db, MorphHardfork::default()), + NoOpInspector, + ); + evm.block = MorphBlockEnv { + inner: BlockEnv::default(), + }; + + let err = transfer_erc20_with_evm( + &mut evm, + from, + to, + token, + U256::from(4), + Some(original_balance), + ) + .unwrap_err(); + + assert!(matches!( + err, + EVMError::Transaction(MorphInvalidTransaction::TokenTransferFailed { .. }) + )); + let slot_state = evm + .ctx_ref() + .journal() + .state + .get(&token) + .and_then(|account| account.storage.get(&U256::ZERO)) + .unwrap(); + assert_eq!(slot_state.present_value, original_balance); + } + + #[test] + fn evm_call_balance_of_is_read_only() { + let token = address!("3000000000000000000000000000000000000003"); + let account = address!("1000000000000000000000000000000000000001"); + let original_balance = U256::from(50); + let contract_code = mutating_return_code(1, 42); + + let mut db = CacheDB::new(EmptyDB::default()); + db.insert_account_info( + token, + AccountInfo { + code_hash: keccak256(contract_code.as_ref()), + code: Some(Bytecode::new_raw(contract_code)), + ..Default::default() + }, + ); + db.insert_account_storage(token, U256::ZERO, original_balance) + .unwrap(); + + let mut evm = MorphEvm::new( + MorphContext::new(db, MorphHardfork::default()), + NoOpInspector, + ); + evm.block = MorphBlockEnv { + inner: BlockEnv::default(), + }; + + let balance = evm_call_balance_of(&mut evm, token, account); + + assert_eq!(balance, U256::from(42)); + let slot_state = evm + .ctx_ref() + .journal() + .state + .get(&token) + .and_then(|acct| acct.storage.get(&U256::ZERO)) + .unwrap(); + assert_eq!(slot_state.present_value, original_balance); + } +} diff --git a/crates/revm/src/l1block.rs b/crates/revm/src/l1block.rs index f38aed6..cbe5b6a 100644 --- a/crates/revm/src/l1block.rs +++ b/crates/revm/src/l1block.rs @@ -145,14 +145,6 @@ pub struct L1BlockInfo { pub l1_blob_scalar: U256, /// The current call data gas: `l1_commit_scalar * l1_base_fee` (zero if before Curie). pub calldata_gas: U256, - /// Set when token fee deduction occurred in the current transaction. - /// - /// Token fee deduction calls `mark_cold()` on modified storage slots. The - /// main tx's first SLOAD then triggers `mark_warm_with_transaction_id()` - /// which incorrectly resets `original_value = present_value`. This flag - /// tells `sload_morph` to restore the true DB-committed original value, - /// scoping the fix to only transactions that need it. - pub had_token_fee_deduction: bool, } impl L1BlockInfo { @@ -193,7 +185,6 @@ impl L1BlockInfo { l1_commit_scalar, l1_blob_scalar, calldata_gas, - had_token_fee_deduction: false, }) } } diff --git a/crates/revm/src/lib.rs b/crates/revm/src/lib.rs index 75727d6..338c95a 100644 --- a/crates/revm/src/lib.rs +++ b/crates/revm/src/lib.rs @@ -51,6 +51,7 @@ pub mod exec; pub mod handler; pub mod l1block; pub mod precompiles; +pub mod runtime; pub mod token_fee; mod tx; @@ -78,6 +79,7 @@ pub use l1block::{ L1BlockInfo, }; pub use precompiles::MorphPrecompiles; +pub use runtime::MorphTxRuntime; pub use token_fee::{ L2_TOKEN_REGISTRY_ADDRESS, TokenFeeInfo, compute_mapping_slot, compute_mapping_slot_for_address, encode_balance_of_calldata, query_erc20_balance, diff --git a/crates/revm/src/runtime.rs b/crates/revm/src/runtime.rs new file mode 100644 index 0000000..77eb37d --- /dev/null +++ b/crates/revm/src/runtime.rs @@ -0,0 +1,58 @@ +//! Morph transaction-scoped runtime state. +//! +//! This module stores per-transaction execution metadata that should not live in +//! the transaction input (`MorphTxEnv`) or fee-oracle data structures. + +use alloy_primitives::{Address, U256}; +use revm::{primitives::HashMap, state::EvmState}; + +/// Execution-scoped state for a single Morph transaction. +/// +/// The only tracked data today is the set of storage slots that Morph +/// intentionally re-marks cold after token-fee deduction. revm may later warm +/// these slots again and overwrite their `original_value`, so we retain the true +/// tx-original value here and restore it on every affected access path. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MorphTxRuntime { + forced_cold_slot_originals: HashMap<(Address, U256), U256>, +} + +impl MorphTxRuntime { + /// Clears all transaction-scoped state. + #[inline] + pub fn reset(&mut self) { + self.forced_cold_slot_originals.clear(); + } + + /// Records the true tx-original value for a slot that will be marked cold. + #[inline] + pub fn track_forced_cold_slot(&mut self, address: Address, slot: U256, original_value: U256) { + self.forced_cold_slot_originals + .entry((address, slot)) + .or_insert(original_value); + } + + /// Returns the tracked tx-original value for a forced-cold slot, if any. + #[inline] + pub fn tracked_original_value(&self, address: Address, slot: U256) -> Option { + self.forced_cold_slot_originals + .get(&(address, slot)) + .copied() + } + + /// Restores a tracked tx-original value into journal state. + #[inline] + pub fn restore_tracked_original_value( + &self, + state: &mut EvmState, + address: Address, + slot: U256, + ) -> Option { + let original_value = self.tracked_original_value(address, slot)?; + let slot_state = state + .get_mut(&address) + .and_then(|account| account.storage.get_mut(&slot))?; + slot_state.original_value = original_value; + Some(original_value) + } +} From 2cb667b42c317958509f7e1cc40f4031dab92fd8 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 11 Mar 2026 17:52:03 +0800 Subject: [PATCH 30/35] refactor: remove should_compute_state_root override and export fetch_geth_disk_root Remove custom should_compute_state_root to use the default trait behavior. Make fetch_geth_disk_root public for use by the state-root-check CLI tool. --- crates/node/src/validator.rs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/crates/node/src/validator.rs b/crates/node/src/validator.rs index 4b88d56..99e897c 100644 --- a/crates/node/src/validator.rs +++ b/crates/node/src/validator.rs @@ -15,8 +15,7 @@ use reth_chainspec::EthChainSpec; use reth_errors::ConsensusError; use reth_node_api::{ AddOnsContext, BlockTy, FullNodeComponents, InvalidPayloadAttributesError, NewPayloadError, - NodeTypes, PayloadAttributes, PayloadTypes, PayloadValidator, StateRootDecisionInput, - StateRootValidator, + NodeTypes, PayloadAttributes, PayloadTypes, PayloadValidator, StateRootValidator, }; use reth_node_builder::{ invalid_block_hook::InvalidBlockHookExt, @@ -295,13 +294,6 @@ impl PayloadValidator for MorphEngineValidator { } impl StateRootValidator for MorphEngineValidator { - fn should_compute_state_root(&self, input: &StateRootDecisionInput) -> bool { - // Long-term behavior: always compute after Jade. - // Temporary behavior: if geth RPC is configured, also compute before Jade - // so we can cross-check against geth's `morph_diskRoot`. - self.chain_spec.is_jade_active_at_timestamp(input.timestamp) || self.geth_rpc_url.is_some() - } - fn validate_state_root( &self, block: &RecoveredBlock, @@ -403,7 +395,7 @@ impl std::fmt::Display for JsonRpcError { /// This calls geth's `morph_diskRoot` method with the given block number to obtain /// the MPT-format state root (`diskRoot`) for cross-validation against reth's /// computed root. -fn fetch_geth_disk_root(geth_url: &str, block_number: u64) -> Result { +pub fn fetch_geth_disk_root(geth_url: &str, block_number: u64) -> Result { let block_hex = format!("0x{block_number:x}"); let body = serde_json::json!({ "jsonrpc": "2.0", From 22dd2202f128b7085c1f6d2198a2ba5cdee27018 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 11 Mar 2026 17:52:19 +0800 Subject: [PATCH 31/35] feat: add state-root-check CLI tool for MPT root comparison Standalone binary that opens reth DB read-only and computes the MPT state root at a given block, with optional geth morph_diskRoot cross-validation and bisect mode to find the first divergent block. --- Cargo.lock | 14 ++ Cargo.toml | 2 + bin/state-root-check/Cargo.toml | 24 ++++ bin/state-root-check/src/lib.rs | 76 +++++++++++ bin/state-root-check/src/main.rs | 217 +++++++++++++++++++++++++++++++ crates/node/src/node.rs | 6 + 6 files changed, 339 insertions(+) create mode 100644 bin/state-root-check/Cargo.toml create mode 100644 bin/state-root-check/src/lib.rs create mode 100644 bin/state-root-check/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 1997598..80ebb31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9882,6 +9882,20 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "state-root-check" +version = "0.1.0" +dependencies = [ + "alloy-primitives", + "clap", + "eyre", + "morph-chainspec", + "morph-node", + "reth-provider", + "reth-storage-api", + "reth-trie", +] + [[package]] name = "static_assertions" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index 6920ce1..c5807ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ publish = false resolver = "3" members = [ "bin/morph-reth", + "bin/state-root-check", "crates/chainspec", "crates/consensus", "crates/engine-api", @@ -103,6 +104,7 @@ reth-storage-api = { git = "https://github.com/morph-l2/reth", rev = "f13757781d reth-tasks = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } reth-tracing = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } reth-trie = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } +reth-trie-db = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } reth-transaction-pool = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e" } reth-zstd-compressors = { git = "https://github.com/morph-l2/reth", rev = "f13757781d2f6b0c1ec1c3e38f3ac32004bff24e", default-features = false } diff --git a/bin/state-root-check/Cargo.toml b/bin/state-root-check/Cargo.toml new file mode 100644 index 0000000..a84d786 --- /dev/null +++ b/bin/state-root-check/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "state-root-check" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[dependencies] +alloy-primitives.workspace = true +clap.workspace = true +eyre.workspace = true +morph-chainspec.workspace = true +morph-node.workspace = true +reth-provider.workspace = true +reth-storage-api.workspace = true +reth-trie.workspace = true + +[[bin]] +name = "state-root-check" +path = "src/main.rs" diff --git a/bin/state-root-check/src/lib.rs b/bin/state-root-check/src/lib.rs new file mode 100644 index 0000000..a675a8e --- /dev/null +++ b/bin/state-root-check/src/lib.rs @@ -0,0 +1,76 @@ +use alloy_primitives::B256; +use eyre::{Result, ensure}; + +#[cfg(test)] +mod tests { + use super::find_first_mismatch; + use eyre::Result; + + #[test] + fn returns_first_divergent_block_for_monotonic_range() -> Result<()> { + let mismatch_from = 103_u64; + + let got = find_first_mismatch(100, 105, |block| Ok(block < mismatch_from))?; + + assert_eq!(got, Some(mismatch_from)); + Ok(()) + } + + #[test] + fn returns_none_when_everything_matches() -> Result<()> { + let got = find_first_mismatch(100, 105, |_block| Ok(true))?; + + assert_eq!(got, None); + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlockRootComparison { + pub block_number: u64, + pub reth_root: B256, + pub geth_disk_root: B256, +} + +impl BlockRootComparison { + pub fn is_match(&self) -> bool { + self.reth_root == self.geth_disk_root + } +} + +pub fn find_first_mismatch(from: u64, to: u64, mut matches: F) -> Result> +where + F: FnMut(u64) -> Result, +{ + ensure!( + from <= to, + "invalid range: from-block {from} is greater than to-block {to}" + ); + + if !matches(from)? { + return Ok(Some(from)); + } + + if matches(to)? { + return Ok(None); + } + + let mut low = from.saturating_add(1); + let mut high = to; + let mut first = to; + + while low <= high { + let mid = low + (high - low) / 2; + if matches(mid)? { + low = mid.saturating_add(1); + } else { + first = mid; + if mid == 0 { + break; + } + high = mid - 1; + } + } + + Ok(Some(first)) +} diff --git a/bin/state-root-check/src/main.rs b/bin/state-root-check/src/main.rs new file mode 100644 index 0000000..1878112 --- /dev/null +++ b/bin/state-root-check/src/main.rs @@ -0,0 +1,217 @@ +use alloy_primitives::B256; +use clap::{Parser, ValueEnum}; +use eyre::{Context, ContextCompat, Result, ensure}; +use morph_chainspec::{MORPH_HOODI, MORPH_MAINNET, MorphChainSpec}; +use morph_node::{MorphNode, validator::fetch_geth_disk_root}; +use reth_provider::{ + ProviderFactory, + providers::{ProviderNodeTypes, ReadOnlyConfig}, +}; +use reth_storage_api::{BlockNumReader, StateRootProvider}; +use reth_trie::HashedPostState; +use state_root_check::{BlockRootComparison, find_first_mismatch}; +use std::{collections::HashMap, path::PathBuf, sync::Arc}; + +#[derive(Debug, Clone, Copy, Default, ValueEnum)] +enum ChainArg { + Morph, + #[default] + #[value(name = "morph-hoodi")] + MorphHoodi, +} + +impl ChainArg { + fn chain_spec(self) -> Arc { + match self { + Self::Morph => MORPH_MAINNET.clone(), + Self::MorphHoodi => MORPH_HOODI.clone(), + } + } +} + +#[derive(Debug, Parser)] +#[command(name = "state-root-check")] +#[command(about = "Compare local reth MPT roots with geth morph_diskRoot")] +struct Args { + #[arg(long)] + datadir: PathBuf, + #[arg(long, default_value = "morph-hoodi")] + chain: ChainArg, + #[arg(long)] + geth_rpc_url: Option, + #[arg(long, conflicts_with_all = ["fresh_root_at", "bisect"])] + block: Option, + #[arg(long, conflicts_with_all = ["block", "bisect"])] + fresh_root_at: Option, + #[arg(long, requires_all = ["from_block", "to_block"], conflicts_with_all = ["block", "fresh_root_at"])] + bisect: bool, + #[arg(long, requires = "bisect")] + from_block: Option, + #[arg(long, requires = "bisect")] + to_block: Option, +} + +enum Mode { + LocalRoot { + block: u64, + }, + Compare { + block: u64, + geth_rpc_url: String, + }, + Bisect { + from: u64, + to: u64, + geth_rpc_url: String, + }, +} + +fn main() -> Result<()> { + let args = Args::parse(); + ensure!( + args.datadir.exists(), + "datadir does not exist: {}", + args.datadir.display() + ); + + let factory = MorphNode::provider_factory_builder() + .open_read_only( + args.chain.chain_spec(), + ReadOnlyConfig::from_datadir(&args.datadir), + ) + .with_context(|| format!("failed to open datadir {}", args.datadir.display()))?; + + let latest_block = factory.best_block_number()?; + let mode = resolve_mode(&args, latest_block)?; + + match mode { + Mode::LocalRoot { block } => { + let root = compute_local_state_root(&factory, block)?; + println!("fresh_root_at #{block}: {root:#x}"); + } + Mode::Compare { + block, + geth_rpc_url, + } => { + let comparison = compare_block_roots(&factory, &geth_rpc_url, block)?; + print_comparison(&comparison); + } + Mode::Bisect { + from, + to, + geth_rpc_url, + } => { + let mut cache = HashMap::new(); + let first = find_first_mismatch(from, to, |block| { + if let Some(is_match) = cache.get(&block) { + return Ok(*is_match); + } + + let comparison = compare_block_roots(&factory, &geth_rpc_url, block)?; + let is_match = comparison.is_match(); + print_probe(&comparison); + cache.insert(block, is_match); + Ok(is_match) + })?; + + match first { + Some(block) => println!("first_mismatch: {block}"), + None => println!("all blocks matched in range [{from}, {to}]"), + } + } + } + + Ok(()) +} + +fn resolve_mode(args: &Args, latest_block: u64) -> Result { + if let Some(block) = args.fresh_root_at { + return Ok(Mode::LocalRoot { block }); + } + + if args.bisect { + let from = args + .from_block + .context("--from-block is required when --bisect is set")?; + let to = args + .to_block + .context("--to-block is required when --bisect is set")?; + ensure!( + from <= to, + "--from-block must be less than or equal to --to-block" + ); + return Ok(Mode::Bisect { + from, + to, + geth_rpc_url: args + .geth_rpc_url + .clone() + .context("--geth-rpc-url is required for --bisect")?, + }); + } + + let block = args.block.unwrap_or(latest_block); + if let Some(geth_rpc_url) = args.geth_rpc_url.clone() { + Ok(Mode::Compare { + block, + geth_rpc_url, + }) + } else { + Ok(Mode::LocalRoot { block }) + } +} + +fn compute_local_state_root(factory: &ProviderFactory, block: u64) -> Result +where + N: ProviderNodeTypes, +{ + let provider = factory + .history_by_block_number(block) + .with_context(|| format!("failed to open historical state at block {block}"))?; + let root = provider + .state_root(HashedPostState::default()) + .with_context(|| format!("failed to compute local state root at block {block}"))?; + Ok(root) +} + +fn compare_block_roots( + factory: &ProviderFactory, + geth_rpc_url: &str, + block: u64, +) -> Result +where + N: ProviderNodeTypes, +{ + let reth_root = compute_local_state_root(factory, block)?; + let geth_disk_root = + fetch_geth_disk_root(geth_rpc_url, block).map_err(|err| eyre::eyre!(err))?; + Ok(BlockRootComparison { + block_number: block, + reth_root, + geth_disk_root, + }) +} + +fn print_comparison(comparison: &BlockRootComparison) { + let status = if comparison.is_match() { + "MATCH" + } else { + "MISMATCH" + }; + println!("block #{}", comparison.block_number); + println!("reth_root: {:#x}", comparison.reth_root); + println!("geth_disk_root: {:#x}", comparison.geth_disk_root); + println!("status: {status}"); +} + +fn print_probe(comparison: &BlockRootComparison) { + let status = if comparison.is_match() { + "MATCH" + } else { + "MISMATCH" + }; + println!( + "probe #{} => {} reth={:#x} geth={:#x}", + comparison.block_number, status, comparison.reth_root, comparison.geth_disk_root + ); +} diff --git a/crates/node/src/node.rs b/crates/node/src/node.rs index 3951114..9ed7298 100644 --- a/crates/node/src/node.rs +++ b/crates/node/src/node.rs @@ -37,6 +37,7 @@ use reth_payload_primitives::PayloadAttributesBuilder; use reth_primitives_traits::SealedHeader; use reth_provider::{ BlockWriter, CanonChainTracker, DBProvider, DatabaseProviderFactory, EthStorage, + providers::ProviderFactoryBuilder, }; use std::sync::Arc; @@ -60,6 +61,11 @@ impl MorphNode { Self { args } } + /// Instantiates a [`ProviderFactoryBuilder`] for a Morph node. + pub fn provider_factory_builder() -> ProviderFactoryBuilder { + ProviderFactoryBuilder::default() + } + /// Returns a [`ComponentsBuilder`] configured for a Morph node. pub fn components( payload_builder_config: MorphBuilderConfig, From 90bb80050ec6cec0905e56678dc9688e7f1944f6 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 11 Mar 2026 17:52:33 +0800 Subject: [PATCH 32/35] chore: remove legacy-state-root flag and fix geth RPC URL default - Remove RETH_LEGACY_STATE_ROOT env var and --engine.legacy-state-root from both mainnet and hoodi reth-start scripts (always use parallel). - Fix MORPH_GETH_RPC_URL default assignment: use := -> = so that an explicitly empty value is preserved instead of being overridden. --- local-test-hoodi/reth-start.sh | 1 - local-test/common.sh | 2 +- local-test/reth-start.sh | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/local-test-hoodi/reth-start.sh b/local-test-hoodi/reth-start.sh index c87ae25..3359c0c 100755 --- a/local-test-hoodi/reth-start.sh +++ b/local-test-hoodi/reth-start.sh @@ -39,7 +39,6 @@ args=( --log.file.filter info --morph.max-tx-payload-bytes "${MORPH_MAX_TX_PAYLOAD_BYTES}" --nat none - --engine.legacy-state-root --engine.persistence-threshold 256 --engine.memory-block-buffer-target 16 ) diff --git a/local-test/common.sh b/local-test/common.sh index 5bb7fd4..e4dc438 100755 --- a/local-test/common.sh +++ b/local-test/common.sh @@ -35,7 +35,7 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" : "${RETH_BOOTNODES:=}" : "${MORPH_MAX_TX_PAYLOAD_BYTES:=122880}" : "${MORPH_MAX_TX_PER_BLOCK:=}" -: "${MORPH_GETH_RPC_URL:=http://localhost:8546}" +: "${MORPH_GETH_RPC_URL=http://localhost:8546}" check_binary() { local bin_path="$1" local build_hint="$2" diff --git a/local-test/reth-start.sh b/local-test/reth-start.sh index 9d04e20..1c45a9d 100755 --- a/local-test/reth-start.sh +++ b/local-test/reth-start.sh @@ -39,7 +39,6 @@ args=( --log.file.filter info --morph.max-tx-payload-bytes "${MORPH_MAX_TX_PAYLOAD_BYTES}" --nat none - --engine.legacy-state-root --engine.persistence-threshold 256 --engine.memory-block-buffer-target 16 ) From b3e63e9c9ab65372e5157112cb0801e874aa9b22 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 11 Mar 2026 17:52:48 +0800 Subject: [PATCH 33/35] chore: add sync speed benchmark scripts bench-sync.sh: automated Geth vs Reth sync speed comparison with BPS sampling and ETA calculation. bench-sync-multi.sh: multi-round wrapper with mean/min/max aggregation. --- local-test/bench-sync-multi.sh | 174 ++++++++++++++++++ local-test/bench-sync.sh | 310 +++++++++++++++++++++++++++++++++ 2 files changed, 484 insertions(+) create mode 100755 local-test/bench-sync-multi.sh create mode 100755 local-test/bench-sync.sh diff --git a/local-test/bench-sync-multi.sh b/local-test/bench-sync-multi.sh new file mode 100755 index 0000000..0dfcd27 --- /dev/null +++ b/local-test/bench-sync-multi.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Run bench-sync.sh multiple rounds and collect results. + +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" +cd "${REPO_ROOT}" + +: "${ROUNDS:=3}" +: "${TEST_DURATION:=300}" +: "${SKIP_GETH:=0}" +: "${SKIP_RETH:=0}" + +echo "==========================================================" +echo " Multi-Round Benchmark (${ROUNDS} rounds)" +echo " TEST_DURATION=${TEST_DURATION}s per config per round" +echo "==========================================================" +echo + +# Arrays to store per-round results +geth_bps_list=() +reth_bps_list=() + +geth_peak_list=() +reth_peak_list=() + +for round in $(seq 1 "${ROUNDS}"); do + echo "############################################################" + echo " ROUND ${round}/${ROUNDS}" + echo "############################################################" + echo + + # Capture output and parse results + output=$(TEST_DURATION="${TEST_DURATION}" \ + SKIP_GETH="${SKIP_GETH}" \ + SKIP_RETH="${SKIP_RETH}" \ + "${SCRIPT_DIR}/bench-sync.sh" 2>&1) + + echo "${output}" + echo + + # Parse Avg BPS from the RESULTS table + # Format: "Avg BPS 86.25 74.17" + avg_line=$(echo "${output}" | grep "^Avg BPS" || true) + if [[ -n "${avg_line}" ]]; then + geth_avg=$(echo "${avg_line}" | awk '{print $3}') + reth_avg=$(echo "${avg_line}" | awk '{print $4}') + + geth_bps_list+=("${geth_avg}") + reth_bps_list+=("${reth_avg}") + fi + + peak_line=$(echo "${output}" | grep "^Peak BPS" || true) + if [[ -n "${peak_line}" ]]; then + geth_peak=$(echo "${peak_line}" | awk '{print $3}') + reth_peak=$(echo "${peak_line}" | awk '{print $4}') + + geth_peak_list+=("${geth_peak}") + reth_peak_list+=("${reth_peak}") + fi + + echo + echo " Round ${round} complete." + echo +done + +# ─── Compute averages ───────────────────────────────────────────────────────── + +calc_avg() { + local arr=("$@") + local sum=0 + local count=0 + for v in "${arr[@]}"; do + if [[ "${v}" != "0" ]]; then + sum=$(echo "${sum} + ${v}" | bc) + count=$((count + 1)) + fi + done + if [[ ${count} -gt 0 ]]; then + echo "scale=2; ${sum} / ${count}" | bc + else + echo "0" + fi +} + +calc_min() { + local arr=("$@") + local min=999999 + for v in "${arr[@]}"; do + if [[ "${v}" != "0" ]] && [[ $(echo "${v} < ${min}" | bc -l) -eq 1 ]]; then + min="${v}" + fi + done + if [[ "${min}" == "999999" ]]; then echo "0"; else echo "${min}"; fi +} + +calc_max() { + local arr=("$@") + local max=0 + for v in "${arr[@]}"; do + if [[ $(echo "${v} > ${max}" | bc -l) -eq 1 ]]; then + max="${v}" + fi + done + echo "${max}" +} + +# ─── Final Summary ──────────────────────────────────────────────────────────── + +echo "==========================================================" +echo " MULTI-ROUND SUMMARY (${ROUNDS} rounds)" +echo "==========================================================" +echo + +# Per-round data +echo "--- Per-Round Avg BPS ---" +printf "%-8s %12s %12s\n" "Round" "Geth" "Reth" +for i in $(seq 0 $((ROUNDS - 1))); do + printf "%-8s %12s %12s\n" \ + "$((i + 1))" \ + "${geth_bps_list[$i]:-N/A}" \ + "${reth_bps_list[$i]:-N/A}" +done + +echo +echo "--- Aggregated Results (Avg BPS) ---" +geth_mean=$(calc_avg "${geth_bps_list[@]}") +reth_mean=$(calc_avg "${reth_bps_list[@]}") + +geth_min=$(calc_min "${geth_bps_list[@]}") +reth_min=$(calc_min "${reth_bps_list[@]}") + +geth_max=$(calc_max "${geth_bps_list[@]}") +reth_max=$(calc_max "${reth_bps_list[@]}") + +printf "%-12s %12s %12s\n" "" "Geth" "Reth" +printf "%-12s %12s %12s\n" "---" "---" "---" +printf "%-12s %12s %12s\n" "Mean" "${geth_mean}" "${reth_mean}" +printf "%-12s %12s %12s\n" "Min" "${geth_min}" "${reth_min}" +printf "%-12s %12s %12s\n" "Max" "${geth_max}" "${reth_max}" + +echo +echo "--- Peak BPS (per round) ---" +printf "%-8s %12s %12s\n" "Round" "Geth" "Reth" +for i in $(seq 0 $((ROUNDS - 1))); do + printf "%-8s %12s %12s\n" \ + "$((i + 1))" \ + "${geth_peak_list[$i]:-N/A}" \ + "${reth_peak_list[$i]:-N/A}" +done + +# Comparison +echo +echo "--- Geth vs Reth ---" +if [[ $(echo "${geth_mean} > 0 && ${reth_mean} > 0" | bc -l) -eq 1 ]]; then + if [[ $(echo "${reth_mean} > ${geth_mean}" | bc -l) -eq 1 ]]; then + diff_pct=$(echo "scale=1; (${reth_mean} - ${geth_mean}) * 100 / ${reth_mean}" | bc) + echo "Reth faster by ${diff_pct}% (mean: geth=${geth_mean}, reth=${reth_mean})" + elif [[ $(echo "${geth_mean} > ${reth_mean}" | bc -l) -eq 1 ]]; then + diff_pct=$(echo "scale=1; (${geth_mean} - ${reth_mean}) * 100 / ${geth_mean}" | bc) + echo "Geth faster by ${diff_pct}% (mean: geth=${geth_mean}, reth=${reth_mean})" + else + echo "Tie (mean: ${geth_mean})" + fi +else + echo "Insufficient data" +fi + +echo +echo "==========================================================" +echo " Multi-round benchmark complete" +echo "==========================================================" diff --git a/local-test/bench-sync.sh b/local-test/bench-sync.sh new file mode 100755 index 0000000..593e392 --- /dev/null +++ b/local-test/bench-sync.sh @@ -0,0 +1,310 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Benchmark: Geth vs Reth sync speed comparison. +# No geth RPC URL cross-validation — pure sync speed comparison. + +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" +cd "${REPO_ROOT}" + +# ─── Configuration ──────────────────────────────────────────────────────────── +: "${TEST_DURATION:=300}" # seconds to run each config (5 min default) +: "${RPC_WAIT_TIMEOUT:=60}" # seconds to wait for RPC readiness +: "${SAMPLE_INTERVAL:=10}" # seconds between BPS samples +: "${SKIP_GETH:=0}" # set to 1 to skip geth test +: "${SKIP_RETH:=0}" # set to 1 to skip reth test +: "${MAINNET_TIP:=21100000}" # approximate current mainnet tip for ETA calc + +# ─── Helpers ────────────────────────────────────────────────────────────────── + +get_block_number() { + local result + result=$(curl -s -X POST \ + -H "Content-Type: application/json" \ + --data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \ + "http://${RETH_HTTP_ADDR}:${RETH_HTTP_PORT}" 2>/dev/null | jq -r '.result // ""') + if [[ -n "${result}" && "${result}" != "null" ]]; then + printf "%d" "${result}" + else + echo "0" + fi +} + +wait_for_rpc() { + local name="$1" + local retries=0 + echo -n " Waiting for ${name} RPC..." + while [[ ${retries} -lt ${RPC_WAIT_TIMEOUT} ]]; do + if curl -s -X POST \ + -H "Content-Type: application/json" \ + --data '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}' \ + "http://${RETH_HTTP_ADDR}:${RETH_HTTP_PORT}" >/dev/null 2>&1; then + echo " ready" + return 0 + fi + retries=$((retries + 1)) + sleep 1 + done + echo " TIMEOUT" + return 1 +} + +run_bps_sampling() { + local name="$1" + local duration="$2" + local interval="${SAMPLE_INTERVAL}" + + local start_block end_block prev_block + local elapsed=0 sample_count=0 + local total_bps=0 peak_bps=0 + + start_block=$(get_block_number) + prev_block=${start_block} + + echo " Sampling BPS for ${name} (${duration}s, every ${interval}s)..." + + while [[ ${elapsed} -lt ${duration} ]]; do + sleep "${interval}" + elapsed=$((elapsed + interval)) + + local current_block + current_block=$(get_block_number) + local delta=$((current_block - prev_block)) + local bps + bps=$(echo "scale=2; ${delta} / ${interval}" | bc) + + sample_count=$((sample_count + 1)) + total_bps=$(echo "${total_bps} + ${bps}" | bc) + + if [[ $(echo "${bps} > ${peak_bps}" | bc -l) -eq 1 ]]; then + peak_bps=${bps} + fi + + printf " [%3ds] block=%d delta=+%d bps=%.2f\n" "${elapsed}" "${current_block}" "${delta}" "${bps}" + prev_block=${current_block} + done + + end_block=$(get_block_number) + local total_blocks=$((end_block - start_block)) + local avg_bps + avg_bps=$(echo "scale=2; ${total_blocks} / ${duration}" | bc) + + echo " ${name} sampling complete: ${start_block} -> ${end_block} (+${total_blocks} blocks)" + + RESULT_START_BLOCK=${start_block} + RESULT_END_BLOCK=${end_block} + RESULT_TOTAL_BLOCKS=${total_blocks} + RESULT_AVG_BPS=${avg_bps} + RESULT_PEAK_BPS=${peak_bps} +} + +full_reset() { + echo " Resetting all data..." + pm2_stop "morph-geth" 2>/dev/null || true + pm2_stop "morph-reth" 2>/dev/null || true + pm2_stop "morph-node" 2>/dev/null || true + + rm -rf "${RETH_DATA_DIR}/db" "${RETH_DATA_DIR}/static_files" + rm -rf "${GETH_DATA_DIR}/geth" + rm -rf "${NODE_HOME}/data" + mkdir -p "${RETH_DATA_DIR}" "${GETH_DATA_DIR}" "${NODE_HOME}/data" + + cat > "${NODE_HOME}/data/priv_validator_state.json" <<'EOF' +{"height":"0","round":0,"step":0} +EOF + + cleanup_runtime_logs + echo " Reset complete" +} + +stop_all() { + pm2_stop "morph-geth" 2>/dev/null || true + pm2_stop "morph-reth" 2>/dev/null || true + pm2_stop "morph-node" 2>/dev/null || true +} + +format_duration() { + local total_seconds=$1 + local days=$((total_seconds / 86400)) + local hours=$(( (total_seconds % 86400) / 3600 )) + local minutes=$(( (total_seconds % 3600) / 60 )) + if [[ ${days} -gt 0 ]]; then + printf "%dd %dh %dm" "${days}" "${hours}" "${minutes}" + elif [[ ${hours} -gt 0 ]]; then + printf "%dh %dm" "${hours}" "${minutes}" + else + printf "%dm" "${minutes}" + fi +} + +# ─── Main ───────────────────────────────────────────────────────────────────── + +echo "==========================================================" +echo " Morph Sync Speed Benchmark" +echo " Geth vs Reth" +echo "==========================================================" +echo " Duration per config: ${TEST_DURATION}s" +echo " Sample interval: ${SAMPLE_INTERVAL}s" +echo " Mainnet tip (est): ${MAINNET_TIP}" +echo "==========================================================" +echo + +pm2_check + +# Results storage +GETH_AVG_BPS=0; GETH_PEAK_BPS=0; GETH_START=0; GETH_END=0; GETH_TOTAL=0 +RETH_AVG_BPS=0; RETH_PEAK_BPS=0; RETH_START=0; RETH_END=0; RETH_TOTAL=0 + +# ─── Phase 1: Test Geth ────────────────────────────────────────────────────── + +if [[ "${SKIP_GETH}" != "1" ]]; then + check_binary "${GETH_BIN}" "cd ../morph/go-ethereum && make geth" + check_binary "${MORPHNODE_BIN}" "cd ../morph/node && make build" + + echo "=== Phase 1/2: Testing Geth ===" + echo + + full_reset + "${SCRIPT_DIR}/prepare.sh" 2>/dev/null + + echo " Starting morph-geth..." + "${SCRIPT_DIR}/geth-start.sh" + wait_for_rpc "geth" + + echo " Starting morphnode..." + "${SCRIPT_DIR}/node-start.sh" + + echo " Warming up (10s)..." + sleep 10 + + run_bps_sampling "geth" "${TEST_DURATION}" + GETH_AVG_BPS=${RESULT_AVG_BPS} + GETH_PEAK_BPS=${RESULT_PEAK_BPS} + GETH_START=${RESULT_START_BLOCK} + GETH_END=${RESULT_END_BLOCK} + GETH_TOTAL=${RESULT_TOTAL_BLOCKS} + + echo + echo " morphnode Block Sync Rate samples (geth):" + grep "Block Sync Rate" "${NODE_LOG_FILE}" 2>/dev/null | tail -5 | while read -r line; do + echo " ${line}" + done + + echo + echo " Stopping geth test..." + stop_all + + echo + echo "=== Geth test complete ===" + echo +else + echo "=== Skipping Geth test (SKIP_GETH=1) ===" + echo +fi + +# ─── Phase 2: Test Reth ────────────────────────────────────────────────────── + +if [[ "${SKIP_RETH}" != "1" ]]; then + check_binary "${RETH_BIN}" "cargo build --release --bin morph-reth" + check_binary "${MORPHNODE_BIN}" "cd ../morph/node && make build" + + echo "=== Phase 2/2: Testing Reth ===" + echo + + full_reset + "${SCRIPT_DIR}/prepare.sh" 2>/dev/null + + echo " Starting morph-reth..." + MORPH_GETH_RPC_URL="" "${SCRIPT_DIR}/reth-start.sh" + wait_for_rpc "reth" + + echo " Starting morphnode..." + "${SCRIPT_DIR}/node-start.sh" + + echo " Warming up (10s)..." + sleep 10 + + run_bps_sampling "reth" "${TEST_DURATION}" + RETH_AVG_BPS=${RESULT_AVG_BPS} + RETH_PEAK_BPS=${RESULT_PEAK_BPS} + RETH_START=${RESULT_START_BLOCK} + RETH_END=${RESULT_END_BLOCK} + RETH_TOTAL=${RESULT_TOTAL_BLOCKS} + + echo + echo " morphnode Block Sync Rate samples (reth):" + grep "Block Sync Rate" "${NODE_LOG_FILE}" 2>/dev/null | tail -5 | while read -r line; do + echo " ${line}" + done + + echo + echo " Stopping reth test..." + stop_all + + echo + echo "=== Reth test complete ===" + echo +else + echo "=== Skipping Reth test (SKIP_RETH=1) ===" + echo +fi + +# ─── Results ────────────────────────────────────────────────────────────────── + +echo "==========================================================" +echo " RESULTS" +echo "==========================================================" +echo + +printf "%-20s %12s %12s\n" "" "Geth" "Reth" +printf "%-20s %12s %12s\n" "---" "---" "---" +printf "%-20s %12d %12d\n" "Start Block" "${GETH_START}" "${RETH_START}" +printf "%-20s %12d %12d\n" "End Block" "${GETH_END}" "${RETH_END}" +printf "%-20s %12d %12d\n" "Total Blocks" "${GETH_TOTAL}" "${RETH_TOTAL}" +printf "%-20s %12s %12s\n" "Avg BPS" "${GETH_AVG_BPS}" "${RETH_AVG_BPS}" +printf "%-20s %12s %12s\n" "Peak BPS" "${GETH_PEAK_BPS}" "${RETH_PEAK_BPS}" + +# ETA calculation +echo +echo "--- Estimated Full Sync Time (to block ${MAINNET_TIP}) ---" + +if [[ $(echo "${GETH_AVG_BPS} > 0" | bc -l) -eq 1 ]]; then + geth_eta=$(echo "scale=0; ${MAINNET_TIP} / ${GETH_AVG_BPS}" | bc) + printf "%-20s %s (at %.2f bps)\n" "Geth:" "$(format_duration "${geth_eta}")" "${GETH_AVG_BPS}" +else + printf "%-20s N/A (no data)\n" "Geth:" +fi + +if [[ $(echo "${RETH_AVG_BPS} > 0" | bc -l) -eq 1 ]]; then + reth_eta=$(echo "scale=0; ${MAINNET_TIP} / ${RETH_AVG_BPS}" | bc) + printf "%-20s %s (at %.2f bps)\n" "Reth:" "$(format_duration "${reth_eta}")" "${RETH_AVG_BPS}" +else + printf "%-20s N/A (no data)\n" "Reth:" +fi + +# Comparison +echo +echo "--- Geth vs Reth ---" + +if [[ $(echo "${GETH_AVG_BPS} > 0 && ${RETH_AVG_BPS} > 0" | bc -l) -eq 1 ]]; then + if [[ $(echo "${GETH_AVG_BPS} > ${RETH_AVG_BPS}" | bc -l) -eq 1 ]]; then + diff_pct=$(echo "scale=1; (${GETH_AVG_BPS} - ${RETH_AVG_BPS}) * 100 / ${GETH_AVG_BPS}" | bc) + echo "Geth is faster by ${diff_pct}%" + echo " geth=${GETH_AVG_BPS} bps, reth=${RETH_AVG_BPS} bps" + elif [[ $(echo "${RETH_AVG_BPS} > ${GETH_AVG_BPS}" | bc -l) -eq 1 ]]; then + diff_pct=$(echo "scale=1; (${RETH_AVG_BPS} - ${GETH_AVG_BPS}) * 100 / ${RETH_AVG_BPS}" | bc) + echo "Reth is faster by ${diff_pct}%" + echo " geth=${GETH_AVG_BPS} bps, reth=${RETH_AVG_BPS} bps" + else + echo "Tie: both at ${GETH_AVG_BPS} bps" + fi +else + echo "Insufficient data for comparison" +fi + +echo +echo "==========================================================" +echo " Benchmark complete" +echo "==========================================================" From 79dc9410c427f6da85747b0d8c8ab915f8984543 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 11 Mar 2026 18:12:27 +0800 Subject: [PATCH 34/35] refactor: remove geth RPC cross-validation from sync path Remove --morph.geth-rpc-url CLI arg and all per-block geth diskRoot cross-validation during sync. This significantly improves sync speed by eliminating HTTP round-trips on every block. The standalone state-root-check tool (bin/state-root-check) is preserved for offline verification when needed. --- crates/node/src/add_ons.rs | 7 +--- crates/node/src/args.rs | 10 ----- crates/node/src/node.rs | 2 +- crates/node/src/validator.rs | 76 ++-------------------------------- local-test-hoodi/common.sh | 1 - local-test-hoodi/reth-start.sh | 5 --- local-test-hoodi/sync-test.sh | 8 +--- local-test/bench-sync.sh | 2 +- local-test/common.sh | 1 - local-test/reth-start.sh | 5 --- local-test/sync-test.sh | 8 +--- 11 files changed, 9 insertions(+), 116 deletions(-) diff --git a/crates/node/src/add_ons.rs b/crates/node/src/add_ons.rs index 9536ed9..9f5b6f8 100644 --- a/crates/node/src/add_ons.rs +++ b/crates/node/src/add_ons.rs @@ -50,12 +50,7 @@ where { /// Creates a new [`MorphAddOns`] with default configuration. pub fn new() -> Self { - Self::with_geth_rpc_url(None) - } - - /// Creates a new [`MorphAddOns`] with an optional geth RPC URL for state root validation. - pub fn with_geth_rpc_url(geth_rpc_url: Option) -> Self { - let pvb = MorphEngineValidatorBuilder::default().with_geth_rpc_url(geth_rpc_url); + let pvb = MorphEngineValidatorBuilder::default(); Self { inner: RpcAddOns::new( MorphEthApiBuilder::default(), diff --git a/crates/node/src/args.rs b/crates/node/src/args.rs index 5fe2394..f19e07a 100644 --- a/crates/node/src/args.rs +++ b/crates/node/src/args.rs @@ -33,15 +33,6 @@ pub struct MorphArgs { /// Morph Holesky testnet uses 1000 as the default limit. #[arg(long = "morph.max-tx-per-block", value_name = "COUNT")] pub max_tx_per_block: Option, - - /// Geth RPC URL for cross-validating MPT state root via `morph_diskRoot`. - /// - /// Before MPTFork, reth cannot validate ZK-trie state roots. When this URL - /// is set, reth calls the geth node's `morph_diskRoot` RPC to obtain the - /// MPT state root for each block and compares it with reth's computed root. - /// This catches state divergences that gas_used/receipts_root checks may miss. - #[arg(long = "morph.geth-rpc-url", value_name = "URL")] - pub geth_rpc_url: Option, } impl Default for MorphArgs { @@ -49,7 +40,6 @@ impl Default for MorphArgs { Self { max_tx_payload_bytes: MORPH_DEFAULT_MAX_TX_PAYLOAD_BYTES, max_tx_per_block: None, - geth_rpc_url: None, } } } diff --git a/crates/node/src/node.rs b/crates/node/src/node.rs index 9ed7298..e74a7ac 100644 --- a/crates/node/src/node.rs +++ b/crates/node/src/node.rs @@ -132,7 +132,7 @@ where } fn add_ons(&self) -> Self::AddOns { - MorphAddOns::with_geth_rpc_url(self.args.geth_rpc_url.clone()) + MorphAddOns::new() } } diff --git a/crates/node/src/validator.rs b/crates/node/src/validator.rs index 99e897c..9fdba00 100644 --- a/crates/node/src/validator.rs +++ b/crates/node/src/validator.rs @@ -23,7 +23,6 @@ use reth_node_builder::{ }; use reth_primitives_traits::{GotExpected, RecoveredBlock, SealedBlock}; use reth_provider::ChainSpecProvider; -use reth_tracing::tracing; use std::{collections::VecDeque, sync::Arc}; /// Builder for Morph engine validator (payload validation). @@ -31,18 +30,7 @@ use std::{collections::VecDeque, sync::Arc}; /// Creates a validator for validating engine API payloads. #[derive(Debug, Default, Clone)] #[non_exhaustive] -pub struct MorphEngineValidatorBuilder { - /// Optional geth RPC URL for cross-validating MPT state root via `morph_diskRoot`. - pub geth_rpc_url: Option, -} - -impl MorphEngineValidatorBuilder { - /// Sets the geth RPC URL for state root cross-validation. - pub fn with_geth_rpc_url(mut self, url: Option) -> Self { - self.geth_rpc_url = url; - self - } -} +pub struct MorphEngineValidatorBuilder; impl PayloadValidatorBuilder for MorphEngineValidatorBuilder where @@ -52,11 +40,7 @@ where type Validator = MorphEngineValidator; async fn build(self, ctx: &AddOnsContext<'_, Node>) -> eyre::Result { - let mut validator = MorphEngineValidator::new(ctx.node.provider().chain_spec()); - if let Some(url) = self.geth_rpc_url { - validator = validator.with_geth_rpc_url(url); - } - Ok(validator) + Ok(MorphEngineValidator::new(ctx.node.provider().chain_spec())) } } @@ -141,8 +125,6 @@ pub struct MorphEngineValidator { chain_spec: Arc, expected_withdraw_trie_roots: Arc>, expected_withdraw_trie_root_order: Arc>>, - /// Optional geth RPC URL for cross-validating MPT state root via `morph_diskRoot`. - geth_rpc_url: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -160,17 +142,9 @@ impl MorphEngineValidator { chain_spec, expected_withdraw_trie_roots: Arc::new(DashMap::new()), expected_withdraw_trie_root_order: Arc::new(Mutex::new(VecDeque::new())), - geth_rpc_url: None, } } - /// Sets the geth RPC URL for cross-validating MPT state root. - pub fn with_geth_rpc_url(mut self, url: String) -> Self { - tracing::info!(target: "engine::validator", %url, "Enabled state root cross-validation via geth diskRoot RPC"); - self.geth_rpc_url = Some(url); - self - } - fn record_withdraw_trie_root_expectation( &self, block_hash: B256, @@ -299,12 +273,11 @@ impl StateRootValidator for MorphEngineValida block: &RecoveredBlock, computed_state_root: B256, ) -> Result<(), ConsensusError> { - let block_number = block.header().number(); let jade_active = self .chain_spec .is_jade_active_at_timestamp(block.header().timestamp()); - // Always enforce canonical state-root equality in MPT mode. + // Enforce canonical state-root equality in MPT mode (post-Jade). if jade_active { let expected_state_root = block.header().state_root(); if computed_state_root != expected_state_root { @@ -318,48 +291,7 @@ impl StateRootValidator for MorphEngineValida } } - // Temporary cross-validation path: compare with geth's diskRoot when configured. - let Some(geth_url) = self.geth_rpc_url.as_deref() else { - return Ok(()); - }; - - match fetch_geth_disk_root(geth_url, block_number) { - Ok(disk_root) => { - if computed_state_root == disk_root { - tracing::debug!( - target: "engine::validator", - block_number, - ?computed_state_root, - "State root cross-validation passed" - ); - Ok(()) - } else { - tracing::error!( - target: "engine::validator", - block_number, - ?computed_state_root, - ?disk_root, - "State root cross-validation FAILED" - ); - Err(ConsensusError::BodyStateRootDiff( - GotExpected { - got: computed_state_root, - expected: disk_root, - } - .into(), - )) - } - } - Err(err) => { - tracing::warn!( - target: "engine::validator", - block_number, - %err, - "Failed to fetch diskRoot from geth, skipping state root validation" - ); - Ok(()) - } - } + Ok(()) } } diff --git a/local-test-hoodi/common.sh b/local-test-hoodi/common.sh index 87eaf57..c87cdf1 100755 --- a/local-test-hoodi/common.sh +++ b/local-test-hoodi/common.sh @@ -36,7 +36,6 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" : "${RETH_BOOTNODES:=}" : "${MORPH_MAX_TX_PAYLOAD_BYTES:=122880}" : "${MORPH_MAX_TX_PER_BLOCK:=}" -: "${MORPH_GETH_RPC_URL:=}" check_binary() { local bin_path="$1" local build_hint="$2" diff --git a/local-test-hoodi/reth-start.sh b/local-test-hoodi/reth-start.sh index 3359c0c..9556112 100755 --- a/local-test-hoodi/reth-start.sh +++ b/local-test-hoodi/reth-start.sh @@ -48,11 +48,6 @@ if [[ -n "${MORPH_MAX_TX_PER_BLOCK}" ]]; then args+=(--morph.max-tx-per-block "${MORPH_MAX_TX_PER_BLOCK}") fi -# Add optional geth RPC URL for state root cross-validation -if [[ -n "${MORPH_GETH_RPC_URL}" ]]; then - args+=(--morph.geth-rpc-url "${MORPH_GETH_RPC_URL}") -fi - # Add bootnodes if configured if [[ -n "${RETH_BOOTNODES}" ]]; then args+=(--bootnodes "${RETH_BOOTNODES}") diff --git a/local-test-hoodi/sync-test.sh b/local-test-hoodi/sync-test.sh index 7859a93..51640e1 100755 --- a/local-test-hoodi/sync-test.sh +++ b/local-test-hoodi/sync-test.sh @@ -12,7 +12,6 @@ cd "${REPO_ROOT}" : "${SAMPLE_INTERVAL:=10}" # seconds between BPS samples : "${SKIP_GETH:=0}" # set to 1 to skip geth test : "${SKIP_RETH:=0}" # set to 1 to skip reth test -: "${RETH_DISABLE_GETH_RPC_COMPARE:=1}" # 1: do NOT pass --morph.geth-rpc-url during reth benchmark : "${MAINNET_TIP:=21100000}" # approximate current mainnet tip for ETA calc # ─── Helpers ────────────────────────────────────────────────────────────────── @@ -243,12 +242,7 @@ if [[ "${SKIP_RETH}" != "1" ]]; then # Start reth echo " Starting morph-reth..." - if [[ "${RETH_DISABLE_GETH_RPC_COMPARE}" == "1" ]]; then - echo " Reth benchmark mode: disabling morph.geth-rpc-url" - MORPH_GETH_RPC_URL="" "${SCRIPT_DIR}/reth-start.sh" - else - "${SCRIPT_DIR}/reth-start.sh" - fi + "${SCRIPT_DIR}/reth-start.sh" # Wait for reth RPC wait_for_rpc "reth" diff --git a/local-test/bench-sync.sh b/local-test/bench-sync.sh index 593e392..27806e0 100755 --- a/local-test/bench-sync.sh +++ b/local-test/bench-sync.sh @@ -217,7 +217,7 @@ if [[ "${SKIP_RETH}" != "1" ]]; then "${SCRIPT_DIR}/prepare.sh" 2>/dev/null echo " Starting morph-reth..." - MORPH_GETH_RPC_URL="" "${SCRIPT_DIR}/reth-start.sh" + "${SCRIPT_DIR}/reth-start.sh" wait_for_rpc "reth" echo " Starting morphnode..." diff --git a/local-test/common.sh b/local-test/common.sh index e4dc438..2a71cc0 100755 --- a/local-test/common.sh +++ b/local-test/common.sh @@ -35,7 +35,6 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" : "${RETH_BOOTNODES:=}" : "${MORPH_MAX_TX_PAYLOAD_BYTES:=122880}" : "${MORPH_MAX_TX_PER_BLOCK:=}" -: "${MORPH_GETH_RPC_URL=http://localhost:8546}" check_binary() { local bin_path="$1" local build_hint="$2" diff --git a/local-test/reth-start.sh b/local-test/reth-start.sh index 1c45a9d..7400458 100755 --- a/local-test/reth-start.sh +++ b/local-test/reth-start.sh @@ -48,11 +48,6 @@ if [[ -n "${MORPH_MAX_TX_PER_BLOCK}" ]]; then args+=(--morph.max-tx-per-block "${MORPH_MAX_TX_PER_BLOCK}") fi -# Add optional geth RPC URL for state root cross-validation -if [[ -n "${MORPH_GETH_RPC_URL}" ]]; then - args+=(--morph.geth-rpc-url "${MORPH_GETH_RPC_URL}") -fi - # Add bootnodes if configured if [[ -n "${RETH_BOOTNODES}" ]]; then args+=(--bootnodes "${RETH_BOOTNODES}") diff --git a/local-test/sync-test.sh b/local-test/sync-test.sh index 7859a93..51640e1 100755 --- a/local-test/sync-test.sh +++ b/local-test/sync-test.sh @@ -12,7 +12,6 @@ cd "${REPO_ROOT}" : "${SAMPLE_INTERVAL:=10}" # seconds between BPS samples : "${SKIP_GETH:=0}" # set to 1 to skip geth test : "${SKIP_RETH:=0}" # set to 1 to skip reth test -: "${RETH_DISABLE_GETH_RPC_COMPARE:=1}" # 1: do NOT pass --morph.geth-rpc-url during reth benchmark : "${MAINNET_TIP:=21100000}" # approximate current mainnet tip for ETA calc # ─── Helpers ────────────────────────────────────────────────────────────────── @@ -243,12 +242,7 @@ if [[ "${SKIP_RETH}" != "1" ]]; then # Start reth echo " Starting morph-reth..." - if [[ "${RETH_DISABLE_GETH_RPC_COMPARE}" == "1" ]]; then - echo " Reth benchmark mode: disabling morph.geth-rpc-url" - MORPH_GETH_RPC_URL="" "${SCRIPT_DIR}/reth-start.sh" - else - "${SCRIPT_DIR}/reth-start.sh" - fi + "${SCRIPT_DIR}/reth-start.sh" # Wait for reth RPC wait_for_rpc "reth" From b887c7dc9c7df3d3e14c0c99271db2a99a266a59 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 11 Mar 2026 18:36:47 +0800 Subject: [PATCH 35/35] chore: add state-root-check wrapper scripts Add check-state-root.sh for both mainnet and hoodi that builds state-root-check and supports three modes: local-only, compare with geth, and bisect to find first mismatch. --- local-test-hoodi/check-state-root.sh | 82 ++++++++++++++++++++++++++++ local-test/check-state-root.sh | 82 ++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100755 local-test-hoodi/check-state-root.sh create mode 100755 local-test/check-state-root.sh diff --git a/local-test-hoodi/check-state-root.sh b/local-test-hoodi/check-state-root.sh new file mode 100755 index 0000000..5b88233 --- /dev/null +++ b/local-test-hoodi/check-state-root.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" +cd "${REPO_ROOT}" + +# ─── Configuration ──────────────────────────────────────────────────────────── +: "${STATE_ROOT_CHECK_BIN:=./target/release/state-root-check}" +: "${CHAIN:=morph-hoodi}" +: "${GETH_RPC_URL:=}" +: "${CHECK_BLOCK:=}" # specific block to check (default: latest) +: "${BISECT:=0}" # set to 1 to bisect for first mismatch +: "${BISECT_FROM:=0}" # bisect range start +: "${BISECT_TO:=}" # bisect range end (default: latest) + +# ─── Build ──────────────────────────────────────────────────────────────────── + +echo "Building state-root-check..." +cargo build --release -p state-root-check +echo "Build complete: ${STATE_ROOT_CHECK_BIN}" +echo + +# ─── Run ────────────────────────────────────────────────────────────────────── + +args=( + --datadir "${RETH_DATA_DIR}" + --chain "${CHAIN}" +) + +if [[ "${BISECT}" == "1" ]]; then + # Bisect mode: find first mismatch in a range + if [[ -z "${GETH_RPC_URL}" ]]; then + echo "ERROR: --bisect requires GETH_RPC_URL" + echo "Usage: GETH_RPC_URL=http://localhost:8546 BISECT=1 $0" + exit 1 + fi + args+=(--bisect --from-block "${BISECT_FROM}") + if [[ -n "${BISECT_TO}" ]]; then + args+=(--to-block "${BISECT_TO}") + else + # Default to latest block from reth RPC + latest=$(curl -s -X POST -H "Content-Type: application/json" \ + --data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \ + "http://${RETH_HTTP_ADDR}:${RETH_HTTP_PORT}" 2>/dev/null | jq -r '.result // ""') + if [[ -n "${latest}" && "${latest}" != "null" ]]; then + args+=(--to-block "$(printf "%d" "${latest}")") + else + echo "ERROR: cannot determine latest block. Is reth running?" + exit 1 + fi + fi + args+=(--geth-rpc-url "${GETH_RPC_URL}") + + echo "=== Bisect Mode ===" + echo " Range: ${BISECT_FROM} .. ${args[*]: -1}" + echo " Geth: ${GETH_RPC_URL}" +elif [[ -n "${GETH_RPC_URL}" ]]; then + # Compare mode: compare reth vs geth at a specific block + args+=(--geth-rpc-url "${GETH_RPC_URL}") + if [[ -n "${CHECK_BLOCK}" ]]; then + args+=(--block "${CHECK_BLOCK}") + echo "=== Compare Mode (block #${CHECK_BLOCK}) ===" + else + echo "=== Compare Mode (latest block) ===" + fi + echo " Geth: ${GETH_RPC_URL}" +else + # Local-only mode: just compute reth state root + if [[ -n "${CHECK_BLOCK}" ]]; then + args+=(--fresh-root-at "${CHECK_BLOCK}") + echo "=== Local Mode (block #${CHECK_BLOCK}) ===" + else + echo "=== Local Mode (latest block) ===" + fi +fi + +echo " Datadir: ${RETH_DATA_DIR}" +echo + +"${STATE_ROOT_CHECK_BIN}" "${args[@]}" diff --git a/local-test/check-state-root.sh b/local-test/check-state-root.sh new file mode 100755 index 0000000..29a7946 --- /dev/null +++ b/local-test/check-state-root.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" +cd "${REPO_ROOT}" + +# ─── Configuration ──────────────────────────────────────────────────────────── +: "${STATE_ROOT_CHECK_BIN:=./target/release/state-root-check}" +: "${CHAIN:=morph}" +: "${GETH_RPC_URL:=}" +: "${CHECK_BLOCK:=}" # specific block to check (default: latest) +: "${BISECT:=0}" # set to 1 to bisect for first mismatch +: "${BISECT_FROM:=0}" # bisect range start +: "${BISECT_TO:=}" # bisect range end (default: latest) + +# ─── Build ──────────────────────────────────────────────────────────────────── + +echo "Building state-root-check..." +cargo build --release -p state-root-check +echo "Build complete: ${STATE_ROOT_CHECK_BIN}" +echo + +# ─── Run ────────────────────────────────────────────────────────────────────── + +args=( + --datadir "${RETH_DATA_DIR}" + --chain "${CHAIN}" +) + +if [[ "${BISECT}" == "1" ]]; then + # Bisect mode: find first mismatch in a range + if [[ -z "${GETH_RPC_URL}" ]]; then + echo "ERROR: --bisect requires GETH_RPC_URL" + echo "Usage: GETH_RPC_URL=http://localhost:8546 BISECT=1 $0" + exit 1 + fi + args+=(--bisect --from-block "${BISECT_FROM}") + if [[ -n "${BISECT_TO}" ]]; then + args+=(--to-block "${BISECT_TO}") + else + # Default to latest block from reth RPC + latest=$(curl -s -X POST -H "Content-Type: application/json" \ + --data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \ + "http://${RETH_HTTP_ADDR}:${RETH_HTTP_PORT}" 2>/dev/null | jq -r '.result // ""') + if [[ -n "${latest}" && "${latest}" != "null" ]]; then + args+=(--to-block "$(printf "%d" "${latest}")") + else + echo "ERROR: cannot determine latest block. Is reth running?" + exit 1 + fi + fi + args+=(--geth-rpc-url "${GETH_RPC_URL}") + + echo "=== Bisect Mode ===" + echo " Range: ${BISECT_FROM} .. ${args[*]: -1}" + echo " Geth: ${GETH_RPC_URL}" +elif [[ -n "${GETH_RPC_URL}" ]]; then + # Compare mode: compare reth vs geth at a specific block + args+=(--geth-rpc-url "${GETH_RPC_URL}") + if [[ -n "${CHECK_BLOCK}" ]]; then + args+=(--block "${CHECK_BLOCK}") + echo "=== Compare Mode (block #${CHECK_BLOCK}) ===" + else + echo "=== Compare Mode (latest block) ===" + fi + echo " Geth: ${GETH_RPC_URL}" +else + # Local-only mode: just compute reth state root + if [[ -n "${CHECK_BLOCK}" ]]; then + args+=(--fresh-root-at "${CHECK_BLOCK}") + echo "=== Local Mode (block #${CHECK_BLOCK}) ===" + else + echo "=== Local Mode (latest block) ===" + fi +fi + +echo " Datadir: ${RETH_DATA_DIR}" +echo + +"${STATE_ROOT_CHECK_BIN}" "${args[@]}"