diff --git a/.config/nextest.toml b/.config/nextest.toml index 8933e2c..f652f4b 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,8 +1,21 @@ [profile.default] slow-timeout = { period = "30s", terminate-after = 4 } +# E2E integration tests spawn full nodes — give them more time +[[profile.default.overrides]] +filter = "package(morph-node) & binary(it)" +slow-timeout = { period = "120s", terminate-after = 3 } + [profile.ci] test-threads = "num-cpus" retries = { backoff = "exponential", count = 2, delay = "2s", jitter = true } fail-fast = false slow-timeout = { period = "30s", terminate-after = 4 } + +# E2E integration tests spawn full nodes — each needs exclusive MDBX resources. +# threads-required = 2 means nextest counts each as needing 2 of the test-threads +# slots, so only 1 runs at a time on CI (2 slots / 2 required = 1 concurrent). +[[profile.ci.overrides]] +filter = "package(morph-node) & binary(it)" +threads-required = 2 +slow-timeout = { period = "120s", terminate-after = 3 } diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 4c300cb..dccdc16 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -58,4 +58,5 @@ jobs: - name: Run Clippy run: cargo clippy --all --all-targets -- -D warnings - + - name: Run Clippy for feature-gated integration tests + run: cargo clippy -p morph-node --test it --features test-utils -- -D warnings diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bf0e04d..8e20a83 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,3 +58,27 @@ jobs: - name: Run doc tests run: cargo test --doc --all --verbose + + e2e: + name: E2E Tests + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + + - name: Install nextest + uses: taiki-e/install-action@v2 + with: + tool: cargo-nextest + + - name: Run E2E tests + run: cargo nextest run --profile ci -p morph-node --test it --features test-utils diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index 24fda2e..fb81c88 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -84,6 +84,11 @@ morph-payload-types.workspace = true morph-primitives.workspace = true serde_json.workspace = true +[[test]] +name = "it" +path = "tests/it/main.rs" +required-features = ["test-utils"] + [features] default = [] test-utils = [ diff --git a/crates/node/tests/it/block_building.rs b/crates/node/tests/it/block_building.rs new file mode 100644 index 0000000..7ae89ab --- /dev/null +++ b/crates/node/tests/it/block_building.rs @@ -0,0 +1,176 @@ +//! Block building integration tests. +//! +//! Verifies that the Morph payload builder correctly assembles blocks under +//! various conditions: empty blocks, pool transactions, and mixed L1+L2 ordering. + +use alloy_primitives::{Address, U256}; +use morph_node::test_utils::{ + L1MessageBuilder, TestNodeBuilder, advance_chain, advance_empty_block, +}; +use reth_payload_primitives::BuiltPayload; + +use super::helpers::{advance_block_with_l1_messages, wallet_to_arc}; + +/// An empty block (no pool transactions, no L1 messages) should be built +/// successfully with 0 transactions and valid header fields. +#[tokio::test(flavor = "multi_thread")] +async fn empty_block_has_no_transactions() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let payload = advance_empty_block(&mut node).await?; + let block = payload.block(); + + assert_eq!( + block.body().transactions.len(), + 0, + "empty block should have no transactions" + ); + assert_eq!(block.header().inner.number, 1, "block number should be 1"); + + Ok(()) +} + +/// A block containing a single EIP-1559 transfer transaction. +#[tokio::test(flavor = "multi_thread")] +async fn block_with_single_transfer() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let wallet = wallet_to_arc(wallet); + + let payloads = advance_chain(1, &mut node, wallet).await?; + + let block = payloads[0].block(); + assert_eq!(block.header().inner.number, 1); + assert_eq!( + block.body().transactions.len(), + 1, + "block should contain the transfer tx" + ); + + Ok(()) +} + +/// Advance 10 blocks with sequential transfers; verify block numbers are monotonic. +#[tokio::test(flavor = "multi_thread")] +async fn sequential_blocks_with_transfers() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let wallet = wallet_to_arc(wallet); + + let payloads = advance_chain(10, &mut node, wallet).await?; + + assert_eq!(payloads.len(), 10); + for (i, payload) in payloads.iter().enumerate() { + let block = payload.block(); + assert_eq!(block.header().inner.number, (i + 1) as u64); + assert_eq!(block.body().transactions.len(), 1); + } + + Ok(()) +} + +/// A block with a single L1 message at the start and no L2 transactions. +#[tokio::test(flavor = "multi_thread")] +async fn block_with_l1_message_only() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let l1_msg = L1MessageBuilder::new(0) + .with_target(Address::with_last_byte(0xAA)) + .with_value(U256::from(0)) + .with_gas_limit(50_000) + .build_encoded(); + + let payload = advance_block_with_l1_messages(&mut node, vec![l1_msg]).await?; + let block = payload.block(); + + assert_eq!(block.header().inner.number, 1); + assert_eq!( + block.body().transactions.len(), + 1, + "block should contain the L1 message" + ); + + Ok(()) +} + +/// A block with L1 messages preceding L2 pool transactions. +/// L1 messages must always appear first in the block. +#[tokio::test(flavor = "multi_thread")] +async fn l1_messages_precede_l2_transactions() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + // Inject L2 transaction into the pool first + let wallet_arc = wallet_to_arc(wallet); + let raw_tx = { + let mut w = wallet_arc.lock().await; + let nonce = w.inner_nonce; + w.inner_nonce += 1; + morph_node::test_utils::make_transfer_tx(w.chain_id, w.inner.clone(), nonce).await + }; + node.rpc.inject_tx(raw_tx).await?; + + // Build a block with an L1 message — L2 tx from pool should follow + let l1_msg = L1MessageBuilder::new(0) + .with_target(Address::with_last_byte(0xBB)) + .with_gas_limit(50_000) + .build_encoded(); + + let payload = advance_block_with_l1_messages(&mut node, vec![l1_msg]).await?; + let block = payload.block(); + + // Should have 2 transactions: 1 L1 message + 1 L2 transfer + assert_eq!( + block.body().transactions.len(), + 2, + "block should have 1 L1 message + 1 L2 tx" + ); + + // First transaction must be the L1 message (type 0x7E) + let first_tx = block.body().transactions.first().unwrap(); + assert!( + first_tx.is_l1_msg(), + "first transaction in block must be an L1 message" + ); + + Ok(()) +} + +/// Multiple L1 messages with strictly sequential queue indices in one block. +#[tokio::test(flavor = "multi_thread")] +async fn multiple_l1_messages_sequential_queue_indices() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let l1_msgs = L1MessageBuilder::build_sequential(0, 3); + + let payload = advance_block_with_l1_messages(&mut node, l1_msgs).await?; + let block = payload.block(); + + assert_eq!(block.body().transactions.len(), 3); + + for (expected_index, tx) in block.body().transactions.iter().enumerate() { + assert!(tx.is_l1_msg()); + assert_eq!( + tx.queue_index(), + Some(expected_index as u64), + "queue_index should be sequential" + ); + } + + Ok(()) +} diff --git a/crates/node/tests/it/consensus.rs b/crates/node/tests/it/consensus.rs new file mode 100644 index 0000000..07c5a46 --- /dev/null +++ b/crates/node/tests/it/consensus.rs @@ -0,0 +1,302 @@ +//! Consensus rule enforcement integration tests. +//! +//! Verifies that the Morph node correctly rejects blocks that violate protocol +//! consensus rules: +//! - L1 messages must precede all L2 transactions (ordering constraint) +//! - L1 messages within a block must have strictly sequential queue indices +//! - Post-Jade blocks with a wrong state root are rejected + +use alloy_primitives::B256; +use morph_node::test_utils::{ + HardforkSchedule, L1MessageBuilder, TestNodeBuilder, make_transfer_tx, +}; +use reth_payload_primitives::BuiltPayload; + +use super::helpers::{ + advance_block_with_l1_messages, build_block_no_submit, craft_and_try_import_block, + expect_payload_build_failure, +}; + +/// A block where an L2 transaction appears before an L1 message is rejected. +/// +/// Morph protocol requires that all L1 messages occupy the leading positions in +/// a block. A block with an L2 tx followed by an L1 msg violates this rule. +#[tokio::test(flavor = "multi_thread")] +async fn l1_message_after_l2_tx_is_rejected() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + // Inject an L2 transfer into the pool so that the payload builder picks it up. + let raw_tx = make_transfer_tx(wallet.chain_id, wallet.inner.clone(), 0).await; + node.rpc.inject_tx(raw_tx).await?; + + // Build a valid block: 1 L1 message + 1 L2 tx from pool (correct order). + let l1_msg = L1MessageBuilder::new(0).build_encoded(); + let base_payload = build_block_no_submit(&mut node, vec![l1_msg]).await?; + + // The valid block must have 2 transactions with L1 message first. + assert_eq!(base_payload.block().body().transactions.len(), 2); + assert!(base_payload.block().body().transactions[0].is_l1_msg()); + + // Swap the order so the L2 tx appears first and the L1 message comes second. + let accepted = craft_and_try_import_block(&mut node, &base_payload, |block| { + block.body.transactions.swap(0, 1); + }) + .await?; + + assert!( + !accepted, + "block with L2 tx before L1 message must be rejected by consensus" + ); + + Ok(()) +} + +/// Two L1 messages with the same queue index in one block are rejected at build time. +/// +/// Queue indices within a block must be strictly increasing. Duplicate indices +/// would create an ambiguous ordering and break the cross-block monotonicity +/// invariant tracked in the parent header's `next_l1_msg_index` field. +#[tokio::test(flavor = "multi_thread")] +async fn l1_message_duplicate_queue_index_rejected() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + // Both messages claim queue index 0 — this is a protocol violation. + let msg_a = L1MessageBuilder::new(0).build_encoded(); + let msg_b = L1MessageBuilder::new(0).build_encoded(); + + let error = expect_payload_build_failure(&mut node, vec![msg_a, msg_b]).await?; + + assert!( + error.to_lowercase().contains("queue index"), + "error message should mention queue index, got: {error}" + ); + + Ok(()) +} + +/// L1 messages with a gap in queue indices are rejected at build time. +/// +/// Queue indices must be contiguous. A gap (e.g. 0 then 2, skipping 1) means +/// a message was dropped, which is not allowed by the L2MessageQueue contract. +#[tokio::test(flavor = "multi_thread")] +async fn l1_message_gap_queue_index_rejected() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + // Index 0 then index 2 — index 1 is skipped. + let msg_0 = L1MessageBuilder::new(0).build_encoded(); + let msg_2 = L1MessageBuilder::new(2).build_encoded(); + + let error = expect_payload_build_failure(&mut node, vec![msg_0, msg_2]).await?; + + assert!( + error.to_lowercase().contains("queue index"), + "error message should mention queue index, got: {error}" + ); + + Ok(()) +} + +/// A post-Jade block with a tampered state root is rejected. +/// +/// After the Jade hardfork, morph-reth uses a standard MPT state root and +/// validates it on import. Any mismatch must cause the block to be INVALID. +#[tokio::test(flavor = "multi_thread")] +async fn post_jade_state_root_mismatch_is_rejected() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new() + .with_schedule(HardforkSchedule::AllActive) + .build() + .await?; + let mut node = nodes.pop().unwrap(); + + // Build a valid block without submitting it. + let base_payload = build_block_no_submit(&mut node, vec![]).await?; + + // Replace the state root with a bogus value and try to import. + let accepted = craft_and_try_import_block(&mut node, &base_payload, |block| { + block.header.inner.state_root = B256::from([0xFF; 32]); + }) + .await?; + + assert!( + !accepted, + "post-Jade block with wrong state root must be rejected" + ); + + Ok(()) +} + +/// A block whose number jumps ahead (parent is genesis at 0, block claims number 2) is rejected. +#[tokio::test(flavor = "multi_thread")] +async fn block_number_jump_rejected() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let base = build_block_no_submit(&mut node, vec![]).await?; + // Base block has number=1. Change to 2 -> gap + let accepted = craft_and_try_import_block(&mut node, &base, |block| { + block.header.inner.number = 2; + }) + .await?; + assert!(!accepted, "block number jump (0->2) should be rejected"); + Ok(()) +} + +/// A block pointing to a non-existent parent hash is not accepted as valid. +#[tokio::test(flavor = "multi_thread")] +async fn wrong_parent_hash_rejected() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let base = build_block_no_submit(&mut node, vec![]).await?; + let accepted = craft_and_try_import_block(&mut node, &base, |block| { + block.header.inner.parent_hash = alloy_primitives::B256::from([0xFF; 32]); + }) + .await?; + assert!( + !accepted, + "block with unknown parent hash should not be accepted" + ); + Ok(()) +} + +/// A block whose timestamp equals the parent's timestamp is rejected (pre-Emerald). +/// Under Emerald+, `timestamp == parent.timestamp` is legal, so we use PreViridian +/// schedule which is pre-Emerald. +#[tokio::test(flavor = "multi_thread")] +async fn timestamp_not_greater_than_parent_rejected() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new() + .with_schedule(morph_node::test_utils::HardforkSchedule::PreViridian) + .build() + .await?; + let mut node = nodes.pop().unwrap(); + + let base = build_block_no_submit(&mut node, vec![]).await?; + // Set timestamp to 0 (same as genesis parent timestamp) + let accepted = craft_and_try_import_block(&mut node, &base, |block| { + block.header.inner.timestamp = 0; + }) + .await?; + assert!( + !accepted, + "timestamp <= parent.timestamp should be rejected" + ); + Ok(()) +} + +/// A block claiming gasUsed > gasLimit is rejected. +#[tokio::test(flavor = "multi_thread")] +async fn gas_used_exceeds_gas_limit_rejected() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let base = build_block_no_submit(&mut node, vec![]).await?; + let accepted = craft_and_try_import_block(&mut node, &base, |block| { + block.header.inner.gas_used = block.header.inner.gas_limit + 1; + }) + .await?; + assert!(!accepted, "gasUsed > gasLimit should be rejected"); + Ok(()) +} + +/// A block with gas limit more than 1/1024 higher than parent is rejected. +#[tokio::test(flavor = "multi_thread")] +async fn gas_limit_excessive_increase_rejected() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let base = build_block_no_submit(&mut node, vec![]).await?; + // Double the gas limit -- far exceeds 1/1024 change + let accepted = craft_and_try_import_block(&mut node, &base, |block| { + block.header.inner.gas_limit *= 2; + }) + .await?; + assert!(!accepted, "gas limit excessive increase should be rejected"); + Ok(()) +} + +/// A block whose next_l1_msg_index is less than parent's value is rejected. +/// +/// First advance one block with L1 messages (sets next_l1_msg_index = 2), +/// then try to import a block with next_l1_msg_index = 0. +#[tokio::test(flavor = "multi_thread")] +async fn next_l1_msg_index_decreases_rejected() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + // Block 1: include 2 L1 messages -> next_l1_msg_index becomes 2 + let l1_msgs = L1MessageBuilder::build_sequential(0, 2); + advance_block_with_l1_messages(&mut node, l1_msgs).await?; + + // Build block 2 (no L1 msgs), modify next_l1_msg_index to 0 (< parent's 2) + let base2 = build_block_no_submit(&mut node, vec![]).await?; + let accepted = craft_and_try_import_block(&mut node, &base2, |block| { + block.header.next_l1_msg_index = 0; + }) + .await?; + assert!(!accepted, "next_l1_msg_index < parent should be rejected"); + Ok(()) +} + +/// A block with L1 messages but next_l1_msg_index too low is rejected. +/// +/// Block has L1 messages with queue indices 0, 1 -> next_l1_msg_index should be >= 2. +/// We set it to 1 (insufficient) -> should be rejected. +#[tokio::test(flavor = "multi_thread")] +async fn next_l1_msg_index_insufficient_for_l1_msgs() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + // Build block with 2 L1 messages (queue 0,1) but don't submit + let l1_msgs = L1MessageBuilder::build_sequential(0, 2); + let base = build_block_no_submit(&mut node, l1_msgs).await?; + + // Modify next_l1_msg_index to 1 (should be >= 2) + let accepted = craft_and_try_import_block(&mut node, &base, |block| { + block.header.next_l1_msg_index = 1; + }) + .await?; + assert!(!accepted, "next_l1_msg_index < required should be rejected"); + Ok(()) +} + +/// A block may advance `next_l1_msg_index` past the included messages to account for skips. +#[tokio::test(flavor = "multi_thread")] +async fn next_l1_msg_index_can_skip_past_included_messages() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + // Build block with queue indices 0,1 and then advance header.next_l1_msg_index to 4. + // This models the sequencer skipping queue indices 2 and 3 while still including 0 and 1. + let l1_msgs = L1MessageBuilder::build_sequential(0, 2); + let base = build_block_no_submit(&mut node, l1_msgs).await?; + + let accepted = craft_and_try_import_block(&mut node, &base, |block| { + block.header.next_l1_msg_index = 4; + }) + .await?; + + assert!( + accepted, + "next_l1_msg_index may advance past included L1 messages to represent skipped queue indices" + ); + Ok(()) +} diff --git a/crates/node/tests/it/engine.rs b/crates/node/tests/it/engine.rs new file mode 100644 index 0000000..627dd6d --- /dev/null +++ b/crates/node/tests/it/engine.rs @@ -0,0 +1,192 @@ +//! Engine API behavior integration tests. +//! +//! Verifies engine-level semantics that are distinct from consensus rule +//! enforcement — in particular the state-root validation gating introduced +//! by the Jade hardfork. + +use alloy_consensus::BlockHeader; +use alloy_primitives::{Address, B256}; +use alloy_rpc_types_engine::PayloadAttributes; +use jsonrpsee::core::client::ClientT; +use morph_node::test_utils::{HardforkSchedule, TestNodeBuilder}; +use morph_payload_types::{ + AssembleL2BlockParams, ExecutableL2Data, GenericResponse, MorphPayloadAttributes, + MorphPayloadBuilderAttributes, +}; +use reth_payload_primitives::{BuiltPayload, PayloadBuilderAttributes}; +use reth_provider::BlockReaderIdExt; + +use super::helpers::{build_block_no_submit, craft_and_try_import_block}; + +/// Pre-Jade: a block with a wrong state root is still accepted. +/// +/// Before Jade, morph-reth computes an MPT state root but the canonical +/// chain uses ZK-trie roots. Rather than implementing ZK-trie, morph-reth +/// skips state root validation entirely in pre-Jade mode. A tampered state +/// root must therefore not cause rejection. +/// +/// This is the mirror image of `post_jade_state_root_mismatch_is_rejected` +/// in `consensus.rs` — together they prove the Jade hardfork boundary. +#[tokio::test(flavor = "multi_thread")] +async fn state_root_validation_skipped_pre_jade() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new() + .with_schedule(HardforkSchedule::PreJade) + .build() + .await?; + let mut node = nodes.pop().unwrap(); + + // Build a valid block without submitting it. + let base_payload = build_block_no_submit(&mut node, vec![]).await?; + + // Replace the state root with a bogus value and try to import. + let accepted = craft_and_try_import_block(&mut node, &base_payload, |block| { + block.header.inner.state_root = B256::from([0xFF; 32]); + }) + .await?; + + assert!( + accepted, + "pre-Jade block with wrong state root must be accepted (state root validation skipped)" + ); + + Ok(()) +} + +/// `engine_newL2Block` can import a block assembled over the authenticated RPC. +#[tokio::test(flavor = "multi_thread")] +async fn new_l2_block_imports_assembled_block_over_rpc() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let node = nodes.pop().unwrap(); + + let auth = node.auth_server_handle(); + let client = auth.http_client(); + let mut params = AssembleL2BlockParams::empty(1); + params.timestamp = Some(1); + + let data: ExecutableL2Data = client.request("engine_assembleL2Block", (params,)).await?; + let expected_hash = data.hash; + + let _: () = client.request("engine_newL2Block", (data,)).await?; + + let latest = node + .inner + .provider + .sealed_header_by_number_or_tag(alloy_rpc_types_eth::BlockNumberOrTag::Latest)? + .expect("latest header must exist after importing the block"); + + assert_eq!( + latest.number(), + 1, + "engine_newL2Block should advance the head" + ); + assert_eq!( + latest.hash(), + expected_hash, + "imported canonical head should match the assembled block hash" + ); + + Ok(()) +} + +/// `engine_validateL2Block` rejects a tampered block hash over authenticated RPC. +#[tokio::test(flavor = "multi_thread")] +async fn validate_l2_block_rejects_tampered_hash_over_rpc() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let node = nodes.pop().unwrap(); + + let auth = node.auth_server_handle(); + let client = auth.http_client(); + let mut params = AssembleL2BlockParams::empty(1); + params.timestamp = Some(1); + + let mut data: ExecutableL2Data = client.request("engine_assembleL2Block", (params,)).await?; + data.hash = B256::from([0xFF; 32]); + + let response: GenericResponse = client.request("engine_validateL2Block", (data,)).await?; + + assert!( + !response.success, + "engine_validateL2Block should reject tampered block hashes" + ); + + Ok(()) +} + +/// A non-zero `prev_randao` must not change the built block hash on Morph L2. +#[tokio::test(flavor = "multi_thread")] +async fn payload_builder_hash_matches_block_hash_with_nonzero_prev_randao() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let node = nodes.pop().unwrap(); + + let head = node + .inner + .provider + .sealed_header_by_number_or_tag(alloy_rpc_types_eth::BlockNumberOrTag::Latest)?; + let (head_hash, head_ts) = head + .map(|h| (h.hash(), h.timestamp())) + .unwrap_or((B256::ZERO, 0)); + + let attrs = MorphPayloadBuilderAttributes::try_new( + head_hash, + MorphPayloadAttributes { + inner: PayloadAttributes { + timestamp: head_ts + 1, + prev_randao: B256::repeat_byte(0xAA), + suggested_fee_recipient: Address::ZERO, + withdrawals: Some(vec![]), + parent_beacon_block_root: Some(B256::ZERO), + }, + transactions: Some(vec![]), + gas_limit: None, + base_fee_per_gas: None, + }, + 3, + )?; + + let payload_id = node + .inner + .payload_builder_handle + .send_new_payload(attrs) + .await? + .map_err(|e| eyre::eyre!("payload build failed: {e}"))?; + + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + let payload = loop { + if tokio::time::Instant::now() > deadline { + return Err(eyre::eyre!("timeout waiting for payload {payload_id:?}")); + } + match node + .inner + .payload_builder_handle + .best_payload(payload_id) + .await + { + Some(Ok(p)) => break p, + Some(Err(e)) => return Err(eyre::eyre!("payload build error: {e}")), + None => tokio::time::sleep(std::time::Duration::from_millis(50)).await, + } + }; + + assert_eq!( + payload.block().header().mix_hash(), + Some(B256::ZERO), + "Morph blocks should always use a zero mix_hash" + ); + assert_eq!( + payload.block().hash(), + payload.executable_data.hash, + "ExecutableL2Data hash should match the built block hash" + ); + + Ok(()) +} diff --git a/crates/node/tests/it/evm.rs b/crates/node/tests/it/evm.rs new file mode 100644 index 0000000..35e02ff --- /dev/null +++ b/crates/node/tests/it/evm.rs @@ -0,0 +1,388 @@ +//! EVM execution E2E tests. +//! +//! Verifies correct EVM behavior in real blocks: +//! - Contract deployment and storage reads +//! - Constructor revert handling +//! - BLOCKHASH custom Morph semantics +//! - SELFDESTRUCT opcode behavior +//! - L1 fee calculation for calldata + +use alloy_consensus::transaction::TxHashRef; +use alloy_primitives::{Address, B256, U256, keccak256}; +use morph_node::test_utils::{MorphTxBuilder, TestNodeBuilder, make_deploy_tx, wallet_at_index}; +use reth_payload_primitives::BuiltPayload; +use reth_provider::{ReceiptProvider, StateProviderFactory}; + +// ============================================================================= +// Bytecode constants +// ============================================================================= + +/// Init code: stores 42 at slot 0, returns empty runtime code. +/// +/// PUSH1 42; PUSH1 0; SSTORE; PUSH1 0; PUSH1 0; RETURN +const STORE_42: &[u8] = &[ + 0x60, 0x2a, // PUSH1 42 + 0x60, 0x00, // PUSH1 0 (slot) + 0x55, // SSTORE + 0x60, 0x00, // PUSH1 0 (return length) + 0x60, 0x00, // PUSH1 0 (return offset) + 0xf3, // RETURN → empty runtime code +]; + +/// Init code: reads BLOCKHASH(NUMBER-1), stores result at slot 0, returns empty runtime code. +/// +/// PUSH1 1; NUMBER; SUB; BLOCKHASH; PUSH1 0; SSTORE; PUSH1 0; PUSH1 0; RETURN +const STORE_BLOCKHASH: &[u8] = &[ + 0x60, 0x01, // PUSH1 1 + 0x43, // NUMBER + 0x03, // SUB → block.number - 1 + 0x40, // BLOCKHASH + 0x60, 0x00, // PUSH1 0 (slot) + 0x55, // SSTORE + 0x60, 0x00, // PUSH1 0 (return length) + 0x60, 0x00, // PUSH1 0 (return offset) + 0xf3, // RETURN +]; + +/// Init code: constructor always REVERTs. +/// +/// PUSH1 0; PUSH1 0; REVERT +const REVERT_ALWAYS: &[u8] = &[ + 0x60, 0x00, // PUSH1 0 (revert length) + 0x60, 0x00, // PUSH1 0 (revert offset) + 0xfd, // REVERT +]; + +/// Init code: deploys a contract whose runtime code calls SELFDESTRUCT(address(0)). +/// +/// Constructor copies 3 bytes of runtime code (PUSH1 0; SELFDESTRUCT) into memory +/// and returns them as the deployed bytecode. +/// +/// Init code layout (15 bytes total): +/// bytes 0..12: constructor (CODECOPY + RETURN) +/// bytes 12..15: runtime code [PUSH1 0; SELFDESTRUCT] +const SELFDESTRUCT_CONTRACT_INIT: &[u8] = &[ + // Constructor: copy runtime code into memory and return it + 0x60, 0x03, // PUSH1 3 (runtime code size) + 0x60, 0x0c, // PUSH1 12 (offset of runtime code within init code) + 0x60, 0x00, // PUSH1 0 (memory destination) + 0x39, // CODECOPY + 0x60, 0x03, // PUSH1 3 (return size) + 0x60, 0x00, // PUSH1 0 (return offset) + 0xf3, // RETURN + // Runtime code (at offset 12): + 0x60, 0x00, // PUSH1 0 (beneficiary = address(0)) + 0xff, // SELFDESTRUCT +]; + +// ============================================================================= +// Helpers +// ============================================================================= + +/// Chain ID used in the test genesis. +const TEST_CHAIN_ID: u64 = 2910; + +/// Address of the first test account (funded in test genesis). +const ACCOUNT0: Address = alloy_primitives::address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + +// ============================================================================= +// Tests +// ============================================================================= + +/// Deploying a contract that writes to storage: the value is visible via the state provider. +#[tokio::test(flavor = "multi_thread")] +async fn contract_deploy_stores_state() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let signer = wallet_at_index(0, TEST_CHAIN_ID); + let raw_tx = make_deploy_tx(TEST_CHAIN_ID, signer, 0, STORE_42)?; + node.rpc.inject_tx(raw_tx).await?; + + let payload = node.advance_block().await?; + let block = payload.block(); + + // Verify the deploy tx was included + assert_eq!(block.body().transactions.len(), 1); + + // Contract address: CREATE(ACCOUNT0, nonce=0) + let contract_addr = Address::create(&ACCOUNT0, 0); + + // Read slot 0 from the deployed contract + let state = node.inner.provider.latest()?; + let slot_val = state + .storage(contract_addr, B256::ZERO)? + .unwrap_or_default(); + + assert_eq!( + slot_val, + U256::from(42), + "contract slot 0 must be 42 after deployment" + ); + + Ok(()) +} + +/// A constructor that REVERTs: the receipt status is false, gas is consumed. +#[tokio::test(flavor = "multi_thread")] +async fn contract_revert_receipt_status_false() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let signer = wallet_at_index(0, TEST_CHAIN_ID); + let raw_tx = make_deploy_tx(TEST_CHAIN_ID, signer, 0, REVERT_ALWAYS)?; + node.rpc.inject_tx(raw_tx).await?; + + let payload = node.advance_block().await?; + let tx_hash = *payload + .block() + .body() + .transactions + .first() + .unwrap() + .tx_hash(); + + let receipt = node + .inner + .provider + .receipt_by_hash(tx_hash)? + .expect("receipt must exist after block import"); + + use alloy_consensus::TxReceipt; + assert!( + !receipt.status(), + "constructor revert → receipt status must be false" + ); + assert!( + receipt.as_receipt().cumulative_gas_used > 0, + "gas must be consumed even for failed deployment" + ); + + Ok(()) +} + +/// Contract state written in block N is readable from block N+1. +#[tokio::test(flavor = "multi_thread")] +async fn contract_state_persists_across_blocks() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + use morph_node::test_utils::advance_empty_block; + + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + // Block 1: deploy the contract + let signer = wallet_at_index(0, TEST_CHAIN_ID); + let raw_tx = make_deploy_tx(TEST_CHAIN_ID, signer, 0, STORE_42)?; + node.rpc.inject_tx(raw_tx).await?; + node.advance_block().await?; + + // Block 2: empty block + advance_empty_block(&mut node).await?; + + // Slot 0 should still hold 42 after another block + let contract_addr = Address::create(&ACCOUNT0, 0); + let state = node.inner.provider.latest()?; + let slot_val = state + .storage(contract_addr, B256::ZERO)? + .unwrap_or_default(); + + assert_eq!( + slot_val, + U256::from(42), + "contract state must persist across blocks" + ); + + Ok(()) +} + +/// BLOCKHASH opcode inside a constructor returns the Morph custom keccak256 value. +/// +/// Morph's BLOCKHASH formula: keccak256(chain_id_be8 || block_number_be8) +/// At block 1, BLOCKHASH(0) = keccak256(2910u64 BE || 0u64 BE) +#[tokio::test(flavor = "multi_thread")] +async fn blockhash_opcode_returns_morph_custom_value() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + // Deploy STORE_BLOCKHASH in block 1. + // Constructor executes BLOCKHASH(NUMBER - 1) = BLOCKHASH(0). + let signer = wallet_at_index(0, TEST_CHAIN_ID); + let raw_tx = make_deploy_tx(TEST_CHAIN_ID, signer, 0, STORE_BLOCKHASH)?; + node.rpc.inject_tx(raw_tx).await?; + node.advance_block().await?; + + let contract_addr = Address::create(&ACCOUNT0, 0); + let state = node.inner.provider.latest()?; + let stored = state + .storage(contract_addr, B256::ZERO)? + .unwrap_or_default(); + + // Expected: keccak256(2910u64 BE || 0u64 BE) as U256 + // This matches morph_blockhash_value(chain_id=2910, number=0) in crates/revm/src/evm.rs + let mut hash_input = [0u8; 16]; + hash_input[..8].copy_from_slice(&TEST_CHAIN_ID.to_be_bytes()); + // hash_input[8..] stays zero (block number = 0) + let expected = U256::from_be_bytes(*keccak256(hash_input)); + + assert_eq!( + stored, expected, + "BLOCKHASH(0) at block 1 must match Morph custom keccak formula" + ); + + Ok(()) +} + +/// SELFDESTRUCT opcode (0xff) is disabled in Morph — calls result in a failed receipt. +/// +/// Morph's EVM replaces SELFDESTRUCT with `Instruction::unknown()` to match +/// go-ethereum behavior. A contract that executes SELFDESTRUCT will halt with an +/// error, producing a receipt with status=false. Crucially, this must not panic. +#[tokio::test(flavor = "multi_thread")] +async fn selfdestruct_opcode_disabled() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let signer = wallet_at_index(0, TEST_CHAIN_ID); + + // Block 1: deploy the SELFDESTRUCT contract (constructor itself doesn't call SELFDESTRUCT, + // so deployment should succeed — it just returns the runtime code) + let raw_deploy = make_deploy_tx(TEST_CHAIN_ID, signer.clone(), 0, SELFDESTRUCT_CONTRACT_INIT)?; + node.rpc.inject_tx(raw_deploy).await?; + node.advance_block().await?; + + let contract_addr = Address::create(&ACCOUNT0, 0); + + // Block 2: call the contract (runtime code executes PUSH1 0; SELFDESTRUCT) + // SELFDESTRUCT is disabled → transaction reverts → receipt.status() == false + let raw_call = MorphTxBuilder::new(TEST_CHAIN_ID, signer, 1) + .with_v1_eth_fee() + .with_to(contract_addr) + .with_gas_limit(100_000) + .build_signed()?; + node.rpc.inject_tx(raw_call).await?; + let payload = node.advance_block().await?; + + let tx_hash = *payload + .block() + .body() + .transactions + .first() + .unwrap() + .tx_hash(); + let receipt = node + .inner + .provider + .receipt_by_hash(tx_hash)? + .expect("receipt must exist for SELFDESTRUCT call"); + + use alloy_consensus::TxReceipt; + // Morph disables SELFDESTRUCT — the call must fail (not panic), status=false + assert!( + !receipt.status(), + "SELFDESTRUCT is disabled in Morph — receipt must be false" + ); + + Ok(()) +} + +/// A transaction with calldata has a non-zero L1 fee (blob-based DA cost). +/// +/// Post-Curie: l1_fee = (commitScalar * l1BaseFee + len * blobBaseFee * blobScalar) / 1e9 +/// With l1BaseFee=0 but blobBaseFee=1 and blobScalar=417565260, any non-trivial tx size → fee > 0. +#[tokio::test(flavor = "multi_thread")] +async fn l1_fee_nonzero_for_calldata_tx() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + // Transaction with 100 bytes of non-zero calldata + let signer = wallet_at_index(0, TEST_CHAIN_ID); + let raw_tx = MorphTxBuilder::new(TEST_CHAIN_ID, signer, 0) + .with_v1_eth_fee() + .with_data(vec![0xab; 100]) + .build_signed()?; + node.rpc.inject_tx(raw_tx).await?; + + let payload = node.advance_block().await?; + let tx_hash = *payload + .block() + .body() + .transactions + .first() + .unwrap() + .tx_hash(); + + let receipt = node + .inner + .provider + .receipt_by_hash(tx_hash)? + .expect("receipt must exist"); + + assert!( + receipt.l1_fee() > U256::ZERO, + "L1 fee must be non-zero for a transaction with calldata (l1_fee={})", + receipt.l1_fee() + ); + + Ok(()) +} + +/// A transaction with large calldata incurs a higher L1 fee than one with empty calldata. +/// +/// Verifies that the blob-based L1 fee scales with transaction size, as expected +/// from Curie's `len * blobBaseFee * blobScalar` formula. +#[tokio::test(flavor = "multi_thread")] +async fn empty_calldata_vs_large_calldata_l1_fee_difference() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let signer = wallet_at_index(0, TEST_CHAIN_ID); + + // Block 1: transaction with no extra calldata + let tx_empty = MorphTxBuilder::new(TEST_CHAIN_ID, signer.clone(), 0) + .with_v1_eth_fee() + .build_signed()?; + node.rpc.inject_tx(tx_empty).await?; + let p1 = node.advance_block().await?; + let hash_empty = *p1.block().body().transactions.first().unwrap().tx_hash(); + let receipt_empty = node + .inner + .provider + .receipt_by_hash(hash_empty)? + .expect("receipt for empty-calldata tx"); + + // Block 2: transaction with 200 bytes of non-zero calldata + let tx_large = MorphTxBuilder::new(TEST_CHAIN_ID, signer, 1) + .with_v1_eth_fee() + .with_data(vec![0xff; 200]) + .build_signed()?; + node.rpc.inject_tx(tx_large).await?; + let p2 = node.advance_block().await?; + let hash_large = *p2.block().body().transactions.first().unwrap().tx_hash(); + let receipt_large = node + .inner + .provider + .receipt_by_hash(hash_large)? + .expect("receipt for large-calldata tx"); + + assert!( + receipt_large.l1_fee() > receipt_empty.l1_fee(), + "large calldata tx must have higher L1 fee than empty calldata tx \ + (large={}, empty={})", + receipt_large.l1_fee(), + receipt_empty.l1_fee() + ); + + Ok(()) +} diff --git a/crates/node/tests/it/hardfork.rs b/crates/node/tests/it/hardfork.rs new file mode 100644 index 0000000..3ed5fe1 --- /dev/null +++ b/crates/node/tests/it/hardfork.rs @@ -0,0 +1,123 @@ +//! Hardfork boundary integration tests. +//! +//! Verifies that morph-reth behaves correctly across different hardfork +//! activation schedules. These tests parametrize `HardforkSchedule` to ensure +//! block building works under both "all active" and "pre-Jade" configurations. + +use morph_node::test_utils::{ + HardforkSchedule, TestNodeBuilder, advance_chain, advance_empty_block, +}; +use reth_payload_primitives::BuiltPayload; + +use super::helpers::wallet_to_arc; + +/// With all Morph hardforks active (including Jade), blocks are built +/// and the chain advances successfully. +#[tokio::test(flavor = "multi_thread")] +async fn all_active_chain_advances() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new() + .with_schedule(HardforkSchedule::AllActive) + .build() + .await?; + let mut node = nodes.pop().unwrap(); + let wallet = wallet_to_arc(wallet); + + let payloads = advance_chain(5, &mut node, wallet).await?; + assert_eq!(payloads.len(), 5); + + for (i, payload) in payloads.iter().enumerate() { + let block = payload.block(); + assert_eq!(block.header().inner.number, (i + 1) as u64); + assert!(!block.body().transactions.is_empty()); + } + + Ok(()) +} + +/// With Jade disabled (pre-Jade schedule), blocks are still built correctly. +/// +/// This tests the pre-Jade behavior: +/// - State root validation is skipped (ZK-trie not implemented) +/// - All other hardforks are active +#[tokio::test(flavor = "multi_thread")] +async fn pre_jade_chain_advances() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new() + .with_schedule(HardforkSchedule::PreJade) + .build() + .await?; + let mut node = nodes.pop().unwrap(); + let wallet = wallet_to_arc(wallet); + + let payloads = advance_chain(5, &mut node, wallet).await?; + assert_eq!(payloads.len(), 5); + + for (i, payload) in payloads.iter().enumerate() { + let block = payload.block(); + assert_eq!(block.header().inner.number, (i + 1) as u64); + } + + Ok(()) +} + +/// Verify that an empty block can be produced under pre-Jade schedule. +#[tokio::test(flavor = "multi_thread")] +async fn pre_jade_empty_block() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new() + .with_schedule(HardforkSchedule::PreJade) + .build() + .await?; + let mut node = nodes.pop().unwrap(); + + let payload = advance_empty_block(&mut node).await?; + let block = payload.block(); + + assert_eq!(block.header().inner.number, 1); + assert_eq!(block.body().transactions.len(), 0); + + Ok(()) +} + +/// EIP-7702 transaction is accepted when Viridian hardfork is active. +#[tokio::test(flavor = "multi_thread")] +async fn eip7702_accepted_viridian_active() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new() + .with_schedule(HardforkSchedule::AllActive) // Viridian active + .build() + .await?; + let mut node = nodes.pop().unwrap(); + + let raw_tx = morph_node::test_utils::make_eip7702_tx(wallet.chain_id, wallet.inner.clone(), 0)?; + node.rpc.inject_tx(raw_tx).await?; + let payload = node.advance_block().await?; + assert_eq!( + payload.block().body().transactions.len(), + 1, + "EIP-7702 should be accepted" + ); + Ok(()) +} + +/// EIP-7702 transaction is rejected when Viridian hardfork is NOT active. +#[tokio::test(flavor = "multi_thread")] +async fn eip7702_rejected_viridian_inactive() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new() + .with_schedule(HardforkSchedule::PreViridian) // Viridian NOT active + .build() + .await?; + let node = nodes.pop().unwrap(); + + let raw_tx = morph_node::test_utils::make_eip7702_tx(wallet.chain_id, wallet.inner.clone(), 0)?; + let result = node.rpc.inject_tx(raw_tx).await; + assert!(result.is_err(), "EIP-7702 must be rejected before Viridian"); + Ok(()) +} diff --git a/crates/node/tests/it/helpers.rs b/crates/node/tests/it/helpers.rs new file mode 100644 index 0000000..ecd8349 --- /dev/null +++ b/crates/node/tests/it/helpers.rs @@ -0,0 +1,270 @@ +//! Shared test helper utilities used across integration test modules. + +use alloy_consensus::BlockHeader; +use alloy_primitives::{Address, B256, Bytes}; +use alloy_rpc_types_engine::PayloadAttributes; +use morph_node::test_utils::MorphTestNode; +use morph_payload_types::{ + MorphBuiltPayload, MorphPayloadAttributes, MorphPayloadBuilderAttributes, MorphPayloadTypes, +}; +use reth_e2e_test_utils::wallet::Wallet; +use reth_node_api::PayloadTypes; +use reth_payload_primitives::{BuiltPayload, PayloadBuilderAttributes}; +use reth_provider::BlockReaderIdExt; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// Wrap a [`Wallet`] in an `Arc>` for use in `advance_chain`. +pub(crate) fn wallet_to_arc(wallet: Wallet) -> Arc> { + Arc::new(Mutex::new(wallet)) +} + +/// Advance one block with the given L1 messages injected via custom payload attributes. +/// +/// This bypasses the node's default attributes generator and instead creates +/// custom attributes with L1 messages, then submits the block via the engine API. +/// +/// L2 transactions already in the pool will also be included after the L1 messages. +/// +/// NOTE: Uses direct `resolve_kind` polling instead of the event stream to +/// avoid state leakage between sequential calls in multi-block tests. +pub(crate) async fn advance_block_with_l1_messages( + node: &mut MorphTestNode, + l1_messages: Vec, +) -> eyre::Result { + let head = node + .inner + .provider + .sealed_header_by_number_or_tag(alloy_rpc_types_eth::BlockNumberOrTag::Latest)?; + + let (head_hash, head_ts) = head + .map(|h| (h.hash(), h.timestamp())) + .unwrap_or((B256::ZERO, 0)); + + let rpc_attrs = MorphPayloadAttributes { + inner: PayloadAttributes { + timestamp: head_ts + 1, + prev_randao: B256::ZERO, + suggested_fee_recipient: Address::ZERO, + withdrawals: Some(vec![]), + parent_beacon_block_root: Some(B256::ZERO), + }, + transactions: Some(l1_messages), + gas_limit: None, + base_fee_per_gas: None, + }; + + let attrs = MorphPayloadBuilderAttributes::try_new(head_hash, rpc_attrs, 3) + .map_err(|e| eyre::eyre!("failed to build payload attributes: {e}"))?; + + let payload_id = node + .inner + .payload_builder_handle + .send_new_payload(attrs) + .await? + .map_err(|e| eyre::eyre!("payload build failed: {e}"))?; + + // Brief delay before polling to let the payload builder process pool transactions. + // Without this, the builder might emit its first result before picking up L2 txs. + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Poll until the payload builder has produced a result (or 10s timeout) + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + let payload = loop { + if tokio::time::Instant::now() > deadline { + return Err(eyre::eyre!("timeout waiting for payload {payload_id:?}")); + } + match node + .inner + .payload_builder_handle + .best_payload(payload_id) + .await + { + Some(Ok(p)) => break p, + Some(Err(e)) => return Err(eyre::eyre!("payload build error: {e}")), + None => tokio::time::sleep(std::time::Duration::from_millis(50)).await, + } + }; + + // Submit via engine API and wait for canonical head to update + node.submit_payload(payload.clone()).await?; + let block_hash = payload.block().hash(); + node.update_forkchoice(block_hash, block_hash).await?; + // Ensure the canonical head is actually at this block before returning, + // so the next payload build sees the correct parent. + node.sync_to(block_hash).await?; + + Ok(payload) +} + +/// Build a block with L1 messages but do NOT submit it. +/// Returns the built payload for inspection or modification. +pub(crate) async fn build_block_no_submit( + node: &mut MorphTestNode, + l1_messages: Vec, +) -> eyre::Result { + let head = node + .inner + .provider + .sealed_header_by_number_or_tag(alloy_rpc_types_eth::BlockNumberOrTag::Latest)?; + + let (head_hash, head_ts) = head + .map(|h| (h.hash(), h.timestamp())) + .unwrap_or((B256::ZERO, 0)); + + let rpc_attrs = MorphPayloadAttributes { + inner: PayloadAttributes { + timestamp: head_ts + 1, + prev_randao: B256::ZERO, + suggested_fee_recipient: Address::ZERO, + withdrawals: Some(vec![]), + parent_beacon_block_root: Some(B256::ZERO), + }, + transactions: Some(l1_messages), + gas_limit: None, + base_fee_per_gas: None, + }; + + let attrs = MorphPayloadBuilderAttributes::try_new(head_hash, rpc_attrs, 3) + .map_err(|e| eyre::eyre!("failed to build payload attributes: {e}"))?; + + let payload_id = node + .inner + .payload_builder_handle + .send_new_payload(attrs) + .await? + .map_err(|e| eyre::eyre!("payload build failed: {e}"))?; + + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + if tokio::time::Instant::now() > deadline { + return Err(eyre::eyre!("timeout waiting for payload")); + } + match node + .inner + .payload_builder_handle + .best_payload(payload_id) + .await + { + Some(Ok(p)) => return Ok(p), + Some(Err(e)) => return Err(eyre::eyre!("payload build error: {e}")), + None => tokio::time::sleep(std::time::Duration::from_millis(50)).await, + } + } +} + +/// Craft a block by modifying a valid payload, then try to import it via engine API. +/// +/// Returns `true` if the block was accepted (VALID/SYNCING), `false` if rejected (INVALID). +/// The modification function receives a mutable reference to the unsealed block. +/// +/// After modification, `transactions_root` is recomputed and the block is re-sealed. +pub(crate) async fn craft_and_try_import_block( + node: &mut MorphTestNode, + base_payload: &MorphBuiltPayload, + modify: impl FnOnce(&mut morph_primitives::Block), +) -> eyre::Result { + use alloy_consensus::proofs; + use reth_primitives_traits::SealedBlock; + + // Extract unsealed block. + // sealed.header() returns &SealedHeader; .inner is the MorphHeader itself. + let sealed = base_payload.block(); + let morph_header: morph_primitives::MorphHeader = sealed.header().inner.clone().into(); + let body = sealed.body().clone(); + let mut block = morph_primitives::Block::new(morph_header, body); + + // Apply the caller's modification + modify(&mut block); + + // Recompute transactions_root into the inner alloy Header field + block.header.inner.transactions_root = + proofs::calculate_transaction_root(&block.body.transactions); + + // Seal with the new hash (recomputes block hash from header) + let modified_sealed = SealedBlock::seal_slow(block); + + // Convert to execution payload and try to import + let execution_data = MorphPayloadTypes::block_to_payload(modified_sealed); + let status = node + .inner + .add_ons_handle + .beacon_engine_handle + .new_payload(execution_data) + .await?; + + // Only VALID means the block was fully accepted and executed. + // SYNCING (unknown parent) or INVALID both count as "not accepted". + Ok(status.is_valid()) +} + +/// Try to build a block with the given L1 messages but expect the payload builder to fail. +/// +/// Returns `Ok(error_message)` if the builder rejects the payload, +/// `Err(...)` if the builder unexpectedly succeeds. +pub(crate) async fn expect_payload_build_failure( + node: &mut MorphTestNode, + l1_messages: Vec, +) -> eyre::Result { + let head = node + .inner + .provider + .sealed_header_by_number_or_tag(alloy_rpc_types_eth::BlockNumberOrTag::Latest)?; + + let (head_hash, head_ts) = head + .map(|h| (h.hash(), h.timestamp())) + .unwrap_or((B256::ZERO, 0)); + + let rpc_attrs = MorphPayloadAttributes { + inner: PayloadAttributes { + timestamp: head_ts + 1, + prev_randao: B256::ZERO, + suggested_fee_recipient: Address::ZERO, + withdrawals: Some(vec![]), + parent_beacon_block_root: Some(B256::ZERO), + }, + transactions: Some(l1_messages), + gas_limit: None, + base_fee_per_gas: None, + }; + + let attrs = MorphPayloadBuilderAttributes::try_new(head_hash, rpc_attrs, 3) + .map_err(|e| eyre::eyre!("failed to build payload attributes: {e}"))?; + + let payload_id = match node + .inner + .payload_builder_handle + .send_new_payload(attrs) + .await? + { + Ok(id) => id, + Err(e) => return Ok(e.to_string()), + }; + + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + if tokio::time::Instant::now() > deadline { + return Err(eyre::eyre!( + "timeout — payload builder neither succeeded nor failed" + )); + } + match node + .inner + .payload_builder_handle + .best_payload(payload_id) + .await + { + Some(Err(e)) => return Ok(e.to_string()), + Some(Ok(_)) => { + return Err(eyre::eyre!( + "expected payload build failure, but it succeeded" + )); + } + None => tokio::time::sleep(std::time::Duration::from_millis(50)).await, + } + } +} diff --git a/crates/node/tests/it/l1_messages.rs b/crates/node/tests/it/l1_messages.rs new file mode 100644 index 0000000..027bf74 --- /dev/null +++ b/crates/node/tests/it/l1_messages.rs @@ -0,0 +1,163 @@ +//! L1 message handling integration tests. +//! +//! Verifies that L1 message transactions (type 0x7E) follow Morph's protocol rules: +//! - Must appear at the start of the block before any L2 transactions +//! - Must have strictly sequential queue indices within a block +//! - Queue index must continue monotonically across blocks +//! - Gas is prepaid on L1; L2 block gas accounting reflects this + +use alloy_primitives::{Address, U256}; +use morph_node::test_utils::{L1MessageBuilder, TestNodeBuilder, advance_empty_block}; +use reth_payload_primitives::BuiltPayload; + +use super::helpers::advance_block_with_l1_messages; + +/// A single L1 message is included at the start of the block. +#[tokio::test(flavor = "multi_thread")] +async fn single_l1_message_included() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let l1_msg = L1MessageBuilder::new(0) + .with_target(Address::with_last_byte(0x01)) + .with_value(U256::ZERO) + .with_gas_limit(21_000) + .build_encoded(); + + let payload = advance_block_with_l1_messages(&mut node, vec![l1_msg]).await?; + let block = payload.block(); + + assert_eq!(block.body().transactions.len(), 1); + + let tx = block.body().transactions.first().unwrap(); + assert!(tx.is_l1_msg(), "only transaction must be an L1 message"); + assert_eq!(tx.queue_index(), Some(0)); + + Ok(()) +} + +/// Three L1 messages with queue indices 0, 1, 2 are all included in one block. +#[tokio::test(flavor = "multi_thread")] +async fn three_sequential_l1_messages_in_one_block() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let l1_msgs = L1MessageBuilder::build_sequential(0, 3); + + let payload = advance_block_with_l1_messages(&mut node, l1_msgs).await?; + let block = payload.block(); + + assert_eq!(block.body().transactions.len(), 3); + + for (expected_qi, tx) in block.body().transactions.iter().enumerate() { + assert!(tx.is_l1_msg(), "tx {expected_qi} should be L1 message"); + assert_eq!( + tx.queue_index(), + Some(expected_qi as u64), + "queue_index should be {expected_qi}" + ); + } + + Ok(()) +} + +/// L1 messages across multiple blocks must have strictly continuous queue indices. +/// +/// Block 1: queue indices 0, 1 → next expected is 2 +/// Block 2: queue indices 2, 3 → continues from where block 1 left off +/// +/// This verifies that `next_l1_msg_index` from parent header is correctly +/// used to enforce cross-block continuity. +#[tokio::test(flavor = "multi_thread")] +async fn l1_messages_across_blocks_continuous() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + // Block 1: queue indices 0, 1 + let block1_msgs = L1MessageBuilder::build_sequential(0, 2); + let payload1 = advance_block_with_l1_messages(&mut node, block1_msgs).await?; + assert_eq!(payload1.block().body().transactions.len(), 2); + + // Block 2: queue indices 2, 3 (continues from where block 1 left off) + let block2_msgs = L1MessageBuilder::build_sequential(2, 2); + let payload2 = advance_block_with_l1_messages(&mut node, block2_msgs).await?; + assert_eq!(payload2.block().body().transactions.len(), 2); + + let block2_txs = payload2.block().body().transactions.as_slice(); + assert_eq!(block2_txs[0].queue_index(), Some(2)); + assert_eq!(block2_txs[1].queue_index(), Some(3)); + + Ok(()) +} + +/// When a block has no L1 messages, queue index tracking is unchanged. +/// L1 messages in a later block can continue from any higher index. +#[tokio::test(flavor = "multi_thread")] +async fn l1_messages_resume_after_empty_block() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + // Block 1: queue indices 0, 1 + let first_msgs = L1MessageBuilder::build_sequential(0, 2); + let payload1 = advance_block_with_l1_messages(&mut node, first_msgs).await?; + assert_eq!(payload1.block().body().transactions.len(), 2); + + // Block 2: no L1 messages (truly empty block, pool is also empty) + let payload2 = advance_empty_block(&mut node).await?; + assert_eq!(payload2.block().body().transactions.len(), 0); + + // Block 3: queue index continues from 2 + let third_msgs = L1MessageBuilder::build_sequential(2, 2); + let payload3 = advance_block_with_l1_messages(&mut node, third_msgs).await?; + + let txs = payload3.block().body().transactions.as_slice(); + assert_eq!(txs[0].queue_index(), Some(2)); + assert_eq!(txs[1].queue_index(), Some(3)); + + Ok(()) +} + +/// L1 message gas is tracked in gasUsed (prepaid on L1). +/// +/// The block's `gasUsed` increases to reflect the execution cost of L1 messages, +/// but no L2 account is charged. The actual cost must not exceed the `gas_limit` +/// specified in the L1 message. +#[tokio::test(flavor = "multi_thread")] +async fn l1_message_gas_is_tracked() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let gas_limit = 50_000u64; + let l1_msg = L1MessageBuilder::new(0) + .with_target(Address::with_last_byte(0x42)) + .with_gas_limit(gas_limit) + .build_encoded(); + + let payload = advance_block_with_l1_messages(&mut node, vec![l1_msg]).await?; + let block = payload.block(); + + // gasUsed should be > 0 (execution cost is tracked) + assert!( + block.header().inner.gas_used > 0, + "L1 message gas usage must be tracked" + ); + // Must not exceed the message's own gas limit + assert!( + block.header().inner.gas_used <= gas_limit, + "gas used {} must not exceed L1 message gas limit {}", + block.header().inner.gas_used, + gas_limit + ); + + Ok(()) +} diff --git a/crates/node/tests/it/main.rs b/crates/node/tests/it/main.rs new file mode 100644 index 0000000..13f5577 --- /dev/null +++ b/crates/node/tests/it/main.rs @@ -0,0 +1,18 @@ +//! Morph node integration tests. +//! +//! These are real E2E tests that spin up ephemeral Morph nodes with in-memory +//! databases, produce blocks via the Engine API, and verify the chain advances +//! correctly under various conditions. + +mod helpers; + +mod block_building; +mod consensus; +mod engine; +mod evm; +mod hardfork; +mod l1_messages; +mod morph_tx; +mod rpc; +mod sync; +mod txpool; diff --git a/crates/node/tests/it/morph_tx.rs b/crates/node/tests/it/morph_tx.rs new file mode 100644 index 0000000..f7702ee --- /dev/null +++ b/crates/node/tests/it/morph_tx.rs @@ -0,0 +1,590 @@ +//! MorphTx (type 0x7F) integration tests. +//! +//! Tests the full lifecycle of MorphTx transactions: +//! - Pool acceptance/rejection based on version and fee type +//! - Block inclusion with fee token payment +//! - Receipt fields (version, fee_token_id, fee_rate, token_scale) +//! +//! # Test ERC20 Setup +//! +//! The test genesis (`tests/assets/test-genesis.json`) pre-deploys: +//! - L2TokenRegistry at `0x5300000000000000000000000000000000000021` +//! with token_id=1 registered, price_ratio=1e18, decimals=18 +//! - Test ERC20 at `0x5300000000000000000000000000000000000022` +//! with 1000 tokens pre-funded for test account 0 and 1 + +use alloy_primitives::Address; +use morph_node::test_utils::{HardforkSchedule, MorphTxBuilder, TEST_TOKEN_ID, TestNodeBuilder}; +use reth_payload_primitives::BuiltPayload; + +use super::helpers::wallet_to_arc; + +// ============================================================================= +// MorphTx v1 (ETH fee) — simplest variant, no token contract needed +// ============================================================================= + +/// MorphTx v1 with ETH fee is accepted by the pool and included in a block. +/// +/// fee_token_id=0 means ETH payment, same as EIP-1559 but the receipt +/// preserves version=1 in the MorphTx-specific fields. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v1_eth_fee_included_in_block() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + // Build a MorphTx v1 with ETH fee + let raw_tx = MorphTxBuilder::new(wallet.chain_id, wallet.inner.clone(), wallet.inner_nonce) + .with_v1_eth_fee() + .with_to(Address::with_last_byte(0x42)) + .build_signed()?; + + node.rpc.inject_tx(raw_tx).await?; + let payload = node.advance_block().await?; + let block = payload.block(); + + assert_eq!( + block.body().transactions.len(), + 1, + "MorphTx v1 should be included in block" + ); + + // Verify transaction type is 0x7F (MorphTx) + + let tx = block.body().transactions.first().unwrap(); + assert!( + tx.is_morph_tx(), + "transaction should be MorphTx (type 0x7F)" + ); + + Ok(()) +} + +/// Multiple MorphTx v1 transactions are included in sequence. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v1_multiple_in_sequence() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, mut wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + // Inject 3 MorphTx v1 (ETH fee) with sequential nonces + for i in 0..3 { + let raw_tx = MorphTxBuilder::new(wallet.chain_id, wallet.inner.clone(), i) + .with_v1_eth_fee() + .build_signed()?; + node.rpc.inject_tx(raw_tx).await?; + wallet.inner_nonce += 1; + } + + let payload = node.advance_block().await?; + assert_eq!( + payload.block().body().transactions.len(), + 3, + "all 3 MorphTx v1 should be included" + ); + + Ok(()) +} + +// ============================================================================= +// MorphTx v0 (ERC20 fee) — needs L2TokenRegistry + token balance in genesis +// ============================================================================= + +/// MorphTx v0 with ERC20 fee is accepted and included in a block. +/// +/// This test relies on the test genesis having: +/// - L2TokenRegistry with token_id=1 registered +/// - Test ERC20 with 1000 tokens pre-funded for test account 0 +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v0_erc20_fee_included_in_block() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let raw_tx = MorphTxBuilder::new(wallet.chain_id, wallet.inner.clone(), 0) + .with_v0_token_fee(TEST_TOKEN_ID) + .with_to(Address::with_last_byte(0x42)) + .build_signed()?; + + node.rpc.inject_tx(raw_tx).await?; + let payload = node.advance_block().await?; + let block = payload.block(); + + assert_eq!( + block.body().transactions.len(), + 1, + "MorphTx v0 with ERC20 fee should be included" + ); + + let tx = block.body().transactions.first().unwrap(); + assert!(tx.is_morph_tx()); + assert_eq!( + tx.fee_token_id(), + Some(TEST_TOKEN_ID), + "fee_token_id should be preserved" + ); + + Ok(()) +} + +/// MorphTx v1 with ERC20 fee (fee_token_id > 0, version=1). +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v1_erc20_fee_included_in_block() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let raw_tx = MorphTxBuilder::new(wallet.chain_id, wallet.inner.clone(), 0) + .with_v1_token_fee(TEST_TOKEN_ID) + .with_to(Address::with_last_byte(0x42)) + .build_signed()?; + + node.rpc.inject_tx(raw_tx).await?; + let payload = node.advance_block().await?; + let block = payload.block(); + + assert_eq!(block.body().transactions.len(), 1); + + let tx = block.body().transactions.first().unwrap(); + assert!(tx.is_morph_tx()); + assert_eq!(tx.fee_token_id(), Some(TEST_TOKEN_ID)); + + Ok(()) +} + +// ============================================================================= +// MorphTx v1 Jade gating +// ============================================================================= + +/// MorphTx v1 is rejected by the pool when Jade hardfork is NOT active. +/// +/// Before Jade, only MorphTx v0 is allowed. Version 1 transactions must +/// be rejected at the pool level to prevent inclusion in pre-Jade blocks. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v1_rejected_before_jade() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + // Use PreJade schedule — Jade is NOT active + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new() + .with_schedule(HardforkSchedule::PreJade) + .build() + .await?; + let node = nodes.pop().unwrap(); + + let raw_tx = MorphTxBuilder::new(wallet.chain_id, wallet.inner.clone(), 0) + .with_v1_eth_fee() + .build_signed()?; + + // Pool should reject v1 MorphTx before Jade activation + let result = node.rpc.inject_tx(raw_tx).await; + assert!( + result.is_err(), + "MorphTx v1 should be rejected by pool before Jade" + ); + + Ok(()) +} + +/// MorphTx v0 (ERC20 fee) IS accepted before Jade. +/// +/// Only v1 is gated — v0 has always been valid. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v0_accepted_before_jade() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new() + .with_schedule(HardforkSchedule::PreJade) + .build() + .await?; + let mut node = nodes.pop().unwrap(); + + let raw_tx = MorphTxBuilder::new(wallet.chain_id, wallet.inner.clone(), 0) + .with_v0_token_fee(TEST_TOKEN_ID) + .build_signed()?; + + node.rpc.inject_tx(raw_tx).await?; + let payload = node.advance_block().await?; + assert_eq!( + payload.block().body().transactions.len(), + 1, + "MorphTx v0 should still be accepted pre-Jade" + ); + + Ok(()) +} + +// ============================================================================= +// Mixed transaction types in one block +// ============================================================================= + +/// A block can contain both EIP-1559 and MorphTx transactions. +#[tokio::test(flavor = "multi_thread")] +async fn mixed_tx_types_in_one_block() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let wallet_arc = wallet_to_arc(wallet); + + // Inject EIP-1559 transfer + let eip1559_tx = { + let mut w = wallet_arc.lock().await; + let nonce = w.inner_nonce; + w.inner_nonce += 1; + morph_node::test_utils::make_transfer_tx(w.chain_id, w.inner.clone(), nonce).await + }; + node.rpc.inject_tx(eip1559_tx).await?; + + // Inject MorphTx v1 (ETH fee) + let morph_tx = { + let w = wallet_arc.lock().await; + let nonce = w.inner_nonce; + MorphTxBuilder::new(w.chain_id, w.inner.clone(), nonce) + .with_v1_eth_fee() + .build_signed()? + }; + node.rpc.inject_tx(morph_tx).await?; + + let payload = node.advance_block().await?; + let block = payload.block(); + + assert_eq!( + block.body().transactions.len(), + 2, + "block should have both EIP-1559 and MorphTx" + ); + + // Verify transaction types + + let types: Vec = block + .body() + .transactions + .iter() + .map(|tx| tx.is_morph_tx()) + .collect(); + + assert!( + types.contains(&false) && types.contains(&true), + "block should contain both EIP-1559 and MorphTx" + ); + + Ok(()) +} + +// ============================================================================= +// MorphTx pool rejection — invalid token and insufficient balance +// ============================================================================= + +/// MorphTx v0 with an unregistered fee_token_id (99) is rejected by the pool. +/// +/// The L2TokenRegistry only has token_id=1 registered in the test genesis. +/// Token 99 does not exist, so the pool should reject the transaction. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_invalid_token_rejected_by_pool() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let node = nodes.pop().unwrap(); + + let raw_tx = MorphTxBuilder::new(wallet.chain_id, wallet.inner.clone(), 0) + .with_v0_token_fee(99) + .build_signed()?; + + let result = node.rpc.inject_tx(raw_tx).await; + assert!( + result.is_err(), + "MorphTx with unregistered token_id=99 must be rejected" + ); + + Ok(()) +} + +/// MorphTx v0 from an account with zero token balance is rejected by the pool. +/// +/// Account index 2 has ETH but no tokens in the test genesis. Attempting to pay +/// fees with TEST_TOKEN_ID should fail because the sender has no token balance. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_insufficient_token_balance_rejected() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let node = nodes.pop().unwrap(); + + // Account 2 has ETH only, no tokens in genesis + let signer = morph_node::test_utils::wallet_at_index(2, wallet.chain_id); + + let raw_tx = MorphTxBuilder::new(wallet.chain_id, signer, 0) + .with_v0_token_fee(TEST_TOKEN_ID) + .build_signed()?; + + let result = node.rpc.inject_tx(raw_tx).await; + assert!( + result.is_err(), + "MorphTx from account with no token balance must be rejected" + ); + + Ok(()) +} + +/// MorphTx v0 with fee_token_id=0 must be rejected (v0 requires token fee). +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v0_fee_token_id_zero_rejected() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let node = nodes.pop().unwrap(); + + let raw_tx = MorphTxBuilder::new(wallet.chain_id, wallet.inner.clone(), 0) + .with_raw_morph_config( + 0, + 0, + alloy_primitives::U256::from(100_000_000_000_000_000_000u128), + ) + .build_signed()?; + let result = node.rpc.inject_tx(raw_tx).await; + assert!( + result.is_err(), + "v0 MorphTx with fee_token_id=0 must be rejected" + ); + Ok(()) +} + +// NOTE: v0 + reference / v0 + memo tests are omitted because v0's wire +// format does not encode reference/memo fields. Setting them in the builder +// has no effect — they get dropped during RLP encoding, so the pool never +// sees them. These constraints are enforced at the consensus validation +// level (TxMorph::validate_version), tested in crates/primitives unit tests. + +/// MorphTx with memo > 64 bytes must be rejected (any version). +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_memo_exceeds_64_bytes_rejected() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let node = nodes.pop().unwrap(); + + let raw_tx = MorphTxBuilder::new(wallet.chain_id, wallet.inner.clone(), 0) + .with_v1_eth_fee() + .with_memo(alloy_primitives::Bytes::from(vec![0xBB; 65])) // 65 bytes > 64 max + .build_signed()?; + let result = node.rpc.inject_tx(raw_tx).await; + assert!( + result.is_err(), + "MorphTx with memo > 64 bytes must be rejected" + ); + Ok(()) +} + +/// MorphTx v0 with fee_limit=0 should be accepted — the handler uses the +/// full account token balance as the effective limit. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_fee_limit_zero_accepted() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let raw_tx = MorphTxBuilder::new(wallet.chain_id, wallet.inner.clone(), 0) + .with_raw_morph_config(0, TEST_TOKEN_ID, alloy_primitives::U256::ZERO) // fee_limit=0 + .build_signed()?; + node.rpc.inject_tx(raw_tx).await?; + let payload = node.advance_block().await?; + assert_eq!( + payload.block().body().transactions.len(), + 1, + "fee_limit=0 should be accepted" + ); + Ok(()) +} + +// ============================================================================= +// ERC20 token fee — balance deduction and revert behavior +// ============================================================================= + +/// Helper: compute the ERC20 balance storage slot for an account. +/// +/// For the test token (balance mapping at slot 1): +/// slot = keccak256(address_left_padded_to_32 ++ slot_1_as_be32) +fn token_balance_slot(account: Address) -> alloy_primitives::B256 { + let mut preimage = [0u8; 64]; + preimage[12..32].copy_from_slice(account.as_slice()); + preimage[63] = 1; // slot 1 + alloy_primitives::keccak256(preimage) +} + +/// After a successful MorphTx v0 with ERC20 fee, the sender's token balance +/// must decrease (fee was charged from tokens, not ETH). +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v0_token_balance_decreases() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + use reth_provider::StateProviderFactory; + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let sender = alloy_primitives::address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + let token_addr = morph_node::test_utils::TEST_TOKEN_ADDRESS; + let bal_slot = token_balance_slot(sender); + + // Token balance before + let state_before = node.inner.provider.latest()?; + let bal_before = state_before + .storage(token_addr, bal_slot)? + .unwrap_or_default(); + assert!( + bal_before > alloy_primitives::U256::ZERO, + "test account must have pre-funded tokens" + ); + + // Send a MorphTx v0 with ERC20 fee (simple call, should succeed) + let raw_tx = MorphTxBuilder::new(wallet.chain_id, wallet.inner.clone(), 0) + .with_v0_token_fee(TEST_TOKEN_ID) + .with_to(Address::with_last_byte(0x42)) + .build_signed()?; + node.rpc.inject_tx(raw_tx).await?; + node.advance_block().await?; + + // Token balance after + let state_after = node.inner.provider.latest()?; + let bal_after = state_after + .storage(token_addr, bal_slot)? + .unwrap_or_default(); + + assert!( + bal_after < bal_before, + "token balance must decrease after MorphTx v0 (fee deducted in tokens)" + ); + + Ok(()) +} + +/// Init code that deploys a contract whose runtime always REVERTs. +/// +/// Constructor (12 bytes): CODECOPY + RETURN → deploys runtime below. +/// Runtime (5 bytes): PUSH1 0; PUSH1 0; REVERT. +const RUNTIME_REVERT_INIT: &[u8] = &[ + 0x60, 0x05, // PUSH1 5 (runtime code size) + 0x60, 0x0C, // PUSH1 12 (offset of runtime in init code) + 0x60, 0x00, // PUSH1 0 (memory dest) + 0x39, // CODECOPY + 0x60, 0x05, // PUSH1 5 (return size) + 0x60, 0x00, // PUSH1 0 (return offset) + 0xf3, // RETURN + // Runtime code (at offset 12): + 0x60, 0x00, // PUSH1 0 + 0x60, 0x00, // PUSH1 0 + 0xfd, // REVERT +]; + +/// When the main tx reverts, the ERC20 gas fee is still charged. +/// +/// Scenario: +/// 1. Block 1: Deploy a contract whose runtime always REVERTs (EIP-1559 tx) +/// 2. Block 2: Call that contract with MorphTx v0 (ERC20 fee) +/// 3. Verify: receipt.status = false, but token balance decreased +/// +/// This exercises the handler's `validate_and_deduct_token_fee` (charges fee +/// upfront) and `reimburse_caller_token_fee` (partial refund for unused gas) +/// paths when the main transaction execution reverts. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v0_token_fee_still_charged_on_revert() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + use alloy_consensus::TxReceipt; + use alloy_consensus::transaction::TxHashRef; + use morph_node::test_utils::{make_deploy_tx, wallet_at_index}; + use reth_provider::{ReceiptProvider, StateProviderFactory}; + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let sender = alloy_primitives::address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + let token_addr = morph_node::test_utils::TEST_TOKEN_ADDRESS; + let bal_slot = token_balance_slot(sender); + let chain_id = wallet.chain_id; + + // Token balance before any transactions + let bal_before = node + .inner + .provider + .latest()? + .storage(token_addr, bal_slot)? + .unwrap_or_default(); + + // Block 1: deploy the "runtime revert" contract with a standard EIP-1559 tx + let deploy_signer = wallet_at_index(0, chain_id); + let deploy_tx = make_deploy_tx(chain_id, deploy_signer, 0, RUNTIME_REVERT_INIT)?; + node.rpc.inject_tx(deploy_tx).await?; + node.advance_block().await?; + + let revert_contract = Address::create(&sender, 0); + + // Block 2: call the reverting contract with MorphTx v0 (ERC20 fee) + let morph_tx = MorphTxBuilder::new(chain_id, wallet.inner.clone(), 1) + .with_v0_token_fee(TEST_TOKEN_ID) + .with_to(revert_contract) + .with_gas_limit(100_000) + .build_signed()?; + node.rpc.inject_tx(morph_tx).await?; + let payload = node.advance_block().await?; + + // Verify receipt: status must be false (main tx reverted) + let tx_hash = *payload + .block() + .body() + .transactions + .first() + .unwrap() + .tx_hash(); + let receipt = node + .inner + .provider + .receipt_by_hash(tx_hash)? + .expect("receipt must exist"); + + assert!( + !receipt.status(), + "main tx should revert (runtime REVERT contract)" + ); + + // Token balance must have decreased even though the main tx reverted. + // Fee was deducted upfront; only unused gas is partially refunded. + let bal_after = node + .inner + .provider + .latest()? + .storage(token_addr, bal_slot)? + .unwrap_or_default(); + + assert!( + bal_after < bal_before, + "token balance must decrease even when main tx reverts \ + (fee deducted upfront, partial refund for unused gas). \ + before={bal_before}, after={bal_after}" + ); + + // The receipt should carry MorphTx-specific fee fields + match &receipt { + morph_primitives::MorphReceipt::Morph(morph_receipt) => { + assert_eq!( + morph_receipt.fee_token_id, + Some(TEST_TOKEN_ID), + "receipt must carry fee_token_id" + ); + assert!( + morph_receipt.fee_rate.is_some(), + "receipt must carry fee_rate" + ); + assert!( + morph_receipt.token_scale.is_some(), + "receipt must carry token_scale" + ); + } + other => panic!( + "expected MorphReceipt::Morph variant, got {:?}", + other.tx_type() + ), + } + + Ok(()) +} diff --git a/crates/node/tests/it/rpc.rs b/crates/node/tests/it/rpc.rs new file mode 100644 index 0000000..2666922 --- /dev/null +++ b/crates/node/tests/it/rpc.rs @@ -0,0 +1,588 @@ +//! Basic RPC response verification tests. +//! +//! Ensures that the node's JSON-RPC interface returns correct data +//! for common eth_ namespace methods after blocks have been produced. + +use alloy_consensus::{BlockHeader, SignableTransaction, TxLegacy, transaction::TxHashRef}; +use alloy_eips::Encodable2718; +use alloy_primitives::{Address, B256, Bytes, Sealable, TxKind, U256}; +use alloy_signer::SignerSync; +use jsonrpsee::core::client::ClientT; +use morph_node::test_utils::{ + L1MessageBuilder, MorphTestNode, MorphTxBuilder, TEST_TOKEN_ID, TestNodeBuilder, advance_chain, +}; +use morph_primitives::MorphTxEnvelope; +use reth_payload_primitives::BuiltPayload; +use reth_provider::{ + AccountReader, BlockReader, BlockReaderIdExt, HeaderProvider, ReceiptProvider, + StateProviderFactory, TransactionsProvider, +}; +use reth_tasks::TaskManager; +use serde_json::Value; + +use super::helpers::wallet_to_arc; + +/// Block number advances correctly after producing blocks. +#[tokio::test(flavor = "multi_thread")] +async fn block_number_advances_correctly() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let wallet = wallet_to_arc(wallet); + + // Before any blocks: genesis is block 0 + let number_before = node + .inner + .provider + .sealed_header_by_number_or_tag(alloy_rpc_types_eth::BlockNumberOrTag::Latest)? + .map(|h| h.number()) + .unwrap_or(0); + assert_eq!(number_before, 0); + + // Advance 3 blocks + advance_chain(3, &mut node, wallet).await?; + + let number_after = node + .inner + .provider + .sealed_header_by_number_or_tag(alloy_rpc_types_eth::BlockNumberOrTag::Latest)? + .map(|h| h.number()) + .unwrap_or(0); + assert_eq!(number_after, 3); + + Ok(()) +} + +/// Block hash returned by the payload builder matches what's stored in the DB. +#[tokio::test(flavor = "multi_thread")] +async fn block_hash_consistent_with_storage() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let wallet = wallet_to_arc(wallet); + + let payloads = advance_chain(3, &mut node, wallet).await?; + + for (i, payload) in payloads.iter().enumerate() { + let expected_hash = payload.block().hash(); + let block_num = (i + 1) as u64; + + let header = node + .inner + .provider + .header_by_number(block_num)? + .expect("header should be stored"); + + // Verify the stored header, when hashed, matches what the payload builder returned + assert_eq!( + header.hash_slow(), + expected_hash, + "block {block_num}: stored hash does not match payload hash" + ); + } + + Ok(()) +} + +/// Each produced block contains the expected number of transactions. +#[tokio::test(flavor = "multi_thread")] +async fn block_transaction_count_correct() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let wallet = wallet_to_arc(wallet); + + let payloads = advance_chain(3, &mut node, wallet).await?; + + for (i, payload) in payloads.iter().enumerate() { + let block_num = (i + 1) as u64; + + let stored_block = node + .inner + .provider + .block_by_number(block_num)? + .expect("block should be stored"); + + // Block from provider must have the same tx count as from payload builder + assert_eq!( + stored_block.body.transactions.len(), + payload.block().body().transactions.len(), + "block {block_num}: tx count mismatch between payload and stored block" + ); + assert_eq!( + stored_block.body.transactions.len(), + 1, + "each advance_chain block should have 1 tx" + ); + } + + Ok(()) +} + +/// Transactions are retrievable by hash after block import. +#[tokio::test(flavor = "multi_thread")] +async fn transaction_retrievable_by_hash() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let wallet = wallet_to_arc(wallet); + + let payloads = advance_chain(1, &mut node, wallet).await?; + let block = payloads[0].block(); + + let tx = block + .body() + .transactions + .first() + .expect("block should have a tx"); + let tx_hash = *tx.tx_hash(); + + // Retrieve via provider + let fetched = node + .inner + .provider + .transaction_by_hash(tx_hash)? + .expect("tx should be retrievable by hash"); + + assert_eq!(*fetched.tx_hash(), tx_hash); + + Ok(()) +} + +/// Block gas_used reflects the actual execution cost of transactions. +#[tokio::test(flavor = "multi_thread")] +async fn block_gas_used_reflects_execution() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let wallet = wallet_to_arc(wallet); + + let payloads = advance_chain(1, &mut node, wallet).await?; + let block = payloads[0].block(); + + // A simple EIP-1559 transfer uses exactly 21,000 gas + assert_eq!( + block.header().inner.gas_used, + 21_000, + "simple transfer should use exactly 21,000 gas" + ); + + Ok(()) +} + +/// MorphTx v0 receipt stored in the database carries the expected ERC20 fee fields. +/// +/// After including a MorphTx v0 (ERC20 fee) in a block, the receipt retrieved +/// from the provider must have `fee_token_id`, `fee_rate`, `token_scale`, and +/// `fee_limit` populated by the receipt builder. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_receipt_contains_fee_fields() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + // Build and inject a MorphTx v0 with ERC20 fee payment + let raw_tx = MorphTxBuilder::new(wallet.chain_id, wallet.inner.clone(), 0) + .with_v0_token_fee(TEST_TOKEN_ID) + .build_signed()?; + node.rpc.inject_tx(raw_tx).await?; + + let payload = node.advance_block().await?; + + // Extract the transaction hash from the sealed block + let tx = payload + .block() + .body() + .transactions + .first() + .expect("block must contain the MorphTx"); + let tx_hash = *tx.tx_hash(); + + // Retrieve the receipt from the provider + let receipt = node + .inner + .provider + .receipt_by_hash(tx_hash)? + .expect("receipt must exist after block import"); + + // The receipt must be the Morph variant and carry populated fee fields + match &receipt { + morph_primitives::MorphReceipt::Morph(morph_receipt) => { + assert_eq!( + morph_receipt.fee_token_id, + Some(TEST_TOKEN_ID), + "fee_token_id must match the submitted transaction" + ); + assert!( + morph_receipt.fee_rate.is_some(), + "fee_rate must be present in MorphTx v0 receipt" + ); + assert!( + morph_receipt.token_scale.is_some(), + "token_scale must be present in MorphTx v0 receipt" + ); + assert!( + morph_receipt.fee_limit.is_some(), + "fee_limit must be present in MorphTx v0 receipt" + ); + } + other => panic!( + "expected MorphReceipt::Morph variant, got {:?}", + other.tx_type() + ), + } + + Ok(()) +} + +/// ETH balance decreases after a transfer transaction. +#[tokio::test(flavor = "multi_thread")] +async fn balance_decreases_after_eth_transfer() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let wallet = wallet_to_arc(wallet); + + // Get balance before + let state_before = node.inner.provider.latest()?; + let sender = alloy_primitives::address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + let bal_before = state_before + .basic_account(&sender)? + .map(|a| a.balance) + .unwrap_or_default(); + + advance_chain(1, &mut node, wallet).await?; + + let state_after = node.inner.provider.latest()?; + let bal_after = state_after + .basic_account(&sender)? + .map(|a| a.balance) + .unwrap_or_default(); + + assert!( + bal_after < bal_before, + "balance should decrease after transfer (gas + value spent)" + ); + Ok(()) +} + +/// Nonce increments by 1 after a successful transaction. +#[tokio::test(flavor = "multi_thread")] +async fn nonce_increments_after_tx() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let wallet = wallet_to_arc(wallet); + + let sender = alloy_primitives::address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + + let state_before = node.inner.provider.latest()?; + let nonce_before = state_before + .basic_account(&sender)? + .map(|a| a.nonce) + .unwrap_or(0); + assert_eq!(nonce_before, 0, "nonce should start at 0"); + + advance_chain(1, &mut node, wallet).await?; + + let state_after = node.inner.provider.latest()?; + let nonce_after = state_after + .basic_account(&sender)? + .map(|a| a.nonce) + .unwrap_or(0); + assert_eq!(nonce_after, 1, "nonce should be 1 after one tx"); + Ok(()) +} + +/// L1 message receipt has l1_fee = 0 (gas is prepaid on L1). +#[tokio::test(flavor = "multi_thread")] +async fn l1_message_receipt_l1_fee_is_zero() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let l1_msg = L1MessageBuilder::new(0) + .with_target(alloy_primitives::Address::with_last_byte(0x42)) + .with_gas_limit(50_000) + .build_encoded(); + let payload = super::helpers::advance_block_with_l1_messages(&mut node, vec![l1_msg]).await?; + + let tx = payload.block().body().transactions.first().unwrap(); + let tx_hash = *tx.tx_hash(); + + let receipt = node + .inner + .provider + .receipt_by_hash(tx_hash)? + .expect("L1 message receipt must exist"); + + assert_eq!( + receipt.l1_fee(), + alloy_primitives::U256::ZERO, + "L1 message l1_fee must be 0" + ); + Ok(()) +} + +/// `eth_getTransactionReceipt` exposes Morph-specific receipt fields over JSON-RPC. +#[tokio::test(flavor = "multi_thread")] +async fn transaction_receipt_exposes_morph_fields_over_rpc() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let reference = B256::with_last_byte(0x44); + let memo = alloy_primitives::Bytes::from_static(b"invoice-42"); + let expected_reference = reference.to_string(); + let expected_memo = memo.to_string(); + + let raw_tx = MorphTxBuilder::new(wallet.chain_id, wallet.inner.clone(), 0) + .with_v1_token_fee(TEST_TOKEN_ID) + .with_reference(reference) + .with_memo(memo) + .with_data(vec![0xaa; 16]) + .build_signed()?; + node.rpc.inject_tx(raw_tx).await?; + + let payload = node.advance_block().await?; + let tx_hash = *payload + .block() + .body() + .transactions + .first() + .unwrap() + .tx_hash(); + let client = node + .rpc_client() + .ok_or_else(|| eyre::eyre!("HTTP RPC client not available"))?; + + let receipt: Value = client + .request("eth_getTransactionReceipt", (tx_hash,)) + .await?; + + assert_eq!(receipt["type"].as_str(), Some("0x7f")); + assert_eq!(receipt["version"].as_u64(), Some(1)); + assert_eq!(receipt["feeTokenID"].as_str(), Some("0x1")); + assert_eq!( + receipt["reference"].as_str(), + Some(expected_reference.as_str()) + ); + assert_eq!(receipt["memo"].as_str(), Some(expected_memo.as_str())); + assert!( + receipt["feeRate"].as_str().is_some(), + "feeRate should be serialized for token-fee MorphTx receipts" + ); + assert!( + receipt["tokenScale"].as_str().is_some(), + "tokenScale should be serialized for token-fee MorphTx receipts" + ); + assert!( + receipt["feeLimit"].as_str().is_some(), + "feeLimit should be serialized for token-fee MorphTx receipts" + ); + assert!( + receipt["l1Fee"] + .as_str() + .is_some_and(|value| value != "0x0"), + "l1Fee should be serialized as a non-zero quantity for calldata txs" + ); + + Ok(()) +} + +/// `eth_getTransactionByHash` exposes MorphTx reference and memo over JSON-RPC. +#[tokio::test(flavor = "multi_thread")] +async fn transaction_by_hash_exposes_morph_fields_over_rpc() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let reference = B256::with_last_byte(0x55); + let memo = alloy_primitives::Bytes::from_static(b"memo-check"); + let expected_reference = reference.to_string(); + let expected_memo = memo.to_string(); + + let raw_tx = MorphTxBuilder::new(wallet.chain_id, wallet.inner.clone(), 0) + .with_v1_token_fee(TEST_TOKEN_ID) + .with_reference(reference) + .with_memo(memo) + .build_signed()?; + node.rpc.inject_tx(raw_tx).await?; + + let payload = node.advance_block().await?; + let tx_hash = *payload + .block() + .body() + .transactions + .first() + .unwrap() + .tx_hash(); + let client = node + .rpc_client() + .ok_or_else(|| eyre::eyre!("HTTP RPC client not available"))?; + + let tx: Value = client + .request("eth_getTransactionByHash", (tx_hash,)) + .await?; + + assert_eq!(tx["hash"].as_str(), Some(tx_hash.to_string().as_str())); + assert_eq!(tx["type"].as_str(), Some("0x7f")); + assert_eq!(tx["version"].as_u64(), Some(1)); + assert_eq!(tx["feeTokenID"].as_str(), Some("0x1")); + assert!(tx["feeLimit"].as_str().is_some()); + assert_eq!(tx["reference"].as_str(), Some(expected_reference.as_str())); + assert_eq!(tx["memo"].as_str(), Some(expected_memo.as_str())); + + Ok(()) +} + +/// Produces a simple one-transaction block on the standard Jade profile and returns the +/// node, task manager, and identifiers needed by the replay-based debug / trace RPCs. +async fn build_standard_jade_block_for_debug_trace() +-> eyre::Result<(MorphTestNode, TaskManager, B256, B256)> { + let (mut nodes, tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let tx = TxLegacy { + chain_id: Some(wallet.chain_id), + nonce: 0, + gas_limit: 21_000, + gas_price: 20_000_000_000u128, + to: TxKind::Call(Address::with_last_byte(0x42)), + value: U256::from(100), + input: Bytes::new(), + }; + let sig = wallet + .inner + .sign_hash_sync(&tx.signature_hash()) + .map_err(|e| eyre::eyre!("signing failed: {e}"))?; + let raw_tx: Bytes = MorphTxEnvelope::Legacy(tx.into_signed(sig)) + .encoded_2718() + .into(); + node.rpc.inject_tx(raw_tx).await?; + + let payload = node.advance_block().await?; + let tx_hash = *payload + .block() + .body() + .transactions + .first() + .expect("produced block should contain the submitted tx") + .tx_hash(); + let block_hash = payload.block().hash(); + + Ok((node, tasks, tx_hash, block_hash)) +} + +/// Comprehensive test: debug + trace replay APIs on a standard Jade block with Cancun active. +/// +/// Uses internal APIs (debug_api / trace_api) directly via `node.rpc.inner`, +/// matching the approach on `main`. This avoids HTTP serialization overhead +/// and the TaskManager lifetime pitfalls of the HTTP path. +#[tokio::test(flavor = "multi_thread")] +async fn debug_trace_replay_apis_work_for_standard_jade_block() -> eyre::Result<()> { + use alloy_rpc_types_eth::TransactionRequest; + use morph_rpc::MorphTransactionRequest; + + reth_tracing::init_test_tracing(); + + let (node, _tasks, tx_hash, block_hash) = build_standard_jade_block_for_debug_trace().await?; + + // Verify parent_beacon_block_root is None (Morph L2 does not use beacon chain) + let block = node + .inner + .provider + .block_by_hash(block_hash)? + .expect("block should exist"); + assert!( + block.header.inner.parent_beacon_block_root.is_none(), + "Morph L2 blocks must not carry parentBeaconBlockRoot" + ); + + // ---------------------------------------------------------------- + // debug_traceTransaction (default structLogs tracer) + // ---------------------------------------------------------------- + node.rpc + .inner + .debug_api() + .debug_trace_transaction(tx_hash, Default::default()) + .await?; + + // ---------------------------------------------------------------- + // debug_traceBlock by hash and by number + // ---------------------------------------------------------------- + let traces_by_hash = node + .rpc + .inner + .debug_api() + .debug_trace_block(block_hash.into(), Default::default()) + .await?; + assert_eq!( + traces_by_hash.len(), + 1, + "block should contain exactly one tx trace" + ); + + let traces_by_number = node + .rpc + .inner + .debug_api() + .debug_trace_block(1u64.into(), Default::default()) + .await?; + assert_eq!(traces_by_number.len(), 1); + + // ---------------------------------------------------------------- + // trace_transaction (parity-style) + // ---------------------------------------------------------------- + let parity_traces = node + .rpc + .inner + .trace_api() + .trace_transaction(tx_hash) + .await?; + assert!( + parity_traces.is_some_and(|t| !t.is_empty()), + "trace_transaction should return non-empty traces" + ); + + // ---------------------------------------------------------------- + // trace_block (parity-style) + // ---------------------------------------------------------------- + let block_traces = node + .rpc + .inner + .trace_api() + .trace_block(block_hash.into()) + .await?; + assert!( + block_traces.is_some_and(|t| !t.is_empty()), + "trace_block should return non-empty traces" + ); + + // ---------------------------------------------------------------- + // debug_traceCall + // ---------------------------------------------------------------- + let call = MorphTransactionRequest::from(TransactionRequest { + from: Some(Address::with_last_byte(0x01)), + to: Some(Address::with_last_byte(0x42).into()), + gas: Some(21_000), + gas_price: Some(20_000_000_000), + value: Some(U256::ZERO), + ..Default::default() + }); + node.rpc + .inner + .debug_api() + .debug_trace_call(call, Some(block_hash.into()), Default::default()) + .await?; + + Ok(()) +} diff --git a/crates/node/tests/it/sync.rs b/crates/node/tests/it/sync.rs new file mode 100644 index 0000000..5ebac50 --- /dev/null +++ b/crates/node/tests/it/sync.rs @@ -0,0 +1,45 @@ +//! Block sync integration tests. +//! +//! Tests that the Morph node can produce and import blocks via the Engine API. + +use morph_node::test_utils::{advance_chain, setup}; +use reth_payload_primitives::BuiltPayload; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// Verifies that the Morph node can sync a chain of blocks. +/// +/// This is the core E2E test — it starts a real node, generates transfer +/// transactions, produces blocks via the payload builder, and imports them +/// through the Engine API (newPayload + forkchoiceUpdated). +#[tokio::test] +async fn can_sync() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = setup(1, false).await?; + let mut node = nodes.pop().unwrap(); + let wallet = Arc::new(Mutex::new(wallet)); + + // Advance the chain by 10 blocks, each containing a transfer tx + let payloads = advance_chain(10, &mut node, wallet.clone()).await?; + + assert_eq!(payloads.len(), 10, "should have produced 10 payloads"); + + // Verify block numbers are sequential + for (i, payload) in payloads.iter().enumerate() { + let block = payload.block(); + assert_eq!( + block.header().inner.number, + (i + 1) as u64, + "block number should be sequential" + ); + // Each block should have at least one transaction (the transfer) + assert!( + !block.body().transactions.is_empty(), + "block {} should contain transactions", + i + 1 + ); + } + + Ok(()) +} diff --git a/crates/node/tests/it/txpool.rs b/crates/node/tests/it/txpool.rs new file mode 100644 index 0000000..c994576 --- /dev/null +++ b/crates/node/tests/it/txpool.rs @@ -0,0 +1,327 @@ +//! Transaction pool E2E tests. +//! +//! Verifies transaction pool acceptance and rejection behavior: +//! - L1 messages must NOT enter the pool +//! - Nonce-too-low transactions are rejected +//! - Insufficient balance transactions are rejected +//! - Legacy (type 0x00) transactions are accepted + +use alloy_consensus::TxLegacy; +use alloy_eips::eip2718::Encodable2718; +use alloy_primitives::{Address, Bytes, TxKind, U256}; +use morph_node::test_utils::{ + L1MessageBuilder, TestNodeBuilder, advance_chain, make_eip4844_tx, make_transfer_tx, +}; +use morph_primitives::MorphTxEnvelope; +use reth_payload_primitives::BuiltPayload; + +use super::helpers::wallet_to_arc; + +/// L1 message transactions must be rejected by the pool. +/// +/// L1 messages are injected via payload attributes, never through the +/// transaction pool. The pool MUST reject them to prevent unauthorized +/// L1→L2 deposits. +#[tokio::test(flavor = "multi_thread")] +async fn l1_message_rejected_by_pool() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, _wallet) = TestNodeBuilder::new().build().await?; + let node = nodes.pop().unwrap(); + + let l1_msg = L1MessageBuilder::new(0) + .with_target(Address::with_last_byte(0x01)) + .with_gas_limit(21_000) + .build_encoded(); + + let result = node.rpc.inject_tx(l1_msg).await; + assert!( + result.is_err(), + "L1 messages must be rejected by the transaction pool" + ); + + Ok(()) +} + +/// A legacy (type 0x00) transaction is accepted by the pool and included in a block. +#[tokio::test(flavor = "multi_thread")] +async fn legacy_tx_accepted() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + // Build a legacy transaction (type 0x00) + use alloy_signer::SignerSync; + let legacy_tx = TxLegacy { + chain_id: Some(wallet.chain_id), + nonce: 0, + gas_limit: 21_000, + gas_price: 20_000_000_000u128, + to: TxKind::Call(Address::with_last_byte(0x42)), + value: U256::from(100), + input: Bytes::new(), + }; + + use alloy_consensus::SignableTransaction; + let sig = wallet + .inner + .sign_hash_sync(&legacy_tx.signature_hash()) + .map_err(|e| eyre::eyre!("signing failed: {e}"))?; + let signed = legacy_tx.into_signed(sig); + let envelope = MorphTxEnvelope::Legacy(signed); + let encoded: Bytes = envelope.encoded_2718().into(); + + node.rpc.inject_tx(encoded).await?; + let payload = node.advance_block().await?; + + assert_eq!( + payload.block().body().transactions.len(), + 1, + "legacy transaction should be included" + ); + + Ok(()) +} + +/// A transaction with nonce too low is rejected by the pool. +/// +/// After advancing 1 block (nonce 0 used), submitting another tx +/// with nonce 0 should fail. +#[tokio::test(flavor = "multi_thread")] +async fn nonce_too_low_rejected() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let wallet = wallet_to_arc(wallet); + + // Advance 1 block (uses nonce 0) + advance_chain(1, &mut node, wallet.clone()).await?; + + // Try to submit another tx with nonce 0 (already used) + let w = wallet.lock().await; + let stale_tx = make_transfer_tx(w.chain_id, w.inner.clone(), 0).await; + drop(w); + + let result = node.rpc.inject_tx(stale_tx).await; + assert!( + result.is_err(), + "transaction with nonce=0 (already used) should be rejected" + ); + + Ok(()) +} + +/// A transaction with higher-than-expected nonce is accepted by pool (queued). +/// +/// The pool should accept future-nonce transactions for queuing, even +/// though they can't be executed immediately. +#[tokio::test(flavor = "multi_thread")] +async fn future_nonce_queued() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let node = nodes.pop().unwrap(); + + // Submit tx with nonce=5 (account nonce is 0, so this is "future") + let future_tx = make_transfer_tx(wallet.chain_id, wallet.inner.clone(), 5).await; + let result = node.rpc.inject_tx(future_tx).await; + + // Pool should accept the transaction for queuing (not reject it) + assert!( + result.is_ok(), + "future nonce tx should be accepted for queuing" + ); + + Ok(()) +} + +/// A future-nonce transaction queued in the pool is promoted and included once +/// the gap transactions are submitted. +/// +/// Sequence: +/// 1. Submit nonce=2 → queued (gap: nonces 0 and 1 are missing) +/// 2. Build an empty block → nonce=2 cannot execute, block is empty +/// 3. Submit nonce=0 and nonce=1 → all three are now pending +/// 4. Build another block → all three transactions should be included +#[tokio::test(flavor = "multi_thread")] +async fn future_nonce_queued_then_promoted_after_gap_filled() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + // Submit nonce=2 — this is a future nonce; nonces 0 and 1 are missing + let future_tx = make_transfer_tx(wallet.chain_id, wallet.inner.clone(), 2).await; + node.rpc.inject_tx(future_tx).await?; + + // Build an empty block: nonce=2 is queued but cannot be executed yet. + // We must use advance_empty_block to avoid hanging (advance_block waits for ≥1 tx). + let empty_payload = morph_node::test_utils::advance_empty_block(&mut node).await?; + assert_eq!( + empty_payload.block().body().transactions.len(), + 0, + "block should be empty when only a queued (future-nonce) tx is in the pool" + ); + + // Submit nonce=0 and nonce=1 to fill the gap + let tx0 = make_transfer_tx(wallet.chain_id, wallet.inner.clone(), 0).await; + node.rpc.inject_tx(tx0).await?; + let tx1 = make_transfer_tx(wallet.chain_id, wallet.inner.clone(), 1).await; + node.rpc.inject_tx(tx1).await?; + + // Build blocks until all 3 transactions are included. + // Typically one block suffices (nonces 0, 1, 2 are all pending after promotion), + // but we allow up to two blocks in case the queued tx isn't promoted until after + // the first block is sealed. + // Use advance_empty_block (bypasses event stream) — it still picks up pool txs. + let payload_a = morph_node::test_utils::advance_empty_block(&mut node).await?; + let count_a = payload_a.block().body().transactions.len(); + + let total = if count_a < 3 { + let payload_b = morph_node::test_utils::advance_empty_block(&mut node).await?; + count_a + payload_b.block().body().transactions.len() + } else { + count_a + }; + + assert_eq!( + total, 3, + "all 3 transactions (nonces 0, 1, 2) should eventually be included" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn eip2930_accepted_by_pool() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let raw_tx = morph_node::test_utils::make_eip2930_tx(wallet.chain_id, wallet.inner.clone(), 0)?; + node.rpc.inject_tx(raw_tx).await?; + let payload = node.advance_block().await?; + assert_eq!(payload.block().body().transactions.len(), 1); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn eip4844_tx_rejected_by_pool() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let node = nodes.pop().unwrap(); + + let blob_tx = make_eip4844_tx(wallet.chain_id, wallet.inner.clone(), 0)?; + let result = node.rpc.inject_tx(blob_tx).await; + assert!( + result.is_err(), + "EIP-4844 blob transactions (type 0x03) must be rejected" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn duplicate_tx_rejected_by_pool() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let node = nodes.pop().unwrap(); + + let raw_tx = make_transfer_tx(wallet.chain_id, wallet.inner.clone(), 0).await; + node.rpc.inject_tx(raw_tx.clone()).await?; + let result = node.rpc.inject_tx(raw_tx).await; + assert!(result.is_err(), "duplicate transaction must be rejected"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn tx_gas_limit_exceeds_block_limit_rejected() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let node = nodes.pop().unwrap(); + + use alloy_consensus::{SignableTransaction, TxEip1559}; + use alloy_signer::SignerSync; + let tx = TxEip1559 { + chain_id: wallet.chain_id, + nonce: 0, + gas_limit: 30_000_001, // exceeds 30M block gas limit + max_fee_per_gas: 20_000_000_000u128, + max_priority_fee_per_gas: 20_000_000_000u128, + to: alloy_primitives::TxKind::Call(alloy_primitives::Address::with_last_byte(0x42)), + value: alloy_primitives::U256::from(100), + access_list: Default::default(), + input: alloy_primitives::Bytes::new(), + }; + let sig = wallet + .inner + .sign_hash_sync(&tx.signature_hash()) + .map_err(|e| eyre::eyre!("{e}"))?; + let envelope = morph_primitives::MorphTxEnvelope::Eip1559(tx.into_signed(sig)); + use alloy_eips::eip2718::Encodable2718; + let raw: alloy_primitives::Bytes = envelope.encoded_2718().into(); + + let result = node.rpc.inject_tx(raw).await; + assert!( + result.is_err(), + "tx with gas_limit > block gas limit must be rejected" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn tx_max_fee_below_base_fee_accepted_for_queuing() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let node = nodes.pop().unwrap(); + + use alloy_consensus::{SignableTransaction, TxEip1559}; + use alloy_signer::SignerSync; + let tx = TxEip1559 { + chain_id: wallet.chain_id, + nonce: 0, + gas_limit: 21_000, + max_fee_per_gas: 500_000u128, // below base fee of 1_000_000 + max_priority_fee_per_gas: 500_000u128, + to: alloy_primitives::TxKind::Call(alloy_primitives::Address::with_last_byte(0x42)), + value: alloy_primitives::U256::from(100), + access_list: Default::default(), + input: alloy_primitives::Bytes::new(), + }; + let sig = wallet + .inner + .sign_hash_sync(&tx.signature_hash()) + .map_err(|e| eyre::eyre!("{e}"))?; + let envelope = morph_primitives::MorphTxEnvelope::Eip1559(tx.into_signed(sig)); + use alloy_eips::eip2718::Encodable2718; + let raw: alloy_primitives::Bytes = envelope.encoded_2718().into(); + + // reth pools low-fee txs for future execution when baseFee drops, + // so they are accepted into the queued set, not rejected outright. + let result = node.rpc.inject_tx(raw).await; + assert!( + result.is_ok(), + "tx with maxFeePerGas < baseFee should be accepted for queuing" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v1_zero_eth_balance_rejected() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; + let node = nodes.pop().unwrap(); + + use morph_node::test_utils::MorphTxBuilder; + let poor_signer = alloy_signer_local::PrivateKeySigner::random(); + let raw_tx = MorphTxBuilder::new(wallet.chain_id, poor_signer, 0) + .with_v1_eth_fee() + .build_signed()?; + let result = node.rpc.inject_tx(raw_tx).await; + assert!( + result.is_err(), + "MorphTx from zero-balance account must be rejected" + ); + Ok(()) +}