From 91bfda519604ac296932a99c7ecca2f3cc740615 Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 18 Mar 2026 16:20:43 +0800 Subject: [PATCH 1/3] test: add comprehensive test coverage and E2E integration tests - Add unit tests across all morph-reth crates (primitives, chainspec, revm, evm, txpool, consensus, payload-builder, payload-types, engine-api, node, rpc) - Add E2E integration test (can_sync) that starts an ephemeral Morph node, produces 10 blocks via Engine API, and verifies chain advancement - Add test_utils module with setup(), advance_chain(), and morph_payload_attributes() helpers following scroll-reth's pattern - Add From for MorphPayloadBuilderAttributes to enable reth E2E test framework compatibility - Add test-utils feature gate for E2E test dependencies --- Cargo.lock | 108 +++++ crates/chainspec/src/constants.rs | 51 +++ crates/chainspec/src/hardfork.rs | 50 +++ crates/consensus/src/validation.rs | 415 +++++++++++++++++- crates/engine-api/src/builder.rs | 168 +++++-- crates/engine-api/src/error.rs | 75 +++- crates/engine-api/src/validator.rs | 46 ++ crates/evm/src/assemble.rs | 64 +++ crates/evm/src/block/receipt.rs | 274 ++++++++++++ crates/evm/src/config.rs | 189 ++++++++ crates/node/Cargo.toml | 25 +- crates/node/src/args.rs | 24 + crates/node/src/lib.rs | 2 + crates/node/src/node.rs | 90 ++++ crates/node/src/test_utils.rs | 146 ++++++ crates/node/src/validator.rs | 204 +++++++++ crates/node/tests/it/main.rs | 7 + crates/node/tests/it/sync.rs | 45 ++ crates/payload/builder/src/builder.rs | 303 +++++++++++++ crates/payload/types/src/attributes.rs | 197 +++++++++ .../payload/types/src/executable_l2_data.rs | 63 +++ crates/payload/types/src/lib.rs | 98 +++++ crates/payload/types/src/params.rs | 50 +++ crates/payload/types/src/safe_l2_data.rs | 50 +++ crates/primitives/src/header.rs | 57 +++ crates/primitives/src/receipt/envelope.rs | 175 ++++++++ crates/revm/src/error.rs | 96 ++++ crates/revm/src/evm.rs | 81 ++++ crates/revm/src/l1block.rs | 135 ++++++ crates/revm/src/precompiles.rs | 132 ++++++ crates/revm/src/token_fee.rs | 92 ++++ crates/rpc/Cargo.toml | 2 + crates/rpc/src/error.rs | 93 ++++ crates/rpc/src/eth/receipt.rs | 100 +++++ crates/rpc/src/eth/transaction.rs | 326 ++++++++++++++ crates/rpc/src/types/receipt.rs | 130 ++++++ crates/rpc/src/types/request.rs | 134 ++++++ crates/txpool/src/error.rs | 107 +++++ crates/txpool/src/morph_tx_validation.rs | 182 +++++++- crates/txpool/src/transaction.rs | 172 ++++++++ 40 files changed, 4693 insertions(+), 65 deletions(-) create mode 100644 crates/node/src/test_utils.rs create mode 100644 crates/node/tests/it/main.rs create mode 100644 crates/node/tests/it/sync.rs diff --git a/Cargo.lock b/Cargo.lock index 54ad644..55e4617 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4812,10 +4812,13 @@ name = "morph-node" version = "0.1.0" dependencies = [ "alloy-consensus", + "alloy-eips", + "alloy-genesis", "alloy-hardforks", "alloy-primitives", "alloy-rpc-types-engine", "alloy-rpc-types-eth", + "alloy-signer-local", "clap", "dashmap 6.1.0", "eyre", @@ -4831,6 +4834,7 @@ dependencies = [ "parking_lot", "reth-chainspec", "reth-db", + "reth-e2e-test-utils", "reth-engine-local", "reth-engine-tree", "reth-errors", @@ -4849,6 +4853,7 @@ dependencies = [ "reth-transaction-pool", "reth-trie", "serde", + "serde_json", "tokio", "tokio-stream", ] @@ -4997,6 +5002,7 @@ dependencies = [ "reth-transaction-pool", "revm", "serde", + "serde_json", "thiserror 2.0.18", "tokio", "tracing", @@ -6840,12 +6846,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 +6863,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 +7041,7 @@ dependencies = [ "parking_lot", "rayon", "reth-chain-state", + "reth-chainspec", "reth-consensus", "reth-db", "reth-engine-primitives", @@ -6987,9 +7056,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 +7683,7 @@ dependencies = [ "auto_impl", "derive_more", "futures", + "parking_lot", "reth-consensus", "reth-eth-wire-types", "reth-ethereum-primitives", @@ -8027,6 +8101,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 +8588,7 @@ dependencies = [ "num-traits", "rayon", "reqwest", + "reth-chainspec", "reth-codecs", "reth-config", "reth-consensus", @@ -8509,6 +8597,7 @@ dependencies = [ "reth-era", "reth-era-downloader", "reth-era-utils", + "reth-ethereum-primitives", "reth-etl", "reth-evm", "reth-execution-types", @@ -8524,8 +8613,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 +8754,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 +8829,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..0681223 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 @@ -1498,4 +1479,392 @@ mod tests { Err(ConsensusError::TimestampIsInPast { .. }) )); } + + // ======================================================================== + // Coinbase / FeeVault Validation Tests + // ======================================================================== + + #[test] + fn test_validate_header_coinbase_non_zero_with_fee_vault() { + let chain_spec = create_test_chainspec(); + 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); + // If the test chain_spec has fee vault enabled, this should fail + if consensus.chain_spec().is_fee_vault_enabled() { + 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/src/builder.rs b/crates/engine-api/src/builder.rs index 74a588e..1481368 100644 --- a/crates/engine-api/src/builder.rs +++ b/crates/engine-api/src/builder.rs @@ -53,7 +53,7 @@ pub struct RealMorphL2EngineApi { engine_state_tracker: Arc, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq)] struct InMemoryHead { number: u64, hash: B256, @@ -955,34 +955,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 +1113,142 @@ 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}"), + } + } } 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..b4049f2 100644 --- a/crates/evm/src/assemble.rs +++ b/crates/evm/src/assemble.rs @@ -132,3 +132,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..d8388b1 100644 --- a/crates/evm/src/block/receipt.rs +++ b/crates/evm/src/block/receipt.rs @@ -229,3 +229,277 @@ 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, + l1_fee: U256::ZERO, + 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 + 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!(matches!(receipt, MorphReceipt::Morph(_))); + assert_eq!(receipt.l1_fee(), l1_fee); + } + + #[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!(matches!(receipt, MorphReceipt::Morph(_))); + assert_eq!(receipt.l1_fee(), l1_fee); + } + + #[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); + } +} diff --git a/crates/evm/src/config.rs b/crates/evm/src/config.rs index 231f39e..1acea39 100644 --- a/crates/evm/src/config.rs +++ b/crates/evm/src/config.rs @@ -149,3 +149,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..6c3cd37 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,34 @@ 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-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-tasks.workspace = true +serde_json.workspace = true + +[[test]] +name = "it" +path = "tests/it/main.rs" +required-features = ["test-utils"] [features] default = [] +test-utils = [ + "reth-e2e-test-utils", + "dep:reth-tasks", + "dep:tokio", + "dep:alloy-eips", + "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..ded4491 --- /dev/null +++ b/crates/node/src/test_utils.rs @@ -0,0 +1,146 @@ +//! Test utilities for Morph node E2E testing. +//! +//! Provides helpers for setting up an ephemeral Morph node with an in-memory +//! database, creating payload attributes, and advancing the chain — following +//! the same pattern as scroll-reth's `test_utils`. + +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::MorphPayloadBuilderAttributes; +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>>; + +/// Creates an ephemeral Morph node for E2E testing. +/// +/// This spins up a fully functional Morph node with an in-memory database, +/// connected to other nodes if `num_nodes > 1`. Follows scroll-reth's +/// `setup()` pattern. +pub async fn setup( + num_nodes: usize, + is_dev: bool, +) -> eyre::Result<(Vec, TaskManager, Wallet)> { + // Build a minimal test genesis with all Morph hardforks activated at genesis. + let genesis: Genesis = serde_json::from_value(serde_json::json!({ + "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": "0x4200000000000000000000000000000000000011" + } + }, + "nonce": "0x0", + "timestamp": "0x0", + "extraData": "0x", + "gasLimit": "0x1c9c380", + "difficulty": "0x0", + "mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "coinbase": "0x4200000000000000000000000000000000000011", + "alloc": { + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266": { + "balance": "0x200000000000000000000000000000000000000000000000000000000000000" + } + }, + "baseFeePerGas": "0xf4240" + }))?; + + let chain_spec = morph_chainspec::MorphChainSpec::from_genesis(genesis); + + reth_e2e_test_utils::setup_engine( + num_nodes, + Arc::new(chain_spec), + is_dev, + Default::default(), + morph_payload_attributes, + ) + .await +} + +/// 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 +} + +/// Creates a signed transfer transaction with an explicit nonce. +/// +/// The morph reth fork does not include `transfer_tx_nonce_bytes`, so we +/// build the transaction request manually and delegate signing to the +/// framework's `TransactionTestContext::sign_tx`. +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(21000), + max_fee_per_gas: Some(20e9 as u128), + max_priority_fee_per_gas: Some(20e9 as u128), + 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. +/// +/// This is the attributes generator function passed to reth's E2E test framework. +/// It creates minimal attributes with no L1 messages, suitable for testing. +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)) +} 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/it/main.rs b/crates/node/tests/it/main.rs new file mode 100644 index 0000000..3fad8d3 --- /dev/null +++ b/crates/node/tests/it/main.rs @@ -0,0 +1,7 @@ +//! Morph node integration tests. +//! +//! These are real E2E tests that spin up an ephemeral Morph node with an +//! in-memory database, produce blocks via the Engine API, and verify the +//! chain advances correctly. + +mod sync; 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/payload/builder/src/builder.rs b/crates/payload/builder/src/builder.rs index cd9f127..688dcc0 100644 --- a/crates/payload/builder/src/builder.rs +++ b/crates/payload/builder/src/builder.rs @@ -750,3 +750,306 @@ 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 _ = ctx.best_transaction_attributes(7_000_000_000); + } + + #[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/revm/src/error.rs b/crates/revm/src/error.rs index 0ce1d80..399fc58 100644 --- a/crates/revm/src/error.rs +++ b/crates/revm/src/error.rs @@ -81,3 +81,99 @@ 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(_))); + } + + #[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..2cfc4fc 100644 --- a/crates/revm/src/l1block.rs +++ b/crates/revm/src/l1block.rs @@ -311,4 +311,139 @@ 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() { + let l1_commit_scalar = U256::from(230_759_955_285u64); + let l1_base_fee = U256::from(30_000_000_000u64); // 30 gwei + let l1_blob_base_fee = U256::from(1); + let l1_blob_scalar = U256::from(417_565_260); + let calldata_gas = l1_commit_scalar.saturating_mul(l1_base_fee); + + let info = L1BlockInfo { + l1_base_fee, + l1_blob_base_fee, + l1_commit_scalar, + l1_blob_scalar, + calldata_gas, + ..Default::default() + }; + + // Test with 100 bytes of input + let input = vec![0xff; 100]; + let cost = info.calculate_tx_l1_cost(&input, MorphHardfork::Curie); + + // blob_gas = len * blob_base_fee * blob_scalar = 100 * 1 * 417_565_260 + // total = (calldata_gas + blob_gas) / 1e9 + let blob_gas = U256::from(100u64) + .saturating_mul(l1_blob_base_fee) + .saturating_mul(l1_blob_scalar); + let expected = calldata_gas + .saturating_add(blob_gas) + .wrapping_div(U256::from(1_000_000_000u64)); + assert_eq!(cost, expected); + } + + /// 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..482618e 100644 --- a/crates/revm/src/precompiles.rs +++ b/crates/revm/src/precompiles.rs @@ -577,6 +577,138 @@ 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" + ); + } + #[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/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..3041242 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,109 @@ 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 { .. } | MorphTxError::TokenInfoFetchFailed { .. } + ), + "expected token not found or fetch failed, 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); + } +} From f4ae49c9282774c6c4cfc8cd5b5370562aa04bd4 Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 18 Mar 2026 17:54:55 +0800 Subject: [PATCH 2/3] test: address code review feedback - Fix no-op FeeVault test by creating chainspec with feeVaultAddress and making assertion unconditional - Use non-zero l1_fee in L1Msg receipt test to verify builder ignores it - Destructure MorphReceipt::Morph to assert fee_token_id, fee_rate, token_scale, fee_limit, reference, memo fields - Narrow token-not-found assertion to deterministic TokenNotFound path --- crates/consensus/src/validation.rs | 38 +++++++++++++++++++----- crates/evm/src/block/receipt.rs | 36 +++++++++++++++++++--- crates/txpool/src/morph_tx_validation.rs | 7 ++--- 3 files changed, 65 insertions(+), 16 deletions(-) diff --git a/crates/consensus/src/validation.rs b/crates/consensus/src/validation.rs index 0681223..cee5ea8 100644 --- a/crates/consensus/src/validation.rs +++ b/crates/consensus/src/validation.rs @@ -1486,7 +1486,34 @@ mod tests { #[test] fn test_validate_header_coinbase_non_zero_with_fee_vault() { - let chain_spec = create_test_chainspec(); + // 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() @@ -1507,12 +1534,9 @@ mod tests { let sealed = SealedHeader::seal_slow(header); let result = consensus.validate_header(&sealed); - // If the test chain_spec has fee vault enabled, this should fail - if consensus.chain_spec().is_fee_vault_enabled() { - assert!(result.is_err()); - let err_str = result.unwrap_err().to_string(); - assert!(err_str.contains("coinbase")); - } + assert!(result.is_err()); + let err_str = result.unwrap_err().to_string(); + assert!(err_str.contains("coinbase")); } // ======================================================================== diff --git a/crates/evm/src/block/receipt.rs b/crates/evm/src/block/receipt.rs index d8388b1..a60623e 100644 --- a/crates/evm/src/block/receipt.rs +++ b/crates/evm/src/block/receipt.rs @@ -390,7 +390,9 @@ mod tests { tx: &tx, result: make_success_result(21000), cumulative_gas_used: 21000, - l1_fee: U256::ZERO, + // 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![], @@ -398,7 +400,7 @@ mod tests { let receipt = builder.build_receipt(ctx); assert!(matches!(receipt, MorphReceipt::L1Msg(_))); - // L1 messages return ZERO for l1_fee + // L1 messages return ZERO for l1_fee regardless of what was passed in assert_eq!(receipt.l1_fee(), U256::ZERO); } @@ -429,8 +431,22 @@ mod tests { }; let receipt = builder.build_receipt(ctx); - assert!(matches!(receipt, MorphReceipt::Morph(_))); 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] @@ -452,8 +468,20 @@ mod tests { let receipt = builder.build_receipt(ctx); // Should still be MorphReceipt::Morph variant, just without token fields - assert!(matches!(receipt, MorphReceipt::Morph(_))); 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] diff --git a/crates/txpool/src/morph_tx_validation.rs b/crates/txpool/src/morph_tx_validation.rs index 3041242..a0d0c1d 100644 --- a/crates/txpool/src/morph_tx_validation.rs +++ b/crates/txpool/src/morph_tx_validation.rs @@ -452,11 +452,8 @@ mod tests { // 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 { .. } | MorphTxError::TokenInfoFetchFailed { .. } - ), - "expected token not found or fetch failed, got {err:?}" + matches!(err, MorphTxError::TokenNotFound { .. }), + "expected TokenNotFound, got {err:?}" ); } } From 4b375bab35507ba31b894c87c7a8d5abe024b19a Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 18 Mar 2026 17:56:43 +0800 Subject: [PATCH 3/3] style: fmt --all --- crates/consensus/src/validation.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/consensus/src/validation.rs b/crates/consensus/src/validation.rs index cee5ea8..fc288d1 100644 --- a/crates/consensus/src/validation.rs +++ b/crates/consensus/src/validation.rs @@ -1513,7 +1513,10 @@ mod tests { }); 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"); + 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()