diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000..f652f4b --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +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/build.yml b/.github/workflows/build.yml index 12c64eb..a406645 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,6 +9,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + env: CARGO_TERM_COLOR: always RUSTFLAGS: -D warnings diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index bde7fa1..02468c2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,6 +9,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + env: CARGO_TERM_COLOR: always @@ -48,3 +52,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 32ae261..8e20a83 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,8 +9,13 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + env: CARGO_TERM_COLOR: always + RUST_MIN_STACK: 8388608 jobs: test: @@ -28,8 +33,13 @@ jobs: with: cache-on-failure: true + - name: Install nextest + uses: taiki-e/install-action@v2 + with: + tool: cargo-nextest + - name: Run tests - run: cargo test --all --verbose + run: cargo nextest run --profile ci --workspace doc-test: name: Doc Tests @@ -49,3 +59,26 @@ 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/.gitignore b/.gitignore index 487d601..383b2e0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ CLAUDE.md +.worktrees/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 305b27f..915bc91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4811,13 +4811,19 @@ name = "morph-node" version = "0.1.0" dependencies = [ "alloy-consensus", + "alloy-eips", + "alloy-genesis", "alloy-hardforks", "alloy-primitives", + "alloy-rlp", "alloy-rpc-types-engine", "alloy-rpc-types-eth", + "alloy-signer", + "alloy-signer-local", "clap", "dashmap 6.1.0", "eyre", + "jsonrpsee", "morph-chainspec", "morph-consensus", "morph-engine-api", @@ -4830,6 +4836,7 @@ dependencies = [ "parking_lot", "reth-chainspec", "reth-db", + "reth-e2e-test-utils", "reth-engine-local", "reth-engine-tree", "reth-errors", @@ -4848,6 +4855,7 @@ dependencies = [ "reth-transaction-pool", "reth-trie", "serde", + "serde_json", "tokio", "tokio-stream", ] @@ -6840,12 +6848,16 @@ dependencies = [ "rayon", "reth-config", "reth-consensus", + "reth-ethereum-primitives", "reth-metrics", "reth-network-p2p", "reth-network-peers", "reth-primitives-traits", + "reth-provider", "reth-storage-api", "reth-tasks", + "reth-testing-utils", + "tempfile", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -6853,6 +6865,64 @@ dependencies = [ "tracing", ] +[[package]] +name = "reth-e2e-test-utils" +version = "1.10.0" +source = "git+https://github.com/morph-l2/reth?rev=1dd722773844d1a3c50a691dc09f6cdf8e6bd00e#1dd722773844d1a3c50a691dc09f6cdf8e6bd00e" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-network", + "alloy-primitives", + "alloy-provider", + "alloy-rlp", + "alloy-rpc-types-engine", + "alloy-rpc-types-eth", + "alloy-signer", + "alloy-signer-local", + "derive_more", + "eyre", + "futures-util", + "jsonrpsee", + "reth-chainspec", + "reth-cli-commands", + "reth-config", + "reth-consensus", + "reth-db", + "reth-db-common", + "reth-engine-local", + "reth-engine-primitives", + "reth-ethereum-primitives", + "reth-network-api", + "reth-network-p2p", + "reth-network-peers", + "reth-node-api", + "reth-node-builder", + "reth-node-core", + "reth-node-ethereum", + "reth-payload-builder", + "reth-payload-builder-primitives", + "reth-payload-primitives", + "reth-primitives", + "reth-primitives-traits", + "reth-provider", + "reth-rpc-api", + "reth-rpc-builder", + "reth-rpc-eth-api", + "reth-rpc-server-types", + "reth-stages-types", + "reth-tasks", + "reth-tokio-util", + "reth-tracing", + "revm", + "serde_json", + "tempfile", + "tokio", + "tokio-stream", + "tracing", + "url", +] + [[package]] name = "reth-ecies" version = "1.10.0" @@ -6973,6 +7043,7 @@ dependencies = [ "parking_lot", "rayon", "reth-chain-state", + "reth-chainspec", "reth-consensus", "reth-db", "reth-engine-primitives", @@ -6987,9 +7058,13 @@ dependencies = [ "reth-primitives-traits", "reth-provider", "reth-prune", + "reth-prune-types", "reth-revm", + "reth-stages", "reth-stages-api", + "reth-static-file", "reth-tasks", + "reth-tracing", "reth-trie", "reth-trie-parallel", "reth-trie-sparse", @@ -7610,6 +7685,7 @@ dependencies = [ "auto_impl", "derive_more", "futures", + "parking_lot", "reth-consensus", "reth-eth-wire-types", "reth-ethereum-primitives", @@ -8027,6 +8103,19 @@ dependencies = [ "reth-primitives-traits", ] +[[package]] +name = "reth-primitives" +version = "1.10.0" +source = "git+https://github.com/morph-l2/reth?rev=1dd722773844d1a3c50a691dc09f6cdf8e6bd00e#1dd722773844d1a3c50a691dc09f6cdf8e6bd00e" +dependencies = [ + "alloy-consensus", + "once_cell", + "reth-ethereum-forks", + "reth-ethereum-primitives", + "reth-primitives-traits", + "reth-static-file-types", +] + [[package]] name = "reth-primitives-traits" version = "1.10.0" @@ -8501,6 +8590,7 @@ dependencies = [ "num-traits", "rayon", "reqwest", + "reth-chainspec", "reth-codecs", "reth-config", "reth-consensus", @@ -8509,6 +8599,7 @@ dependencies = [ "reth-era", "reth-era-downloader", "reth-era-utils", + "reth-ethereum-primitives", "reth-etl", "reth-evm", "reth-execution-types", @@ -8524,8 +8615,10 @@ dependencies = [ "reth-static-file-types", "reth-storage-api", "reth-storage-errors", + "reth-testing-utils", "reth-trie", "reth-trie-db", + "tempfile", "thiserror 2.0.18", "tokio", "tracing", @@ -8663,6 +8756,22 @@ dependencies = [ "tracing-futures", ] +[[package]] +name = "reth-testing-utils" +version = "1.10.0" +source = "git+https://github.com/morph-l2/reth?rev=1dd722773844d1a3c50a691dc09f6cdf8e6bd00e#1dd722773844d1a3c50a691dc09f6cdf8e6bd00e" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-genesis", + "alloy-primitives", + "rand 0.8.5", + "rand 0.9.2", + "reth-ethereum-primitives", + "reth-primitives-traits", + "secp256k1 0.30.0", +] + [[package]] name = "reth-tokio-util" version = "1.10.0" @@ -8722,6 +8831,7 @@ dependencies = [ "futures-util", "metrics", "parking_lot", + "paste", "pin-project", "rand 0.9.2", "reth-chain-state", diff --git a/Cargo.toml b/Cargo.toml index cb39de1..c44c5b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -185,3 +185,5 @@ pyroscope_pprofrs = "0.2.10" [patch.crates-io] vergen = { git = "https://github.com/rustyhorde/vergen", rev = "a43c276d2b68d05832a429cc4540755541ca4950" } vergen-lib = { git = "https://github.com/rustyhorde/vergen", rev = "a43c276d2b68d05832a429cc4540755541ca4950" } + + diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index ec2333d..fb81c88 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -42,6 +42,7 @@ reth-trie.workspace = true # Alloy alloy-consensus.workspace = true +alloy-genesis.workspace = true alloy-hardforks.workspace = true alloy-primitives.workspace = true alloy-rpc-types-engine.workspace = true @@ -55,12 +56,47 @@ parking_lot.workspace = true tokio-stream.workspace = true serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true + +# Optional: E2E testing framework +reth-e2e-test-utils = { workspace = true, optional = true } +reth-tasks = { workspace = true, optional = true } +tokio = { workspace = true, features = ["sync"], optional = true } +alloy-eips = { workspace = true, optional = true } +alloy-rlp = { workspace = true, optional = true } +alloy-signer = { workspace = true, optional = true } +alloy-signer-local = { workspace = true, optional = true } [dev-dependencies] -tokio.workspace = true +tokio = { workspace = true, features = ["full"] } reth-db = { workspace = true, features = ["test-utils"] } +reth-e2e-test-utils.workspace = true reth-node-core.workspace = true +reth-payload-primitives.workspace = true reth-tasks.workspace = true +reth-tracing.workspace = true +alloy-consensus.workspace = true +alloy-primitives.workspace = true +alloy-rpc-types-engine.workspace = true +alloy-rpc-types-eth.workspace = true +jsonrpsee.workspace = true +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 = [ + "dep:reth-e2e-test-utils", + "dep:reth-tasks", + "dep:alloy-signer", + "dep:tokio", + "dep:alloy-eips", + "dep:alloy-rlp", + "dep:alloy-signer-local", +] diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index 13eb385..1af9dc7 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -23,6 +23,8 @@ pub mod add_ons; pub mod args; pub mod components; pub mod node; +#[cfg(feature = "test-utils")] +pub mod test_utils; pub mod validator; // Re-export main node types diff --git a/crates/node/src/node.rs b/crates/node/src/node.rs index 5f9ad75..03cd40b 100644 --- a/crates/node/src/node.rs +++ b/crates/node/src/node.rs @@ -66,16 +66,6 @@ impl MorphNode { ProviderFactoryBuilder::default() } - fn payload_builder_config(&self) -> MorphBuilderConfig { - let config = - MorphBuilderConfig::default().with_max_da_block_size(self.args.max_tx_payload_bytes); - - match self.args.max_tx_per_block { - Some(max_tx) => config.with_max_tx_per_block(max_tx), - None => config, - } - } - /// Returns a [`ComponentsBuilder`] configured for a Morph node. pub fn components( payload_builder_config: MorphBuilderConfig, @@ -128,7 +118,17 @@ where type AddOns = MorphAddOns>; fn components_builder(&self) -> Self::ComponentsBuilder { - Self::components(self.payload_builder_config()) + // Build payload config from args + let payload_config = + MorphBuilderConfig::default().with_max_da_block_size(self.args.max_tx_payload_bytes); + + let payload_config = if let Some(max_tx) = self.args.max_tx_per_block { + payload_config.with_max_tx_per_block(max_tx) + } else { + payload_config + }; + + Self::components(payload_config) } fn add_ons(&self) -> Self::AddOns { @@ -227,3 +227,93 @@ fn unix_timestamp_now() -> u64 { .unwrap_or_default() .as_secs() } + +#[cfg(test)] +mod tests { + use super::*; + use morph_chainspec::MORPH_HOODI; + use reth_payload_primitives::PayloadAttributesBuilder; + + #[test] + fn morph_node_default() { + let node = MorphNode::default(); + assert_eq!( + node.args.max_tx_payload_bytes, + super::super::args::MORPH_DEFAULT_MAX_TX_PAYLOAD_BYTES + ); + assert!(node.args.max_tx_per_block.is_none()); + } + + #[test] + fn morph_node_new_with_args() { + let args = super::super::args::MorphArgs { + max_tx_payload_bytes: 200_000, + max_tx_per_block: Some(500), + }; + let node = MorphNode::new(args); + assert_eq!(node.args.max_tx_payload_bytes, 200_000); + assert_eq!(node.args.max_tx_per_block, Some(500)); + } + + #[test] + fn payload_attributes_builder_produces_valid_attributes() { + let chain_spec = MORPH_HOODI.clone(); + let builder = MorphPayloadAttributesBuilder::new(chain_spec); + + // Create a parent header at a known timestamp + let parent_header = MorphHeader { + inner: alloy_consensus::Header { + timestamp: 1_000_000, + ..Default::default() + }, + ..Default::default() + }; + let parent = reth_primitives_traits::SealedHeader::seal_slow(parent_header); + + let attrs = builder.build(&parent); + + // Timestamp should be at least parent + 1 + assert!(attrs.inner.timestamp > 1_000_000); + // No L1 transactions in local mining mode + assert!(attrs.transactions.is_none()); + assert!(attrs.gas_limit.is_none()); + assert!(attrs.base_fee_per_gas.is_none()); + } + + #[test] + fn payload_attributes_builder_timestamp_uses_wall_clock_when_ahead() { + let chain_spec = MORPH_HOODI.clone(); + let builder = MorphPayloadAttributesBuilder::new(chain_spec); + + // Use a parent header with timestamp = 0 (very old) + let parent_header = MorphHeader { + inner: alloy_consensus::Header { + timestamp: 0, + ..Default::default() + }, + ..Default::default() + }; + let parent = reth_primitives_traits::SealedHeader::seal_slow(parent_header); + + let attrs = builder.build(&parent); + + // When parent is very old, timestamp should be approximately wall clock time + let now = unix_timestamp_now(); + assert!(attrs.inner.timestamp >= now.saturating_sub(2)); + assert!(attrs.inner.timestamp <= now.saturating_add(2)); + } + + #[test] + fn node_types_check() { + // Verify that MorphNode implements NodeTypes with the correct associated types + fn assert_node_types< + N: reth_node_api::NodeTypes< + Primitives = morph_primitives::MorphPrimitives, + ChainSpec = morph_chainspec::MorphChainSpec, + Payload = morph_payload_types::MorphPayloadTypes, + >, + >() { + } + assert_node_types::(); + } +} diff --git a/crates/node/src/test_utils.rs b/crates/node/src/test_utils.rs new file mode 100644 index 0000000..0b0a084 --- /dev/null +++ b/crates/node/src/test_utils.rs @@ -0,0 +1,887 @@ +//! Test utilities for Morph node E2E testing. +//! +//! Provides helpers for setting up ephemeral Morph nodes, creating payload +//! attributes, building test transactions, and advancing the chain. +//! +//! # Quick Start +//! +//! ```ignore +//! use morph_node::test_utils::{TestNodeBuilder, HardforkSchedule, advance_chain}; +//! +//! // Spin up a node with all forks active at t=0 +//! let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; +//! let mut node = nodes.pop().unwrap(); +//! +//! // Advance 10 blocks with transfer transactions +//! let wallet = Arc::new(Mutex::new(wallet)); +//! let payloads = advance_chain(10, &mut node, wallet).await?; +//! ``` + +use crate::MorphNode; +use alloy_eips::eip2718::Encodable2718; +use alloy_genesis::Genesis; +use alloy_primitives::{Address, B256, Bytes, TxKind, U256}; +use alloy_rpc_types_engine::PayloadAttributes; +use alloy_rpc_types_eth::TransactionRequest; +use alloy_signer_local::PrivateKeySigner; +use morph_payload_types::{MorphBuiltPayload, MorphPayloadBuilderAttributes}; +use morph_primitives::{ + MorphTxEnvelope, TxL1Msg, TxMorph, transaction::l1_transaction::L1_TX_TYPE_ID, +}; +use reth_e2e_test_utils::{ + NodeHelperType, TmpDB, transaction::TransactionTestContext, wallet::Wallet, +}; +use reth_node_api::NodeTypesWithDBAdapter; +use reth_payload_builder::EthPayloadBuilderAttributes; +use reth_provider::providers::BlockchainProvider; +use reth_tasks::TaskManager; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// Morph Node Helper type alias for E2E tests. +pub type MorphTestNode = + NodeHelperType>>; + +// ============================================================================= +// HardforkSchedule +// ============================================================================= + +/// Hardfork activation schedule presets for integration tests. +/// +/// Controls which Morph hardforks are active at genesis time (t=0). +/// Use `#[test_case]` or similar to parametrize tests across schedules. +/// +/// # Example +/// +/// ```ignore +/// #[test_case(HardforkSchedule::AllActive ; "all active")] +/// #[test_case(HardforkSchedule::PreJade ; "pre jade")] +/// #[tokio::test(flavor = "multi_thread")] +/// async fn test_block_building(schedule: HardforkSchedule) -> eyre::Result<()> { +/// let (mut nodes, _, wallet) = TestNodeBuilder::new().with_schedule(schedule).build().await?; +/// // ... +/// } +/// ``` +#[derive(Clone, Copy, Debug, Default)] +pub enum HardforkSchedule { + /// All Morph hardforks active at genesis (block 0 / timestamp 0). + /// + /// This is the default. Tests run against the latest protocol version. + #[default] + AllActive, + + /// Jade is NOT active; all other forks are active at t=0. + /// + /// Use this to test pre-Jade behavior: state root validation skipped, + /// MorphTx v1 rejected, etc. + PreJade, + + /// Viridian, Emerald, and Jade are NOT active; all earlier forks are at t=0. + /// + /// Use this to test pre-Viridian behavior: EIP-7702 rejected, etc. + PreViridian, + + /// Forks that are currently active on Hoodi testnet are set to t=0; + /// forks not yet activated on Hoodi are disabled (u64::MAX). + /// + /// Use this to ensure your test exercises the same rules as Hoodi. + Hoodi, + + /// Forks that are currently active on mainnet are set to t=0; + /// forks not yet activated on mainnet are disabled (u64::MAX). + /// + /// Use this to ensure your test exercises the same rules as mainnet. + Mainnet, +} + +impl HardforkSchedule { + /// Reference genesis JSON for this schedule (if any). + /// + /// Returns the raw genesis JSON string for Hoodi/Mainnet networks, + /// used to determine which forks are currently active on those networks. + fn reference_genesis_json(&self) -> Option<&'static str> { + match self { + Self::AllActive | Self::PreJade | Self::PreViridian => None, + Self::Hoodi => Some(include_str!("../../chainspec/res/genesis/hoodi.json")), + Self::Mainnet => Some(include_str!("../../chainspec/res/genesis/mainnet.json")), + } + } + + /// Apply this schedule's fork timestamps to a mutable genesis JSON value. + /// + /// - `AllActive`: no changes (test genesis already has all forks at 0) + /// - `PreJade`: set `jadeTime` to `u64::MAX` + /// - `Hoodi`/`Mainnet`: compare each `*Time` key against the reference network; + /// forks active now → 0, forks not yet active → `u64::MAX`. + /// Block-based forks (`*Block`) are always kept at 0. + pub fn apply(&self, genesis: &mut serde_json::Value) { + match self { + Self::AllActive => { + // nothing to do — test genesis has all forks at 0 + } + Self::PreJade => { + // Disable only Jade; all other forks remain at 0. + let config = genesis["config"].as_object_mut().expect("genesis.config"); + config.insert("jadeTime".to_string(), serde_json::json!(u64::MAX)); + } + Self::PreViridian => { + let config = genesis["config"].as_object_mut().expect("genesis.config"); + config.insert("viridianTime".to_string(), serde_json::json!(u64::MAX)); + config.insert("emeraldTime".to_string(), serde_json::json!(u64::MAX)); + config.insert("jadeTime".to_string(), serde_json::json!(u64::MAX)); + } + Self::Hoodi | Self::Mainnet => { + let reference_json = self.reference_genesis_json().unwrap(); + let reference: serde_json::Value = + serde_json::from_str(reference_json).expect("reference genesis must parse"); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after epoch") + .as_secs(); + + let config = genesis["config"] + .as_object_mut() + .expect("genesis.config must be object"); + + // For each *Time key in the test genesis, override based on reference network. + for (key, value) in config.iter_mut() { + if !key.ends_with("Time") { + continue; + } + let new_ts = match reference["config"][key.as_str()].as_u64() { + // Fork already active on reference network → activate at t=0 in test. + Some(ts) if ts <= now => 0u64, + // Fork not yet active or absent → disable in test. + _ => u64::MAX, + }; + *value = serde_json::json!(new_ts); + } + } + } + } +} + +// ============================================================================= +// TestNodeBuilder +// ============================================================================= + +/// Builder for configuring and launching ephemeral Morph test nodes. +/// +/// # Example +/// +/// ```ignore +/// // Single node with all forks active (default) +/// let (mut nodes, _tasks, wallet) = TestNodeBuilder::new().build().await?; +/// +/// // Single node with Jade disabled +/// let (mut nodes, _tasks, wallet) = TestNodeBuilder::new() +/// .with_schedule(HardforkSchedule::PreJade) +/// .build() +/// .await?; +/// +/// // Two nodes connected to each other +/// let (nodes, _tasks, wallet) = TestNodeBuilder::new() +/// .with_num_nodes(2) +/// .build() +/// .await?; +/// ``` +pub struct TestNodeBuilder { + genesis_json: serde_json::Value, + schedule: HardforkSchedule, + num_nodes: usize, + is_dev: bool, +} + +impl Default for TestNodeBuilder { + fn default() -> Self { + Self::new() + } +} + +impl TestNodeBuilder { + /// Create a builder pre-loaded with the test genesis (`tests/assets/test-genesis.json`). + /// + /// The test genesis has all Morph hardforks active at block/timestamp 0 and + /// funds four test accounts derived from the standard test mnemonic. + pub fn new() -> Self { + let genesis_json: serde_json::Value = + serde_json::from_str(include_str!("../tests/assets/test-genesis.json")) + .expect("test-genesis.json must be valid JSON"); + + Self { + genesis_json, + schedule: HardforkSchedule::AllActive, + num_nodes: 1, + is_dev: false, + } + } + + /// Set the hardfork schedule. + pub fn with_schedule(mut self, schedule: HardforkSchedule) -> Self { + self.schedule = schedule; + self + } + + /// Override the gas limit in the genesis block. + pub fn with_gas_limit(mut self, gas_limit: u64) -> Self { + self.genesis_json["gasLimit"] = serde_json::json!(format!("{gas_limit:#x}")); + self + } + + /// Set the number of nodes to start. + /// + /// When `num_nodes > 1`, all nodes are interconnected via a simulated P2P network. + pub fn with_num_nodes(mut self, n: usize) -> Self { + self.num_nodes = n; + self + } + + /// Enable or disable dev mode (auto-sealing blocks every 100ms). + pub fn with_dev(mut self, is_dev: bool) -> Self { + self.is_dev = is_dev; + self + } + + /// Build and launch the configured nodes. + /// + /// Returns the node handles, the task manager, and a wallet derived from + /// the standard test mnemonic (`test test test ... junk`). + pub async fn build(mut self) -> eyre::Result<(Vec, TaskManager, Wallet)> { + // Apply the hardfork schedule to the genesis JSON before parsing. + self.schedule.apply(&mut self.genesis_json); + + let genesis: Genesis = serde_json::from_value(self.genesis_json)?; + let chain_spec = morph_chainspec::MorphChainSpec::from_genesis(genesis); + + reth_e2e_test_utils::setup_engine( + self.num_nodes, + Arc::new(chain_spec), + self.is_dev, + Default::default(), + morph_payload_attributes, + ) + .await + } +} + +// ============================================================================= +// Backward-compatible setup() function +// ============================================================================= + +/// Creates ephemeral Morph nodes for E2E testing. +/// +/// Convenience wrapper around [`TestNodeBuilder`] for tests that don't need +/// custom hardfork schedules or node counts. +/// +/// # Parameters +/// - `num_nodes`: number of interconnected nodes to create +/// - `is_dev`: whether to enable dev mode (auto-sealing every 100ms) +pub async fn setup( + num_nodes: usize, + is_dev: bool, +) -> eyre::Result<(Vec, TaskManager, Wallet)> { + TestNodeBuilder::new() + .with_num_nodes(num_nodes) + .with_dev(is_dev) + .build() + .await +} + +// ============================================================================= +// Chain advancement helpers +// ============================================================================= + +/// Advance the chain by `length` blocks, each containing one transfer transaction. +/// +/// Returns the built payloads for inspection. +pub async fn advance_chain( + length: usize, + node: &mut MorphTestNode, + wallet: Arc>, +) -> eyre::Result> { + node.advance(length as u64, |_| { + let wallet = wallet.clone(); + Box::pin(async move { + let mut wallet = wallet.lock().await; + let nonce = wallet.inner_nonce; + wallet.inner_nonce += 1; + transfer_tx_with_nonce(wallet.chain_id, wallet.inner.clone(), nonce).await + }) + }) + .await +} + +/// Advance the chain by one block without injecting any transactions. +/// +/// Uses the same direct `send_new_payload` + `resolve_kind` approach as the +/// L1 message helper to avoid polluting the payload event stream. +pub async fn advance_empty_block(node: &mut MorphTestNode) -> eyre::Result { + use alloy_consensus::BlockHeader; + use reth_node_api::PayloadKind; + use reth_payload_primitives::{BuiltPayload, PayloadBuilderAttributes}; + use reth_provider::BlockReaderIdExt; + + let head = node + .inner + .provider + .sealed_header_by_number_or_tag(alloy_rpc_types_eth::BlockNumberOrTag::Latest) + .map_err(|e| eyre::eyre!("provider error: {e}"))?; + + let (head_hash, head_ts) = head + .map(|h| (h.hash(), h.timestamp())) + .unwrap_or((B256::ZERO, 0)); + + let rpc_attrs = morph_payload_types::MorphPayloadAttributes { + inner: alloy_rpc_types_engine::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(vec![]), + 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}"))?; + + // Poll until the payload is available (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 empty block payload")); + } + match node + .inner + .payload_builder_handle + .resolve_kind(payload_id, PayloadKind::Earliest) + .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, + } + }; + + node.submit_payload(payload.clone()).await?; + let block_hash = payload.block().hash(); + node.update_forkchoice(block_hash, block_hash).await?; + node.sync_to(block_hash).await?; + + Ok(payload) +} + +/// Standard test mnemonic phrase (Hardhat/Foundry default). +pub const TEST_MNEMONIC: &str = "test test test test test test test test test test test junk"; + +/// Return a signer for account at HD derivation index `idx` with the given chain ID. +/// +/// Index 0 → `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` (has ETH + tokens in genesis) +/// Index 1 → `0x70997970C51812dc3A010C7d01b50e0d17dc79C8` (has ETH + tokens in genesis) +/// Index 2 → `0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC` (has ETH only, no tokens) +/// Index 3 → `0x90F79bf6EB2c4f870365E785982E1f101E93b906` (has ETH only, no tokens) +pub fn wallet_at_index(idx: u32, chain_id: u64) -> PrivateKeySigner { + use alloy_signer::Signer; + use alloy_signer_local::coins_bip39::English; + alloy_signer_local::MnemonicBuilder::::default() + .phrase(TEST_MNEMONIC) + .derivation_path(format!("m/44'/60'/0'/0/{idx}")) + .expect("valid derivation path") + .build() + .expect("wallet must build from test mnemonic") + .with_chain_id(Some(chain_id)) +} + +/// Creates a signed EIP-1559 transfer transaction with an explicit nonce. +/// +/// Public version for use in test helpers outside this module. +pub async fn make_transfer_tx(chain_id: u64, signer: PrivateKeySigner, nonce: u64) -> Bytes { + transfer_tx_with_nonce(chain_id, signer, nonce).await +} + +/// Creates a signed EIP-2930 (type 0x01) transaction. +pub fn make_eip2930_tx(chain_id: u64, signer: PrivateKeySigner, nonce: u64) -> eyre::Result { + use alloy_consensus::{SignableTransaction, TxEip2930}; + use alloy_signer::SignerSync; + + let tx = TxEip2930 { + chain_id, + nonce, + gas_price: 20_000_000_000u128, + gas_limit: 21_000, + to: TxKind::Call(Address::with_last_byte(0x42)), + value: U256::from(100), + access_list: Default::default(), + input: Bytes::new(), + }; + let sig = signer + .sign_hash_sync(&tx.signature_hash()) + .map_err(|e| eyre::eyre!("signing failed: {e}"))?; + let envelope = MorphTxEnvelope::Eip2930(tx.into_signed(sig)); + Ok(envelope.encoded_2718().into()) +} + +/// Creates a signed EIP-4844 (type 0x03) transaction. +pub fn make_eip4844_tx(chain_id: u64, signer: PrivateKeySigner, nonce: u64) -> eyre::Result { + use alloy_consensus::{EthereumTxEnvelope, SignableTransaction, TxEip4844}; + use alloy_signer::SignerSync; + + let tx = TxEip4844 { + chain_id, + nonce, + gas_limit: 100_000, + max_fee_per_gas: 20_000_000_000u128, + max_priority_fee_per_gas: 20_000_000_000u128, + max_fee_per_blob_gas: 1u128, + to: Address::with_last_byte(0x42), + value: U256::from(100), + access_list: Default::default(), + input: Bytes::new(), + blob_versioned_hashes: vec![B256::with_last_byte(0x01)], + }; + let sig = signer + .sign_hash_sync(&tx.signature_hash()) + .map_err(|e| eyre::eyre!("signing failed: {e}"))?; + let envelope = EthereumTxEnvelope::Eip4844(tx.into_signed(sig)); + Ok(envelope.encoded_2718().into()) +} + +/// Creates a signed EIP-7702 (type 0x04) transaction. +pub fn make_eip7702_tx(chain_id: u64, signer: PrivateKeySigner, nonce: u64) -> eyre::Result { + use alloy_consensus::{SignableTransaction, TxEip7702}; + use alloy_eips::eip7702::Authorization; + use alloy_signer::SignerSync; + + let delegate_to = Address::with_last_byte(0x42); + let authorization = Authorization { + chain_id: U256::from(chain_id), + address: delegate_to, + nonce, + }; + let auth_sig = signer + .sign_hash_sync(&authorization.signature_hash()) + .map_err(|e| eyre::eyre!("auth signing failed: {e}"))?; + let signed_auth = authorization.into_signed(auth_sig); + + let tx = TxEip7702 { + chain_id, + nonce, + gas_limit: 100_000, + max_fee_per_gas: 20_000_000_000u128, + max_priority_fee_per_gas: 20_000_000_000u128, + to: delegate_to, + value: U256::ZERO, + access_list: Default::default(), + authorization_list: vec![signed_auth], + input: Bytes::new(), + }; + let sig = signer + .sign_hash_sync(&tx.signature_hash()) + .map_err(|e| eyre::eyre!("tx signing failed: {e}"))?; + let envelope = MorphTxEnvelope::Eip7702(tx.into_signed(sig)); + Ok(envelope.encoded_2718().into()) +} + +/// Creates a signed EIP-1559 contract deployment transaction (CREATE). +/// +/// The returned bytes can be injected into the pool via `node.rpc.inject_tx()`. +/// The deployed contract address is computed by `Address::create(sender, nonce)`. +pub fn make_deploy_tx( + chain_id: u64, + signer: PrivateKeySigner, + nonce: u64, + init_code: impl Into, +) -> eyre::Result { + use alloy_consensus::{SignableTransaction, TxEip1559}; + use alloy_signer::SignerSync; + + let tx = TxEip1559 { + chain_id, + nonce, + gas_limit: 500_000, + max_fee_per_gas: 20_000_000_000u128, + max_priority_fee_per_gas: 20_000_000_000u128, + to: TxKind::Create, + value: U256::ZERO, + access_list: Default::default(), + input: init_code.into(), + }; + let sig = signer + .sign_hash_sync(&tx.signature_hash()) + .map_err(|e| eyre::eyre!("signing failed: {e}"))?; + let envelope = MorphTxEnvelope::Eip1559(tx.into_signed(sig)); + Ok(envelope.encoded_2718().into()) +} + +/// Creates a signed EIP-1559 transfer transaction with an explicit nonce. +async fn transfer_tx_with_nonce(chain_id: u64, signer: PrivateKeySigner, nonce: u64) -> Bytes { + let tx = TransactionRequest { + nonce: Some(nonce), + value: Some(U256::from(100)), + to: Some(TxKind::Call(Address::random())), + gas: Some(21_000), + max_fee_per_gas: Some(20_000_000_000u128), + max_priority_fee_per_gas: Some(20_000_000_000u128), + chain_id: Some(chain_id), + ..Default::default() + }; + let signed = TransactionTestContext::sign_tx(signer, tx).await; + signed.encoded_2718().into() +} + +/// Creates Morph payload attributes for a given timestamp. +/// +/// The attributes generator function passed to reth's E2E test framework. +/// Creates minimal attributes with no L1 messages, suitable for basic tests. +/// Use [`L1MessageBuilder`] + [`advance_block_with_l1_messages`] (in +/// `tests/it/helpers.rs`) for tests that need L1 messages. +pub fn morph_payload_attributes(timestamp: u64) -> MorphPayloadBuilderAttributes { + let attributes = PayloadAttributes { + timestamp, + prev_randao: B256::ZERO, + suggested_fee_recipient: Address::ZERO, + withdrawals: Some(vec![]), + parent_beacon_block_root: Some(B256::ZERO), + }; + + MorphPayloadBuilderAttributes::from(EthPayloadBuilderAttributes::new(B256::ZERO, attributes)) +} + +// ============================================================================= +// L1MessageBuilder +// ============================================================================= + +/// Builder for constructing test L1 message transactions. +/// +/// L1 messages are deposit-style transactions that arrive from the L1 contract +/// queue. They must appear at the start of a block with strictly sequential +/// `queue_index` values. +/// +/// # Example +/// +/// ```ignore +/// use morph_node::test_utils::L1MessageBuilder; +/// +/// // Build a simple ETH deposit L1 message +/// let l1_msg: Bytes = L1MessageBuilder::new(0) +/// .with_target(recipient_address) +/// .with_value(U256::from(1_000_000_000_000_000_000u128)) // 1 ETH +/// .build_encoded(); +/// ``` +#[derive(Debug, Clone)] +pub struct L1MessageBuilder { + /// Queue index of this message in the L2MessageQueue contract. + queue_index: u64, + /// L1 address that originally sent this message. + sender: Address, + /// L2 target address to call. + target: Address, + /// Wei value to transfer to the target. + value: U256, + /// Gas limit for L2 execution (prepaid on L1). + gas_limit: u64, + /// Optional calldata. + data: Bytes, +} + +impl L1MessageBuilder { + /// Create a new builder with the given queue index. + /// + /// Defaults: sender = `0x01`, target = zero address, + /// value = 0, gas_limit = 100_000, data = empty. + pub fn new(queue_index: u64) -> Self { + Self { + queue_index, + sender: Address::with_last_byte(0x01), + target: Address::ZERO, + value: U256::ZERO, + gas_limit: 100_000, + data: Bytes::new(), + } + } + + /// Set the L1 sender address. + pub fn with_sender(mut self, sender: Address) -> Self { + self.sender = sender; + self + } + + /// Set the L2 target address to call. + pub fn with_target(mut self, target: Address) -> Self { + self.target = target; + self + } + + /// Set the Wei value to transfer. + pub fn with_value(mut self, value: U256) -> Self { + self.value = value; + self + } + + /// Set the gas limit for L2 execution. + pub fn with_gas_limit(mut self, gas_limit: u64) -> Self { + self.gas_limit = gas_limit; + self + } + + /// Set the calldata for this message. + pub fn with_data(mut self, data: impl Into) -> Self { + self.data = data.into(); + self + } + + /// Build the L1 message and encode it as EIP-2718 bytes. + /// + /// The returned bytes can be passed directly as an element of + /// `MorphPayloadAttributes::transactions`. + pub fn build_encoded(self) -> Bytes { + let tx = TxL1Msg { + queue_index: self.queue_index, + gas_limit: self.gas_limit, + to: self.target, + value: self.value, + sender: self.sender, + input: self.data, + }; + + // EIP-2718 encoding: type byte (0x7E) + RLP body + let mut buf = Vec::with_capacity(1 + tx.fields_len() + 4); + // TxL1Msg::encode_2718 writes the type byte followed by RLP list + buf.put_u8(L1_TX_TYPE_ID); + use alloy_rlp::{BufMut, Header}; + let header = Header { + list: true, + payload_length: tx.fields_len(), + }; + header.encode(&mut buf); + tx.encode_fields(&mut buf); + + buf.into() + } + + /// Build a sequence of N sequential L1 messages starting at `start_index`. + /// + /// Convenience method for tests that need multiple consecutive L1 messages. + pub fn build_sequential(start_index: u64, count: u64) -> Vec { + (start_index..start_index + count) + .map(|i| Self::new(i).build_encoded()) + .collect() + } +} + +// ============================================================================= +// MorphTx test constants +// ============================================================================= + +/// Token ID of the test ERC20 token registered in the test genesis. +/// +/// The token is registered in L2TokenRegistry at +/// `0x5300000000000000000000000000000000000021` with: +/// - token_id = 1 +/// - token_address = `TEST_TOKEN_ADDRESS` +/// - price_ratio = 1e18 (1:1 with ETH) +/// - decimals = 18, isActive = true +pub const TEST_TOKEN_ID: u16 = 1; + +/// Address of the test ERC20 token deployed in the test genesis. +/// +/// Pre-funded with 1000 tokens (1e21 wei) for test accounts 0 and 1. +/// Address: `0x5300000000000000000000000000000000000022` +pub const TEST_TOKEN_ADDRESS: Address = Address::new([ + 0x53, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x22, +]); + +// ============================================================================= +// MorphTxBuilder +// ============================================================================= + +/// Builder for constructing and signing MorphTx (type 0x7F) transactions. +/// +/// MorphTx is Morph's custom transaction type that supports: +/// - v0: ERC20 fee payment (`fee_token_id > 0`) +/// - v1: ETH or ERC20 fee, with optional `reference` and `memo` fields +/// +/// # Example — v0 ERC20 fee +/// +/// ```ignore +/// use morph_node::test_utils::{MorphTxBuilder, TEST_TOKEN_ID}; +/// +/// let raw = MorphTxBuilder::new(chain_id, signer, nonce) +/// .with_v0_token_fee(TEST_TOKEN_ID) +/// .build_signed()?; +/// ``` +/// +/// # Example — v1 ETH fee +/// +/// ```ignore +/// let raw = MorphTxBuilder::new(chain_id, signer, nonce) +/// .with_v1_eth_fee() +/// .build_signed()?; +/// ``` +pub struct MorphTxBuilder { + chain_id: u64, + signer: PrivateKeySigner, + nonce: u64, + gas_limit: u64, + max_fee_per_gas: u128, + max_priority_fee_per_gas: u128, + to: TxKind, + value: U256, + input: Bytes, + version: u8, + fee_token_id: u16, + fee_limit: U256, + reference: Option, + memo: Option, +} + +impl MorphTxBuilder { + /// Create a new builder with sensible defaults. + /// + /// Defaults to v0, fee_token_id=0 (must call `with_v0_token_fee` or + /// `with_v1_eth_fee` before building). + pub fn new(chain_id: u64, signer: PrivateKeySigner, nonce: u64) -> Self { + Self { + chain_id, + signer, + nonce, + gas_limit: 100_000, + max_fee_per_gas: 20_000_000_000u128, + max_priority_fee_per_gas: 20_000_000_000u128, + to: TxKind::Call(Address::with_last_byte(0x42)), + value: U256::ZERO, + input: Bytes::new(), + version: 0, + fee_token_id: 0, + fee_limit: U256::ZERO, + reference: None, + memo: None, + } + } + + /// Configure as MorphTx **v0** with ERC20 fee payment. + /// + /// - `fee_token_id` must be > 0 (v0 requires ERC20 fee) + /// - Sets a generous `fee_limit` (1e20 tokens) to avoid rejection + pub fn with_v0_token_fee(mut self, fee_token_id: u16) -> Self { + assert!(fee_token_id > 0, "v0 MorphTx requires fee_token_id > 0"); + self.version = 0; + self.fee_token_id = fee_token_id; + self.fee_limit = U256::from(100_000_000_000_000_000_000u128); // 100 tokens + self + } + + /// Configure as MorphTx **v1** with ETH fee payment (fee_token_id = 0). + /// + /// This is the simplest MorphTx variant — fee is paid in ETH like EIP-1559, + /// but the receipt preserves the MorphTx version/reference/memo fields. + pub fn with_v1_eth_fee(mut self) -> Self { + self.version = 1; + self.fee_token_id = 0; + self.fee_limit = U256::ZERO; + self + } + + /// Configure as MorphTx **v1** with ERC20 fee payment. + pub fn with_v1_token_fee(mut self, fee_token_id: u16) -> Self { + assert!(fee_token_id > 0, "v1 ERC20 fee requires fee_token_id > 0"); + self.version = 1; + self.fee_token_id = fee_token_id; + self.fee_limit = U256::from(100_000_000_000_000_000_000u128); // 100 tokens + self + } + + /// Configure raw MorphTx fields, bypassing version-specific assertions. + /// + /// Use this for testing structurally invalid MorphTx configurations + /// (e.g., v0 + fee_token_id=0, v0 + reference, memo > 64 bytes). + pub fn with_raw_morph_config( + mut self, + version: u8, + fee_token_id: u16, + fee_limit: U256, + ) -> Self { + self.version = version; + self.fee_token_id = fee_token_id; + self.fee_limit = fee_limit; + self + } + + /// Set the recipient address. + pub fn with_to(mut self, to: Address) -> Self { + self.to = TxKind::Call(to); + self + } + + /// Set the ETH value to transfer. + pub fn with_value(mut self, value: U256) -> Self { + self.value = value; + self + } + + /// Set calldata. + pub fn with_data(mut self, data: impl Into) -> Self { + self.input = data.into(); + self + } + + /// Set an optional reference (v1 only). + pub fn with_reference(mut self, reference: B256) -> Self { + self.reference = Some(reference); + self + } + + /// Set an optional memo (v1 only, max 64 bytes). + pub fn with_memo(mut self, memo: impl Into) -> Self { + self.memo = Some(memo.into()); + self + } + + /// Set gas limit. + pub fn with_gas_limit(mut self, gas_limit: u64) -> Self { + self.gas_limit = gas_limit; + self + } + + /// Build and sign the MorphTx, returning EIP-2718 encoded bytes. + pub fn build_signed(self) -> eyre::Result { + use alloy_consensus::SignableTransaction; + use alloy_signer::SignerSync; + + let tx = TxMorph { + chain_id: self.chain_id, + nonce: self.nonce, + gas_limit: self.gas_limit, + max_fee_per_gas: self.max_fee_per_gas, + max_priority_fee_per_gas: self.max_priority_fee_per_gas, + to: self.to, + value: self.value, + access_list: Default::default(), + version: self.version, + fee_token_id: self.fee_token_id, + fee_limit: self.fee_limit, + reference: self.reference, + memo: self.memo, + input: self.input, + }; + + let sig = self + .signer + .sign_hash_sync(&tx.signature_hash()) + .map_err(|e| eyre::eyre!("signing failed: {e}"))?; + let signed = tx.into_signed(sig); + let envelope = MorphTxEnvelope::Morph(signed); + Ok(envelope.encoded_2718().into()) + } +} diff --git a/crates/node/tests/assets/test-genesis.json b/crates/node/tests/assets/test-genesis.json new file mode 100644 index 0000000..c26e622 --- /dev/null +++ b/crates/node/tests/assets/test-genesis.json @@ -0,0 +1,86 @@ +{ + "config": { + "chainId": 2910, + "homesteadBlock": 0, + "eip150Block": 0, + "eip155Block": 0, + "eip158Block": 0, + "byzantiumBlock": 0, + "constantinopleBlock": 0, + "petersburgBlock": 0, + "istanbulBlock": 0, + "berlinBlock": 0, + "londonBlock": 0, + "shanghaiTime": 0, + "cancunTime": 0, + "pragueTime": 0, + "bernoulliBlock": 0, + "curieBlock": 0, + "morph203Time": 0, + "viridianTime": 0, + "emeraldTime": 0, + "jadeTime": 0, + "morph": { + "feeVaultAddress": "0x530000000000000000000000000000000000000a" + } + }, + "nonce": "0x0", + "timestamp": "0x0", + "extraData": "0x", + "gasLimit": "0x1c9c380", + "difficulty": "0x0", + "mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "coinbase": "0x530000000000000000000000000000000000000a", + "alloc": { + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266": { + "balance": "0x200000000000000000000000000000000000000000000000000000000000000" + }, + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8": { + "balance": "0x200000000000000000000000000000000000000000000000000000000000000" + }, + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC": { + "balance": "0x200000000000000000000000000000000000000000000000000000000000000" + }, + "0x90F79bf6EB2c4f870365E785982E1f101E93b906": { + "balance": "0x200000000000000000000000000000000000000000000000000000000000000" + }, + "0x530000000000000000000000000000000000000a": { + "balance": "0x0" + }, + "0x530000000000000000000000000000000000000f": { + "balance": "0x0", + "code": "0x00", + "storage": { + "0x0000000000000000000000000000000000000000000000000000000000000006": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000000000000000000000000000007": "0x00000000000000000000000000000000000000000000000000000035ba5d7b55", + "0x0000000000000000000000000000000000000000000000000000000000000008": "0x0000000000000000000000000000000000000000000000000000000018e38a4c", + "0x0000000000000000000000000000000000000000000000000000000000000009": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + }, + "0x5300000000000000000000000000000000000001": { + "balance": "0x0", + "code": "0x60806040", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "code": "0x00", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000005300000000000000000000000000000000000022", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000002", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000" + } + }, + "0x5300000000000000000000000000000000000022": { + "balance": "0x0", + "code": "0x00", + "storage": { + "0xa3c1274aadd82e4d12c8004c33fb244ca686dad4fcc8957fc5668588c11d9502": "0x00000000000000000000000000000000000000000000003635c9adc5dea00000", + "0x3c8e904cdb19937d60d41c8d984b1a8803ad6e0891b4f9e032dcec2a22c2c7f5": "0x00000000000000000000000000000000000000000000003635c9adc5dea00000" + } + } + }, + "baseFeePerGas": "0xf4240" +} 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(()) +}