diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000..0bdcc7e --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,25 @@ +[profile.default] +# Each integration test opens an MDBX environment. macOS limits concurrent +# memory-mapped files per process. Cap concurrent tests to avoid ENOMEM (error 12). +test-threads = 4 + +[profile.ci] +# GitHub Actions ubuntu-latest has 4 vCPUs / 16GB RAM. +# Each E2E test spawns a full reth node (MDBX + tokio runtime + rayon pools). +# Limit to 2 concurrent tests to avoid MDBX resource exhaustion. +test-threads = 2 +retries = 2 +fail-fast = false + +# 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 } + +# Integration tests are heavier — give them more time +[[profile.default.overrides]] +filter = "package(morph-node) & binary(it)" +slow-timeout = { period = "120s", terminate-after = 3 } diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e541007..e272a5d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -54,3 +54,6 @@ 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 c6cfe34..6e6c7bd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,3 +61,35 @@ jobs: - name: Run doc tests run: cargo test --doc --all --verbose + e2e: + name: E2E Tests + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + RUST_MIN_STACK: 8388608 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-e2e-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-e2e- + + - 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 54ad644..c09f48f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4760,7 +4760,6 @@ dependencies = [ "alloy-consensus", "alloy-eips", "alloy-genesis", - "alloy-hardforks", "alloy-primitives", "alloy-rpc-types-engine", "async-trait", @@ -4812,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", @@ -4831,6 +4836,7 @@ dependencies = [ "parking_lot", "reth-chainspec", "reth-db", + "reth-e2e-test-utils", "reth-engine-local", "reth-engine-tree", "reth-errors", @@ -4849,6 +4855,7 @@ dependencies = [ "reth-transaction-pool", "reth-trie", "serde", + "serde_json", "tokio", "tokio-stream", ] @@ -4953,7 +4960,6 @@ dependencies = [ "derive_more", "eyre", "morph-chainspec", - "morph-evm", "morph-primitives", "reth-ethereum-primitives", "reth-evm", @@ -4997,6 +5003,7 @@ dependencies = [ "reth-transaction-pool", "revm", "serde", + "serde_json", "thiserror 2.0.18", "tokio", "tracing", @@ -6840,12 +6847,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 +6864,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 +7042,7 @@ dependencies = [ "parking_lot", "rayon", "reth-chain-state", + "reth-chainspec", "reth-consensus", "reth-db", "reth-engine-primitives", @@ -6987,9 +7057,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 +7684,7 @@ dependencies = [ "auto_impl", "derive_more", "futures", + "parking_lot", "reth-consensus", "reth-eth-wire-types", "reth-ethereum-primitives", @@ -8027,6 +8102,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 +8589,7 @@ dependencies = [ "num-traits", "rayon", "reqwest", + "reth-chainspec", "reth-codecs", "reth-config", "reth-consensus", @@ -8509,6 +8598,7 @@ dependencies = [ "reth-era", "reth-era-downloader", "reth-era-utils", + "reth-ethereum-primitives", "reth-etl", "reth-evm", "reth-execution-types", @@ -8524,8 +8614,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 +8755,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 +8830,7 @@ dependencies = [ "futures-util", "metrics", "parking_lot", + "paste", "pin-project", "rand 0.9.2", "reth-chain-state", diff --git a/crates/chainspec/src/constants.rs b/crates/chainspec/src/constants.rs index 3190fce..efbc953 100644 --- a/crates/chainspec/src/constants.rs +++ b/crates/chainspec/src/constants.rs @@ -49,3 +49,54 @@ pub const L2_MESSAGE_QUEUE_ADDRESS: Address = address!("530000000000000000000000 /// /// This is slot 33, which stores the Merkle root for L2->L1 messages. pub const L2_MESSAGE_QUEUE_WITHDRAW_TRIE_ROOT_SLOT: U256 = U256::from_limbs([33, 0, 0, 0]); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_chain_ids_are_distinct() { + assert_ne!(MORPH_MAINNET_CHAIN_ID, MORPH_HOODI_CHAIN_ID); + } + + #[test] + fn test_chain_id_values() { + assert_eq!(MORPH_MAINNET_CHAIN_ID, 2818); + assert_eq!(MORPH_HOODI_CHAIN_ID, 2910); + } + + #[test] + fn test_genesis_hashes_are_distinct() { + assert_ne!(MORPH_MAINNET_GENESIS_HASH, MORPH_HOODI_GENESIS_HASH); + assert_ne!( + MORPH_MAINNET_GENESIS_STATE_ROOT, + MORPH_HOODI_GENESIS_STATE_ROOT + ); + } + + #[test] + fn test_genesis_hashes_are_nonzero() { + assert_ne!(MORPH_MAINNET_GENESIS_HASH, B256::ZERO); + assert_ne!(MORPH_HOODI_GENESIS_HASH, B256::ZERO); + assert_ne!(MORPH_MAINNET_GENESIS_STATE_ROOT, B256::ZERO); + assert_ne!(MORPH_HOODI_GENESIS_STATE_ROOT, B256::ZERO); + } + + #[test] + fn test_l2_message_queue_address() { + assert_eq!( + L2_MESSAGE_QUEUE_ADDRESS, + address!("5300000000000000000000000000000000000001") + ); + } + + #[test] + fn test_withdraw_trie_root_slot() { + assert_eq!(L2_MESSAGE_QUEUE_WITHDRAW_TRIE_ROOT_SLOT, U256::from(33)); + } + + #[test] + fn test_base_fee() { + assert_eq!(MORPH_BASE_FEE, 1_000_000); + } +} diff --git a/crates/chainspec/src/hardfork.rs b/crates/chainspec/src/hardfork.rs index df2dd5c..ee2745f 100644 --- a/crates/chainspec/src/hardfork.rs +++ b/crates/chainspec/src/hardfork.rs @@ -297,4 +297,54 @@ mod tests { assert_eq!(MorphHardfork::from(SpecId::PRAGUE), MorphHardfork::Viridian); assert_eq!(MorphHardfork::from(SpecId::OSAKA), MorphHardfork::Jade); } + + #[test] + fn test_is_bernoulli() { + assert!(MorphHardfork::Bernoulli.is_bernoulli()); + assert!(MorphHardfork::Curie.is_bernoulli()); + assert!(MorphHardfork::Morph203.is_bernoulli()); + assert!(MorphHardfork::Viridian.is_bernoulli()); + assert!(MorphHardfork::Emerald.is_bernoulli()); + assert!(MorphHardfork::Jade.is_bernoulli()); + } + + /// SpecIds below CANCUN should map to Morph203 (the latest CANCUN-level hardfork). + #[test] + fn test_specid_below_cancun_maps_to_morph203() { + assert_eq!( + MorphHardfork::from(SpecId::SHANGHAI), + MorphHardfork::Morph203 + ); + assert_eq!( + MorphHardfork::from(SpecId::HOMESTEAD), + MorphHardfork::Morph203 + ); + } + + /// Verify bidirectional mapping consistency: Hardfork -> SpecId -> Hardfork + /// always returns the latest hardfork sharing that SpecId. + #[test] + fn test_specid_roundtrip_returns_latest_for_spec() { + // Bernoulli -> CANCUN -> Morph203 (latest CANCUN hardfork) + let spec = SpecId::from(MorphHardfork::Bernoulli); + assert_eq!(MorphHardfork::from(spec), MorphHardfork::Morph203); + + // Emerald -> OSAKA -> Jade (latest OSAKA hardfork) + let spec = SpecId::from(MorphHardfork::Emerald); + assert_eq!(MorphHardfork::from(spec), MorphHardfork::Jade); + } + + #[test] + fn test_default_hardfork_is_jade() { + assert_eq!(MorphHardfork::default(), MorphHardfork::Jade); + } + + #[test] + fn test_hardfork_ordering() { + assert!(MorphHardfork::Bernoulli < MorphHardfork::Curie); + assert!(MorphHardfork::Curie < MorphHardfork::Morph203); + assert!(MorphHardfork::Morph203 < MorphHardfork::Viridian); + assert!(MorphHardfork::Viridian < MorphHardfork::Emerald); + assert!(MorphHardfork::Emerald < MorphHardfork::Jade); + } } diff --git a/crates/consensus/src/validation.rs b/crates/consensus/src/validation.rs index 8cec1e4..e2c7803 100644 --- a/crates/consensus/src/validation.rs +++ b/crates/consensus/src/validation.rs @@ -673,7 +673,7 @@ mod tests { use alloy_consensus::{Header, Signed}; use alloy_genesis::Genesis; use alloy_primitives::{Address, B64, B256, Bytes, Signature, U256}; - use morph_primitives::transaction::TxL1Msg; + use morph_primitives::transaction::{MAX_MEMO_LENGTH, MORPH_TX_VERSION_0, TxL1Msg}; fn create_test_chainspec() -> Arc { let genesis_json = serde_json::json!({ @@ -958,8 +958,8 @@ mod tests { } #[test] - fn test_validate_l1_messages_in_block_next_index_too_low() { - // Valid sequential L1 messages (0, 1, 2) but header.next_l1_msg_index < last+1 + fn test_validate_l1_messages_in_block_wrong_next_l1_msg_index() { + // Valid sequential L1 messages (0, 1, 2) but wrong next_l1_msg_index in header let txs = [ create_l1_msg_tx(0), create_l1_msg_tx(1), @@ -967,7 +967,7 @@ mod tests { create_regular_tx(), ]; - // Header says 2 but minimum is 3 (last=2, 2+1=3) — INVALID + // Header says 2 but should be 3 (last=2, 2+1=3). Value < min_expected triggers error. let result = validate_l1_messages_in_block(&txs, 2); assert!(result.is_err()); let err_str = result.unwrap_err().to_string(); @@ -975,25 +975,6 @@ mod tests { assert!(err_str.contains("got 2")); } - #[test] - fn test_validate_l1_messages_in_block_skipped_messages_allowed() { - // L1 messages 0, 1, 2 but header says next=5 (messages 3, 4 were skipped). - // This is valid — Morph allows the sequencer to skip L1 messages. - let txs = [ - create_l1_msg_tx(0), - create_l1_msg_tx(1), - create_l1_msg_tx(2), - create_regular_tx(), - ]; - - // header_next=5 > last+1=3 — valid (2 messages skipped) - assert!(validate_l1_messages_in_block(&txs, 5).is_ok()); - // header_next=3 == last+1=3 — valid (no messages skipped) - assert!(validate_l1_messages_in_block(&txs, 3).is_ok()); - // header_next=100 > last+1=3 — valid (many messages skipped) - assert!(validate_l1_messages_in_block(&txs, 100).is_ok()); - } - #[test] fn test_validate_l1_messages_in_block_multiple_l1_after_regular() { // Multiple L1 messages after regular tx @@ -1389,12 +1370,15 @@ mod tests { #[test] fn test_verify_receipts_empty() { let receipts: [MorphReceipt; 0] = []; - let expected_root = alloy_consensus::proofs::calculate_receipt_root::< - alloy_consensus::ReceiptWithBloom<&MorphReceipt>, - >(&[]); + // Well-known Ethereum empty-trie root (keccak256 of RLP-encoded empty string). + // Using a hardcoded constant instead of calculate_receipt_root(&[]) to avoid + // a circular test that would pass even if the root computation is wrong. + let empty_root: B256 = "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421" + .parse() + .unwrap(); let expected_bloom = Bloom::ZERO; - let result = verify_receipts(expected_root, expected_bloom, &receipts); + let result = verify_receipts(empty_root, expected_bloom, &receipts); assert!(result.is_ok()); } @@ -1498,4 +1482,419 @@ mod tests { Err(ConsensusError::TimestampIsInPast { .. }) )); } + + // ======================================================================== + // Coinbase / FeeVault Validation Tests + // ======================================================================== + + #[test] + fn test_validate_header_coinbase_non_zero_with_fee_vault() { + // Create a chainspec with FeeVault explicitly enabled + let genesis_json = serde_json::json!({ + "config": { + "chainId": 1337, + "homesteadBlock": 0, + "eip150Block": 0, + "eip155Block": 0, + "eip158Block": 0, + "byzantiumBlock": 0, + "constantinopleBlock": 0, + "petersburgBlock": 0, + "istanbulBlock": 0, + "berlinBlock": 0, + "londonBlock": 0, + "bernoulliBlock": 0, + "curieBlock": 0, + "morph203Time": 0, + "viridianTime": 0, + "emeraldTime": 0, + "morph": { + "feeVaultAddress": "0x530000000000000000000000000000000000000a" + } + }, + "alloc": {} + }); + let genesis: Genesis = serde_json::from_value(genesis_json).unwrap(); + let chain_spec = Arc::new(MorphChainSpec::from(genesis)); + assert!( + chain_spec.is_fee_vault_enabled(), + "test chainspec must have FeeVault enabled" + ); + let consensus = MorphConsensus::new(chain_spec); + + let now = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Non-zero coinbase with fee vault enabled should fail + let header = create_morph_header(Header { + nonce: B64::ZERO, + ommers_hash: EMPTY_OMMER_ROOT_HASH, + beneficiary: Address::repeat_byte(0x01), + gas_limit: 30_000_000, + timestamp: now - 10, + base_fee_per_gas: Some(1_000_000), + ..Default::default() + }); + let sealed = SealedHeader::seal_slow(header); + + let result = consensus.validate_header(&sealed); + assert!(result.is_err()); + let err_str = result.unwrap_err().to_string(); + assert!(err_str.contains("coinbase")); + } + + // ======================================================================== + // MorphTx Version Validation Tests + // ======================================================================== + + fn create_morph_tx_v0(fee_token_id: u16) -> MorphTxEnvelope { + use alloy_consensus::Signed; + use morph_primitives::TxMorph; + + let tx = TxMorph { + chain_id: 1337, + nonce: 0, + gas_limit: 21000, + max_fee_per_gas: 2_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + to: alloy_primitives::TxKind::Call(Address::repeat_byte(0x01)), + value: U256::ZERO, + access_list: Default::default(), + version: MORPH_TX_VERSION_0, + fee_token_id, + fee_limit: U256::from(1000u64), + reference: None, + memo: None, + input: Bytes::new(), + }; + MorphTxEnvelope::Morph(Signed::new_unchecked( + tx, + Signature::new(U256::ZERO, U256::ZERO, false), + B256::ZERO, + )) + } + + fn create_morph_tx_v1(fee_token_id: u16) -> MorphTxEnvelope { + use alloy_consensus::Signed; + use morph_primitives::TxMorph; + + let tx = TxMorph { + chain_id: 1337, + nonce: 0, + gas_limit: 21000, + max_fee_per_gas: 2_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + to: alloy_primitives::TxKind::Call(Address::repeat_byte(0x01)), + value: U256::ZERO, + access_list: Default::default(), + version: MORPH_TX_VERSION_1, + fee_token_id, + fee_limit: U256::ZERO, + reference: Some(B256::repeat_byte(0xab)), + memo: Some(Bytes::from_static(b"test-memo")), + input: Bytes::new(), + }; + MorphTxEnvelope::Morph(Signed::new_unchecked( + tx, + Signature::new(U256::ZERO, U256::ZERO, false), + B256::ZERO, + )) + } + + #[test] + fn test_validate_morph_tx_v0_valid() { + // V0 with fee_token_id > 0 and no reference/memo + let txs = [create_morph_tx_v0(1)]; + let result = validate_morph_txs(&txs, false); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_morph_tx_v0_zero_fee_token_rejected() { + // V0 with fee_token_id == 0 should be rejected + let txs = [create_morph_tx_v0(0)]; + let result = validate_morph_txs(&txs, false); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("version 0 MorphTx requires FeeTokenID > 0") + ); + } + + #[test] + fn test_validate_morph_tx_v0_with_reference_rejected() { + use alloy_consensus::Signed; + use morph_primitives::TxMorph; + + let tx = TxMorph { + chain_id: 1337, + nonce: 0, + gas_limit: 21000, + max_fee_per_gas: 2_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + to: alloy_primitives::TxKind::Call(Address::repeat_byte(0x01)), + value: U256::ZERO, + access_list: Default::default(), + version: MORPH_TX_VERSION_0, + fee_token_id: 1, + fee_limit: U256::from(1000u64), + reference: Some(B256::repeat_byte(0x01)), // V0 should not have reference + memo: None, + input: Bytes::new(), + }; + let envelope = MorphTxEnvelope::Morph(Signed::new_unchecked( + tx, + Signature::new(U256::ZERO, U256::ZERO, false), + B256::ZERO, + )); + + let txs = [envelope]; + let result = validate_morph_txs(&txs, false); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("version 0 MorphTx does not support Reference field") + ); + } + + #[test] + fn test_validate_morph_tx_v1_before_jade_rejected() { + // V1 before jade fork should be rejected + let txs = [create_morph_tx_v1(1)]; + let result = validate_morph_txs(&txs, false); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("jade fork not reached") + ); + } + + #[test] + fn test_validate_morph_tx_v1_after_jade_valid() { + // V1 after jade fork should pass + let txs = [create_morph_tx_v1(1)]; + let result = validate_morph_txs(&txs, true); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_morph_tx_v1_fee_token_0_with_fee_limit_rejected() { + use alloy_consensus::Signed; + use morph_primitives::TxMorph; + + // V1 with fee_token_id == 0 and non-zero fee_limit is invalid + let tx = TxMorph { + chain_id: 1337, + nonce: 0, + gas_limit: 21000, + max_fee_per_gas: 2_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + to: alloy_primitives::TxKind::Call(Address::repeat_byte(0x01)), + value: U256::ZERO, + access_list: Default::default(), + version: MORPH_TX_VERSION_1, + fee_token_id: 0, + fee_limit: U256::from(100u64), // non-zero with fee_token_id=0 + reference: None, + memo: None, + input: Bytes::new(), + }; + let envelope = MorphTxEnvelope::Morph(Signed::new_unchecked( + tx, + Signature::new(U256::ZERO, U256::ZERO, false), + B256::ZERO, + )); + + let txs = [envelope]; + let result = validate_morph_txs(&txs, true); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("version 1 MorphTx cannot have FeeLimit when FeeTokenID is 0") + ); + } + + #[test] + fn test_validate_morph_tx_v1_memo_too_long_rejected() { + use alloy_consensus::Signed; + use morph_primitives::TxMorph; + + let tx = TxMorph { + chain_id: 1337, + nonce: 0, + gas_limit: 21000, + max_fee_per_gas: 2_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + to: alloy_primitives::TxKind::Call(Address::repeat_byte(0x01)), + value: U256::ZERO, + access_list: Default::default(), + version: MORPH_TX_VERSION_1, + fee_token_id: 1, + fee_limit: U256::from(100u64), + reference: None, + memo: Some(Bytes::from(vec![0xab; MAX_MEMO_LENGTH + 1])), // too long + input: Bytes::new(), + }; + let envelope = MorphTxEnvelope::Morph(Signed::new_unchecked( + tx, + Signature::new(U256::ZERO, U256::ZERO, false), + B256::ZERO, + )); + + let txs = [envelope]; + let result = validate_morph_txs(&txs, true); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("memo exceeds maximum length") + ); + } + + #[test] + fn test_validate_morph_tx_unsupported_version_rejected() { + use alloy_consensus::Signed; + use morph_primitives::TxMorph; + + let tx = TxMorph { + chain_id: 1337, + nonce: 0, + gas_limit: 21000, + max_fee_per_gas: 2_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + to: alloy_primitives::TxKind::Call(Address::repeat_byte(0x01)), + value: U256::ZERO, + access_list: Default::default(), + version: 99, // Unsupported version + fee_token_id: 1, + fee_limit: U256::from(100u64), + reference: None, + memo: None, + input: Bytes::new(), + }; + let envelope = MorphTxEnvelope::Morph(Signed::new_unchecked( + tx, + Signature::new(U256::ZERO, U256::ZERO, false), + B256::ZERO, + )); + + let txs = [envelope]; + let result = validate_morph_txs(&txs, true); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("unsupported MorphTx version") + ); + } + + #[test] + fn test_validate_morph_txs_skips_non_morph_tx() { + // Regular transactions should be skipped entirely + let txs = [create_regular_tx(), create_l1_msg_tx(0)]; + let result = validate_morph_txs(&txs, false); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_morph_txs_mixed_block() { + // Mixed block with valid V0 MorphTx and regular txs + let txs = [ + create_l1_msg_tx(0), + create_regular_tx(), + create_morph_tx_v0(1), + ]; + let result = validate_morph_txs(&txs, false); + assert!(result.is_ok()); + } + + // ======================================================================== + // L1 Message Queue Index Overflow Tests + // ======================================================================== + + #[test] + fn test_validate_l1_messages_in_block_queue_index_overflow() { + // When prev_queue_index is u64::MAX, checked_add should fail + let txs = [create_l1_msg_tx(u64::MAX - 1), create_l1_msg_tx(u64::MAX)]; + + // last=MAX, MAX+1 overflows + let result = validate_l1_messages_in_block(&txs, 0); + assert!(result.is_err()); + } + + #[test] + fn test_validate_l1_messages_in_block_single_l1() { + let txs = [create_l1_msg_tx(42)]; + // last=42, 42+1=43==header_next + assert!(validate_l1_messages_in_block(&txs, 43).is_ok()); + // Wrong header_next + assert!(validate_l1_messages_in_block(&txs, 42).is_err()); + } + + // ======================================================================== + // Post-Execution Validation Tests + // ======================================================================== + + #[test] + fn test_validate_block_post_execution_gas_mismatch() { + use alloy_consensus::Receipt; + use morph_primitives::{MorphReceipt, MorphTransactionReceipt}; + + let chain_spec = create_test_chainspec(); + let consensus = MorphConsensus::new(chain_spec); + + // Create a receipt with cumulative_gas_used = 21000 + let receipt = MorphReceipt::Legacy(MorphTransactionReceipt::with_l1_fee( + Receipt { + status: true.into(), + cumulative_gas_used: 21000, + logs: vec![], + }, + U256::ZERO, + )); + + let result = alloy_evm::block::BlockExecutionResult { + receipts: vec![receipt], + requests: Default::default(), + gas_used: 21000, + blob_gas_used: 0, + }; + + // Create a block header with gas_used = 50000 (mismatch!) + let header = create_morph_header(Header { + nonce: B64::ZERO, + ommers_hash: EMPTY_OMMER_ROOT_HASH, + gas_limit: 30_000_000, + gas_used: 50000, // Does not match receipt + timestamp: 1000, + base_fee_per_gas: Some(1_000_000), + ..Default::default() + }); + let body = morph_primitives::BlockBody { + transactions: vec![create_regular_tx()], + ommers: vec![], + withdrawals: None, + }; + let block = morph_primitives::Block { header, body }; + let recovered = + reth_primitives_traits::RecoveredBlock::new_unhashed(block, vec![Address::ZERO]); + + let post_result = consensus.validate_block_post_execution(&recovered, &result); + assert!(matches!( + post_result, + Err(ConsensusError::BlockGasUsed { .. }) + )); + } } diff --git a/crates/engine-api/Cargo.toml b/crates/engine-api/Cargo.toml index 654bab0..58cc46a 100644 --- a/crates/engine-api/Cargo.toml +++ b/crates/engine-api/Cargo.toml @@ -27,7 +27,6 @@ reth-rpc-api.workspace = true # alloy alloy-consensus.workspace = true alloy-eips.workspace = true -alloy-hardforks.workspace = true alloy-primitives.workspace = true alloy-rpc-types-engine.workspace = true diff --git a/crates/engine-api/src/builder.rs b/crates/engine-api/src/builder.rs index 74a588e..c5821e7 100644 --- a/crates/engine-api/src/builder.rs +++ b/crates/engine-api/src/builder.rs @@ -4,11 +4,9 @@ use crate::{EngineApiResult, MorphEngineApiError, MorphL2EngineApi}; use alloy_consensus::{ - BlockHeader, EMPTY_OMMER_ROOT_HASH, Header, constants::EMPTY_WITHDRAWALS, - proofs::calculate_transaction_root, + BlockHeader, EMPTY_OMMER_ROOT_HASH, Header, proofs::calculate_transaction_root, }; use alloy_eips::eip2718::Decodable2718; -use alloy_hardforks::EthereumHardforks; use alloy_primitives::{Address, B64, B256, Sealable}; use alloy_rpc_types_engine::PayloadAttributes; use morph_chainspec::MorphChainSpec; @@ -53,7 +51,7 @@ pub struct RealMorphL2EngineApi { engine_state_tracker: Arc, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq)] struct InMemoryHead { number: u64, hash: B256, @@ -696,6 +694,30 @@ impl RealMorphL2EngineApi { &self, data: &ExecutableL2Data, ) -> EngineApiResult<(MorphExecutionData, MorphHeader)> { + let (header, body) = self.header_and_body_from_executable_data(data)?; + let computed_hash = header.hash_slow(); + if computed_hash != data.hash { + return Err(MorphEngineApiError::ValidationFailed(format!( + "block hash mismatch: expected {}, computed {}", + data.hash, computed_hash + ))); + } + let sealed_block = + SealedBlock::new_unchecked(Block::new(header.clone(), body), computed_hash); + + Ok(( + MorphExecutionData::with_expected_withdraw_trie_root( + Arc::new(sealed_block), + data.withdraw_trie_root, + ), + header, + )) + } + + fn header_and_body_from_executable_data( + &self, + data: &ExecutableL2Data, + ) -> EngineApiResult<(MorphHeader, BlockBody)> { let base_fee_per_gas = data .base_fee_per_gas .map(|fee| { @@ -734,12 +756,6 @@ impl RealMorphL2EngineApi { } let logs_bloom = alloy_primitives::Bloom::from_slice(data.logs_bloom.as_ref()); - let shanghai_active = self - .chain_spec - .is_shanghai_active_at_timestamp(data.timestamp); - let cancun_active = self - .chain_spec - .is_cancun_active_at_timestamp(data.timestamp); // Override coinbase to empty address when FeeVault is enabled, // matching go-ethereum's executableDataToBlock (l2_api.go:292-293). let beneficiary = if self.chain_spec.is_fee_vault_enabled() { @@ -756,7 +772,8 @@ impl RealMorphL2EngineApi { state_root: data.state_root, transactions_root: calculate_transaction_root(&txs), receipts_root: data.receipts_root, - withdrawals_root: shanghai_active.then_some(EMPTY_WITHDRAWALS), + // Morph L2 has no withdrawals — always None, matching assemble path. + withdrawals_root: None, logs_bloom, difficulty: Default::default(), number: data.number, @@ -768,8 +785,9 @@ impl RealMorphL2EngineApi { base_fee_per_gas, extra_data: Default::default(), parent_beacon_block_root: None, - blob_gas_used: cancun_active.then_some(0), - excess_blob_gas: cancun_active.then_some(0), + // Morph L2 has no blob transactions — always None, matching assemble path. + blob_gas_used: None, + excess_blob_gas: None, requests_hash: None, }, }; @@ -779,27 +797,7 @@ impl RealMorphL2EngineApi { withdrawals: None, }; - // Compute header hash once and verify against expected hash before - // constructing the sealed block. This avoids the clone + re-hash that - // seal_slow would perform, saving one keccak256 + one MorphHeader clone - // per block import. - let computed_hash = header.hash_slow(); - if computed_hash != data.hash { - return Err(MorphEngineApiError::ValidationFailed(format!( - "block hash mismatch: expected {}, computed {}", - data.hash, computed_hash - ))); - } - let sealed_block = - SealedBlock::new_unchecked(Block::new(header.clone(), body), computed_hash); - - Ok(( - MorphExecutionData::with_expected_withdraw_trie_root( - Arc::new(sealed_block), - data.withdraw_trie_root, - ), - header, - )) + Ok((header, body)) } fn ensure_payload_status_acceptable( @@ -955,34 +953,6 @@ mod tests { assert_eq!(current_head.timestamp, sealed_header.timestamp()); } - #[test] - fn test_resolve_fcu_block_tag_hash_uses_l1_tag_when_available() { - let l1_tag = B256::from([0x11; 32]); - let head = B256::from([0x22; 32]); - - let resolved = resolve_fcu_block_tag_hash(Some(l1_tag), head, 1_700_000_000, 1_700_000_030); - - assert_eq!(resolved, l1_tag); - } - - #[test] - fn test_resolve_fcu_block_tag_hash_falls_back_to_head_for_historical_blocks() { - let head = B256::from([0x33; 32]); - - let resolved = resolve_fcu_block_tag_hash(None, head, 1_700_000_000, 1_700_000_000 + 300); - - assert_eq!(resolved, head); - } - - #[test] - fn test_resolve_fcu_block_tag_hash_returns_zero_near_live_without_l1_tag() { - let head = B256::from([0x44; 32]); - - let resolved = resolve_fcu_block_tag_hash(None, head, 1_700_000_000, 1_700_000_000 + 5); - - assert_eq!(resolved, B256::ZERO); - } - #[test] fn test_apply_executable_data_overrides_aligns_hash_with_engine_data() { let source_header: MorphHeader = Header::default().into(); @@ -1141,4 +1111,183 @@ mod tests { None ); } + + // ========================================================================= + // EngineStateTracker tests + // ========================================================================= + + #[test] + fn test_engine_state_tracker_default_is_none() { + let tracker = EngineStateTracker::default(); + assert!(tracker.current_head().is_none()); + } + + #[test] + fn test_engine_state_tracker_record_local_head() { + let tracker = EngineStateTracker::default(); + let hash = B256::from([0x42; 32]); + tracker.record_local_head(10, hash, 1_700_000_010); + + let head = tracker.current_head().expect("head should be set"); + assert_eq!(head.number, 10); + assert_eq!(head.hash, hash); + assert_eq!(head.timestamp, 1_700_000_010); + } + + #[test] + fn test_engine_state_tracker_overwrites_on_update() { + let tracker = EngineStateTracker::default(); + tracker.record_local_head(10, B256::from([0x01; 32]), 100); + tracker.record_local_head(20, B256::from([0x02; 32]), 200); + + let head = tracker.current_head().expect("head should be set"); + assert_eq!(head.number, 20); + assert_eq!(head.hash, B256::from([0x02; 32])); + assert_eq!(head.timestamp, 200); + } + + #[test] + fn test_engine_state_tracker_ignores_non_canonical_events() { + use reth_node_api::ConsensusEngineEvent; + + let tracker = EngineStateTracker::default(); + + // LiveSyncProgress events should not update the head + // (only CanonicalChainCommitted updates it) + // We can only test CanonicalChainCommitted since other variants + // require complex types. Verify the tracker remains None when no + // CanonicalChainCommitted event is sent. + assert!(tracker.current_head().is_none()); + + // Now send a CanonicalChainCommitted event + let header = MorphHeader { + inner: Header { + number: 5, + timestamp: 500, + ..Default::default() + }, + ..Default::default() + }; + let sealed_header = SealedHeader::seal_slow(header); + tracker.on_consensus_engine_event(&ConsensusEngineEvent::CanonicalChainCommitted( + Box::new(sealed_header), + Duration::ZERO, + )); + + let head = tracker + .current_head() + .expect("head should be set after event"); + assert_eq!(head.number, 5); + } + + #[test] + fn test_engine_state_tracker_concurrent_reads() { + // Verify parking_lot::RwLock allows concurrent reads without panic + let tracker = EngineStateTracker::default(); + tracker.record_local_head(1, B256::ZERO, 100); + + // Multiple reads should not block or panic + let head1 = tracker.current_head(); + let head2 = tracker.current_head(); + assert_eq!(head1, head2); + } + + // ========================================================================= + // apply_executable_data_overrides edge cases + // ========================================================================= + + #[test] + fn test_apply_executable_data_overrides_exact_u64_max_base_fee() { + let recovered = recovered_with_header(Header::default().into()); + let data = ExecutableL2Data { + base_fee_per_gas: Some(u64::MAX as u128), + logs_bloom: Bytes::from(vec![0u8; 256]), + hash: B256::from([0x55; 32]), + ..Default::default() + }; + + // u64::MAX should be accepted (it fits in u64) + let result = apply_executable_data_overrides(recovered, &data); + assert!(result.is_ok()); + let header = result.unwrap().sealed_block().header().clone(); + assert_eq!(header.inner.base_fee_per_gas, Some(u64::MAX)); + } + + #[test] + fn test_apply_executable_data_overrides_empty_logs_bloom() { + let recovered = recovered_with_header(Header::default().into()); + let data = ExecutableL2Data { + logs_bloom: Bytes::new(), + hash: B256::from([0x66; 32]), + ..Default::default() + }; + + let err = apply_executable_data_overrides(recovered, &data).unwrap_err(); + match err { + MorphEngineApiError::ValidationFailed(msg) => { + assert!(msg.contains("logs_bloom must be 256 bytes")); + assert!(msg.contains("0 bytes")); + } + other => panic!("unexpected error: {other}"), + } + } + + #[test] + fn test_apply_executable_data_overrides_oversized_logs_bloom() { + let recovered = recovered_with_header(Header::default().into()); + let data = ExecutableL2Data { + logs_bloom: Bytes::from(vec![0u8; 512]), + hash: B256::from([0x77; 32]), + ..Default::default() + }; + + let err = apply_executable_data_overrides(recovered, &data).unwrap_err(); + match err { + MorphEngineApiError::ValidationFailed(msg) => { + assert!(msg.contains("512 bytes")); + } + other => panic!("unexpected error: {other}"), + } + } + + // ======================================================================== + // resolve_fcu_block_tag_hash tests — all three branches + // ======================================================================== + + /// When L1 provides a tag hash, use it directly regardless of block age. + #[test] + fn test_resolve_fcu_with_l1_tag_hash() { + let l1_hash = B256::repeat_byte(0xAA); + let head_hash = B256::repeat_byte(0xBB); + let result = resolve_fcu_block_tag_hash(Some(l1_hash), head_hash, 0, 9999); + assert_eq!( + result, l1_hash, + "must use the L1-provided hash when present" + ); + } + + /// When no L1 tag and the block is stale (> 60s old), fall back to head hash. + #[test] + fn test_resolve_fcu_stale_block_falls_back_to_head() { + let head_hash = B256::repeat_byte(0xBB); + // block_timestamp = 100, now = 200 → age = 100 > 60s threshold + let result = resolve_fcu_block_tag_hash(None, head_hash, 100, 200); + assert_eq!( + result, head_hash, + "stale block (>60s) without L1 tag must fall back to head_hash" + ); + } + + /// When no L1 tag and the block is fresh (<= 60s old), return zero hash. + #[test] + fn test_resolve_fcu_fresh_block_returns_zero() { + let head_hash = B256::repeat_byte(0xBB); + // block_timestamp = 100, now = 130 → age = 30 ≤ 60s + let result = resolve_fcu_block_tag_hash(None, head_hash, 100, 130); + assert_eq!( + result, + B256::ZERO, + "fresh block (≤60s) without L1 tag must return B256::ZERO" + ); + } } diff --git a/crates/engine-api/src/error.rs b/crates/engine-api/src/error.rs index c86124b..9068f67 100644 --- a/crates/engine-api/src/error.rs +++ b/crates/engine-api/src/error.rs @@ -88,14 +88,27 @@ mod tests { use super::*; #[test] - fn test_error_codes() { + fn test_error_code_discontinuous_block_number() { let err = MorphEngineApiError::DiscontinuousBlockNumber { expected: 100, actual: 102, }; let rpc_err = err.into_rpc_error(); assert_eq!(rpc_err.code(), -32001); + } + #[test] + fn test_error_code_wrong_parent_hash() { + let err = MorphEngineApiError::WrongParentHash { + expected: B256::from([0x01; 32]), + actual: B256::from([0x02; 32]), + }; + let rpc_err = err.into_rpc_error(); + assert_eq!(rpc_err.code(), -32001); + } + + #[test] + fn test_error_code_invalid_transaction() { let err = MorphEngineApiError::InvalidTransaction { index: 0, message: "invalid signature".to_string(), @@ -103,4 +116,64 @@ mod tests { let rpc_err = err.into_rpc_error(); assert_eq!(rpc_err.code(), -32002); } + + #[test] + fn test_error_code_block_build_error() { + let err = MorphEngineApiError::BlockBuildError("out of gas".to_string()); + let rpc_err = err.into_rpc_error(); + assert_eq!(rpc_err.code(), -32003); + } + + #[test] + fn test_error_code_validation_failed() { + let err = MorphEngineApiError::ValidationFailed("invalid state root".to_string()); + let rpc_err = err.into_rpc_error(); + assert_eq!(rpc_err.code(), -32004); + } + + #[test] + fn test_error_code_execution_failed() { + let err = MorphEngineApiError::ExecutionFailed("evm error".to_string()); + let rpc_err = err.into_rpc_error(); + assert_eq!(rpc_err.code(), -32005); + } + + #[test] + fn test_error_code_database() { + let err = MorphEngineApiError::Database("connection lost".to_string()); + let rpc_err = err.into_rpc_error(); + assert_eq!(rpc_err.code(), -32010); + } + + #[test] + fn test_error_code_internal() { + let err = MorphEngineApiError::Internal("unexpected".to_string()); + let rpc_err = err.into_rpc_error(); + assert_eq!(rpc_err.code(), -32099); + } + + #[test] + fn test_error_display_messages() { + let err = MorphEngineApiError::DiscontinuousBlockNumber { + expected: 100, + actual: 102, + }; + assert!(err.to_string().contains("100")); + assert!(err.to_string().contains("102")); + + let err = MorphEngineApiError::InvalidTransaction { + index: 3, + message: "nonce too low".to_string(), + }; + assert!(err.to_string().contains("index 3")); + assert!(err.to_string().contains("nonce too low")); + } + + #[test] + fn test_from_error_to_rpc_error() { + let err = MorphEngineApiError::Internal("test".to_string()); + let rpc_err: ErrorObjectOwned = err.into(); + assert_eq!(rpc_err.code(), -32099); + assert!(rpc_err.message().contains("test")); + } } diff --git a/crates/engine-api/src/validator.rs b/crates/engine-api/src/validator.rs index 2a965aa..43b5d90 100644 --- a/crates/engine-api/src/validator.rs +++ b/crates/engine-api/src/validator.rs @@ -115,4 +115,50 @@ mod tests { // After Jade: should validate (using MPT) assert!(ctx.should_validate_state_root(1000)); } + + #[test] + fn test_validation_context_chain_spec_accessor() { + let chain_spec = create_test_chainspec(Some(1000)); + let ctx = MorphValidationContext::new(chain_spec); + + // Verify the chain_spec accessor returns a valid chain spec + // by checking a hardfork method on it + assert!(ctx.chain_spec().is_jade_active_at_timestamp(1000)); + assert!(!ctx.chain_spec().is_jade_active_at_timestamp(999)); + } + + #[test] + fn test_should_validate_state_root_at_jade_boundary() { + let chain_spec = create_test_chainspec(Some(1000)); + + // Exactly at Jade timestamp: should validate (active) + assert!(should_validate_state_root(&chain_spec, 1000)); + + // One second before: should NOT validate + assert!(!should_validate_state_root(&chain_spec, 999)); + + // One second after: should validate + assert!(should_validate_state_root(&chain_spec, 1001)); + } + + #[test] + fn test_should_validate_state_root_jade_at_zero() { + // Jade active from genesis (timestamp 0) + let chain_spec = create_test_chainspec(Some(0)); + + // Should always validate when Jade is at timestamp 0 + assert!(should_validate_state_root(&chain_spec, 0)); + assert!(should_validate_state_root(&chain_spec, 1)); + assert!(should_validate_state_root(&chain_spec, u64::MAX)); + } + + #[test] + fn test_should_validate_state_root_jade_at_max_timestamp() { + let chain_spec = create_test_chainspec(Some(u64::MAX)); + + // Only u64::MAX should trigger validation + assert!(!should_validate_state_root(&chain_spec, 0)); + assert!(!should_validate_state_root(&chain_spec, u64::MAX - 1)); + assert!(should_validate_state_root(&chain_spec, u64::MAX)); + } } diff --git a/crates/evm/src/assemble.rs b/crates/evm/src/assemble.rs index 94665e7..7b840d9 100644 --- a/crates/evm/src/assemble.rs +++ b/crates/evm/src/assemble.rs @@ -100,7 +100,8 @@ impl BlockAssembler for MorphBlockAssembler { withdrawals_root: None, logs_bloom, timestamp: timestamp.to(), - mix_hash: evm_env.block_env.prevrandao().unwrap_or_default(), + // Morph L2 keeps mix_hash fixed to zero, matching geth's L2 header shape. + mix_hash: Default::default(), nonce: B64::ZERO, // Only include base_fee_per_gas after London (EIP-1559) base_fee_per_gas: is_london_active.then_some(evm_env.block_env.basefee()), @@ -132,3 +133,67 @@ impl BlockAssembler for MorphBlockAssembler { )) } } + +#[cfg(test)] +mod tests { + use super::*; + use morph_chainspec::MorphChainSpec; + use std::sync::Arc; + + fn create_test_chainspec() -> Arc { + let genesis_json = serde_json::json!({ + "config": { + "chainId": 1337, + "homesteadBlock": 0, + "eip150Block": 0, + "eip155Block": 0, + "eip158Block": 0, + "byzantiumBlock": 0, + "constantinopleBlock": 0, + "petersburgBlock": 0, + "istanbulBlock": 0, + "berlinBlock": 0, + "londonBlock": 0, + "mergeNetsplitBlock": 0, + "terminalTotalDifficulty": 0, + "terminalTotalDifficultyPassed": true, + "shanghaiTime": 0, + "cancunTime": 0, + "bernoulliBlock": 0, + "curieBlock": 0, + "morph203Time": 0, + "viridianTime": 0, + "morph": {} + }, + "alloc": {} + }); + let genesis: alloy_genesis::Genesis = serde_json::from_value(genesis_json).unwrap(); + Arc::new(MorphChainSpec::from(genesis)) + } + + #[test] + fn test_assembler_creation_and_chain_spec() { + let chain_spec = create_test_chainspec(); + let assembler = MorphBlockAssembler::new(chain_spec.clone()); + assert_eq!(assembler.chain_spec().inner.chain.id(), 1337); + // chain_spec should be the same Arc + assert!(Arc::ptr_eq(assembler.chain_spec(), &chain_spec)); + } + + #[test] + fn test_assembler_is_clone() { + let chain_spec = create_test_chainspec(); + let assembler = MorphBlockAssembler::new(chain_spec); + let cloned = assembler.clone(); + // Verify cloned assembler has the same chain spec + assert!(Arc::ptr_eq(assembler.chain_spec(), cloned.chain_spec())); + } + + #[test] + fn test_assembler_is_debug() { + let chain_spec = create_test_chainspec(); + let assembler = MorphBlockAssembler::new(chain_spec); + let debug_str = format!("{assembler:?}"); + assert!(debug_str.contains("MorphBlockAssembler")); + } +} diff --git a/crates/evm/src/block/receipt.rs b/crates/evm/src/block/receipt.rs index 2b5f52c..9d38cb1 100644 --- a/crates/evm/src/block/receipt.rs +++ b/crates/evm/src/block/receipt.rs @@ -229,3 +229,433 @@ impl MorphReceiptBuilder for DefaultMorphReceiptBuilder { } } } + +#[cfg(test)] +mod tests { + use super::*; + use alloy_consensus::{Signed, TxLegacy, TxReceipt}; + use alloy_primitives::{Address, Log, Signature, TxKind}; + use morph_primitives::transaction::TxL1Msg; + use revm::context::result::ExecutionResult; + + // We use NoOpInspector-based MorphEvm for the generic E parameter. + // Since build_receipt only uses E::HaltReason, we can use any concrete Evm type. + type TestEvm = crate::evm::MorphEvm; + + fn make_success_result(gas_used: u64) -> ExecutionResult { + ExecutionResult::Success { + reason: revm::context::result::SuccessReason::Stop, + gas_used, + gas_refunded: 0, + logs: vec![], + output: revm::context::result::Output::Call(alloy_primitives::Bytes::new()), + } + } + + fn make_success_with_logs( + gas_used: u64, + logs: Vec, + ) -> ExecutionResult { + ExecutionResult::Success { + reason: revm::context::result::SuccessReason::Stop, + gas_used, + gas_refunded: 0, + logs, + output: revm::context::result::Output::Call(alloy_primitives::Bytes::new()), + } + } + + fn make_revert_result(gas_used: u64) -> ExecutionResult { + ExecutionResult::Revert { + gas_used, + output: alloy_primitives::Bytes::new(), + } + } + + fn create_legacy_tx() -> MorphTxEnvelope { + let tx = TxLegacy { + chain_id: Some(1337), + nonce: 0, + gas_price: 1_000_000_000, + gas_limit: 21000, + to: TxKind::Call(Address::repeat_byte(0x01)), + value: U256::ZERO, + input: alloy_primitives::Bytes::new(), + }; + MorphTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())) + } + + fn create_eip1559_tx() -> MorphTxEnvelope { + use alloy_consensus::TxEip1559; + let tx = TxEip1559 { + chain_id: 1337, + nonce: 0, + gas_limit: 21000, + max_fee_per_gas: 2_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + to: TxKind::Call(Address::repeat_byte(0x02)), + value: U256::ZERO, + input: alloy_primitives::Bytes::new(), + access_list: Default::default(), + }; + MorphTxEnvelope::Eip1559(Signed::new_unhashed(tx, Signature::test_signature())) + } + + fn create_l1_msg_tx() -> MorphTxEnvelope { + use alloy_consensus::Sealed; + let tx = TxL1Msg { + queue_index: 0, + gas_limit: 21000, + to: Address::ZERO, + value: U256::ZERO, + input: alloy_primitives::Bytes::default(), + sender: Address::ZERO, + }; + MorphTxEnvelope::L1Msg(Sealed::new(tx)) + } + + fn create_morph_tx() -> MorphTxEnvelope { + use morph_primitives::TxMorph; + use morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_0; + let tx = TxMorph { + chain_id: 1337, + nonce: 0, + gas_limit: 21000, + max_fee_per_gas: 2_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + to: TxKind::Call(Address::repeat_byte(0x03)), + value: U256::ZERO, + access_list: Default::default(), + version: MORPH_TX_VERSION_0, + fee_token_id: 1, + fee_limit: U256::from(1000u64), + reference: None, + memo: None, + input: alloy_primitives::Bytes::new(), + }; + MorphTxEnvelope::Morph(Signed::new_unhashed(tx, Signature::test_signature())) + } + + #[test] + fn test_build_legacy_receipt() { + let builder = DefaultMorphReceiptBuilder; + let tx = create_legacy_tx(); + let l1_fee = U256::from(5000u64); + + let ctx = MorphReceiptBuilderCtx:: { + tx: &tx, + result: make_success_result(21000), + cumulative_gas_used: 21000, + l1_fee, + morph_tx_fields: None, + pre_fee_logs: vec![], + post_fee_logs: vec![], + }; + + let receipt = builder.build_receipt(ctx); + assert!(matches!(receipt, MorphReceipt::Legacy(_))); + assert_eq!(receipt.l1_fee(), l1_fee); + assert_eq!(receipt.cumulative_gas_used(), 21000); + assert!(receipt.status()); + } + + #[test] + fn test_build_eip1559_receipt() { + let builder = DefaultMorphReceiptBuilder; + let tx = create_eip1559_tx(); + let l1_fee = U256::from(8000u64); + + let ctx = MorphReceiptBuilderCtx:: { + tx: &tx, + result: make_success_result(21000), + cumulative_gas_used: 42000, + l1_fee, + morph_tx_fields: None, + pre_fee_logs: vec![], + post_fee_logs: vec![], + }; + + let receipt = builder.build_receipt(ctx); + assert!(matches!(receipt, MorphReceipt::Eip1559(_))); + assert_eq!(receipt.l1_fee(), l1_fee); + assert_eq!(receipt.cumulative_gas_used(), 42000); + } + + #[test] + fn test_build_l1_msg_receipt_no_l1_fee() { + let builder = DefaultMorphReceiptBuilder; + let tx = create_l1_msg_tx(); + + let ctx = MorphReceiptBuilderCtx:: { + tx: &tx, + result: make_success_result(21000), + cumulative_gas_used: 21000, + // Pass a non-zero l1_fee to verify the builder ignores it for L1 messages. + // L1 message gas is prepaid on L1, so no L1 fee should appear in the receipt. + l1_fee: U256::from(999_999), + morph_tx_fields: None, + pre_fee_logs: vec![], + post_fee_logs: vec![], + }; + + let receipt = builder.build_receipt(ctx); + assert!(matches!(receipt, MorphReceipt::L1Msg(_))); + // L1 messages return ZERO for l1_fee regardless of what was passed in + assert_eq!(receipt.l1_fee(), U256::ZERO); + } + + #[test] + fn test_build_morph_tx_receipt_with_fields() { + let builder = DefaultMorphReceiptBuilder; + let tx = create_morph_tx(); + let l1_fee = U256::from(3000u64); + + let fields = MorphReceiptTxFields { + version: 0, + fee_token_id: 1, + fee_rate: U256::from(2_000_000_000u64), + token_scale: U256::from(10u64).pow(U256::from(18u64)), + fee_limit: U256::from(1000u64), + reference: None, + memo: None, + }; + + let ctx = MorphReceiptBuilderCtx:: { + tx: &tx, + result: make_success_result(21000), + cumulative_gas_used: 21000, + l1_fee, + morph_tx_fields: Some(fields), + pre_fee_logs: vec![], + post_fee_logs: vec![], + }; + + let receipt = builder.build_receipt(ctx); + assert_eq!(receipt.l1_fee(), l1_fee); + + // Destructure the Morph variant and verify all MorphTx-specific fields + let MorphReceipt::Morph(morph_receipt) = &receipt else { + panic!("expected MorphReceipt::Morph, got {:?}", receipt.tx_type()); + }; + assert_eq!(morph_receipt.version, Some(0)); + assert_eq!(morph_receipt.fee_token_id, Some(1)); + assert_eq!(morph_receipt.fee_rate, Some(U256::from(2_000_000_000u64))); + assert_eq!( + morph_receipt.token_scale, + Some(U256::from(10u64).pow(U256::from(18u64))) + ); + assert_eq!(morph_receipt.fee_limit, Some(U256::from(1000u64))); + assert_eq!(morph_receipt.reference, None); + assert_eq!(morph_receipt.memo, None); + } + + #[test] + fn test_build_morph_tx_receipt_without_fields_fallback() { + let builder = DefaultMorphReceiptBuilder; + let tx = create_morph_tx(); + let l1_fee = U256::from(3000u64); + + // Missing morph_tx_fields => should fallback to with_l1_fee + let ctx = MorphReceiptBuilderCtx:: { + tx: &tx, + result: make_success_result(21000), + cumulative_gas_used: 21000, + l1_fee, + morph_tx_fields: None, + pre_fee_logs: vec![], + post_fee_logs: vec![], + }; + + let receipt = builder.build_receipt(ctx); + // Should still be MorphReceipt::Morph variant, just without token fields + assert_eq!(receipt.l1_fee(), l1_fee); + + // Destructure and verify fields are None (fallback path uses with_l1_fee) + let MorphReceipt::Morph(morph_receipt) = &receipt else { + panic!("expected MorphReceipt::Morph, got {:?}", receipt.tx_type()); + }; + assert_eq!(morph_receipt.l1_fee, l1_fee); + assert_eq!(morph_receipt.version, None); + assert_eq!(morph_receipt.fee_token_id, None); + assert_eq!(morph_receipt.fee_rate, None); + assert_eq!(morph_receipt.token_scale, None); + assert_eq!(morph_receipt.fee_limit, None); + assert_eq!(morph_receipt.reference, None); + assert_eq!(morph_receipt.memo, None); + } + + #[test] + fn test_build_receipt_reverted_tx() { + let builder = DefaultMorphReceiptBuilder; + let tx = create_legacy_tx(); + + let ctx = MorphReceiptBuilderCtx:: { + tx: &tx, + result: make_revert_result(15000), + cumulative_gas_used: 15000, + l1_fee: U256::from(100u64), + morph_tx_fields: None, + pre_fee_logs: vec![], + post_fee_logs: vec![], + }; + + let receipt = builder.build_receipt(ctx); + assert!( + !TxReceipt::status(&receipt), + "reverted tx should have status=false" + ); + } + + #[test] + fn test_build_receipt_with_logs() { + let builder = DefaultMorphReceiptBuilder; + let tx = create_legacy_tx(); + let log = Log::new( + Address::repeat_byte(0x01), + vec![B256::repeat_byte(0x02)], + alloy_primitives::Bytes::from_static(b"log-data"), + ) + .unwrap(); + + let ctx = MorphReceiptBuilderCtx:: { + tx: &tx, + result: make_success_with_logs(21000, vec![log]), + cumulative_gas_used: 21000, + l1_fee: U256::ZERO, + morph_tx_fields: None, + pre_fee_logs: vec![], + post_fee_logs: vec![], + }; + + let receipt = builder.build_receipt(ctx); + assert_eq!(TxReceipt::logs(&receipt).len(), 1); + } + + fn make_fee_log(marker: u8) -> Log { + Log::new( + Address::repeat_byte(marker), + vec![B256::repeat_byte(marker)], + alloy_primitives::Bytes::new(), + ) + .unwrap() + } + + /// Fee Transfer logs (pre/post) survive when the main transaction reverts. + /// + /// go-ethereum's StateDB.logs is independent of snapshot/revert — fee logs + /// are always included. revm's ExecutionResult::Revert carries no logs field, + /// so morph-reth caches fee logs in pre_fee_logs/post_fee_logs and merges + /// them unconditionally in the receipt builder. + #[test] + fn test_fee_logs_survive_main_tx_revert() { + let builder = DefaultMorphReceiptBuilder; + let tx = create_legacy_tx(); + + let pre_log = make_fee_log(0xAA); // fee deduction Transfer + let post_log = make_fee_log(0xBB); // fee refund Transfer + + let ctx = MorphReceiptBuilderCtx:: { + tx: &tx, + result: make_revert_result(20_000), + cumulative_gas_used: 20_000, + l1_fee: U256::ZERO, + morph_tx_fields: None, + pre_fee_logs: vec![pre_log.clone()], + post_fee_logs: vec![post_log.clone()], + }; + + let receipt = builder.build_receipt(ctx); + + assert!( + !TxReceipt::status(&receipt), + "reverted tx must have status=false" + ); + + let logs = TxReceipt::logs(&receipt); + // Main tx logs are absent (revert), but fee logs must still be present. + assert_eq!( + logs.len(), + 2, + "pre_fee_log + post_fee_log must appear despite revert" + ); + assert_eq!( + logs[0].address, pre_log.address, + "first log must be pre_fee_log" + ); + assert_eq!( + logs[1].address, post_log.address, + "second log must be post_fee_log" + ); + } + + /// Log ordering on successful tx: [pre_fee_log, main_tx_log, post_fee_log]. + /// + /// Matches go-ethereum's receipt log ordering where fee deduction comes + /// first (before main tx), and fee refund comes last (after main tx). + #[test] + fn test_fee_log_ordering_on_success() { + let builder = DefaultMorphReceiptBuilder; + let tx = create_legacy_tx(); + + let pre_log = make_fee_log(0xAA); + let main_log = make_fee_log(0xCC); + let post_log = make_fee_log(0xBB); + + let ctx = MorphReceiptBuilderCtx:: { + tx: &tx, + result: make_success_with_logs(21_000, vec![main_log.clone()]), + cumulative_gas_used: 21_000, + l1_fee: U256::ZERO, + morph_tx_fields: None, + pre_fee_logs: vec![pre_log.clone()], + post_fee_logs: vec![post_log.clone()], + }; + + let receipt = builder.build_receipt(ctx); + assert!(TxReceipt::status(&receipt)); + + let logs = TxReceipt::logs(&receipt); + assert_eq!(logs.len(), 3, "pre_fee + main + post_fee = 3 logs"); + assert_eq!( + logs[0].address, pre_log.address, + "pre_fee_log must be first" + ); + assert_eq!( + logs[1].address, main_log.address, + "main_tx_log must be second" + ); + assert_eq!( + logs[2].address, post_log.address, + "post_fee_log must be last" + ); + } + + /// Fee logs without refund: only pre_fee_log when no gas is refunded. + /// + /// If all gas is consumed exactly (no unused gas), the post_fee_log + /// may be empty. But the pre_fee_log must always appear. + #[test] + fn test_pre_fee_log_only_no_post_fee() { + let builder = DefaultMorphReceiptBuilder; + let tx = create_legacy_tx(); + + let pre_log = make_fee_log(0xAA); + + let ctx = MorphReceiptBuilderCtx:: { + tx: &tx, + result: make_revert_result(21_000), + cumulative_gas_used: 21_000, + l1_fee: U256::ZERO, + morph_tx_fields: None, + pre_fee_logs: vec![pre_log.clone()], + post_fee_logs: vec![], // no refund + }; + + let receipt = builder.build_receipt(ctx); + assert!(!TxReceipt::status(&receipt)); + + let logs = TxReceipt::logs(&receipt); + assert_eq!(logs.len(), 1, "only pre_fee_log when there is no refund"); + assert_eq!(logs[0].address, pre_log.address); + } +} diff --git a/crates/evm/src/config.rs b/crates/evm/src/config.rs index 231f39e..8adeaa3 100644 --- a/crates/evm/src/config.rs +++ b/crates/evm/src/config.rs @@ -1,5 +1,6 @@ use crate::{MorphBlockAssembler, MorphEvmConfig, MorphEvmError, MorphNextBlockEnvAttributes}; use alloy_consensus::BlockHeader; +use alloy_primitives::B256; use morph_chainspec::hardfork::{MorphHardfork, MorphHardforks}; use morph_primitives::Block; use morph_primitives::{MorphHeader, MorphPrimitives}; @@ -103,7 +104,8 @@ impl ConfigureEvm for MorphEvmConfig { beneficiary: fee_recipient, timestamp: U256::from(attributes.timestamp), difficulty: U256::ZERO, - prevrandao: Some(attributes.prev_randao), + // Morph L2 follows geth's L2 path here: PREVRANDAO/mixHash is fixed to zero. + prevrandao: Some(B256::ZERO), gas_limit: attributes.gas_limit, basefee: attributes.base_fee_per_gas.unwrap_or_else(|| { self.chain_spec() @@ -149,3 +151,192 @@ impl ConfigureEvm for MorphEvmConfig { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use alloy_consensus::Header; + use alloy_primitives::{B256, Bytes, U256}; + use morph_chainspec::MorphChainSpec; + use reth_evm::{ConfigureEvm, NextBlockEnvAttributes}; + use std::sync::Arc; + + fn create_test_chainspec() -> Arc { + let genesis_json = serde_json::json!({ + "config": { + "chainId": 1337, + "homesteadBlock": 0, + "eip150Block": 0, + "eip155Block": 0, + "eip158Block": 0, + "byzantiumBlock": 0, + "constantinopleBlock": 0, + "petersburgBlock": 0, + "istanbulBlock": 0, + "berlinBlock": 0, + "londonBlock": 0, + "mergeNetsplitBlock": 0, + "terminalTotalDifficulty": 0, + "terminalTotalDifficultyPassed": true, + "shanghaiTime": 0, + "cancunTime": 0, + "bernoulliBlock": 0, + "curieBlock": 0, + "morph203Time": 0, + "viridianTime": 0, + "morph": {} + }, + "alloc": {} + }); + let genesis: alloy_genesis::Genesis = serde_json::from_value(genesis_json).unwrap(); + Arc::new(MorphChainSpec::from(genesis)) + } + + fn create_morph_header(number: u64, timestamp: u64) -> MorphHeader { + MorphHeader { + inner: Header { + number, + timestamp, + gas_limit: 30_000_000, + base_fee_per_gas: Some(1_000_000), + ..Default::default() + }, + next_l1_msg_index: 0, + } + } + + #[test] + fn test_evm_env_sets_chain_id() { + let chain_spec = create_test_chainspec(); + let config = MorphEvmConfig::new_with_default_factory(chain_spec); + + let header = create_morph_header(100, 1000); + let env = config.evm_env(&header).unwrap(); + + assert_eq!(env.cfg_env.chain_id, 1337); + } + + #[test] + fn test_evm_env_sets_block_env_fields() { + let chain_spec = create_test_chainspec(); + let config = MorphEvmConfig::new_with_default_factory(chain_spec); + + let header = create_morph_header(100, 1000); + let env = config.evm_env(&header).unwrap(); + + assert_eq!(env.block_env.inner.number, U256::from(100u64)); + assert_eq!(env.block_env.inner.timestamp, U256::from(1000u64)); + assert_eq!(env.block_env.inner.gas_limit, 30_000_000); + assert_eq!(env.block_env.inner.basefee, 1_000_000); + } + + #[test] + fn test_evm_env_blob_gas_placeholder() { + let chain_spec = create_test_chainspec(); + let config = MorphEvmConfig::new_with_default_factory(chain_spec); + + let header = create_morph_header(100, 1000); + let env = config.evm_env(&header).unwrap(); + + // Morph uses placeholder blob gas values + let blob_info = env.block_env.inner.blob_excess_gas_and_price.unwrap(); + assert_eq!(blob_info.excess_blob_gas, 0); + assert_eq!(blob_info.blob_gasprice, 1); + } + + #[test] + fn test_evm_env_eip7623_disabled() { + let chain_spec = create_test_chainspec(); + let config = MorphEvmConfig::new_with_default_factory(chain_spec); + + let header = create_morph_header(100, 1000); + let env = config.evm_env(&header).unwrap(); + + assert!(env.cfg_env.disable_eip7623); + } + + #[test] + fn test_evm_env_tx_gas_limit_cap_matches_header() { + let chain_spec = create_test_chainspec(); + let config = MorphEvmConfig::new_with_default_factory(chain_spec); + + let header = create_morph_header(100, 1000); + let env = config.evm_env(&header).unwrap(); + + assert_eq!(env.cfg_env.tx_gas_limit_cap, Some(30_000_000)); + } + + #[test] + fn test_next_evm_env_increments_block_number() { + let chain_spec = create_test_chainspec(); + let config = MorphEvmConfig::new_with_default_factory(chain_spec); + + let parent = create_morph_header(99, 1000); + let attrs = MorphNextBlockEnvAttributes { + inner: NextBlockEnvAttributes { + timestamp: 1001, + suggested_fee_recipient: alloy_primitives::Address::ZERO, + prev_randao: B256::repeat_byte(0xcc), + gas_limit: 30_000_000, + parent_beacon_block_root: None, + withdrawals: None, + extra_data: Bytes::new(), + }, + base_fee_per_gas: Some(500_000), + }; + + let env = config.next_evm_env(&parent, &attrs).unwrap(); + + assert_eq!(env.block_env.inner.number, U256::from(100u64)); + assert_eq!(env.block_env.inner.timestamp, U256::from(1001u64)); + assert_eq!(env.block_env.inner.basefee, 500_000); + } + + #[test] + fn test_context_for_block_populates_fields() { + let chain_spec = create_test_chainspec(); + let config = MorphEvmConfig::new_with_default_factory(chain_spec); + + let header = create_morph_header(100, 1000); + let block = morph_primitives::Block { + header, + body: morph_primitives::BlockBody { + transactions: vec![], + ommers: vec![], + withdrawals: None, + }, + }; + let sealed = SealedBlock::seal_slow(block); + + let ctx = config.context_for_block(&sealed).unwrap(); + assert_eq!(ctx.parent_hash, sealed.header().parent_hash()); + assert!(ctx.ommers.is_empty()); + } + + #[test] + fn test_context_for_next_block_uses_parent_hash() { + let chain_spec = create_test_chainspec(); + let config = MorphEvmConfig::new_with_default_factory(chain_spec); + + let parent = create_morph_header(99, 1000); + let parent_sealed = SealedHeader::seal_slow(parent); + + let attrs = MorphNextBlockEnvAttributes { + inner: NextBlockEnvAttributes { + timestamp: 1001, + suggested_fee_recipient: alloy_primitives::Address::ZERO, + prev_randao: B256::ZERO, + gas_limit: 30_000_000, + parent_beacon_block_root: None, + withdrawals: None, + extra_data: Bytes::new(), + }, + base_fee_per_gas: None, + }; + + let ctx = config + .context_for_next_block(&parent_sealed, attrs) + .unwrap(); + assert_eq!(ctx.parent_hash, parent_sealed.hash()); + } +} 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/args.rs b/crates/node/src/args.rs index f19e07a..333e77d 100644 --- a/crates/node/src/args.rs +++ b/crates/node/src/args.rs @@ -78,4 +78,28 @@ mod tests { assert_eq!(args.max_tx_payload_bytes, 100000); assert_eq!(args.max_tx_per_block, Some(500)); } + + #[test] + fn test_all_args_combined() { + let args = CommandParser::::parse_from([ + "test", + "--morph.max-tx-payload-bytes", + "200000", + "--morph.max-tx-per-block", + "1000", + ]) + .args; + assert_eq!(args.max_tx_payload_bytes, 200000); + assert_eq!(args.max_tx_per_block, Some(1000)); + } + + #[test] + fn test_default_trait_impl() { + let args = MorphArgs::default(); + assert_eq!( + args.max_tx_payload_bytes, + MORPH_DEFAULT_MAX_TX_PAYLOAD_BYTES + ); + assert!(args.max_tx_per_block.is_none()); + } } 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 e74a7ac..03cd40b 100644 --- a/crates/node/src/node.rs +++ b/crates/node/src/node.rs @@ -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/src/validator.rs b/crates/node/src/validator.rs index 37f6dc9..6931ac4 100644 --- a/crates/node/src/validator.rs +++ b/crates/node/src/validator.rs @@ -376,4 +376,208 @@ mod tests { .is_none() ); } + + #[test] + fn test_record_and_take_expectation_roundtrip() { + let validator = MorphEngineValidator::new(test_chain_spec()); + let hash = B256::from([0x42; 32]); + let expected_root = B256::from([0xee; 32]); + + validator.record_withdraw_trie_root_expectation( + hash, + WithdrawTrieRootExpectation::Verify(expected_root), + ); + + // Take should return the expectation and remove it + let result = validator.take_withdraw_trie_root_expectation(hash); + assert_eq!( + result, + Some(WithdrawTrieRootExpectation::Verify(expected_root)) + ); + + // Taking again should return None + assert!( + validator + .take_withdraw_trie_root_expectation(hash) + .is_none() + ); + } + + #[test] + fn test_record_skip_validation_expectation() { + let validator = MorphEngineValidator::new(test_chain_spec()); + let hash = B256::from([0x99; 32]); + + validator.record_withdraw_trie_root_expectation( + hash, + WithdrawTrieRootExpectation::SkipValidation, + ); + + let result = validator.take_withdraw_trie_root_expectation(hash); + assert_eq!(result, Some(WithdrawTrieRootExpectation::SkipValidation)); + } + + #[test] + fn test_duplicate_record_overwrites_value() { + let validator = MorphEngineValidator::new(test_chain_spec()); + let hash = B256::from([0x11; 32]); + let root1 = B256::from([0xaa; 32]); + let root2 = B256::from([0xbb; 32]); + + validator.record_withdraw_trie_root_expectation( + hash, + WithdrawTrieRootExpectation::Verify(root1), + ); + validator.record_withdraw_trie_root_expectation( + hash, + WithdrawTrieRootExpectation::Verify(root2), + ); + + let result = validator.take_withdraw_trie_root_expectation(hash); + assert_eq!(result, Some(WithdrawTrieRootExpectation::Verify(root2))); + } + + #[test] + fn test_take_nonexistent_returns_none() { + let validator = MorphEngineValidator::new(test_chain_spec()); + let hash = B256::from([0xff; 32]); + assert!( + validator + .take_withdraw_trie_root_expectation(hash) + .is_none() + ); + } + + #[test] + fn test_updated_withdraw_trie_root_wrong_address() { + // If storage update is for a different address, should return None + let wrong_address = keccak256(alloy_primitives::Address::ZERO); + let hashed_slot = keccak256(B256::from(L2_MESSAGE_QUEUE_WITHDRAW_TRIE_ROOT_SLOT)); + let state = HashedPostState::from_hashed_storage( + wrong_address, + HashedStorage::from_iter(false, [(hashed_slot, U256::from_be_bytes([0x11; 32]))]), + ); + assert!( + MorphEngineValidator::updated_withdraw_trie_root_from_hashed_state(&state).is_none() + ); + } + + #[test] + fn test_updated_withdraw_trie_root_wrong_slot() { + // Correct address but wrong slot + let hashed_address = keccak256(L2_MESSAGE_QUEUE_ADDRESS); + let wrong_slot = keccak256(B256::from(alloy_primitives::U256::from(999))); + let state = HashedPostState::from_hashed_storage( + hashed_address, + HashedStorage::from_iter(false, [(wrong_slot, U256::from_be_bytes([0x22; 32]))]), + ); + assert!( + MorphEngineValidator::updated_withdraw_trie_root_from_hashed_state(&state).is_none() + ); + } + + #[test] + fn test_validate_payload_attributes_timestamp_not_in_past() { + use alloy_rpc_types_engine::PayloadAttributes; + use morph_payload_types::MorphPayloadAttributes; + use reth_node_api::PayloadValidator; + + let validator = MorphEngineValidator::new(test_chain_spec()); + + // Create a header with timestamp 100 + let parent_header = MorphHeader { + inner: alloy_consensus::Header { + timestamp: 100, + ..Default::default() + }, + ..Default::default() + }; + let parent = reth_primitives_traits::SealedHeader::seal_slow(parent_header); + + // Attributes with timestamp = 99 (before parent) should fail + let attr = MorphPayloadAttributes { + inner: PayloadAttributes { + timestamp: 99, + prev_randao: B256::ZERO, + suggested_fee_recipient: alloy_primitives::Address::ZERO, + withdrawals: None, + parent_beacon_block_root: None, + }, + transactions: None, + gas_limit: None, + base_fee_per_gas: None, + }; + assert!( + validator + .validate_payload_attributes_against_header(&attr, parent.header()) + .is_err() + ); + + // Attributes with timestamp = 100 (equal to parent) should pass + let attr_same = MorphPayloadAttributes { + inner: PayloadAttributes { + timestamp: 100, + prev_randao: B256::ZERO, + suggested_fee_recipient: alloy_primitives::Address::ZERO, + withdrawals: None, + parent_beacon_block_root: None, + }, + transactions: None, + gas_limit: None, + base_fee_per_gas: None, + }; + assert!( + validator + .validate_payload_attributes_against_header(&attr_same, parent.header()) + .is_ok() + ); + + // Attributes with timestamp = 101 (after parent) should pass + let attr_future = MorphPayloadAttributes { + inner: PayloadAttributes { + timestamp: 101, + prev_randao: B256::ZERO, + suggested_fee_recipient: alloy_primitives::Address::ZERO, + withdrawals: None, + parent_beacon_block_root: None, + }, + transactions: None, + gas_limit: None, + base_fee_per_gas: None, + }; + assert!( + validator + .validate_payload_attributes_against_header(&attr_future, parent.header()) + .is_ok() + ); + } + + #[test] + fn test_validate_state_root_jade_not_active_always_ok() { + // On Hoodi, Jade is not activated. validate_state_root should always + // return Ok even with mismatched state roots. + use morph_primitives::MorphHeader; + use reth_primitives_traits::{RecoveredBlock, SealedBlock}; + + let validator = MorphEngineValidator::new(test_chain_spec()); + + let header = MorphHeader { + inner: alloy_consensus::Header { + timestamp: 0, + state_root: B256::from([0xaa; 32]), + ..Default::default() + }, + ..Default::default() + }; + let block = morph_primitives::Block { + header, + body: Default::default(), + }; + let sealed = SealedBlock::seal_slow(block); + let recovered = RecoveredBlock::new_sealed(sealed, vec![]); + + // Different computed root, but Jade is not active + let result = validator.validate_state_root(&recovered, B256::from([0xbb; 32])); + assert!(result.is_ok()); + } } 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..03483bf --- /dev/null +++ b/crates/node/tests/it/rpc.rs @@ -0,0 +1,584 @@ +//! 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(()) +} diff --git a/crates/payload/builder/src/builder.rs b/crates/payload/builder/src/builder.rs index cd9f127..7741a12 100644 --- a/crates/payload/builder/src/builder.rs +++ b/crates/payload/builder/src/builder.rs @@ -750,3 +750,308 @@ where Ok(BuildOutcomeKind::Better { payload }) } + +#[cfg(test)] +mod tests { + use super::*; + + // ========================================================================= + // ExecutionInfo tests + // ========================================================================= + + #[test] + fn test_execution_info_default() { + let info = ExecutionInfo::default(); + assert_eq!(info.cumulative_gas_used, 0); + assert_eq!(info.cumulative_da_bytes_used, 0); + assert_eq!(info.total_fees, U256::ZERO); + assert_eq!(info.next_l1_message_index, 0); + assert_eq!(info.transaction_count, 0); + } + + #[test] + fn test_execution_info_new_with_l1_index() { + let info = ExecutionInfo::new(42); + assert_eq!(info.next_l1_message_index, 42); + assert_eq!(info.cumulative_gas_used, 0); + assert_eq!(info.cumulative_da_bytes_used, 0); + assert_eq!(info.total_fees, U256::ZERO); + assert_eq!(info.transaction_count, 0); + } + + #[test] + fn test_execution_info_new_with_zero_index() { + let info = ExecutionInfo::new(0); + assert_eq!(info.next_l1_message_index, 0); + } + + #[test] + fn test_execution_info_new_with_max_index() { + let info = ExecutionInfo::new(u64::MAX); + assert_eq!(info.next_l1_message_index, u64::MAX); + } + + // ========================================================================= + // is_tx_over_limits tests + // ========================================================================= + + #[test] + fn test_is_tx_over_limits_within_gas_no_da() { + let info = ExecutionInfo { + cumulative_gas_used: 100_000, + ..Default::default() + }; + // tx_gas + cumulative = 100_000 + 21_000 = 121_000, block limit = 30_000_000 + assert!(!info.is_tx_over_limits(21_000, 100, 30_000_000, None)); + } + + #[test] + fn test_is_tx_over_limits_exceeds_gas_limit() { + let info = ExecutionInfo { + cumulative_gas_used: 29_990_000, + ..Default::default() + }; + // tx_gas + cumulative = 29_990_000 + 21_000 = 30_011_000 > 30_000_000 + assert!(info.is_tx_over_limits(21_000, 100, 30_000_000, None)); + } + + #[test] + fn test_is_tx_over_limits_exactly_at_gas_limit() { + let info = ExecutionInfo { + cumulative_gas_used: 29_979_000, + ..Default::default() + }; + // tx_gas + cumulative = 29_979_000 + 21_000 = 30_000_000 == block limit + // Uses > comparison, so exactly at limit is NOT over + assert!(!info.is_tx_over_limits(21_000, 100, 30_000_000, None)); + } + + #[test] + fn test_is_tx_over_limits_one_over_gas_limit() { + let info = ExecutionInfo { + cumulative_gas_used: 29_979_001, + ..Default::default() + }; + // tx_gas + cumulative = 29_979_001 + 21_000 = 30_000_001 > 30_000_000 + assert!(info.is_tx_over_limits(21_000, 100, 30_000_000, None)); + } + + #[test] + fn test_is_tx_over_limits_exceeds_da_limit() { + let info = ExecutionInfo { + cumulative_da_bytes_used: 120_000, + ..Default::default() + }; + // da_used + tx_size = 120_000 + 10_000 = 130_000 < 131_072, NOT over + assert!(!info.is_tx_over_limits(21_000, 10_000, 30_000_000, Some(128 * 1024))); + + // da_used + tx_size = 120_000 + 12_000 = 132_000 > 131_072 + assert!(info.is_tx_over_limits(21_000, 12_000, 30_000_000, Some(128 * 1024))); + } + + #[test] + fn test_is_tx_over_limits_da_limit_none_ignores_da() { + let info = ExecutionInfo { + cumulative_da_bytes_used: u64::MAX, + ..Default::default() + }; + // Even with max DA usage, no DA limit means it's not over + assert!(!info.is_tx_over_limits(21_000, 1_000, 30_000_000, None)); + } + + #[test] + fn test_is_tx_over_limits_da_limit_exactly_at_boundary() { + let info = ExecutionInfo { + cumulative_da_bytes_used: 100, + ..Default::default() + }; + // da_used + tx_size = 100 + 100 = 200 == da_limit, NOT over (uses > not >=) + assert!(!info.is_tx_over_limits(21_000, 100, 30_000_000, Some(200))); + + // da_used + tx_size = 100 + 101 = 201 > 200 + assert!(info.is_tx_over_limits(21_000, 101, 30_000_000, Some(200))); + } + + #[test] + fn test_is_tx_over_limits_gas_ok_but_da_exceeded() { + let info = ExecutionInfo { + cumulative_gas_used: 100_000, + cumulative_da_bytes_used: 500, + ..Default::default() + }; + assert!(info.is_tx_over_limits(21_000, 600, 30_000_000, Some(1000))); + } + + #[test] + fn test_is_tx_over_limits_da_ok_but_gas_exceeded() { + let info = ExecutionInfo { + cumulative_gas_used: 29_990_000, + cumulative_da_bytes_used: 100, + ..Default::default() + }; + assert!(info.is_tx_over_limits(21_000, 100, 30_000_000, Some(1_000_000))); + } + + #[test] + fn test_is_tx_over_limits_zero_gas_tx() { + let info = ExecutionInfo::default(); + assert!(!info.is_tx_over_limits(0, 0, 30_000_000, None)); + } + + #[test] + fn test_is_tx_over_limits_zero_block_gas_limit() { + let info = ExecutionInfo::default(); + assert!(info.is_tx_over_limits(1, 0, 0, None)); + // 0 > 0 is false + assert!(!info.is_tx_over_limits(0, 0, 0, None)); + } + + // ========================================================================= + // MorphPayloadBuilder constructor tests + // ========================================================================= + + fn test_evm_config() -> MorphEvmConfig { + MorphEvmConfig::new_with_default_factory(morph_chainspec::MORPH_MAINNET.clone()) + } + + #[test] + fn test_morph_payload_builder_new_default_config() { + let builder = MorphPayloadBuilder::<(), ()>::new((), test_evm_config(), ()); + assert_eq!(builder.config, MorphBuilderConfig::default()); + } + + #[test] + fn test_morph_payload_builder_with_config() { + let config = MorphBuilderConfig::default().with_gas_limit(10_000_000); + let builder = + MorphPayloadBuilder::<(), ()>::with_config((), test_evm_config(), (), config.clone()); + assert_eq!(builder.config, config); + } + + #[test] + fn test_morph_payload_builder_set_config() { + let builder = MorphPayloadBuilder::<(), ()>::new((), test_evm_config(), ()); + let config = MorphBuilderConfig::default() + .with_gas_limit(5_000_000) + .with_max_tx_per_block(500); + let builder = builder.set_config(config.clone()); + assert_eq!(builder.config, config); + } + + // ========================================================================= + // MorphPayloadBuilderCtx helper tests + // ========================================================================= + + fn test_ctx(best_payload: Option) -> MorphPayloadBuilderCtx { + MorphPayloadBuilderCtx { + evm_config: test_evm_config(), + config: PayloadConfig::new( + Arc::new(SealedHeader::seal_slow(MorphHeader::default())), + MorphPayloadBuilderAttributes::try_new( + B256::ZERO, + morph_payload_types::MorphPayloadAttributes::default(), + 1, + ) + .unwrap(), + ), + cancel: Default::default(), + best_payload, + builder_config: MorphBuilderConfig::default(), + } + } + + #[test] + fn test_best_transaction_attributes() { + let ctx = test_ctx(None); + let attrs = ctx.best_transaction_attributes(7_000_000_000); + assert_eq!(attrs.basefee, 7_000_000_000); + assert!(attrs.blob_fee.is_none()); + } + + #[test] + fn test_is_better_payload_no_previous() { + let ctx = test_ctx(None); + assert!(ctx.is_better_payload(U256::ZERO)); + assert!(ctx.is_better_payload(U256::from(100))); + } + + #[test] + fn test_payload_id_is_deterministic() { + let ctx = test_ctx(None); + let id1 = ctx.payload_id(); + let id2 = ctx.payload_id(); + assert_eq!(id1, id2); + } + + #[test] + fn test_parent_returns_correct_header() { + let ctx = test_ctx(None); + assert_eq!(ctx.parent().number(), 0); + } + + // ========================================================================= + // read_withdraw_trie_root tests (requires mock DB) + // ========================================================================= + + struct MockDb { + storage_value: U256, + } + + impl revm::Database for MockDb { + type Error = std::convert::Infallible; + + fn basic( + &mut self, + _address: alloy_primitives::Address, + ) -> Result, Self::Error> { + Ok(None) + } + + fn code_by_hash( + &mut self, + _code_hash: B256, + ) -> Result { + Ok(revm::bytecode::Bytecode::default()) + } + + fn storage( + &mut self, + _address: alloy_primitives::Address, + _index: U256, + ) -> Result { + Ok(self.storage_value) + } + + fn block_hash(&mut self, _number: u64) -> Result { + Ok(B256::ZERO) + } + } + + #[test] + fn test_read_withdraw_trie_root_zero() { + let mut db = MockDb { + storage_value: U256::ZERO, + }; + let root = read_withdraw_trie_root(&mut db).unwrap(); + assert_eq!(root, B256::ZERO); + } + + #[test] + fn test_read_withdraw_trie_root_nonzero() { + let expected = B256::from([0xAB; 32]); + let mut db = MockDb { + storage_value: expected.into(), + }; + let root = read_withdraw_trie_root(&mut db).unwrap(); + assert_eq!(root, expected); + } + + #[test] + fn test_read_withdraw_trie_root_max_value() { + let mut db = MockDb { + storage_value: U256::MAX, + }; + let root = read_withdraw_trie_root(&mut db).unwrap(); + assert_eq!(root, B256::from(U256::MAX)); + } +} diff --git a/crates/payload/types/src/attributes.rs b/crates/payload/types/src/attributes.rs index 74f2de5..ef84713 100644 --- a/crates/payload/types/src/attributes.rs +++ b/crates/payload/types/src/attributes.rs @@ -174,6 +174,17 @@ impl MorphPayloadBuilderAttributes { } } +impl From for MorphPayloadBuilderAttributes { + fn from(inner: EthPayloadBuilderAttributes) -> Self { + Self { + inner, + transactions: vec![], + gas_limit: None, + base_fee_per_gas: None, + } + } +} + /// Compute payload ID from parent hash and attributes. /// /// Uses SHA-256 hashing with the version byte as the first byte of the result. @@ -348,4 +359,190 @@ mod tests { let attrs: MorphPayloadAttributes = serde_json::from_str(json).expect("deserialize"); assert_eq!(attrs.transactions.as_ref().unwrap().len(), 1); } + + #[test] + fn test_payload_id_different_versions_are_distinct() { + let parent = B256::random(); + let attrs = create_test_attributes(); + + // Every distinct version should produce a different ID + let ids: Vec<_> = (0..=5) + .map(|v| payload_id_morph(&parent, &attrs, v)) + .collect(); + for i in 0..ids.len() { + for j in (i + 1)..ids.len() { + assert_ne!(ids[i], ids[j], "version {i} and {j} should differ"); + } + } + } + + #[test] + fn test_payload_id_different_parents() { + let attrs = create_test_attributes(); + + let id1 = payload_id_morph(&B256::from([0x01; 32]), &attrs, 1); + let id2 = payload_id_morph(&B256::from([0x02; 32]), &attrs, 1); + + assert_ne!(id1, id2); + } + + #[test] + fn test_payload_id_different_timestamps() { + let parent = B256::random(); + let mut attrs1 = create_test_attributes(); + attrs1.inner.timestamp = 100; + let mut attrs2 = create_test_attributes(); + attrs2.inner.timestamp = 200; + + let id1 = payload_id_morph(&parent, &attrs1, 1); + let id2 = payload_id_morph(&parent, &attrs2, 1); + + assert_ne!(id1, id2); + } + + #[test] + fn test_payload_id_none_vs_empty_transactions() { + let parent = B256::random(); + let mut attrs1 = create_test_attributes(); + attrs1.transactions = None; + let mut attrs2 = create_test_attributes(); + attrs2.transactions = Some(vec![]); + + let id1 = payload_id_morph(&parent, &attrs1, 1); + let id2 = payload_id_morph(&parent, &attrs2, 1); + + // None vs Some(empty) should produce different IDs because + // we hash whether the field is Some or None + assert_ne!(id1, id2); + } + + #[test] + fn test_payload_id_with_gas_limit_override() { + let parent = B256::random(); + let mut attrs1 = create_test_attributes(); + attrs1.gas_limit = None; + let mut attrs2 = create_test_attributes(); + attrs2.gas_limit = Some(30_000_000); + + let id1 = payload_id_morph(&parent, &attrs1, 1); + let id2 = payload_id_morph(&parent, &attrs2, 1); + + assert_ne!(id1, id2); + } + + #[test] + fn test_payload_id_with_base_fee_override() { + let parent = B256::random(); + let mut attrs1 = create_test_attributes(); + attrs1.base_fee_per_gas = None; + let mut attrs2 = create_test_attributes(); + attrs2.base_fee_per_gas = Some(1_000_000_000); + + let id1 = payload_id_morph(&parent, &attrs1, 1); + let id2 = payload_id_morph(&parent, &attrs2, 1); + + assert_ne!(id1, id2); + } + + #[test] + fn test_payload_id_with_withdrawals() { + let parent = B256::random(); + let mut attrs1 = create_test_attributes(); + attrs1.inner.withdrawals = None; + let mut attrs2 = create_test_attributes(); + attrs2.inner.withdrawals = Some(vec![]); + + let id1 = payload_id_morph(&parent, &attrs1, 1); + let id2 = payload_id_morph(&parent, &attrs2, 1); + + assert_ne!(id1, id2); + } + + #[test] + fn test_payload_id_with_beacon_root() { + let parent = B256::random(); + let mut attrs1 = create_test_attributes(); + attrs1.inner.parent_beacon_block_root = None; + let mut attrs2 = create_test_attributes(); + attrs2.inner.parent_beacon_block_root = Some(B256::from([0x42; 32])); + + let id1 = payload_id_morph(&parent, &attrs1, 1); + let id2 = payload_id_morph(&parent, &attrs2, 1); + + assert_ne!(id1, id2); + } + + #[test] + fn test_payload_attributes_trait_impl() { + use reth_payload_primitives::PayloadAttributes as _; + + let mut attrs = create_test_attributes(); + attrs.inner.timestamp = 42; + attrs.inner.withdrawals = Some(vec![]); + attrs.inner.parent_beacon_block_root = Some(B256::from([0x01; 32])); + + assert_eq!(attrs.timestamp(), 42); + assert!(attrs.withdrawals().is_some()); + assert_eq!( + attrs.parent_beacon_block_root(), + Some(B256::from([0x01; 32])) + ); + } + + #[test] + fn test_builder_attributes_has_l1_messages_empty() { + let attrs = MorphPayloadBuilderAttributes::try_new(B256::ZERO, create_test_attributes(), 1) + .unwrap(); + assert!(!attrs.has_l1_messages()); + } + + #[test] + fn test_builder_attributes_accessors() { + let parent = B256::from([0x42; 32]); + let mut rpc_attrs = create_test_attributes(); + rpc_attrs.inner.timestamp = 999; + rpc_attrs.inner.suggested_fee_recipient = Address::from([0x01; 20]); + rpc_attrs.inner.prev_randao = B256::from([0x02; 32]); + rpc_attrs.gas_limit = Some(30_000_000); + rpc_attrs.base_fee_per_gas = Some(1_000_000_000); + + let attrs = MorphPayloadBuilderAttributes::try_new(parent, rpc_attrs, 1).unwrap(); + + assert_eq!(attrs.parent(), parent); + assert_eq!(attrs.timestamp(), 999); + assert_eq!(attrs.suggested_fee_recipient(), Address::from([0x01; 20])); + assert_eq!(attrs.prev_randao(), B256::from([0x02; 32])); + assert!(attrs.parent_beacon_block_root().is_none()); + assert_eq!(attrs.gas_limit, Some(30_000_000)); + assert_eq!(attrs.base_fee_per_gas, Some(1_000_000_000)); + } + + #[test] + fn test_serde_with_gas_and_base_fee_overrides() { + let json = r#"{ + "timestamp": "0x499602d2", + "prevRandao": "0x0000000000000000000000000000000000000000000000000000000000000001", + "suggestedFeeRecipient": "0x0000000000000000000000000000000000000002", + "gasLimit": "0x1c9c380", + "baseFeePerGas": "0x3b9aca00" + }"#; + + let attrs: MorphPayloadAttributes = serde_json::from_str(json).expect("deserialize"); + assert_eq!(attrs.gas_limit, Some(30_000_000)); + assert_eq!(attrs.base_fee_per_gas, Some(1_000_000_000)); + } + + #[test] + fn test_serde_optional_fields_absent() { + let json = r#"{ + "timestamp": "0x1", + "prevRandao": "0x0000000000000000000000000000000000000000000000000000000000000000", + "suggestedFeeRecipient": "0x0000000000000000000000000000000000000000" + }"#; + + let attrs: MorphPayloadAttributes = serde_json::from_str(json).expect("deserialize"); + assert!(attrs.transactions.is_none()); + assert!(attrs.gas_limit.is_none()); + assert!(attrs.base_fee_per_gas.is_none()); + } } diff --git a/crates/payload/types/src/executable_l2_data.rs b/crates/payload/types/src/executable_l2_data.rs index 3862072..35e0268 100644 --- a/crates/payload/types/src/executable_l2_data.rs +++ b/crates/payload/types/src/executable_l2_data.rs @@ -184,4 +184,67 @@ mod tests { assert_eq!(data.gas_used, 21000); assert_eq!(data.next_l1_message_index, 10); } + + #[test] + fn test_transaction_count_multiple() { + let mut data = ExecutableL2Data::default(); + data.transactions.push(Bytes::from(vec![0x01])); + data.transactions.push(Bytes::from(vec![0x02])); + data.transactions.push(Bytes::from(vec![0x03])); + assert_eq!(data.transaction_count(), 3); + assert!(data.has_transactions()); + } + + #[test] + fn test_serde_with_base_fee() { + let data = ExecutableL2Data { + base_fee_per_gas: Some(1_000_000_000), + ..Default::default() + }; + + let json = serde_json::to_string(&data).expect("serialize"); + assert!(json.contains("baseFeePerGas")); + + let decoded: ExecutableL2Data = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(decoded.base_fee_per_gas, Some(1_000_000_000)); + } + + #[test] + fn test_serde_with_large_base_fee() { + // u128 base fee that exceeds u64 + let data = ExecutableL2Data { + base_fee_per_gas: Some(u128::MAX), + ..Default::default() + }; + + let json = serde_json::to_string(&data).expect("serialize"); + let decoded: ExecutableL2Data = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(decoded.base_fee_per_gas, Some(u128::MAX)); + } + + #[test] + fn test_serde_empty_transactions_present() { + let data = ExecutableL2Data { + transactions: vec![], + ..Default::default() + }; + + let json = serde_json::to_string(&data).expect("serialize"); + let decoded: ExecutableL2Data = serde_json::from_str(&json).expect("deserialize"); + assert!(decoded.transactions.is_empty()); + assert!(!decoded.has_transactions()); + } + + #[test] + fn test_clone_and_equality() { + let data = ExecutableL2Data { + parent_hash: B256::from([0x11; 32]), + number: 42, + gas_used: 21000, + ..Default::default() + }; + + let cloned = data.clone(); + assert_eq!(data, cloned); + } } diff --git a/crates/payload/types/src/lib.rs b/crates/payload/types/src/lib.rs index 018a67b..a6159f8 100644 --- a/crates/payload/types/src/lib.rs +++ b/crates/payload/types/src/lib.rs @@ -134,3 +134,101 @@ impl PayloadTypes for MorphPayloadTypes { MorphExecutionData::new(Arc::new(block)) } } + +#[cfg(test)] +mod tests { + use super::*; + use alloy_consensus::Header; + use morph_primitives::{BlockBody, MorphHeader}; + use reth_primitives_traits::Block as _; + + fn create_test_block() -> SealedBlock { + let header: MorphHeader = Header::default().into(); + let body = BlockBody::default(); + let block = Block::new(header, body); + block.seal_slow() + } + + // ========================================================================= + // MorphExecutionData tests + // ========================================================================= + + #[test] + fn test_execution_data_new_no_withdraw_root() { + let block = Arc::new(create_test_block()); + let data = MorphExecutionData::new(block); + assert!(data.expected_withdraw_trie_root.is_none()); + } + + #[test] + fn test_execution_data_with_withdraw_root() { + let block = Arc::new(create_test_block()); + let root = B256::from([0xAA; 32]); + let data = MorphExecutionData::with_expected_withdraw_trie_root(block, root); + assert_eq!(data.expected_withdraw_trie_root, Some(root)); + } + + #[test] + fn test_execution_data_with_zero_withdraw_root() { + let block = Arc::new(create_test_block()); + let data = MorphExecutionData::with_expected_withdraw_trie_root(block, B256::ZERO); + assert_eq!(data.expected_withdraw_trie_root, Some(B256::ZERO)); + } + + #[test] + fn test_execution_payload_trait_no_withdrawals() { + let block = Arc::new(create_test_block()); + let data = MorphExecutionData::new(block); + // L2 doesn't have withdrawals + assert!(data.withdrawals().is_none()); + } + + #[test] + fn test_execution_payload_trait_no_access_list() { + let block = Arc::new(create_test_block()); + let data = MorphExecutionData::new(block); + assert!(data.block_access_list().is_none()); + } + + #[test] + fn test_execution_payload_trait_empty_block_counts() { + let block = Arc::new(create_test_block()); + let data = MorphExecutionData::new(block.clone()); + assert_eq!(data.transaction_count(), 0); + assert_eq!(data.gas_used(), 0); + assert_eq!(data.block_number(), 0); + assert_eq!(data.block_hash(), block.hash()); + } + + #[test] + fn test_execution_payload_trait_timestamps_and_hashes() { + let header = MorphHeader { + inner: Header { + timestamp: 1_700_000_000, + parent_hash: B256::from([0x11; 32]), + ..Default::default() + }, + ..Default::default() + }; + let block = Block::new(header, BlockBody::default()); + let sealed = Arc::new(block.seal_slow()); + let data = MorphExecutionData::new(sealed.clone()); + + assert_eq!(data.timestamp(), 1_700_000_000); + assert_eq!(data.parent_hash(), B256::from([0x11; 32])); + assert_eq!(data.block_hash(), sealed.hash()); + } + + // ========================================================================= + // MorphPayloadTypes::block_to_payload tests + // ========================================================================= + + #[test] + fn test_block_to_payload() { + let block = create_test_block(); + let hash = block.hash(); + let data = MorphPayloadTypes::block_to_payload(block); + assert_eq!(data.block_hash(), hash); + assert!(data.expected_withdraw_trie_root.is_none()); + } +} diff --git a/crates/payload/types/src/params.rs b/crates/payload/types/src/params.rs index 0b5e87b..77343b4 100644 --- a/crates/payload/types/src/params.rs +++ b/crates/payload/types/src/params.rs @@ -132,4 +132,54 @@ mod tests { let decoded: GenericResponse = serde_json::from_str(&json).expect("deserialize"); assert_eq!(response, decoded); } + + #[test] + fn test_assemble_params_with_timestamp() { + let mut params = AssembleL2BlockParams::new(100, vec![]); + assert!(params.timestamp.is_none()); + + params.timestamp = Some(1_700_000_000); + assert_eq!(params.timestamp, Some(1_700_000_000)); + } + + #[test] + fn test_assemble_params_serde_with_timestamp() { + let json = r#"{ + "number": "0x64", + "transactions": [], + "timestamp": "0x6553f100" + }"#; + + let params: AssembleL2BlockParams = serde_json::from_str(json).expect("deserialize"); + assert_eq!(params.number, 100); + assert_eq!(params.timestamp, Some(0x6553f100)); + } + + #[test] + fn test_assemble_params_serde_without_timestamp() { + let json = r#"{ + "number": "0x1", + "transactions": ["0xdead"] + }"#; + + let params: AssembleL2BlockParams = serde_json::from_str(json).expect("deserialize"); + assert_eq!(params.number, 1); + assert!(params.timestamp.is_none()); + assert_eq!(params.transactions.len(), 1); + } + + #[test] + fn test_assemble_params_default() { + let params = AssembleL2BlockParams::default(); + assert_eq!(params.number, 0); + assert!(params.transactions.is_empty()); + assert!(params.timestamp.is_none()); + } + + #[test] + fn test_generic_response_failure_serde() { + let response = GenericResponse::failure(); + let json = serde_json::to_string(&response).expect("serialize"); + assert_eq!(json, r#"{"success":false}"#); + } } diff --git a/crates/payload/types/src/safe_l2_data.rs b/crates/payload/types/src/safe_l2_data.rs index 7fc654d..a67b553 100644 --- a/crates/payload/types/src/safe_l2_data.rs +++ b/crates/payload/types/src/safe_l2_data.rs @@ -103,4 +103,54 @@ mod tests { let decoded: SafeL2Data = serde_json::from_str(&json).expect("deserialize"); assert_eq!(data, decoded); } + + #[test] + fn test_safe_l2_data_new() { + let data = SafeL2Data::new(); + assert_eq!(data, SafeL2Data::default()); + } + + #[test] + fn test_transaction_helpers() { + let mut data = SafeL2Data::default(); + assert!(!data.has_transactions()); + assert_eq!(data.transaction_count(), 0); + + data.transactions.push(Bytes::from(vec![0x01])); + data.transactions.push(Bytes::from(vec![0x02])); + assert!(data.has_transactions()); + assert_eq!(data.transaction_count(), 2); + } + + #[test] + fn test_serde_camel_case() { + let json = r#"{ + "number": "0x64", + "gasLimit": "0x1c9c380", + "baseFeePerGas": "0x3b9aca00", + "timestamp": "0x499602d2", + "transactions": ["0xdead"] + }"#; + + let data: SafeL2Data = serde_json::from_str(json).expect("deserialize"); + assert_eq!(data.number, 100); + assert_eq!(data.gas_limit, 30_000_000); + assert_eq!(data.base_fee_per_gas, Some(1_000_000_000)); + assert_eq!(data.timestamp, 1234567890); + assert_eq!(data.transaction_count(), 1); + } + + #[test] + fn test_clone_and_equality() { + let data = SafeL2Data { + number: 42, + gas_limit: 30_000_000, + base_fee_per_gas: Some(100), + timestamp: 999, + transactions: vec![Bytes::from(vec![0x01, 0x02])], + }; + + let cloned = data.clone(); + assert_eq!(data, cloned); + } } diff --git a/crates/primitives/src/header.rs b/crates/primitives/src/header.rs index 4b3d40a..90ac3ed 100644 --- a/crates/primitives/src/header.rs +++ b/crates/primitives/src/header.rs @@ -315,4 +315,61 @@ mod tests { assert_eq!(header, deserialized); } + + #[test] + fn test_morph_header_rlp_roundtrip() { + let inner = create_test_header(); + let header = MorphHeader { + inner, + next_l1_msg_index: 42, + }; + + let mut buf = Vec::new(); + alloy_rlp::Encodable::encode(&header, &mut buf); + + let decoded = ::decode(&mut buf.as_slice()) + .expect("RLP decode should succeed"); + + assert_eq!(header, decoded); + } + + #[test] + fn test_morph_header_size() { + let inner = create_test_header(); + let header = MorphHeader { + inner: inner.clone(), + next_l1_msg_index: 0, + }; + + let inner_size = reth_primitives_traits::InMemorySize::size(&inner); + let header_size = reth_primitives_traits::InMemorySize::size(&header); + + // MorphHeader size = inner size + size_of::() for next_l1_msg_index + assert_eq!(header_size, inner_size + core::mem::size_of::()); + } + + #[test] + fn test_morph_header_mut_trait() { + use reth_primitives_traits::header::HeaderMut; + + let inner = create_test_header(); + let mut header: MorphHeader = inner.into(); + + let new_hash = b256!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + header.set_parent_hash(new_hash); + assert_eq!(header.parent_hash(), new_hash); + + header.set_block_number(999); + assert_eq!(header.number(), 999); + + header.set_timestamp(12345); + assert_eq!(header.timestamp(), 12345); + + let new_root = b256!("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + header.set_state_root(new_root); + assert_eq!(header.state_root(), new_root); + + header.set_difficulty(U256::from(42u64)); + assert_eq!(header.difficulty(), U256::from(42u64)); + } } diff --git a/crates/primitives/src/receipt/envelope.rs b/crates/primitives/src/receipt/envelope.rs index 7f5c3d8..f408ef5 100644 --- a/crates/primitives/src/receipt/envelope.rs +++ b/crates/primitives/src/receipt/envelope.rs @@ -309,3 +309,178 @@ impl From for MorphReceiptEnvelope { } } } + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::Address; + + fn create_test_log() -> Log { + Log::new_unchecked(Address::ZERO, vec![], alloy_primitives::Bytes::new()) + } + + fn create_test_receipt(tx_type: MorphTxType) -> MorphReceiptEnvelope { + MorphReceiptEnvelope::from_parts(true, 21000, &[create_test_log()], tx_type) + } + + #[test] + fn test_tx_type() { + assert_eq!( + create_test_receipt(MorphTxType::Legacy).tx_type(), + MorphTxType::Legacy + ); + assert_eq!( + create_test_receipt(MorphTxType::Eip2930).tx_type(), + MorphTxType::Eip2930 + ); + assert_eq!( + create_test_receipt(MorphTxType::Eip1559).tx_type(), + MorphTxType::Eip1559 + ); + assert_eq!( + create_test_receipt(MorphTxType::Eip7702).tx_type(), + MorphTxType::Eip7702 + ); + assert_eq!( + create_test_receipt(MorphTxType::L1Msg).tx_type(), + MorphTxType::L1Msg + ); + assert_eq!( + create_test_receipt(MorphTxType::Morph).tx_type(), + MorphTxType::Morph + ); + } + + #[test] + fn test_status_and_cumulative_gas() { + let receipt = create_test_receipt(MorphTxType::Legacy); + assert!(receipt.is_success()); + assert!(receipt.status()); + assert_eq!(receipt.cumulative_gas_used(), 21000); + } + + #[test] + fn test_logs_and_bloom() { + let receipt = create_test_receipt(MorphTxType::Eip1559); + assert_eq!(receipt.logs().len(), 1); + // Bloom includes the address even for Address::ZERO, so it's non-zero + let bloom = receipt.logs_bloom(); + assert_ne!(*bloom, Bloom::ZERO); + } + + #[test] + fn test_as_l1_message_receipt() { + let l1_receipt = create_test_receipt(MorphTxType::L1Msg); + assert!(l1_receipt.as_l1_message_receipt().is_some()); + assert!(l1_receipt.as_l1_message_receipt_with_bloom().is_some()); + + let non_l1_receipt = create_test_receipt(MorphTxType::Legacy); + assert!(non_l1_receipt.as_l1_message_receipt().is_none()); + assert!(non_l1_receipt.as_l1_message_receipt_with_bloom().is_none()); + } + + #[test] + fn test_type_flag() { + assert_eq!(create_test_receipt(MorphTxType::Legacy).type_flag(), None); + assert_eq!( + create_test_receipt(MorphTxType::Eip2930).type_flag(), + Some(1) + ); + assert_eq!( + create_test_receipt(MorphTxType::Eip1559).type_flag(), + Some(2) + ); + assert_eq!( + create_test_receipt(MorphTxType::Eip7702).type_flag(), + Some(4) + ); + assert_eq!( + create_test_receipt(MorphTxType::L1Msg).type_flag(), + Some(0x7e) + ); + assert_eq!( + create_test_receipt(MorphTxType::Morph).type_flag(), + Some(0x7f) + ); + } + + #[test] + fn test_typed2718_ty() { + assert_eq!(create_test_receipt(MorphTxType::Legacy).ty(), 0); + assert_eq!(create_test_receipt(MorphTxType::Eip2930).ty(), 1); + assert_eq!(create_test_receipt(MorphTxType::Eip1559).ty(), 2); + assert_eq!(create_test_receipt(MorphTxType::Eip7702).ty(), 4); + assert_eq!(create_test_receipt(MorphTxType::L1Msg).ty(), 0x7e); + assert_eq!(create_test_receipt(MorphTxType::Morph).ty(), 0x7f); + } + + #[test] + fn test_eip2718_roundtrip_legacy() { + let receipt = create_test_receipt(MorphTxType::Legacy); + let mut buf = Vec::new(); + receipt.encode_2718(&mut buf); + let decoded = MorphReceiptEnvelope::decode_2718(&mut buf.as_slice()).unwrap(); + assert_eq!(receipt, decoded); + } + + #[test] + fn test_eip2718_roundtrip_eip1559() { + let receipt = create_test_receipt(MorphTxType::Eip1559); + let mut buf = Vec::new(); + receipt.encode_2718(&mut buf); + let decoded = MorphReceiptEnvelope::decode_2718(&mut buf.as_slice()).unwrap(); + assert_eq!(receipt, decoded); + } + + #[test] + fn test_eip2718_roundtrip_l1msg() { + let receipt = create_test_receipt(MorphTxType::L1Msg); + let mut buf = Vec::new(); + receipt.encode_2718(&mut buf); + let decoded = MorphReceiptEnvelope::decode_2718(&mut buf.as_slice()).unwrap(); + assert_eq!(receipt, decoded); + } + + #[test] + fn test_eip2718_roundtrip_morph() { + let receipt = create_test_receipt(MorphTxType::Morph); + let mut buf = Vec::new(); + receipt.encode_2718(&mut buf); + let decoded = MorphReceiptEnvelope::decode_2718(&mut buf.as_slice()).unwrap(); + assert_eq!(receipt, decoded); + } + + #[test] + fn test_rlp_roundtrip() { + let receipt = create_test_receipt(MorphTxType::Eip1559); + let mut buf = Vec::new(); + Encodable::encode(&receipt, &mut buf); + let decoded = ::decode(&mut buf.as_slice()).unwrap(); + assert_eq!(receipt, decoded); + } + + #[test] + fn test_failed_receipt() { + let receipt = MorphReceiptEnvelope::from_parts(false, 50000, &[], MorphTxType::Eip1559); + assert!(!receipt.is_success()); + assert!(!receipt.status()); + assert_eq!(receipt.cumulative_gas_used(), 50000); + assert!(receipt.logs().is_empty()); + } + + #[test] + fn test_legacy_is_legacy() { + let receipt = create_test_receipt(MorphTxType::Legacy); + assert!(receipt.is_legacy()); + } + + #[test] + fn test_non_legacy_not_is_legacy() { + let receipt = create_test_receipt(MorphTxType::Eip1559); + assert!(!receipt.is_legacy()); + let receipt = create_test_receipt(MorphTxType::L1Msg); + assert!(!receipt.is_legacy()); + let receipt = create_test_receipt(MorphTxType::Morph); + assert!(!receipt.is_legacy()); + } +} diff --git a/crates/primitives/src/transaction/l1_transaction.rs b/crates/primitives/src/transaction/l1_transaction.rs index 4df1f90..e9b85c0 100644 --- a/crates/primitives/src/transaction/l1_transaction.rs +++ b/crates/primitives/src/transaction/l1_transaction.rs @@ -176,8 +176,10 @@ impl Transaction for TxL1Msg { 0 } - fn effective_gas_price(&self, _base_fee: Option) -> u128 { - 0 + fn effective_gas_price(&self, base_fee: Option) -> u128 { + // L1 messages are prepaid on L1 and have no gas price themselves. + // Return baseFee as the effective gas price to match go-ethereum behavior. + base_fee.unwrap_or(0) as u128 } fn is_dynamic_fee(&self) -> bool { @@ -377,7 +379,8 @@ mod tests { assert_eq!(tx.max_priority_fee_per_gas(), None); assert_eq!(tx.max_fee_per_blob_gas(), None); assert_eq!(tx.priority_fee_or_price(), 0); - assert_eq!(tx.effective_gas_price(Some(100)), 0); + assert_eq!(tx.effective_gas_price(Some(100)), 100); // returns baseFee for receipts + assert_eq!(tx.effective_gas_price(None), 0); // no baseFee → 0 (execution path) assert!(!tx.is_dynamic_fee()); assert!(!tx.is_create()); // L1 messages can never create contracts assert_eq!( diff --git a/crates/revm/Cargo.toml b/crates/revm/Cargo.toml index 86295bb..968390c 100644 --- a/crates/revm/Cargo.toml +++ b/crates/revm/Cargo.toml @@ -35,7 +35,6 @@ thiserror.workspace = true [dev-dependencies] eyre.workspace = true alloy-primitives = { workspace = true, features = ["rand"] } -morph-evm.workspace = true [features] reth = ["dep:reth-storage-api"] diff --git a/crates/revm/src/error.rs b/crates/revm/src/error.rs index 0ce1d80..ee67ade 100644 --- a/crates/revm/src/error.rs +++ b/crates/revm/src/error.rs @@ -81,3 +81,102 @@ impl reth_rpc_eth_types::error::api::FromEvmHalt } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_messages() { + let err = MorphInvalidTransaction::TokenNotRegistered(5); + assert_eq!(err.to_string(), "Token with ID 5 is not registered"); + + let err = MorphInvalidTransaction::TokenIdZeroNotSupported; + assert_eq!( + err.to_string(), + "Token ID 0 is not supported for gas payment" + ); + + let err = MorphInvalidTransaction::TokenNotActive(3); + assert_eq!( + err.to_string(), + "Token with ID 3 is not active for gas payment" + ); + + let err = MorphInvalidTransaction::TokenTransferFailed { + reason: "balance too low".into(), + }; + assert!(err.to_string().contains("balance too low")); + + let err = MorphInvalidTransaction::InsufficientTokenBalance { + required: U256::from(100), + available: U256::from(50), + }; + assert!(err.to_string().contains("100")); + assert!(err.to_string().contains("50")); + } + + #[test] + fn test_is_nonce_too_low() { + // Morph-specific errors are not nonce-too-low + assert!(!MorphInvalidTransaction::TokenNotRegistered(1).is_nonce_too_low()); + assert!(!MorphInvalidTransaction::TokenIdZeroNotSupported.is_nonce_too_low()); + assert!(!MorphInvalidTransaction::TokenNotActive(1).is_nonce_too_low()); + + // Wrapped Ethereum nonce-too-low should be detected + let eth_err = InvalidTransaction::NonceTooLow { tx: 5, state: 10 }; + let morph_err = MorphInvalidTransaction::EthInvalidTransaction(eth_err); + assert!(morph_err.is_nonce_too_low()); + } + + #[test] + fn test_as_invalid_tx_err() { + // Morph-specific errors return None + assert!( + MorphInvalidTransaction::TokenNotRegistered(1) + .as_invalid_tx_err() + .is_none() + ); + + // Wrapped Ethereum errors return Some + let eth_err = InvalidTransaction::NonceTooLow { tx: 5, state: 10 }; + let morph_err = MorphInvalidTransaction::EthInvalidTransaction(eth_err.clone()); + assert_eq!(morph_err.as_invalid_tx_err(), Some(ð_err)); + } + + #[test] + fn test_from_invalid_transaction() { + let eth_err = InvalidTransaction::NonceTooLow { tx: 5, state: 10 }; + let morph_err: MorphInvalidTransaction = eth_err.into(); + assert!(matches!( + morph_err, + MorphInvalidTransaction::EthInvalidTransaction(_) + )); + } + + #[test] + fn test_into_evm_error() { + let morph_err = MorphInvalidTransaction::TokenNotRegistered(1); + let evm_err: EVMError = morph_err.into(); + assert!(matches!( + evm_err, + EVMError::Transaction(MorphInvalidTransaction::TokenNotRegistered(1)) + )); + } + + #[test] + fn test_morph_halt_reason_from_ethereum() { + let halt = HaltReason::OutOfGas(revm::context::result::OutOfGasError::Basic); + let morph_halt: MorphHaltReason = halt.clone().into(); + assert_eq!(morph_halt, MorphHaltReason::Ethereum(halt)); + } + + #[test] + fn test_error_equality() { + let err1 = MorphInvalidTransaction::TokenNotRegistered(5); + let err2 = MorphInvalidTransaction::TokenNotRegistered(5); + let err3 = MorphInvalidTransaction::TokenNotRegistered(6); + assert_eq!(err1, err2); + assert_ne!(err1, err3); + } +} diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index c4308a4..c42a69c 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -314,6 +314,87 @@ mod tests { assert_eq!(morph_blockhash_value(2818, 2_662_437), expected); } + #[test] + fn morph_blockhash_block_zero() { + // Block 0 requested from block 1 — block 0 is within [1-256, 1) = [0, 1), so valid + let result = morph_blockhash_result(2818, 1, 0); + assert_ne!(result, U256::ZERO, "block 0 from block 1 should be valid"); + + // Block 0 requested from block 0 — current block returns zero + let result = morph_blockhash_result(2818, 0, 0); + assert_eq!( + result, + U256::ZERO, + "block 0 from block 0 should be zero (current block)" + ); + } + + #[test] + fn morph_blockhash_chain_id_zero() { + // chain_id=0 should still produce a deterministic hash + let result = morph_blockhash_value(0, 100); + assert_ne!(result, U256::ZERO, "chain_id=0 should still produce a hash"); + + // Different chain_ids produce different hashes + let result_0 = morph_blockhash_value(0, 100); + let result_1 = morph_blockhash_value(1, 100); + assert_ne!( + result_0, result_1, + "different chain_ids should produce different hashes" + ); + } + + #[test] + fn morph_blockhash_small_current_block() { + let chain_id = 2818; + // current_number = 5, so valid range is [0, 5) + // Block 0 through 4 should be valid + for n in 0..5 { + assert_ne!( + morph_blockhash_result(chain_id, 5, n), + U256::ZERO, + "block {n} from block 5 should be valid" + ); + } + // Block 5 (current) should be zero + assert_eq!(morph_blockhash_result(chain_id, 5, 5), U256::ZERO); + } + + #[test] + fn morph_blockhash_boundary_256() { + let chain_id = 2818; + let current = 300; + + // current - 256 = 44 (inclusive lower bound) + assert_ne!( + morph_blockhash_result(chain_id, current, 44), + U256::ZERO, + "block current-256 should be valid" + ); + + // current - 257 = 43 (out of range) + assert_eq!( + morph_blockhash_result(chain_id, current, 43), + U256::ZERO, + "block current-257 should be zero" + ); + + // current - 1 = 299 (valid, most recent) + assert_ne!( + morph_blockhash_result(chain_id, current, 299), + U256::ZERO, + "block current-1 should be valid" + ); + } + + #[test] + fn morph_blockhash_deterministic() { + // Same inputs always produce the same output + let a = morph_blockhash_value(2818, 1000); + let b = morph_blockhash_value(2818, 1000); + assert_eq!(a, b, "blockhash should be deterministic"); + } + #[test] fn morph_blockhash_window_matches_geth_rules() { let chain_id = 2818_u64; diff --git a/crates/revm/src/l1block.rs b/crates/revm/src/l1block.rs index cbe5b6a..bdc5e05 100644 --- a/crates/revm/src/l1block.rs +++ b/crates/revm/src/l1block.rs @@ -311,4 +311,145 @@ mod tests { let cost = info.calculate_tx_l1_cost(&input, MorphHardfork::Bernoulli); assert_eq!(cost, U256::from(80_000_000_000u64)); } + + #[test] + fn test_data_gas_empty_input_pre_curie() { + let info = L1BlockInfo { + l1_fee_overhead: U256::from(200), + ..Default::default() + }; + // Empty input: 0 byte cost + 200 overhead + 64 extra = 264 + let gas = info.data_gas(&[], MorphHardfork::Bernoulli); + assert_eq!(gas, U256::from(264)); + } + + #[test] + fn test_data_gas_empty_input_curie() { + let info = L1BlockInfo { + l1_blob_base_fee: U256::from(10), + l1_blob_scalar: U256::from(2), + ..Default::default() + }; + // Empty input: 0 * 10 * 2 = 0 + let gas = info.data_gas(&[], MorphHardfork::Curie); + assert_eq!(gas, U256::ZERO); + } + + #[test] + fn test_calculate_tx_l1_cost_curie() { + // Use Morph mainnet initial Curie oracle values: + // l1_commit_scalar = 230_759_955_285 + // l1_base_fee = 30 gwei = 30_000_000_000 + // l1_blob_base_fee = 1 + // l1_blob_scalar = 417_565_260 + // + // Step-by-step (all integer arithmetic, no rounding): + // calldata_gas = commit_scalar × base_fee + // = 230_759_955_285 × 30_000_000_000 + // = 6_922_798_658_550_000_000_000 + // blob_gas = len × blob_base_fee × blob_scalar + // = 100 × 1 × 417_565_260 + // = 41_756_526_000 + // total = (calldata_gas + blob_gas) / 1_000_000_000 + // = (6_922_798_658_550_000_000_000 + 41_756_526_000) / 1_000_000_000 + // = 6_922_798_658_591 (integer division, truncated) + // + // Pre-computed to avoid a circular test that would pass even if the + // formula itself were wrong. + let calldata_gas = + U256::from(230_759_955_285u64).saturating_mul(U256::from(30_000_000_000u64)); + + let info = L1BlockInfo { + l1_base_fee: U256::from(30_000_000_000u64), + l1_blob_base_fee: U256::from(1), + l1_commit_scalar: U256::from(230_759_955_285u64), + l1_blob_scalar: U256::from(417_565_260), + calldata_gas, + ..Default::default() + }; + + let input = vec![0xff; 100]; + let cost = info.calculate_tx_l1_cost(&input, MorphHardfork::Curie); + assert_eq!(cost, U256::from(6_922_798_658_591u64)); + } + + /// Verify the L1 fee cap at u64::MAX for circuit compatibility. + #[test] + fn test_l1_fee_cap_at_u64_max() { + let info = L1BlockInfo { + l1_base_fee: U256::MAX, + l1_fee_overhead: U256::from(0), + l1_base_fee_scalar: U256::MAX, + ..Default::default() + }; + + let input = vec![0xff; 100]; + let cost = info.calculate_tx_l1_cost(&input, MorphHardfork::Bernoulli); + + // Should be capped at u64::MAX + assert_eq!(cost, U256::from(u64::MAX)); + } + + #[test] + fn test_data_gas_all_zero_bytes() { + let info = L1BlockInfo { + l1_fee_overhead: U256::from(0), + ..Default::default() + }; + // 4 zero bytes * 4 gas + 0 overhead + 64 extra = 80 + let input = vec![0x00; 4]; + let gas = info.data_gas(&input, MorphHardfork::Bernoulli); + assert_eq!(gas, U256::from(4 * 4 + 64)); + } + + #[test] + fn test_data_gas_all_nonzero_bytes() { + let info = L1BlockInfo { + l1_fee_overhead: U256::from(0), + ..Default::default() + }; + // 4 non-zero bytes * 16 gas + 0 overhead + 64 extra = 128 + let input = vec![0xff; 4]; + let gas = info.data_gas(&input, MorphHardfork::Bernoulli); + assert_eq!(gas, U256::from(4 * 16 + 64)); + } + + #[test] + fn test_l1_cost_zero_with_zero_params() { + let info = L1BlockInfo::default(); + let input = vec![0xff; 10]; + // All parameters are zero, so cost is zero (tx_l1_gas * 0 * 0 / precision = 0) + let cost = info.calculate_tx_l1_cost(&input, MorphHardfork::Bernoulli); + assert_eq!(cost, U256::ZERO); + } + + #[test] + fn test_curie_oracle_storage_constants() { + assert_eq!(CURIE_L1_GAS_PRICE_ORACLE_STORAGE.len(), 4); + // Verify the 4 slots are the expected ones + assert_eq!( + CURIE_L1_GAS_PRICE_ORACLE_STORAGE[0].0, + GPO_L1_BLOB_BASE_FEE_SLOT + ); + assert_eq!( + CURIE_L1_GAS_PRICE_ORACLE_STORAGE[1].0, + GPO_COMMIT_SCALAR_SLOT + ); + assert_eq!(CURIE_L1_GAS_PRICE_ORACLE_STORAGE[2].0, GPO_BLOB_SCALAR_SLOT); + assert_eq!(CURIE_L1_GAS_PRICE_ORACLE_STORAGE[3].0, GPO_IS_CURIE_SLOT); + } + + #[test] + fn test_gpo_storage_slot_ordering() { + // Slots should be sequential per the contract layout + assert_eq!(GPO_OWNER_SLOT, U256::from(0)); + assert_eq!(GPO_L1_BASE_FEE_SLOT, U256::from(1)); + assert_eq!(GPO_OVERHEAD_SLOT, U256::from(2)); + assert_eq!(GPO_SCALAR_SLOT, U256::from(3)); + assert_eq!(GPO_WHITELIST_SLOT, U256::from(4)); + assert_eq!(GPO_L1_BLOB_BASE_FEE_SLOT, U256::from(6)); + assert_eq!(GPO_COMMIT_SCALAR_SLOT, U256::from(7)); + assert_eq!(GPO_BLOB_SCALAR_SLOT, U256::from(8)); + assert_eq!(GPO_IS_CURIE_SLOT, U256::from(9)); + } } diff --git a/crates/revm/src/precompiles.rs b/crates/revm/src/precompiles.rs index 0dc8b12..436b12d 100644 --- a/crates/revm/src/precompiles.rs +++ b/crates/revm/src/precompiles.rs @@ -577,6 +577,246 @@ mod tests { assert!(!emerald_p.contains(&addresses::POINT_EVALUATION)); } + #[test] + fn test_bernoulli_disabled_ripemd160_returns_error() { + let precompiles = bernoulli(); + let ripemd = precompiles.get(&addresses::RIPEMD160).unwrap(); + + // Calling the disabled stub should return an error (consuming all forwarded gas) + let result = ripemd.execute(b"hello", 100_000); + assert!(result.is_err(), "disabled ripemd160 should return error"); + let err = result.unwrap_err(); + match err { + PrecompileError::Other(msg) => { + assert!( + msg.contains("ripemd160"), + "error message should mention ripemd160" + ); + } + _ => panic!("expected PrecompileError::Other, got: {err:?}"), + } + } + + #[test] + fn test_bernoulli_disabled_blake2f_returns_error() { + let precompiles = bernoulli(); + let blake2f = precompiles.get(&addresses::BLAKE2F).unwrap(); + + // Calling the disabled stub should return an error (consuming all forwarded gas) + let result = blake2f.execute(b"hello", 100_000); + assert!(result.is_err(), "disabled blake2f should return error"); + let err = result.unwrap_err(); + match err { + PrecompileError::Other(msg) => { + assert!( + msg.contains("blake2f"), + "error message should mention blake2f" + ); + } + _ => panic!("expected PrecompileError::Other, got: {err:?}"), + } + } + + #[test] + fn test_morph203_ripemd160_works() { + let precompiles = morph203(); + let ripemd = precompiles.get(&addresses::RIPEMD160).unwrap(); + + // In Morph203, ripemd160 is re-enabled and should work (not return disabled error) + let result = ripemd.execute(b"hello", 100_000); + assert!( + result.is_ok(), + "morph203 ripemd160 should be functional: {result:?}" + ); + } + + #[test] + fn test_morph203_blake2f_works() { + let precompiles = morph203(); + let blake2f = precompiles.get(&addresses::BLAKE2F).unwrap(); + + // blake2f requires specific input format (213 bytes), but the point is it should + // NOT return the "disabled" error. An invalid-input error is acceptable. + let result = blake2f.execute(b"hello", 100_000); + // Either Ok (valid input) or Err (invalid input format, NOT disabled error) + if let Err(PrecompileError::Other(msg)) = &result { + assert!( + !msg.contains("disabled"), + "morph203 blake2f should NOT be disabled: {msg}" + ); + } + } + + #[test] + fn test_bernoulli_pairing_has_no_pair_limit() { + let precompiles = bernoulli(); + let pairing = precompiles.get(&addresses::BN256_PAIRING).unwrap(); + + // 5 pairs (960 bytes) — Bernoulli uses Berlin pairing with no 4-pair limit + let input = vec![0u8; 5 * 192]; + let result = pairing.execute(&input, 1_000_000); + // Should succeed (zero-padded valid points), NOT rejected for size + assert!( + result.is_ok(), + "Bernoulli pairing should accept 5 pairs (no limit)" + ); + } + + #[test] + fn test_morph203_pairing_exact_boundary() { + let precompiles = morph203(); + let pairing = precompiles.get(&addresses::BN256_PAIRING).unwrap(); + + // Exactly 4 pairs (768 bytes) — should succeed + let input = vec![0u8; 4 * 192]; + assert!( + pairing.execute(&input, 1_000_000).is_ok(), + "pairing with exactly 4 pairs should succeed" + ); + + // 4 pairs + 1 byte (769 bytes) — should be rejected + let input = vec![0u8; 4 * 192 + 1]; + assert!( + pairing.execute(&input, 1_000_000).is_err(), + "pairing with 769 bytes should be rejected" + ); + } + + #[test] + fn test_jade_uses_emerald_precompiles() { + let emerald_p = MorphPrecompiles::new_with_spec(MorphHardfork::Emerald); + let jade_p = MorphPrecompiles::new_with_spec(MorphHardfork::Jade); + + assert_eq!( + emerald_p.precompiles().len(), + jade_p.precompiles().len(), + "Jade should use same precompile set as Emerald" + ); + assert!(jade_p.contains(&addresses::P256_VERIFY)); + assert!(jade_p.contains(&addresses::BLS12_G1ADD)); + assert!(!jade_p.contains(&addresses::POINT_EVALUATION)); + } + + #[test] + fn test_default_precompiles_use_jade() { + let default_p = MorphPrecompiles::default(); + let jade_p = MorphPrecompiles::new_with_spec(MorphHardfork::Jade); + + assert_eq!( + default_p.precompiles().len(), + jade_p.precompiles().len(), + "Default precompiles should match Jade" + ); + } + + /// ECRECOVER (0x01) recovers the correct signer address from a known valid signature. + /// + /// Test vector from go-ethereum ecrecover tests (ethereum/tests suite). + /// Input: hash || v || r || s (128 bytes total) + /// Expected output: zero-padded 32-byte address + #[test] + fn test_ecrecover_valid_signature() { + let precompiles = bernoulli(); + let ecrecover = precompiles.get(&addresses::ECRECOVER).unwrap(); + + // Known valid ECDSA signature over secp256k1 (from ethereum/tests ecRecover suite) + // hash: 0x18c547e4...3d1c + // v=28, r=0x73b16938...75f, s=0xeeb940b1...4549 + // recovered: 0xa94f5374...bf0b + let mut input = [0u8; 128]; + input[..32].copy_from_slice(&[ + 0x18, 0xc5, 0x47, 0xe4, 0xf7, 0xb0, 0xf3, 0x25, 0xad, 0x1e, 0x56, 0xf5, 0x7e, 0x26, + 0xc7, 0x45, 0xb0, 0x9a, 0x3e, 0x50, 0x3d, 0x86, 0xe0, 0x0e, 0x52, 0x55, 0xff, 0x7f, + 0x71, 0x5d, 0x3d, 0x1c, + ]); + input[63] = 0x1c; // v = 28 + input[64..96].copy_from_slice(&[ + 0x73, 0xb1, 0x69, 0x38, 0x92, 0x21, 0x9d, 0x73, 0x6c, 0xab, 0xa5, 0x5b, 0xdb, 0x67, + 0x21, 0x6e, 0x48, 0x55, 0x57, 0xea, 0x6b, 0x6a, 0xf7, 0x5f, 0x37, 0x09, 0x6c, 0x9a, + 0xa6, 0xa5, 0xa7, 0x5f, + ]); + input[96..].copy_from_slice(&[ + 0xee, 0xb9, 0x40, 0xb1, 0xd0, 0x3b, 0x21, 0xe3, 0x6b, 0x0e, 0x47, 0xe7, 0x97, 0x69, + 0xf0, 0x95, 0xfe, 0x2a, 0xb8, 0x55, 0xbd, 0x91, 0xe3, 0xa3, 0x87, 0x56, 0xb7, 0xd7, + 0x5a, 0x9c, 0x45, 0x49, + ]); + + let result = ecrecover.execute(&input, 10_000); + assert!(result.is_ok(), "valid ecrecover should succeed: {result:?}"); + + let output = result.unwrap().bytes; + assert_eq!(output.len(), 32, "ecrecover output must be 32 bytes"); + + // First 12 bytes must be zero (address is right-aligned in 32 bytes) + assert_eq!(&output[..12], &[0u8; 12], "first 12 bytes must be zero"); + + // Recovered address: 0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b + let expected_addr: [u8; 20] = [ + 0xa9, 0x4f, 0x53, 0x74, 0xfc, 0xe5, 0xed, 0xbc, 0x8e, 0x2a, 0x86, 0x97, 0xc1, 0x53, + 0x31, 0x67, 0x7e, 0x6e, 0xbf, 0x0b, + ]; + assert_eq!(&output[12..], &expected_addr, "recovered address mismatch"); + } + + /// SHA256 (0x02) produces the correct digest for empty input. + /// + /// SHA256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + #[test] + fn test_sha256_known_output() { + let precompiles = bernoulli(); + let sha256 = precompiles.get(&addresses::SHA256).unwrap(); + + let result = sha256.execute(&[], 100_000); + assert!(result.is_ok(), "sha256 of empty input should succeed"); + + // SHA256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + let expected: [u8; 32] = [ + 0xe3, 0xb0, 0xc4, 0x42, 0x98, 0xfc, 0x1c, 0x14, 0x9a, 0xfb, 0xf4, 0xc8, 0x99, 0x6f, + 0xb9, 0x24, 0x27, 0xae, 0x41, 0xe4, 0x64, 0x9b, 0x93, 0x4c, 0xa4, 0x95, 0x99, 0x1b, + 0x78, 0x52, 0xb8, 0x55, + ]; + assert_eq!( + &result.unwrap().bytes[..], + &expected, + "sha256(\"\") mismatch" + ); + } + + /// Identity (0x04) passes through its input unchanged. + #[test] + fn test_identity_passthrough() { + let precompiles = bernoulli(); + let identity = precompiles.get(&addresses::IDENTITY).unwrap(); + + let input: &[u8] = &[0x12, 0x34, 0xab, 0xcd]; + let result = identity.execute(input, 100_000); + assert!(result.is_ok(), "identity should succeed"); + assert_eq!( + &result.unwrap().bytes[..], + input, + "identity must return input unchanged" + ); + } + + /// KZG Point Evaluation (0x0a) must be absent from all Morph precompile sets. + /// + /// go-ethereum's Morph never includes KZG in any hardfork precompile set. + #[test] + fn test_kzg_0x0a_absent_all_profiles() { + assert!( + !bernoulli().contains(&addresses::POINT_EVALUATION), + "bernoulli must not include KZG (0x0a)" + ); + assert!( + !morph203().contains(&addresses::POINT_EVALUATION), + "morph203 must not include KZG (0x0a)" + ); + assert!( + !emerald().contains(&addresses::POINT_EVALUATION), + "emerald must not include KZG (0x0a)" + ); + } + #[test] fn test_modexp_len_check() { // Value = 0 (all zeros) — should NOT exceed 32 diff --git a/crates/revm/src/token_fee.rs b/crates/revm/src/token_fee.rs index 9d0e813..fee48b6 100644 --- a/crates/revm/src/token_fee.rs +++ b/crates/revm/src/token_fee.rs @@ -379,4 +379,96 @@ mod tests { address!("5300000000000000000000000000000000000021") ); } + + #[test] + fn test_eth_to_token_amount_zero_scale() { + let info = TokenFeeInfo { + price_ratio: U256::from(2_000_000_000_000_000_000u128), + scale: U256::ZERO, // Misconfigured + ..Default::default() + }; + + let eth_amount = U256::from(1_000_000_000_000_000_000u128); + let token_amount = info.eth_to_token_amount(eth_amount); + // Misconfigured token returns MAX + assert_eq!(token_amount, U256::MAX); + } + + /// Verify rounding up when there's a remainder in the division. + #[test] + fn test_eth_to_token_amount_rounds_up() { + let info = TokenFeeInfo { + price_ratio: U256::from(3), + scale: U256::from(1), + ..Default::default() + }; + + // 10 * 1 / 3 = 3 remainder 1 -> rounds up to 4 + let token_amount = info.eth_to_token_amount(U256::from(10)); + assert_eq!(token_amount, U256::from(4)); + } + + /// Exact division should not round up. + #[test] + fn test_eth_to_token_amount_exact_division() { + let info = TokenFeeInfo { + price_ratio: U256::from(2), + scale: U256::from(1), + ..Default::default() + }; + + // 10 * 1 / 2 = 5 exact + let token_amount = info.eth_to_token_amount(U256::from(10)); + assert_eq!(token_amount, U256::from(5)); + } + + #[test] + fn test_eth_to_token_amount_zero_eth() { + let info = TokenFeeInfo { + price_ratio: U256::from(2), + scale: U256::from(1), + ..Default::default() + }; + + let token_amount = info.eth_to_token_amount(U256::ZERO); + assert_eq!(token_amount, U256::ZERO); + } + + #[test] + fn test_mapping_slot_different_keys_produce_different_slots() { + let slot = U256::from(151); + let key1 = { + let mut k = [0u8; 32]; + k[31] = 1; + k + }; + let key2 = { + let mut k = [0u8; 32]; + k[31] = 2; + k + }; + let result1 = compute_mapping_slot(slot, &key1); + let result2 = compute_mapping_slot(slot, &key2); + assert_ne!(result1, result2); + } + + #[test] + fn test_mapping_slot_for_address_different_accounts() { + let slot = U256::from(1); + let addr1 = address!("1111111111111111111111111111111111111111"); + let addr2 = address!("2222222222222222222222222222222222222222"); + let result1 = compute_mapping_slot_for_address(slot, addr1); + let result2 = compute_mapping_slot_for_address(slot, addr2); + assert_ne!(result1, result2); + } + + #[test] + fn test_encode_balance_of_zero_address() { + let account = Address::ZERO; + let calldata = encode_balance_of_calldata(account); + assert_eq!(calldata.len(), 36); + assert_eq!(&calldata[0..4], &[0x70, 0xa0, 0x82, 0x31]); + // Address should be all zeros + assert!(calldata[4..36].iter().all(|&b| b == 0)); + } } diff --git a/crates/rpc/Cargo.toml b/crates/rpc/Cargo.toml index 889991a..4c1a5dd 100644 --- a/crates/rpc/Cargo.toml +++ b/crates/rpc/Cargo.toml @@ -56,6 +56,8 @@ eyre.workspace = true # Logging tracing.workspace = true +[dev-dependencies] +serde_json.workspace = true [features] default = [] diff --git a/crates/rpc/src/error.rs b/crates/rpc/src/error.rs index 9bd81f1..4f23d07 100644 --- a/crates/rpc/src/error.rs +++ b/crates/rpc/src/error.rs @@ -172,3 +172,96 @@ impl From for MorphEthApiError { match err {} } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn error_display_messages() { + assert_eq!( + MorphEthApiError::BlockNotFound.to_string(), + "block not found" + ); + assert_eq!( + MorphEthApiError::TransactionNotFound(B256::ZERO).to_string(), + format!("transaction {} not found", B256::ZERO) + ); + assert_eq!( + MorphEthApiError::SkippedTransactionNotFound(B256::ZERO).to_string(), + format!("skipped transaction {} not found", B256::ZERO) + ); + assert_eq!( + MorphEthApiError::InvalidBlockNumberOrHash.to_string(), + "invalid block number or hash" + ); + assert_eq!( + MorphEthApiError::StateNotAvailable.to_string(), + "state not available for block" + ); + assert_eq!( + MorphEthApiError::Internal("oops".to_string()).to_string(), + "internal error: oops" + ); + assert_eq!( + MorphEthApiError::Database("db fail".to_string()).to_string(), + "database error: db fail" + ); + assert_eq!( + MorphEthApiError::Provider("provider fail".to_string()).to_string(), + "provider error: provider fail" + ); + } + + #[test] + fn error_to_json_rpc_error_codes() { + let check = |err: MorphEthApiError, expected_code: i32| { + let rpc_err: jsonrpsee::types::ErrorObject<'static> = err.into(); + assert_eq!(rpc_err.code(), expected_code); + }; + + check(MorphEthApiError::BlockNotFound, -32001); + check(MorphEthApiError::TransactionNotFound(B256::ZERO), -32002); + check( + MorphEthApiError::SkippedTransactionNotFound(B256::ZERO), + -32003, + ); + check(MorphEthApiError::InvalidBlockNumberOrHash, -32004); + check(MorphEthApiError::StateNotAvailable, -32005); + check(MorphEthApiError::Internal("x".into()), -32603); + check(MorphEthApiError::Database("x".into()), -32006); + check(MorphEthApiError::Provider("x".into()), -32007); + } + + #[test] + fn as_eth_api_error_returns_inner_for_eth_variant() { + let inner = EthApiError::InvalidParams("test".to_string()); + let err = MorphEthApiError::Eth(inner); + assert!(err.as_err().is_some()); + } + + #[test] + fn as_eth_api_error_returns_none_for_non_eth_variants() { + assert!(MorphEthApiError::BlockNotFound.as_err().is_none()); + assert!(MorphEthApiError::StateNotAvailable.as_err().is_none()); + assert!(MorphEthApiError::Internal("x".into()).as_err().is_none()); + } + + #[test] + fn from_eth_api_error() { + let inner = EthApiError::InvalidParams("test".to_string()); + let err: MorphEthApiError = inner.into(); + assert!(matches!(err, MorphEthApiError::Eth(_))); + } + + #[test] + fn to_morph_err_extension_trait() { + let ok_result: Result = Ok(42); + assert_eq!(ok_result.to_morph_err().unwrap(), 42); + + let err_result: Result = + Err(EthApiError::InvalidParams("bad".to_string())); + let morph_err = err_result.to_morph_err().unwrap_err(); + assert!(matches!(morph_err, MorphEthApiError::Eth(_))); + } +} diff --git a/crates/rpc/src/eth/mod.rs b/crates/rpc/src/eth/mod.rs index cdefd47..0845bfb 100644 --- a/crates/rpc/src/eth/mod.rs +++ b/crates/rpc/src/eth/mod.rs @@ -8,9 +8,12 @@ use eyre::Result; use morph_chainspec::MorphChainSpec; use morph_evm::MorphEvmConfig; use morph_primitives::{MorphHeader, MorphPrimitives}; +use reth_evm::{Database, EvmEnvFor}; use reth_node_api::{FullNodeComponents, FullNodeTypes, NodeTypes}; use reth_node_builder::rpc::{EthApiBuilder, EthApiCtx}; -use reth_provider::ChainSpecProvider; +use reth_primitives_traits::RecoveredBlock; +use reth_provider::{BlockReader, ChainSpecProvider}; +use reth_revm::DatabaseCommit; use reth_rpc::EthApi; use reth_rpc_convert::{RpcConvert, RpcConverter, RpcTypes}; use reth_rpc_eth_api::{ @@ -364,6 +367,17 @@ where MorphEthApiError: reth_rpc_eth_types::error::FromEvmError, Rpc: RpcConvert, { + fn apply_pre_execution_changes( + &self, + _block: &RecoveredBlock<::Block>, + _db: &mut DB, + _evm_env: &EvmEnvFor, + ) -> Result<(), Self::Error> { + // Morph must skip Ethereum's 4788-style pre-block system calls during replay. + // Standard Morph headers omit parentBeaconBlockRoot, so the default Ethereum + // SystemCaller prelude would fail with "EIP-4788 beacon root missing". + Ok(()) + } } // ===== Internal container ===== diff --git a/crates/rpc/src/eth/receipt.rs b/crates/rpc/src/eth/receipt.rs index 26418d4..902de3a 100644 --- a/crates/rpc/src/eth/receipt.rs +++ b/crates/rpc/src/eth/receipt.rs @@ -153,3 +153,103 @@ fn morph_tx_receipt_fields(receipt: &MorphReceipt) -> MorphTxReceiptFields { MorphReceipt::L1Msg(_) => MorphTxReceiptFields::default(), } } + +#[cfg(test)] +mod tests { + use super::*; + use alloy_consensus::Receipt; + use alloy_primitives::{Bytes as PrimitiveBytes, b256}; + use morph_primitives::MorphTransactionReceipt; + + fn make_morph_receipt_with_fields() -> MorphTransactionReceipt { + MorphTransactionReceipt { + inner: Receipt { + status: alloy_consensus::Eip658Value::Eip658(true), + cumulative_gas_used: 100_000, + logs: vec![], + }, + l1_fee: U256::from(5000), + version: Some(1), + fee_token_id: Some(3), + fee_rate: Some(U256::from(2_000_000)), + token_scale: Some(U256::from(1_000_000)), + fee_limit: Some(U256::from(999_999)), + reference: Some(b256!( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + )), + memo: Some(PrimitiveBytes::from("test memo")), + } + } + + #[test] + fn morph_tx_receipt_fields_extracts_all_fields_from_legacy() { + let r = make_morph_receipt_with_fields(); + let receipt = MorphReceipt::Legacy(r.clone()); + let fields = morph_tx_receipt_fields(&receipt); + + assert_eq!(fields.l1_fee, r.l1_fee); + assert_eq!(fields.version, r.version); + assert_eq!(fields.fee_token_id, r.fee_token_id); + assert_eq!(fields.fee_rate, r.fee_rate); + assert_eq!(fields.token_scale, r.token_scale); + assert_eq!(fields.fee_limit, r.fee_limit); + assert_eq!(fields.reference, r.reference); + assert_eq!(fields.memo, r.memo); + } + + #[test] + fn morph_tx_receipt_fields_extracts_from_eip1559() { + let r = make_morph_receipt_with_fields(); + let receipt = MorphReceipt::Eip1559(r.clone()); + let fields = morph_tx_receipt_fields(&receipt); + assert_eq!(fields.l1_fee, r.l1_fee); + assert_eq!(fields.fee_token_id, r.fee_token_id); + } + + #[test] + fn morph_tx_receipt_fields_extracts_from_morph_type() { + let r = make_morph_receipt_with_fields(); + let receipt = MorphReceipt::Morph(r.clone()); + let fields = morph_tx_receipt_fields(&receipt); + assert_eq!(fields.l1_fee, r.l1_fee); + assert_eq!(fields.version, Some(1)); + assert_eq!(fields.fee_token_id, Some(3)); + } + + #[test] + fn l1_msg_receipt_returns_default_fields() { + let receipt = MorphReceipt::L1Msg(Receipt { + status: alloy_consensus::Eip658Value::Eip658(true), + cumulative_gas_used: 50_000, + logs: vec![], + }); + let fields = morph_tx_receipt_fields(&receipt); + + assert_eq!(fields.l1_fee, U256::ZERO); + assert!(fields.version.is_none()); + assert!(fields.fee_token_id.is_none()); + assert!(fields.fee_rate.is_none()); + assert!(fields.token_scale.is_none()); + assert!(fields.fee_limit.is_none()); + assert!(fields.reference.is_none()); + assert!(fields.memo.is_none()); + } + + #[test] + fn morph_tx_receipt_fields_handles_zero_l1_fee() { + let mut r = make_morph_receipt_with_fields(); + r.l1_fee = U256::ZERO; + let receipt = MorphReceipt::Eip2930(r); + let fields = morph_tx_receipt_fields(&receipt); + assert_eq!(fields.l1_fee, U256::ZERO); + } + + #[test] + fn morph_tx_receipt_fields_eip7702() { + let r = make_morph_receipt_with_fields(); + let receipt = MorphReceipt::Eip7702(r.clone()); + let fields = morph_tx_receipt_fields(&receipt); + assert_eq!(fields.l1_fee, r.l1_fee); + assert_eq!(fields.reference, r.reference); + } +} diff --git a/crates/rpc/src/eth/transaction.rs b/crates/rpc/src/eth/transaction.rs index 650fe15..9b4795b 100644 --- a/crates/rpc/src/eth/transaction.rs +++ b/crates/rpc/src/eth/transaction.rs @@ -554,4 +554,330 @@ mod tests { assert!(tx_env.memo.is_none()); assert!(tx_env.version.is_none()); } + + // ========================================================================= + // morph_envelope_from_ethereum tests + // ========================================================================= + + #[test] + fn morph_envelope_from_ethereum_legacy() { + use alloy_consensus::{Signed, TxLegacy}; + let signed = Signed::new_unchecked( + TxLegacy { + gas_limit: 21_000, + ..Default::default() + }, + Signature::new(U256::ZERO, U256::ZERO, false), + Default::default(), + ); + let eth = EthereumTxEnvelope::Legacy(signed); + let result = morph_envelope_from_ethereum(eth); + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), MorphTxEnvelope::Legacy(_))); + } + + #[test] + fn morph_envelope_from_ethereum_eip2930() { + use alloy_consensus::{Signed, TxEip2930}; + let signed = Signed::new_unchecked( + TxEip2930 { + gas_limit: 21_000, + ..Default::default() + }, + Signature::new(U256::ZERO, U256::ZERO, false), + Default::default(), + ); + let eth = EthereumTxEnvelope::Eip2930(signed); + let result = morph_envelope_from_ethereum(eth); + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), MorphTxEnvelope::Eip2930(_))); + } + + #[test] + fn morph_envelope_from_ethereum_eip1559() { + use alloy_consensus::{Signed, TxEip1559}; + let signed = Signed::new_unchecked( + TxEip1559 { + gas_limit: 21_000, + ..Default::default() + }, + Signature::new(U256::ZERO, U256::ZERO, false), + Default::default(), + ); + let eth = EthereumTxEnvelope::Eip1559(signed); + let result = morph_envelope_from_ethereum(eth); + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), MorphTxEnvelope::Eip1559(_))); + } + + #[test] + fn morph_envelope_from_ethereum_eip7702() { + use alloy_consensus::{Signed, TxEip7702}; + let signed = Signed::new_unchecked( + TxEip7702 { + gas_limit: 21_000, + ..Default::default() + }, + Signature::new(U256::ZERO, U256::ZERO, false), + Default::default(), + ); + let eth = EthereumTxEnvelope::Eip7702(signed); + let result = morph_envelope_from_ethereum(eth); + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), MorphTxEnvelope::Eip7702(_))); + } + + #[test] + fn morph_envelope_from_ethereum_rejects_eip4844() { + use alloy_consensus::Signed; + let signed = Signed::new_unchecked( + TxEip4844 { + gas_limit: 21_000, + ..Default::default() + }, + Signature::new(U256::ZERO, U256::ZERO, false), + Default::default(), + ); + let eth = EthereumTxEnvelope::Eip4844(signed); + let result = morph_envelope_from_ethereum(eth); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("EIP-4844")); + } + + // ========================================================================= + // try_build_morph_tx_from_request tests + // ========================================================================= + + #[test] + fn try_build_morph_tx_returns_none_for_standard_tx() { + let req = create_basic_transaction_request(); + let result = try_build_morph_tx_from_request(&req, U64::ZERO, U256::ZERO, None, None); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + #[test] + fn try_build_morph_tx_with_fee_token_id() { + let req = create_basic_transaction_request(); + let result = + try_build_morph_tx_from_request(&req, U64::from(1), U256::from(1_000_000), None, None); + assert!(result.is_ok()); + let tx = result.unwrap().unwrap(); + assert_eq!(tx.fee_token_id, 1); + assert_eq!(tx.fee_limit, U256::from(1_000_000)); + assert_eq!( + tx.version, + morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1 + ); + } + + #[test] + fn try_build_morph_tx_with_reference_only() { + let req = create_basic_transaction_request(); + let reference = B256::random(); + let result = + try_build_morph_tx_from_request(&req, U64::ZERO, U256::ZERO, Some(reference), None); + assert!(result.is_ok()); + let tx = result.unwrap().unwrap(); + assert_eq!(tx.reference, Some(reference)); + assert_eq!(tx.fee_token_id, 0); + } + + #[test] + fn try_build_morph_tx_with_memo_only() { + let req = create_basic_transaction_request(); + let memo = Bytes::from("hello world"); + let result = + try_build_morph_tx_from_request(&req, U64::ZERO, U256::ZERO, None, Some(memo.clone())); + assert!(result.is_ok()); + let tx = result.unwrap().unwrap(); + assert_eq!(tx.memo, Some(memo)); + } + + #[test] + fn try_build_morph_tx_empty_memo_is_not_trigger() { + let req = create_basic_transaction_request(); + let result = + try_build_morph_tx_from_request(&req, U64::ZERO, U256::ZERO, None, Some(Bytes::new())); + assert!(result.is_ok()); + // Empty memo should NOT trigger MorphTx creation + assert!(result.unwrap().is_none()); + } + + #[test] + fn try_build_morph_tx_requires_chain_id() { + let mut req = create_basic_transaction_request(); + req.chain_id = None; + let result = + try_build_morph_tx_from_request(&req, U64::from(1), U256::from(100), None, None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("chain_id")); + } + + #[test] + fn try_build_morph_tx_sets_correct_tx_fields() { + let req = create_basic_transaction_request(); + let result = try_build_morph_tx_from_request( + &req, + U64::from(2), + U256::from(500_000), + Some(B256::random()), + Some(Bytes::from("memo")), + ); + let tx = result.unwrap().unwrap(); + assert_eq!(tx.chain_id, 2818); + assert_eq!(tx.gas_limit, 100000); + assert_eq!(tx.nonce, 1); + assert_eq!(tx.max_fee_per_gas, 1_000_000_000); // falls back to gas_price + assert_eq!(tx.value, U256::from(1000)); + } + + // ========================================================================= + // FromConsensusTx tests + // ========================================================================= + + #[test] + fn from_consensus_tx_l1_message() { + use alloy_primitives::Sealed; + use morph_primitives::TxL1Msg; + + let l1_msg = TxL1Msg { + queue_index: 42, + gas_limit: 100_000, + sender: address!("000000000000000000000000000000000000dead"), + ..Default::default() + }; + let tx = MorphTxEnvelope::L1Msg(Sealed::new_unchecked(l1_msg, B256::default())); + let signer = Address::ZERO; + let tx_info = TransactionInfo { + hash: Some(B256::ZERO), + block_hash: Some(B256::random()), + block_number: Some(10), + index: Some(0), + base_fee: Some(1000), + }; + + let rpc_tx = MorphRpcTransaction::from_consensus_tx(tx, signer, tx_info).unwrap(); + assert_eq!( + rpc_tx.sender, + Some(address!("000000000000000000000000000000000000dead")) + ); + assert_eq!(rpc_tx.queue_index, Some(U64::from(42))); + // L1 messages don't have MorphTx-specific fields + assert!(rpc_tx.version.is_none()); + assert!(rpc_tx.fee_token_id.is_none()); + } + + #[test] + fn from_consensus_tx_morph_tx() { + use alloy_consensus::Signed; + + let morph_tx = TxMorph { + chain_id: 2818, + nonce: 5, + gas_limit: 50_000, + max_fee_per_gas: 2_000_000_000, + max_priority_fee_per_gas: 1_000_000, + fee_token_id: 3, + fee_limit: U256::from(100_000), + version: morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1, + reference: Some(B256::random()), + memo: Some(Bytes::from("hello")), + ..Default::default() + }; + let tx = MorphTxEnvelope::Morph(Signed::new_unchecked( + morph_tx, + Signature::new(U256::ZERO, U256::ZERO, false), + Default::default(), + )); + let signer = address!("0000000000000000000000000000000000000099"); + let tx_info = TransactionInfo { + hash: Some(B256::ZERO), + block_hash: Some(B256::random()), + block_number: Some(100), + index: Some(5), + base_fee: Some(1_000_000_000), + }; + + let rpc_tx = MorphRpcTransaction::from_consensus_tx(tx, signer, tx_info).unwrap(); + // MorphTx should NOT have L1 message fields + assert!(rpc_tx.sender.is_none()); + assert!(rpc_tx.queue_index.is_none()); + // Should have MorphTx-specific fields + assert_eq!( + rpc_tx.version, + Some(morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1) + ); + assert_eq!(rpc_tx.fee_token_id, Some(U64::from(3))); + assert_eq!(rpc_tx.fee_limit, Some(U256::from(100_000))); + assert!(rpc_tx.reference.is_some()); + assert_eq!(rpc_tx.memo, Some(Bytes::from("hello"))); + } + + #[test] + fn from_consensus_tx_standard_eip1559() { + use alloy_consensus::{Signed, TxEip1559}; + + let tx = MorphTxEnvelope::Eip1559(Signed::new_unchecked( + TxEip1559 { + chain_id: 2818, + nonce: 0, + gas_limit: 21_000, + max_fee_per_gas: 2_000_000_000, + max_priority_fee_per_gas: 1_000_000, + ..Default::default() + }, + Signature::new(U256::ZERO, U256::ZERO, false), + Default::default(), + )); + let signer = address!("0000000000000000000000000000000000000001"); + let tx_info = TransactionInfo { + hash: Some(B256::ZERO), + block_hash: None, + block_number: None, + index: None, + base_fee: Some(1_000_000_000), + }; + + let rpc_tx = MorphRpcTransaction::from_consensus_tx(tx, signer, tx_info).unwrap(); + // Standard tx should have no L1 message or MorphTx fields + assert!(rpc_tx.sender.is_none()); + assert!(rpc_tx.queue_index.is_none()); + assert!(rpc_tx.version.is_none()); + assert!(rpc_tx.fee_token_id.is_none()); + assert!(rpc_tx.fee_limit.is_none()); + assert!(rpc_tx.reference.is_none()); + assert!(rpc_tx.memo.is_none()); + } + + #[test] + fn from_consensus_tx_effective_gas_price_calculated() { + use alloy_consensus::{Signed, TxEip1559}; + + let tx = MorphTxEnvelope::Eip1559(Signed::new_unchecked( + TxEip1559 { + chain_id: 2818, + gas_limit: 21_000, + max_fee_per_gas: 3_000_000_000, + max_priority_fee_per_gas: 500_000_000, + ..Default::default() + }, + Signature::new(U256::ZERO, U256::ZERO, false), + Default::default(), + )); + let base_fee = 1_000_000_000u64; + let tx_info = TransactionInfo { + hash: Some(B256::ZERO), + block_hash: Some(B256::random()), + block_number: Some(1), + index: Some(0), + base_fee: Some(base_fee), + }; + + let rpc_tx = MorphRpcTransaction::from_consensus_tx(tx, Address::ZERO, tx_info).unwrap(); + // effective_gas_price = min(max_priority_fee, max_fee - base_fee) + base_fee + // = min(500_000_000, 3_000_000_000 - 1_000_000_000) + 1_000_000_000 + // = 500_000_000 + 1_000_000_000 = 1_500_000_000 + assert_eq!(rpc_tx.inner.effective_gas_price, Some(1_500_000_000)); + } } diff --git a/crates/rpc/src/types/receipt.rs b/crates/rpc/src/types/receipt.rs index 2fbd8d0..93dd2fd 100644 --- a/crates/rpc/src/types/receipt.rs +++ b/crates/rpc/src/types/receipt.rs @@ -115,3 +115,133 @@ impl ReceiptResponse for MorphRpcReceipt { self.inner.state_root() } } + +#[cfg(test)] +mod tests { + use super::*; + use alloy_consensus::{Eip658Value, Receipt, ReceiptWithBloom}; + use alloy_primitives::{Bloom, address, b256}; + + /// Helper to build a minimal TransactionReceipt with a MorphReceiptEnvelope. + fn make_rpc_receipt( + l1_fee: U256, + fee_token_id: Option, + version: Option, + ) -> MorphRpcReceipt { + let inner_receipt = Receipt { + status: Eip658Value::Eip658(true), + cumulative_gas_used: 50_000, + logs: vec![], + }; + let envelope = MorphReceiptEnvelope::Eip1559(ReceiptWithBloom { + receipt: inner_receipt, + logs_bloom: Bloom::ZERO, + }); + let tx_receipt = TransactionReceipt { + inner: envelope, + transaction_hash: b256!( + "0000000000000000000000000000000000000000000000000000000000000001" + ), + transaction_index: Some(0), + block_hash: Some(b256!( + "0000000000000000000000000000000000000000000000000000000000000002" + )), + block_number: Some(42), + gas_used: 21_000, + effective_gas_price: 1_000_000_000, + blob_gas_used: None, + blob_gas_price: None, + from: address!("0000000000000000000000000000000000000001"), + to: Some(address!("0000000000000000000000000000000000000002")), + contract_address: None, + }; + + MorphRpcReceipt { + inner: tx_receipt, + l1_fee, + version, + fee_token_id, + fee_rate: None, + token_scale: None, + fee_limit: None, + reference: None, + memo: None, + } + } + + #[test] + fn receipt_response_delegates_to_inner() { + let receipt = make_rpc_receipt(U256::from(100), None, None); + + assert!(receipt.status()); + assert_eq!(receipt.block_number(), Some(42)); + assert_eq!(receipt.gas_used(), 21_000); + assert_eq!(receipt.effective_gas_price(), 1_000_000_000); + assert_eq!(receipt.blob_gas_used(), None); + assert_eq!(receipt.blob_gas_price(), None); + assert_eq!( + receipt.from(), + address!("0000000000000000000000000000000000000001") + ); + assert_eq!( + receipt.to(), + Some(address!("0000000000000000000000000000000000000002")) + ); + assert_eq!(receipt.contract_address(), None); + assert_eq!(receipt.transaction_index(), Some(0)); + assert_eq!(receipt.cumulative_gas_used(), 50_000); + } + + #[test] + fn receipt_serde_roundtrip_standard() { + let receipt = make_rpc_receipt(U256::from(500), None, None); + let json = serde_json::to_string(&receipt).unwrap(); + let deserialized: MorphRpcReceipt = serde_json::from_str(&json).unwrap(); + assert_eq!(receipt, deserialized); + } + + #[test] + fn receipt_serde_roundtrip_with_morph_fields() { + let mut receipt = make_rpc_receipt(U256::from(1000), Some(U64::from(1)), Some(1)); + receipt.fee_rate = Some(U256::from(2_000_000)); + receipt.token_scale = Some(U256::from(1_000_000)); + receipt.fee_limit = Some(U256::from(500_000)); + receipt.reference = Some(b256!( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + )); + receipt.memo = Some(Bytes::from("hello")); + + let json = serde_json::to_string(&receipt).unwrap(); + let deserialized: MorphRpcReceipt = serde_json::from_str(&json).unwrap(); + assert_eq!(receipt, deserialized); + } + + #[test] + fn receipt_serde_skips_none_fields() { + let receipt = make_rpc_receipt(U256::from(100), None, None); + let json = serde_json::to_string(&receipt).unwrap(); + + // Optional fields should not appear in JSON when None + assert!(!json.contains("version")); + assert!(!json.contains("feeTokenID")); + assert!(!json.contains("feeRate")); + assert!(!json.contains("tokenScale")); + assert!(!json.contains("feeLimit")); + assert!(!json.contains("reference")); + assert!(!json.contains("memo")); + } + + #[test] + fn receipt_serde_l1_fee_field_name() { + let receipt = make_rpc_receipt(U256::from(12345), None, None); + let json = serde_json::to_string(&receipt).unwrap(); + assert!(json.contains("\"l1Fee\"")); + } + + #[test] + fn receipt_serde_fee_token_id_field_name() { + let receipt = make_rpc_receipt(U256::ZERO, Some(U64::from(42)), Some(1)); + let json = serde_json::to_string(&receipt).unwrap(); + assert!(json.contains("\"feeTokenID\"")); + } +} diff --git a/crates/rpc/src/types/request.rs b/crates/rpc/src/types/request.rs index b54992e..f08c36b 100644 --- a/crates/rpc/src/types/request.rs +++ b/crates/rpc/src/types/request.rs @@ -90,3 +90,137 @@ impl From for TransactionRequest { value.inner } } + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{address, b256}; + + fn basic_inner_request() -> TransactionRequest { + TransactionRequest { + from: Some(address!("0000000000000000000000000000000000000001")), + to: Some(address!("0000000000000000000000000000000000000002").into()), + gas: Some(21_000), + gas_price: Some(1_000_000_000), + nonce: Some(0), + ..Default::default() + } + } + + #[test] + fn from_transaction_request_sets_none_fields() { + let inner = basic_inner_request(); + let morph_req: MorphTransactionRequest = inner.clone().into(); + assert_eq!(morph_req.inner, inner); + assert!(morph_req.fee_token_id.is_none()); + assert!(morph_req.fee_limit.is_none()); + assert!(morph_req.reference.is_none()); + assert!(morph_req.memo.is_none()); + } + + #[test] + fn into_transaction_request_strips_morph_fields() { + let morph_req = MorphTransactionRequest { + inner: basic_inner_request(), + fee_token_id: Some(U64::from(1)), + fee_limit: Some(U256::from(500)), + reference: Some(b256!( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + )), + memo: Some(Bytes::from("test")), + }; + let inner: TransactionRequest = morph_req.into(); + assert_eq!(inner, basic_inner_request()); + } + + #[test] + fn as_ref_and_as_mut() { + let mut morph_req = MorphTransactionRequest { + inner: basic_inner_request(), + ..Default::default() + }; + + // AsRef + let inner_ref: &TransactionRequest = morph_req.as_ref(); + assert_eq!(inner_ref.gas, Some(21_000)); + + // AsMut + let inner_mut: &mut TransactionRequest = morph_req.as_mut(); + inner_mut.gas = Some(42_000); + assert_eq!(morph_req.inner.gas, Some(42_000)); + } + + #[test] + fn deref_delegates_to_inner() { + let morph_req = MorphTransactionRequest { + inner: basic_inner_request(), + ..Default::default() + }; + // Deref should allow accessing inner fields directly + assert_eq!(morph_req.gas, Some(21_000)); + assert_eq!(morph_req.nonce, Some(0)); + } + + #[test] + fn serde_roundtrip_without_morph_fields() { + let req = MorphTransactionRequest { + inner: basic_inner_request(), + ..Default::default() + }; + let json = serde_json::to_string(&req).unwrap(); + let deserialized: MorphTransactionRequest = serde_json::from_str(&json).unwrap(); + assert_eq!(req, deserialized); + } + + #[test] + fn serde_roundtrip_with_morph_fields() { + let req = MorphTransactionRequest { + inner: basic_inner_request(), + fee_token_id: Some(U64::from(5)), + fee_limit: Some(U256::from(999)), + reference: Some(b256!( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + )), + memo: Some(Bytes::from("memo data")), + }; + let json = serde_json::to_string(&req).unwrap(); + let deserialized: MorphTransactionRequest = serde_json::from_str(&json).unwrap(); + assert_eq!(req, deserialized); + } + + #[test] + fn serde_field_names_camel_case() { + let req = MorphTransactionRequest { + inner: basic_inner_request(), + fee_token_id: Some(U64::from(1)), + fee_limit: Some(U256::from(100)), + ..Default::default() + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"feeTokenID\"")); + assert!(json.contains("\"feeLimit\"")); + } + + #[test] + fn serde_skips_none_morph_fields() { + let req = MorphTransactionRequest { + inner: basic_inner_request(), + ..Default::default() + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(!json.contains("feeTokenID")); + assert!(!json.contains("feeLimit")); + assert!(!json.contains("reference")); + assert!(!json.contains("memo")); + } + + #[test] + fn default_creates_empty_request() { + let req = MorphTransactionRequest::default(); + assert_eq!(req.inner, TransactionRequest::default()); + assert!(req.fee_token_id.is_none()); + assert!(req.fee_limit.is_none()); + assert!(req.reference.is_none()); + assert!(req.memo.is_none()); + } +} diff --git a/crates/txpool/src/error.rs b/crates/txpool/src/error.rs index 497c885..d676b0b 100644 --- a/crates/txpool/src/error.rs +++ b/crates/txpool/src/error.rs @@ -195,4 +195,111 @@ mod tests { let pool_err: InvalidPoolTransactionError = err.into(); assert!(matches!(pool_err, InvalidPoolTransactionError::Other(_))); } + + #[test] + fn test_error_conversion_insufficient_eth() { + let err = MorphTxError::InsufficientEthForValue { + balance: U256::from(50), + value: U256::from(100), + }; + let pool_err: InvalidPoolTransactionError = err.into(); + assert!(matches!( + pool_err, + InvalidPoolTransactionError::Overdraft { .. } + )); + } + + #[test] + fn test_error_conversion_insufficient_token() { + let err = MorphTxError::InsufficientTokenBalance { + token_id: 1, + token_address: address!("1234567890123456789012345678901234567890"), + balance: U256::from(30), + required: U256::from(60), + }; + let pool_err: InvalidPoolTransactionError = err.into(); + assert!(matches!( + pool_err, + InvalidPoolTransactionError::Overdraft { .. } + )); + } + + #[test] + fn test_is_bad_transaction() { + // Malformed = bad + assert!(MorphTxError::InvalidTokenId.is_bad_transaction()); + assert!( + MorphTxError::InvalidFormat { + reason: "test".into() + } + .is_bad_transaction() + ); + + // Insufficient funds = not bad (shouldn't penalize peer) + assert!( + !MorphTxError::InsufficientTokenBalance { + token_id: 1, + token_address: Address::ZERO, + balance: U256::ZERO, + required: U256::from(1u64), + } + .is_bad_transaction() + ); + + assert!( + !MorphTxError::InsufficientEthForValue { + balance: U256::ZERO, + value: U256::from(1u64), + } + .is_bad_transaction() + ); + + // Token state issues = not bad + assert!(!MorphTxError::TokenNotFound { token_id: 1 }.is_bad_transaction()); + assert!(!MorphTxError::TokenNotActive { token_id: 1 }.is_bad_transaction()); + assert!(!MorphTxError::InvalidPriceRatio { token_id: 1 }.is_bad_transaction()); + assert!( + !MorphTxError::TokenInfoFetchFailed { + token_id: 1, + message: "error".into() + } + .is_bad_transaction() + ); + } + + #[test] + fn test_error_display_all_variants() { + // Verify all variants produce non-empty display strings + let variants: Vec = vec![ + MorphTxError::InvalidTokenId, + MorphTxError::TokenNotFound { token_id: 1 }, + MorphTxError::TokenNotActive { token_id: 2 }, + MorphTxError::InvalidPriceRatio { token_id: 3 }, + MorphTxError::InsufficientTokenBalance { + token_id: 4, + token_address: Address::ZERO, + balance: U256::from(10u64), + required: U256::from(20u64), + }, + MorphTxError::InsufficientEthForValue { + balance: U256::from(5u64), + value: U256::from(10u64), + }, + MorphTxError::TokenInfoFetchFailed { + token_id: 5, + message: "db error".into(), + }, + MorphTxError::InvalidFormat { + reason: "bad version".into(), + }, + ]; + + for err in variants { + let display = err.to_string(); + assert!( + !display.is_empty(), + "Display for {err:?} should not be empty" + ); + } + } } diff --git a/crates/txpool/src/morph_tx_validation.rs b/crates/txpool/src/morph_tx_validation.rs index 9850682..3e05b15 100644 --- a/crates/txpool/src/morph_tx_validation.rs +++ b/crates/txpool/src/morph_tx_validation.rs @@ -209,7 +209,6 @@ mod tests { assert_eq!(input.hardfork, MorphHardfork::Viridian); assert_eq!(input.eth_balance, U256::from(1_000_000_000_000_000_000u128)); assert_eq!(input.l1_data_fee, U256::from(100_000)); - assert_eq!(input.base_fee_per_gas, Some(1_000_000_000)); } #[test] @@ -257,7 +256,43 @@ mod tests { } #[test] - fn test_validate_morph_tx_rejects_v1_before_jade() { + fn test_validate_morph_tx_rejects_non_morph_envelope() { + use alloy_consensus::TxEip1559; + + let sender = address!("1000000000000000000000000000000000000001"); + let tx = TxEip1559 { + chain_id: 2818, + nonce: 0, + gas_limit: 21_000, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + value: U256::ZERO, + input: Default::default(), + max_fee_per_gas: 2_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + access_list: Default::default(), + }; + let envelope = MorphTxEnvelope::Eip1559(Signed::new_unchecked( + tx, + Signature::test_signature(), + B256::ZERO, + )); + + let input = MorphTxValidationInput { + consensus_tx: &envelope, + sender, + eth_balance: U256::from(1_000_000_000_000_000_000u128), + l1_data_fee: U256::ZERO, + base_fee_per_gas: Some(1_000_000_000), + hardfork: MorphHardfork::Viridian, + }; + let mut db = EmptyDB::default(); + + let err = validate_morph_tx(&mut db, &input).unwrap_err(); + assert_eq!(err, MorphTxError::InvalidTokenId); + } + + #[test] + fn test_validate_morph_tx_insufficient_eth_for_value() { let sender = address!("1000000000000000000000000000000000000001"); let tx = TxMorph { chain_id: 2818, @@ -266,12 +301,51 @@ mod tests { max_fee_per_gas: 2_000_000_000, max_priority_fee_per_gas: 1_000_000_000, to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + value: U256::from(10u128.pow(18)), // 1 ETH value + access_list: Default::default(), + version: 0, + fee_token_id: 1, + fee_limit: U256::from(1000u64), + reference: None, + memo: None, + input: Default::default(), + }; + let envelope = MorphTxEnvelope::Morph(Signed::new_unchecked( + tx, + Signature::test_signature(), + B256::ZERO, + )); + let input = MorphTxValidationInput { + consensus_tx: &envelope, + sender, + eth_balance: U256::from(100u64), // Insufficient ETH + l1_data_fee: U256::ZERO, + base_fee_per_gas: Some(1_000_000_000), + hardfork: MorphHardfork::Viridian, + }; + let mut db = EmptyDB::default(); + + let err = validate_morph_tx(&mut db, &input).unwrap_err(); + assert!(matches!(err, MorphTxError::InsufficientEthForValue { .. })); + } + + #[test] + fn test_validate_morph_tx_eth_fee_path_sufficient_balance() { + let sender = address!("1000000000000000000000000000000000000001"); + // fee_token_id = 0 with version 1 (Jade) means ETH-fee path + let tx = TxMorph { + chain_id: 2818, + nonce: 0, + gas_limit: 21_000, + max_fee_per_gas: 1_000_000_000, // 1 Gwei + max_priority_fee_per_gas: 500_000_000, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), value: U256::ZERO, access_list: Default::default(), version: MORPH_TX_VERSION_1, fee_token_id: 0, fee_limit: U256::ZERO, - reference: Some(B256::ZERO), + reference: None, memo: None, input: Default::default(), }; @@ -280,23 +354,106 @@ mod tests { Signature::test_signature(), B256::ZERO, )); + + // gas_fee = 21000 * 1_000_000_000 = 21_000_000_000_000 + // total = gas_fee + l1_data_fee + value = 21_000_000_000_000 + 1000 + 0 let input = MorphTxValidationInput { consensus_tx: &envelope, sender, - eth_balance: U256::from(1_000_000_000_000_000_000u128), - l1_data_fee: U256::ZERO, + eth_balance: U256::from(10u128.pow(18)), // 1 ETH (sufficient) + l1_data_fee: U256::from(1000u64), base_fee_per_gas: Some(1_000_000_000), - hardfork: MorphHardfork::Emerald, + hardfork: MorphHardfork::Jade, + }; + let mut db = EmptyDB::default(); + + let result = validate_morph_tx(&mut db, &input).unwrap(); + assert!( + !result.uses_token_fee, + "fee_token_id=0 should use ETH-fee path" + ); + assert_eq!(result.required_token_amount, U256::ZERO); + } + + #[test] + fn test_validate_morph_tx_eth_fee_path_insufficient_balance() { + let sender = address!("1000000000000000000000000000000000000001"); + let tx = TxMorph { + chain_id: 2818, + nonce: 0, + gas_limit: 21_000, + max_fee_per_gas: 1_000_000_000, + max_priority_fee_per_gas: 500_000_000, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + value: U256::ZERO, + access_list: Default::default(), + version: MORPH_TX_VERSION_1, + fee_token_id: 0, + fee_limit: U256::ZERO, + reference: None, + memo: None, + input: Default::default(), + }; + let envelope = MorphTxEnvelope::Morph(Signed::new_unchecked( + tx, + Signature::test_signature(), + B256::ZERO, + )); + + let input = MorphTxValidationInput { + consensus_tx: &envelope, + sender, + eth_balance: U256::from(100u64), // Way too low + l1_data_fee: U256::from(1000u64), + base_fee_per_gas: Some(1_000_000_000), + hardfork: MorphHardfork::Jade, }; let mut db = EmptyDB::default(); let err = validate_morph_tx(&mut db, &input).unwrap_err(); + assert!(matches!(err, MorphTxError::InsufficientEthForValue { .. })); + } - assert_eq!( - err, - MorphTxError::InvalidFormat { - reason: "MorphTx version 1 is not yet active (jade fork not reached)".to_string(), - } + #[test] + fn test_validate_morph_tx_token_fee_path_token_not_found() { + let sender = address!("1000000000000000000000000000000000000001"); + let tx = TxMorph { + chain_id: 2818, + nonce: 0, + gas_limit: 21_000, + max_fee_per_gas: 1_000_000_000, + max_priority_fee_per_gas: 500_000_000, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + value: U256::ZERO, + access_list: Default::default(), + version: 0, + fee_token_id: 42, // Non-existent token + fee_limit: U256::from(1000u64), + reference: None, + memo: None, + input: Default::default(), + }; + let envelope = MorphTxEnvelope::Morph(Signed::new_unchecked( + tx, + Signature::test_signature(), + B256::ZERO, + )); + + let input = MorphTxValidationInput { + consensus_tx: &envelope, + sender, + eth_balance: U256::from(10u128.pow(18)), + l1_data_fee: U256::ZERO, + base_fee_per_gas: Some(1_000_000_000), + hardfork: MorphHardfork::Viridian, + }; + let mut db = EmptyDB::default(); + + // EmptyDB has no token registry state, so token lookup will fail + let err = validate_morph_tx(&mut db, &input).unwrap_err(); + assert!( + matches!(err, MorphTxError::TokenNotFound { token_id: 42 }), + "expected TokenNotFound {{ token_id: 42 }}, got {err:?}" ); } } diff --git a/crates/txpool/src/transaction.rs b/crates/txpool/src/transaction.rs index 435a019..fdc533e 100644 --- a/crates/txpool/src/transaction.rs +++ b/crates/txpool/src/transaction.rs @@ -210,3 +210,175 @@ impl EthPoolTransaction for MorphPooledTransaction { )) } } + +#[cfg(test)] +mod tests { + use super::*; + use alloy_consensus::{Sealed, Signed, Transaction, TxLegacy}; + use alloy_eips::Encodable2718; + use alloy_eips::eip4844::BlobTransactionSidecar; + use alloy_primitives::{Bytes, Signature, U256}; + use morph_primitives::transaction::TxL1Msg; + use reth_transaction_pool::PoolTransaction; + + fn create_legacy_pooled_tx() -> MorphPooledTransaction { + let tx = TxLegacy { + chain_id: Some(1337), + nonce: 5, + gas_price: 1_000_000_000, + gas_limit: 21000, + to: TxKind::Call(Address::repeat_byte(0x01)), + value: U256::from(100u64), + input: Bytes::new(), + }; + let sig = Signature::test_signature(); + let envelope = MorphTxEnvelope::Legacy(Signed::new_unhashed(tx, sig)); + let recovered = Recovered::new_unchecked(envelope, Address::repeat_byte(0xaa)); + let len = recovered.encode_2718_len(); + MorphPooledTransaction::new(recovered, len) + } + + fn create_l1_msg_pooled_tx(queue_index: u64) -> MorphPooledTransaction { + let tx = TxL1Msg { + queue_index, + gas_limit: 21000, + to: Address::ZERO, + value: U256::ZERO, + input: Bytes::default(), + sender: Address::repeat_byte(0xbb), + }; + let envelope = MorphTxEnvelope::L1Msg(Sealed::new(tx)); + let recovered = Recovered::new_unchecked(envelope, Address::repeat_byte(0xbb)); + let len = recovered.encode_2718_len(); + MorphPooledTransaction::new(recovered, len) + } + + fn create_morph_pooled_tx() -> MorphPooledTransaction { + use morph_primitives::TxMorph; + let tx = TxMorph { + chain_id: 1337, + nonce: 0, + gas_limit: 21000, + max_fee_per_gas: 2_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + to: TxKind::Call(Address::repeat_byte(0x02)), + value: U256::ZERO, + access_list: Default::default(), + version: 0, + fee_token_id: 1, + fee_limit: U256::from(1000u64), + reference: None, + memo: None, + input: Bytes::new(), + }; + let sig = Signature::test_signature(); + let envelope = MorphTxEnvelope::Morph(Signed::new_unhashed(tx, sig)); + let recovered = Recovered::new_unchecked(envelope, Address::repeat_byte(0xcc)); + let len = recovered.encode_2718_len(); + MorphPooledTransaction::new(recovered, len) + } + + #[test] + fn test_is_l1_message() { + let l1_tx = create_l1_msg_pooled_tx(0); + assert!(l1_tx.is_l1_message()); + assert_eq!(l1_tx.queue_index(), Some(0)); + + let legacy_tx = create_legacy_pooled_tx(); + assert!(!legacy_tx.is_l1_message()); + assert_eq!(legacy_tx.queue_index(), None); + } + + #[test] + fn test_is_morph_tx() { + let morph_tx = create_morph_pooled_tx(); + assert!(morph_tx.is_morph_tx()); + + let legacy_tx = create_legacy_pooled_tx(); + assert!(!legacy_tx.is_morph_tx()); + } + + #[test] + fn test_pool_transaction_sender() { + let tx = create_legacy_pooled_tx(); + assert_eq!(tx.sender(), Address::repeat_byte(0xaa)); + } + + #[test] + fn test_pool_transaction_nonce() { + let tx = create_legacy_pooled_tx(); + assert_eq!(tx.nonce(), 5); + } + + #[test] + fn test_pool_transaction_value() { + let tx = create_legacy_pooled_tx(); + assert_eq!(tx.value(), U256::from(100u64)); + } + + #[test] + fn test_pool_transaction_gas_limit() { + let tx = create_legacy_pooled_tx(); + assert_eq!(tx.gas_limit(), 21000); + } + + #[test] + fn test_encoded_2718_is_cached() { + let tx = create_legacy_pooled_tx(); + let bytes1 = tx.encoded_2718().clone(); + let bytes2 = tx.encoded_2718().clone(); + assert_eq!(bytes1, bytes2, "cached encoding should be identical"); + assert!(!bytes1.is_empty()); + } + + #[test] + fn test_from_pooled_roundtrip() { + let original = create_legacy_pooled_tx(); + let hash = *original.hash(); + let sender = original.sender(); + + let consensus = original.into_consensus(); + assert_eq!(consensus.signer(), sender); + + let recreated = MorphPooledTransaction::from_pooled(consensus); + assert_eq!(*recreated.hash(), hash); + assert_eq!(recreated.sender(), sender); + } + + #[test] + fn test_take_blob_returns_none() { + let mut tx = create_legacy_pooled_tx(); + let blob = tx.take_blob(); + assert!(matches!(blob, EthBlobTransactionSidecar::None)); + } + + #[test] + fn test_try_into_pooled_eip4844_returns_none() { + let tx = create_legacy_pooled_tx(); + let sidecar = Arc::new(BlobTransactionSidecarVariant::Eip4844( + BlobTransactionSidecar::default(), + )); + let result = tx.try_into_pooled_eip4844(sidecar); + assert!(result.is_none()); + } + + #[test] + fn test_try_from_eip4844_returns_none() { + // Morph doesn't support blob transactions, so try_from_eip4844 always returns None + let tx = create_legacy_pooled_tx(); + let recovered = tx.into_consensus(); + let sidecar = BlobTransactionSidecar::default(); + let result = MorphPooledTransaction::try_from_eip4844( + recovered, + BlobTransactionSidecarVariant::Eip4844(sidecar), + ); + assert!(result.is_none()); + } + + #[test] + fn test_encoded_length_matches() { + let tx = create_legacy_pooled_tx(); + // encoded_length is set during construction + assert!(tx.encoded_length() > 0); + } +} diff --git a/crates/txpool/src/validator.rs b/crates/txpool/src/validator.rs index ca5a618..0239877 100644 --- a/crates/txpool/src/validator.rs +++ b/crates/txpool/src/validator.rs @@ -244,6 +244,31 @@ where ); } + // Reject EIP-7702 transactions before Viridian hardfork (PRAGUE) + if transaction.is_eip7702() + && !self + .chain_spec() + .is_viridian_active_at_timestamp(self.block_timestamp()) + { + return TransactionValidationOutcome::Invalid( + transaction, + InvalidTransactionError::TxTypeNotSupported.into(), + ); + } + + // Reject MorphTx (0x7F) before Emerald hardfork. + // go-ethereum's MakeSigner only registers MorphTxType from forks.Emerald onwards. + if is_morph_tx(&transaction) + && !self + .chain_spec() + .is_emerald_active_at_timestamp(self.block_timestamp()) + { + return TransactionValidationOutcome::Invalid( + transaction, + InvalidTransactionError::TxTypeNotSupported.into(), + ); + } + // Check if this is a MorphTx (0x7F) - need special handling for ERC20 gas payment let is_morph_tx = is_morph_tx(&transaction);