From dd88fda94ac92053ec8f28df27779b3612546e78 Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 17 Jun 2026 23:10:49 +0800 Subject: [PATCH 01/19] fix(primitives): accept zero-prefixed MorphTx v0 payloads Align legacy MorphTx version routing with geth so zero-prefixed payloads enter the V0 decoder instead of failing as unsupported versions. --- .../src/transaction/morph_transaction.rs | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/primitives/src/transaction/morph_transaction.rs b/crates/primitives/src/transaction/morph_transaction.rs index 49187a3..c975549 100644 --- a/crates/primitives/src/transaction/morph_transaction.rs +++ b/crates/primitives/src/transaction/morph_transaction.rs @@ -384,9 +384,9 @@ impl TxMorph { let first_byte = buf[0]; // Check first byte to determine version: - // - V0 format (legacy AltFeeTx): first byte is RLP list prefix (0xC0-0xFF), no version prefix + // - V0 format (legacy AltFeeTx): first byte is 0 or RLP list prefix (0xC0-0xFF), no version prefix // - V1+ format: first byte is version (0x01, 0x02, ...) followed by RLP - if first_byte >= 0xC0 { + if first_byte == 0 || first_byte >= 0xC0 { // V0 format: direct RLP decode (legacy compatible) Self::decode_fields_v0(buf) } else if first_byte == MORPH_TX_VERSION_1 { @@ -1476,6 +1476,24 @@ mod tests { ); } + /// Issue-1: V0 payload with leading zero byte must be routed to V0 + /// decoding, matching go-ethereum's `decode()` which routes `firstByte == 0` + /// to V0. The resulting error should be about RLP (not "unsupported version"). + #[test] + fn test_decode_fields_accepts_zero_byte_as_v0() { + // A single zero byte is not valid RLP, but the routing should go to V0 + // (not produce "unsupported morph tx version"). + let mut buf: &[u8] = &[0x00]; + let result = TxMorph::decode_fields(&mut buf); + assert!(result.is_err()); + // Must not be the "unsupported version" error. + let err_msg = format!("{}", result.unwrap_err()); + assert!( + !err_msg.contains("unsupported"), + "expected RLP-level error, got: {err_msg}" + ); + } + #[test] fn test_morph_transaction_size() { let tx = TxMorph { From 6a8f84e6f709253ad58200311aea7467e623316e Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 17 Jun 2026 23:13:01 +0800 Subject: [PATCH 02/19] fix(revm): validate token-fee MorphTx fee caps Apply the EIP-1559 fee-cap checks to MorphTx regardless of fee asset so token-fee blocks cannot diverge from geth validation. --- crates/revm/src/handler.rs | 52 +++++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 02a7822..1ccd0cd 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -228,11 +228,10 @@ where // which skips gas-price checks entirely. validation::validate_env::<_, Self::Error>(evm.ctx())?; - // MorphTx V1 ETH-fee: enforce the EIP-1559 priority-fee rule. - // Token-fee MorphTx skips it (fees paid in ERC20); simulation - // paths also skip it. + // MorphTx maps to `TransactionType::Custom`, so revm's standard path + // does not enforce EIP-1559 fee-cap rules. Those rules are independent + // of which asset ultimately pays the fee, matching Morph geth's preCheck. if evm.ctx_ref().tx().is_morph_tx() - && !evm.ctx_ref().tx().uses_token_fee() && !evm.ctx_ref().cfg().is_fee_charge_disabled() { let base_fee = Some(evm.ctx_ref().block().basefee() as u128); @@ -1008,10 +1007,12 @@ fn calculate_caller_fee_with_l1_cost( mod tests { use super::*; use crate::MorphBlockEnv; - use alloy_primitives::{Bytes, address, keccak256}; + use alloy_primitives::{Bytes, TxKind, address, keccak256}; + use morph_primitives::MORPH_TX_TYPE_ID; use morph_chainspec::hardfork::MorphHardfork; use revm::{ - context::BlockEnv, + context::{BlockEnv, TxEnv}, + context_interface::result::InvalidTransaction, database::{CacheDB, EmptyDB}, inspector::NoOpInspector, state::{AccountInfo, Bytecode}, @@ -1037,6 +1038,45 @@ mod tests { ]) } + #[test] + fn validate_env_rejects_token_fee_morph_tx_below_base_fee() { + let mut evm = MorphEvm::new( + MorphContext::new(CacheDB::new(EmptyDB::default()), MorphHardfork::default()), + NoOpInspector, + ); + evm.block = MorphBlockEnv { + inner: BlockEnv { + basefee: 100, + ..Default::default() + }, + }; + evm.tx = MorphTxEnv { + inner: TxEnv { + tx_type: MORPH_TX_TYPE_ID, + gas_limit: 21_000, + gas_price: 99, + gas_priority_fee: Some(1), + kind: TxKind::Call(Address::ZERO), + ..Default::default() + }, + fee_token_id: Some(1), + ..Default::default() + }; + + let err = as Handler>::validate_env( + &MorphEvmHandler::default(), + &mut evm, + ) + .unwrap_err(); + + assert!(matches!( + err, + EVMError::Transaction(MorphInvalidTransaction::EthInvalidTransaction( + InvalidTransaction::GasPriceLessThanBasefee + )) + )); + } + #[test] fn transfer_erc20_with_evm_reverts_state_on_validation_failure() { let from = address!("1000000000000000000000000000000000000001"); From fc728e9245f194ddda43c59dbd48e5e6cc049013 Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 17 Jun 2026 23:14:32 +0800 Subject: [PATCH 03/19] fix(rpc): infer MorphTx version from request fields Build fee-token-only MorphTx requests as V0 while reserving V1 for reference or memo fields, matching geth RPC defaults. --- crates/rpc/src/eth/transaction.rs | 47 ++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/crates/rpc/src/eth/transaction.rs b/crates/rpc/src/eth/transaction.rs index 6727d3f..86f71c8 100644 --- a/crates/rpc/src/eth/transaction.rs +++ b/crates/rpc/src/eth/transaction.rs @@ -3,7 +3,7 @@ use crate::MorphTransactionRequest; use alloy_consensus::{EthereumTxEnvelope, SignableTransaction, TxEip4844}; use alloy_network::TxSigner; -use alloy_primitives::{Signature, TxKind, U64, U256}; +use alloy_primitives::{B256, Bytes, Signature, TxKind, U64, U256}; use alloy_rpc_types_eth::AccessList; use reth_rpc_convert::{SignTxRequestError, SignableTxRequest, TryIntoSimTx, TryIntoTxEnv}; use reth_rpc_eth_types::EthApiError; @@ -15,7 +15,7 @@ use reth_evm::EvmEnv; /// Converts a [`MorphTransactionRequest`] into a simulated transaction envelope. /// /// Handles both standard Ethereum transactions and Morph-specific fee token transactions. -/// All MorphTx transactions are constructed as Version 1. +/// MorphTx version is selected from the Morph-specific fields. impl TryIntoSimTx for MorphTransactionRequest { fn try_into_sim_tx(self) -> Result> { // Try to build a MorphTx; returns None if this should be a standard Ethereum tx @@ -55,7 +55,7 @@ impl TryIntoSimTx for MorphTransactionRequest { /// Builds and signs a transaction from an RPC request. /// /// Supports both standard Ethereum transactions and Morph fee token transactions. -/// All MorphTx transactions are constructed as Version 1. +/// MorphTx version is selected from the Morph-specific fields. impl SignableTxRequest for MorphTransactionRequest { async fn try_build_and_sign( self, @@ -95,7 +95,7 @@ impl SignableTxRequest for MorphTransactionRequest { /// Converts a transaction request into a transaction environment for EVM execution. /// /// Also encodes the transaction for L1 fee calculation. -/// All MorphTx transactions are constructed as Version 1. +/// MorphTx version is selected from the Morph-specific fields. impl TryIntoTxEnv for MorphTransactionRequest { type Err = EthApiError; @@ -130,8 +130,7 @@ impl TryIntoTxEnv for MorphTransactionReq if is_morph_tx { tx_env.inner.tx_type = morph_primitives::MORPH_TX_TYPE_ID; - tx_env.version = - Some(morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1); + tx_env.version = Some(morph_tx_version(reference.as_ref(), memo.as_ref())); } // Required by `MorphEthApi::caller_gas_allowance` (eth/call.rs) to @@ -159,7 +158,7 @@ fn morph_envelope_from_ethereum( /// Attempts to build a [`TxMorph`] from an RPC transaction request. /// -/// Returns `Ok(Some(tx))` if a MorphTx should be constructed (always Version 1), +/// Returns `Ok(Some(tx))` if a MorphTx should be constructed, /// `Ok(None)` if this should be a standard Ethereum transaction, /// or `Err(...)` if there's a validation error. /// @@ -186,8 +185,7 @@ fn try_build_morph_tx_from_request( return Ok(None); } - // All MorphTx are constructed as Version 1 - let version = morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1; + let version = morph_tx_version(reference.as_ref(), memo.as_ref()); // Now build the MorphTx let chain_id = req @@ -225,6 +223,14 @@ fn try_build_morph_tx_from_request( Ok(Some(morph_tx)) } +fn morph_tx_version(reference: Option<&B256>, memo: Option<&Bytes>) -> u8 { + if reference.is_some() || memo.is_some_and(|m| !m.is_empty()) { + morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1 + } else { + morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_0 + } +} + #[cfg(test)] mod tests { use super::*; @@ -407,6 +413,27 @@ mod tests { ); } + #[test] + fn test_fee_token_only_tx_env_uses_morph_tx_version_0() { + let request = MorphTransactionRequest { + inner: create_basic_transaction_request(), + fee_token_id: Some(U64::from(1)), + fee_limit: Some(U256::from(1000000)), + reference: None, + memo: None, + }; + + let evm_env = create_evm_env(false); + let tx_env = request + .try_into_tx_env(&evm_env) + .expect("conversion should succeed"); + + assert_eq!( + tx_env.version, + Some(morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_0) + ); + } + /// MorphTx converted on the `eth_call` path still carries RLP bytes. /// /// Before reth v2.0.0 this test asserted `rlp_bytes.is_none()` on the @@ -604,7 +631,7 @@ mod tests { assert_eq!(tx.fee_limit, U256::from(1_000_000)); assert_eq!( tx.version, - morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1 + morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_0 ); } From 9d3ae51183db046bd5ecf404cd41a0ed97ad2724 Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 17 Jun 2026 23:15:50 +0800 Subject: [PATCH 04/19] fix(rpc): reject gas price on MorphTx requests Return an explicit RPC construction error when legacy gasPrice is mixed with Morph fields instead of silently changing fee semantics. --- crates/rpc/src/eth/transaction.rs | 47 +++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/crates/rpc/src/eth/transaction.rs b/crates/rpc/src/eth/transaction.rs index 86f71c8..99a0051 100644 --- a/crates/rpc/src/eth/transaction.rs +++ b/crates/rpc/src/eth/transaction.rs @@ -185,6 +185,10 @@ fn try_build_morph_tx_from_request( return Ok(None); } + if req.gas_price.is_some() { + return Err("gas_price cannot be used with Morph transaction fields"); + } + let version = morph_tx_version(reference.as_ref(), memo.as_ref()); // Now build the MorphTx @@ -193,7 +197,7 @@ fn try_build_morph_tx_from_request( .ok_or("missing chain_id for morph transaction")?; let gas_limit = req.gas.unwrap_or_default(); let nonce = req.nonce.unwrap_or_default(); - let max_fee_per_gas = req.max_fee_per_gas.or(req.gas_price).unwrap_or_default(); + let max_fee_per_gas = req.max_fee_per_gas.unwrap_or_default(); let max_priority_fee_per_gas = req.max_priority_fee_per_gas.unwrap_or_default(); let access_list: AccessList = req.access_list.clone().unwrap_or_default(); let input = req.input.clone().into_input().unwrap_or_default(); @@ -255,6 +259,15 @@ mod tests { } } + fn create_morph_transaction_request() -> TransactionRequest { + TransactionRequest { + gas_price: None, + max_fee_per_gas: Some(1_000_000_000), + max_priority_fee_per_gas: Some(100_000_000), + ..create_basic_transaction_request() + } + } + /// Helper function to create a basic EvmEnv for testing fn create_evm_env(disable_fee_charge: bool) -> EvmEnv { let mut cfg = CfgEnv::::default(); @@ -359,7 +372,7 @@ mod tests { let memo = Bytes::from("test memo"); let request = MorphTransactionRequest { - inner: create_basic_transaction_request(), + inner: create_morph_transaction_request(), fee_token_id: Some(U64::from(1)), // Triggers MorphTx (use U64, not U256) fee_limit: Some(U256::from(1000000)), reference: Some(reference), @@ -416,7 +429,7 @@ mod tests { #[test] fn test_fee_token_only_tx_env_uses_morph_tx_version_0() { let request = MorphTransactionRequest { - inner: create_basic_transaction_request(), + inner: create_morph_transaction_request(), fee_token_id: Some(U64::from(1)), fee_limit: Some(U256::from(1000000)), reference: None, @@ -447,7 +460,7 @@ mod tests { #[test] fn test_eth_call_with_morph_tx_keeps_rlp_and_morph_fields() { let request = MorphTransactionRequest { - inner: create_basic_transaction_request(), + inner: create_morph_transaction_request(), fee_token_id: Some(U64::from(1)), fee_limit: Some(U256::from(1000000)), reference: Some(B256::random()), @@ -622,7 +635,7 @@ mod tests { #[test] fn try_build_morph_tx_with_fee_token_id() { - let req = create_basic_transaction_request(); + let req = create_morph_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()); @@ -636,8 +649,20 @@ mod tests { } #[test] - fn try_build_morph_tx_with_reference_only() { + fn try_build_morph_tx_rejects_gas_price_with_morph_fields() { 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_eq!( + result.unwrap_err(), + "gas_price cannot be used with Morph transaction fields" + ); + } + + #[test] + fn try_build_morph_tx_with_reference_only() { + let req = create_morph_transaction_request(); let reference = B256::random(); let result = try_build_morph_tx_from_request(&req, U64::ZERO, U256::ZERO, Some(reference), None); @@ -649,7 +674,7 @@ mod tests { #[test] fn try_build_morph_tx_with_memo_only() { - let req = create_basic_transaction_request(); + let req = create_morph_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())); @@ -660,7 +685,7 @@ mod tests { #[test] fn try_build_morph_tx_empty_memo_is_not_trigger() { - let req = create_basic_transaction_request(); + let req = create_morph_transaction_request(); let result = try_build_morph_tx_from_request(&req, U64::ZERO, U256::ZERO, None, Some(Bytes::new())); assert!(result.is_ok()); @@ -670,7 +695,7 @@ mod tests { #[test] fn try_build_morph_tx_requires_chain_id() { - let mut req = create_basic_transaction_request(); + let mut req = create_morph_transaction_request(); req.chain_id = None; let result = try_build_morph_tx_from_request(&req, U64::from(1), U256::from(100), None, None); @@ -680,7 +705,7 @@ mod tests { #[test] fn try_build_morph_tx_sets_correct_tx_fields() { - let req = create_basic_transaction_request(); + let req = create_morph_transaction_request(); let result = try_build_morph_tx_from_request( &req, U64::from(2), @@ -692,7 +717,7 @@ mod tests { 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.max_fee_per_gas, 1_000_000_000); assert_eq!(tx.value, U256::from(1000)); } From b106d610c1fffa446e382bd0ce58adfff34628e1 Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 17 Jun 2026 23:17:21 +0800 Subject: [PATCH 05/19] fix(rpc): treat zero MorphTx reference as absent Allow V0 MorphTx requests with an all-zero reference so RPC version inference and validation match geth normalization. --- .../src/transaction/morph_transaction.rs | 16 ++++++++++-- crates/rpc/src/eth/transaction.rs | 25 ++++++++++++++++--- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/crates/primitives/src/transaction/morph_transaction.rs b/crates/primitives/src/transaction/morph_transaction.rs index c975549..b92511e 100644 --- a/crates/primitives/src/transaction/morph_transaction.rs +++ b/crates/primitives/src/transaction/morph_transaction.rs @@ -239,8 +239,9 @@ impl TxMorph { if self.fee_token_id == 0 { return Err("version 0 MorphTx requires FeeTokenID > 0"); } - // Version 0 does not support Reference field - if self.reference.is_some() { + // Version 0 treats an all-zero Reference as absent, matching geth's + // RPC normalization for backward-compatible V0 transactions. + if self.reference.is_some_and(|reference| reference != B256::ZERO) { return Err("version 0 MorphTx does not support Reference field"); } // Version 0 does not support Memo field @@ -1145,6 +1146,17 @@ mod tests { "version 0 MorphTx does not support Reference field" ); + // Valid: V0 with a zero Reference is treated as absent for geth compatibility. + let v0_with_zero_ref = TxMorph { + max_fee_per_gas: 100, + max_priority_fee_per_gas: 50, + version: MORPH_TX_VERSION_0, + fee_token_id: 1, + reference: Some(B256::ZERO), + ..Default::default() + }; + assert!(v0_with_zero_ref.validate().is_ok()); + // Invalid: V0 with Memo let v0_with_memo = TxMorph { max_fee_per_gas: 100, diff --git a/crates/rpc/src/eth/transaction.rs b/crates/rpc/src/eth/transaction.rs index 99a0051..e0d5303 100644 --- a/crates/rpc/src/eth/transaction.rs +++ b/crates/rpc/src/eth/transaction.rs @@ -125,7 +125,7 @@ impl TryIntoTxEnv for MorphTransactionReq // Determine if this is a MorphTx based on Morph-specific fields let is_morph_tx = fee_token_id.is_some_and(|id| id.to::() > 0) - || reference.is_some() + || is_nonzero_reference(reference.as_ref()) || memo.as_ref().is_some_and(|m| !m.is_empty()); if is_morph_tx { @@ -177,7 +177,7 @@ fn try_build_morph_tx_from_request( // Check if this should be a MorphTx let has_fee_token = fee_token_id_u16 > 0; - let has_reference = reference.is_some(); + let has_reference = is_nonzero_reference(reference.as_ref()); let has_memo = memo.as_ref().is_some_and(|m| !m.is_empty()); if !has_fee_token && !has_reference && !has_memo { @@ -228,13 +228,17 @@ fn try_build_morph_tx_from_request( } fn morph_tx_version(reference: Option<&B256>, memo: Option<&Bytes>) -> u8 { - if reference.is_some() || memo.is_some_and(|m| !m.is_empty()) { + if is_nonzero_reference(reference) || memo.is_some_and(|m| !m.is_empty()) { morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1 } else { morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_0 } } +fn is_nonzero_reference(reference: Option<&B256>) -> bool { + reference.is_some_and(|reference| *reference != B256::ZERO) +} + #[cfg(test)] mod tests { use super::*; @@ -672,6 +676,21 @@ mod tests { assert_eq!(tx.fee_token_id, 0); } + #[test] + fn try_build_morph_tx_treats_zero_reference_as_absent() { + let req = create_morph_transaction_request(); + let result = + try_build_morph_tx_from_request(&req, U64::from(1), U256::ZERO, Some(B256::ZERO), None); + + assert!(result.is_ok()); + let tx = result.unwrap().unwrap(); + assert_eq!( + tx.version, + morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_0 + ); + assert_eq!(tx.reference, Some(B256::ZERO)); + } + #[test] fn try_build_morph_tx_with_memo_only() { let req = create_morph_transaction_request(); From 8468a12fdfbac7af67b39c175332d0e1e34aee75 Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 17 Jun 2026 23:20:01 +0800 Subject: [PATCH 06/19] fix(txpool): drop MorphTx above block gas limit Revalidate pooled MorphTx gas limits against the latest block gas limit so stale over-limit transactions are removed with their descendants. --- crates/txpool/src/maintain.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/txpool/src/maintain.rs b/crates/txpool/src/maintain.rs index 545e517..808f248 100644 --- a/crates/txpool/src/maintain.rs +++ b/crates/txpool/src/maintain.rs @@ -104,6 +104,10 @@ fn consume_token_budget( true } +fn exceeds_block_gas_limit(tx_gas_limit: u64, block_gas_limit: u64) -> bool { + tx_gas_limit > block_gas_limit +} + /// Maintains the Morph transaction pool by revalidating MorphTx transactions. /// /// This task runs continuously and: @@ -134,6 +138,7 @@ where let new_tip = event.tip(); let block_number = new_tip.number(); let block_timestamp = new_tip.timestamp(); + let block_gas_limit = new_tip.gas_limit(); tracing::trace!( target: "morph::txpool::maintain", @@ -232,6 +237,19 @@ where // cloning. Use the pool tx's cached EIP-2718 encoding for L1 fee. let consensus_tx = tx.transaction(); + if exceeds_block_gas_limit(consensus_tx.gas_limit(), block_gas_limit) { + tracing::debug!( + target: "morph::txpool::maintain", + tx_hash = ?tx.hash(), + ?sender, + tx_gas_limit = consensus_tx.gas_limit(), + block_gas_limit, + "Removing MorphTx: gas limit exceeds current block gas limit" + ); + to_remove.push(*tx.hash()); + break; + } + let l1_data_fee = l1_block_info.calculate_tx_l1_cost(tx.encoded_2718(), hardfork); // Use shared validation logic first with current sender ETH budget. @@ -453,4 +471,10 @@ mod tests { Some(U256::from(30u64)) ); } + + #[test] + fn gas_limit_check_rejects_transactions_above_block_limit() { + assert!(exceeds_block_gas_limit(30_000_001, 30_000_000)); + assert!(!exceeds_block_gas_limit(30_000_000, 30_000_000)); + } } From cb19321e5500b924aae66150539f26ced2ed2deb Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 17 Jun 2026 23:21:43 +0800 Subject: [PATCH 07/19] fix(txpool): require max-fee token budget Use max_fee_per_gas for token-fee MorphTx pool admission so mempool validation matches geth's conservative affordability check. --- crates/txpool/src/morph_tx_validation.rs | 5 +++-- crates/txpool/src/validator.rs | 22 ++++++++++++++-------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/crates/txpool/src/morph_tx_validation.rs b/crates/txpool/src/morph_tx_validation.rs index 3e05b15..5a9e139 100644 --- a/crates/txpool/src/morph_tx_validation.rs +++ b/crates/txpool/src/morph_tx_validation.rs @@ -89,7 +89,6 @@ pub fn validate_morph_tx( // Shared fee components used by both ETH-fee and token-fee branches. let gas_limit = U256::from(morph_tx.gas_limit); let max_fee_per_gas = U256::from(morph_tx.max_fee_per_gas); - let effective_gas_price = U256::from(morph_tx.effective_gas_price(input.base_fee_per_gas)); let gas_fee = gas_limit.saturating_mul(max_fee_per_gas); let total_eth_fee = gas_fee.saturating_add(input.l1_data_fee); let total_eth_cost = total_eth_fee.saturating_add(tx_value); @@ -133,7 +132,9 @@ pub fn validate_morph_tx( }); } - let token_gas_fee = gas_limit.saturating_mul(effective_gas_price); + // Txpool admission follows geth's conservative budget check and requires + // enough tokens for the max fee cap. Execution still charges the effective price. + let token_gas_fee = gas_fee; let total_token_fee = token_gas_fee.saturating_add(input.l1_data_fee); let required_token_amount = token_info.eth_to_token_amount(total_token_fee); diff --git a/crates/txpool/src/validator.rs b/crates/txpool/src/validator.rs index e76aedb..442a729 100644 --- a/crates/txpool/src/validator.rs +++ b/crates/txpool/src/validator.rs @@ -885,7 +885,7 @@ mod tests { } #[test] - fn validate_morph_tx_uses_effective_gas_price_for_token_fee_path() { + fn validate_morph_tx_uses_max_fee_for_token_fee_admission() { let client = new_mock_provider(); let signer = address!("0000000000000000000000000000000000000001"); let token = address!("5300000000000000000000000000000000000042"); @@ -915,9 +915,9 @@ mod tests { .disable_balance_check() .build::(InMemoryBlobStore::default()); let validator = MorphTransactionValidator::new(eth_validator); - // Simulate an active chain head with base_fee_per_gas = 10 so the - // effective gas price path is taken (min(max_fee, base_fee + priority) - // = min(100, 11) = 11), yielding required_token_amount = 21_000 * 11. + // Simulate an active chain head with base_fee_per_gas = 10. Execution + // would use effective gas price = min(100, 10 + 1) = 11, but txpool + // admission follows geth's conservative max_fee_per_gas budget. validator .block_info .update(L1BlockInfo::default(), 0, 0, Some(10)); @@ -944,7 +944,7 @@ mod tests { B256::ZERO, )); let recovered = Recovered::new_unchecked(envelope, signer); - let validation = validator + let err = validator .validate_morph_tx_balance( &recovered, signer, @@ -952,9 +952,15 @@ mod tests { U256::ZERO, morph_chainspec::hardfork::MorphHardfork::Viridian, ) - .expect("MorphTx should be affordable when priced with the effective gas price"); + .expect_err("MorphTx should require the max-fee token budget in txpool admission"); - assert!(validation.uses_token_fee); - assert_eq!(validation.required_token_amount, U256::from(231_000u64)); + assert!(matches!( + err, + crate::MorphTxError::InsufficientTokenBalance { + required, + balance, + .. + } if required == U256::from(2_100_000u64) && balance == U256::from(300_000u64) + )); } } From 74ca9f1bcf494ba622eecb2526acc162c819bace Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 17 Jun 2026 23:23:43 +0800 Subject: [PATCH 08/19] fix(rpc): default MorphTx env chain id Fill missing MorphTx request chain IDs from the EVM environment on tx-env conversion so simulation paths do not depend on implicit alloy defaults. --- crates/rpc/src/eth/transaction.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/rpc/src/eth/transaction.rs b/crates/rpc/src/eth/transaction.rs index e0d5303..559d566 100644 --- a/crates/rpc/src/eth/transaction.rs +++ b/crates/rpc/src/eth/transaction.rs @@ -107,7 +107,10 @@ impl TryIntoTxEnv for MorphTransactionReq let fee_limit = self.fee_limit; let reference = self.reference; let memo = self.memo; - let inner = self.inner; + let mut inner = self.inner; + if inner.chain_id.is_none() { + inner.chain_id = Some(evm_env.cfg_env.chain_id); + } let inner_tx_env = inner.try_into_tx_env(evm_env).map_err(EthApiError::from)?; @@ -451,6 +454,26 @@ mod tests { ); } + #[test] + fn test_morph_tx_env_defaults_missing_chain_id_from_evm_env() { + let mut inner = create_morph_transaction_request(); + inner.chain_id = None; + let request = MorphTransactionRequest { + inner, + fee_token_id: Some(U64::from(1)), + fee_limit: Some(U256::from(1000000)), + reference: None, + memo: None, + }; + + let evm_env = create_evm_env(false); + let tx_env = request + .try_into_tx_env(&evm_env) + .expect("conversion should default missing chain_id from EVM env"); + + assert_eq!(tx_env.inner.chain_id, Some(2818)); + } + /// MorphTx converted on the `eth_call` path still carries RLP bytes. /// /// Before reth v2.0.0 this test asserted `rlp_bytes.is_none()` on the From 921dbba42054075f80d3ee31be3b3dce8f597eda Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 17 Jun 2026 23:25:10 +0800 Subject: [PATCH 09/19] fix(rpc): validate MorphTx request input fields Reject conflicting data/input fields and empty contract creation in MorphTx RPC construction to match geth request validation. --- crates/rpc/src/eth/transaction.rs | 37 +++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/rpc/src/eth/transaction.rs b/crates/rpc/src/eth/transaction.rs index 559d566..ddaf361 100644 --- a/crates/rpc/src/eth/transaction.rs +++ b/crates/rpc/src/eth/transaction.rs @@ -203,8 +203,16 @@ fn try_build_morph_tx_from_request( let max_fee_per_gas = req.max_fee_per_gas.unwrap_or_default(); let max_priority_fee_per_gas = req.max_priority_fee_per_gas.unwrap_or_default(); let access_list: AccessList = req.access_list.clone().unwrap_or_default(); - let input = req.input.clone().into_input().unwrap_or_default(); + let input = req + .input + .clone() + .try_into_unique_input() + .map_err(|_| "data and input fields must match")? + .unwrap_or_default(); let to = req.to.unwrap_or(TxKind::Create); + if matches!(to, TxKind::Create) && input.is_empty() { + return Err("contract creation requires initcode"); + } let morph_tx = TxMorph { chain_id, @@ -247,7 +255,7 @@ mod tests { use super::*; use crate::types::transaction::MorphRpcTransaction; use alloy_primitives::{Address, B256, Bytes, address}; - use alloy_rpc_types_eth::{TransactionInfo, TransactionRequest}; + use alloy_rpc_types_eth::{TransactionInfo, TransactionInput, TransactionRequest}; use morph_chainspec::MorphHardfork; use reth_rpc_convert::FromConsensusTx; use revm::context::{BlockEnv, CfgEnv}; @@ -745,6 +753,31 @@ mod tests { assert!(result.unwrap_err().contains("chain_id")); } + #[test] + fn try_build_morph_tx_rejects_conflicting_data_and_input() { + let mut req = create_morph_transaction_request(); + req.input = TransactionInput { + input: Some(Bytes::from_static(&[0x01])), + data: Some(Bytes::from_static(&[0x02])), + }; + + let result = + try_build_morph_tx_from_request(&req, U64::from(1), U256::from(100), None, None); + + assert_eq!(result.unwrap_err(), "data and input fields must match"); + } + + #[test] + fn try_build_morph_tx_rejects_empty_contract_creation() { + let mut req = create_morph_transaction_request(); + req.to = None; + + let result = + try_build_morph_tx_from_request(&req, U64::from(1), U256::from(100), None, None); + + assert_eq!(result.unwrap_err(), "contract creation requires initcode"); + } + #[test] fn try_build_morph_tx_sets_correct_tx_fields() { let req = create_morph_transaction_request(); From 2abb4eeea049ea7a83befd80c66a4da308e1575d Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 17 Jun 2026 23:26:07 +0800 Subject: [PATCH 10/19] docs(revm): fix Bernoulli precompile reference link Point the Bernoulli precompile comment at the matching geth contract map instead of the Morph203 section. --- crates/revm/src/precompiles.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/revm/src/precompiles.rs b/crates/revm/src/precompiles.rs index 2ef2b8e..02d0cf3 100644 --- a/crates/revm/src/precompiles.rs +++ b/crates/revm/src/precompiles.rs @@ -252,7 +252,7 @@ fn bn256_pairing_with_4pair_limit( /// All 9 Berlin addresses are present (so they get warmed via EIP-2929), but 0x03/0x09 /// consume all forwarded gas and return failure when called. /// -/// Matches: +/// Matches: pub fn bernoulli() -> &'static Precompiles { static INSTANCE: OnceLock = OnceLock::new(); INSTANCE.get_or_init(|| { From 0b761eb01d6a6bd796141c66a96509c51d25a451 Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 17 Jun 2026 23:28:19 +0800 Subject: [PATCH 11/19] fix(rpc): validate MorphTx simulation fields Run the same MorphTx structural validation on tx-env conversion so eth_call and estimate paths reject requests that real construction would reject. --- crates/rpc/src/eth/transaction.rs | 44 +++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/crates/rpc/src/eth/transaction.rs b/crates/rpc/src/eth/transaction.rs index ddaf361..1718f03 100644 --- a/crates/rpc/src/eth/transaction.rs +++ b/crates/rpc/src/eth/transaction.rs @@ -134,6 +134,7 @@ impl TryIntoTxEnv for MorphTransactionReq if is_morph_tx { tx_env.inner.tx_type = morph_primitives::MORPH_TX_TYPE_ID; tx_env.version = Some(morph_tx_version(reference.as_ref(), memo.as_ref())); + validate_morph_tx_env(&tx_env, evm_env.cfg_env.chain_id)?; } // Required by `MorphEthApi::caller_gas_allowance` (eth/call.rs) to @@ -250,6 +251,31 @@ fn is_nonzero_reference(reference: Option<&B256>) -> bool { reference.is_some_and(|reference| *reference != B256::ZERO) } +fn validate_morph_tx_env(tx_env: &MorphTxEnv, fallback_chain_id: u64) -> Result<(), EthApiError> { + let morph_tx = TxMorph { + chain_id: tx_env.inner.chain_id.unwrap_or(fallback_chain_id), + nonce: tx_env.inner.nonce, + gas_limit: tx_env.inner.gas_limit, + max_fee_per_gas: tx_env.inner.gas_price, + max_priority_fee_per_gas: tx_env.inner.gas_priority_fee.unwrap_or_default(), + to: tx_env.inner.kind, + value: tx_env.inner.value, + access_list: tx_env.inner.access_list.clone(), + input: tx_env.inner.data.clone(), + fee_token_id: tx_env.fee_token_id.unwrap_or_default(), + fee_limit: tx_env.fee_limit.unwrap_or_default(), + version: tx_env + .version + .unwrap_or(morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1), + reference: tx_env.reference, + memo: tx_env.memo.clone(), + }; + + morph_tx + .validate() + .map_err(|reason| EthApiError::InvalidParams(reason.to_string())) +} + #[cfg(test)] mod tests { use super::*; @@ -482,6 +508,24 @@ mod tests { assert_eq!(tx_env.inner.chain_id, Some(2818)); } + #[test] + fn test_morph_tx_env_rejects_invalid_morph_fields() { + let request = MorphTransactionRequest { + inner: create_morph_transaction_request(), + fee_token_id: None, + fee_limit: Some(U256::from(1)), + reference: None, + memo: Some(Bytes::from_static(b"memo")), + }; + + let evm_env = create_evm_env(false); + let err = request + .try_into_tx_env(&evm_env) + .expect_err("tx-env conversion should validate MorphTx structural fields"); + + assert!(err.to_string().contains("FeeLimit")); + } + /// MorphTx converted on the `eth_call` path still carries RLP bytes. /// /// Before reth v2.0.0 this test asserted `rlp_bytes.is_none()` on the From dd98bc93e0f1bfb7f147fc69c8f0ab6c996fd25f Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 17 Jun 2026 23:29:05 +0800 Subject: [PATCH 12/19] fix(revm): halt on storage original read errors Treat failed committed-storage reads during Morph SLOAD/SSTORE original-value restoration as fatal instead of continuing with uncertain gas accounting state. --- crates/revm/src/evm.rs | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index d8ba55b..003c06c 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -111,9 +111,14 @@ fn sload_morph(context: InstructionContext<'_, MorphContext, E if storage.is_cold { // Read the true committed value from DB (hits State cache, O(1)). // This matches go-eth's GetCommittedState() returning the un-modified DB value. - let db_original = context.host.journaled_state.database.storage(target, key); - if let Ok(db_original) = db_original - && let Some(acc) = context.host.journaled_state.inner.state.get_mut(&target) + let db_original = match context.host.journaled_state.database.storage(target, key) { + Ok(value) => value, + Err(_) => { + context.interpreter.halt_fatal(); + return; + } + }; + if let Some(acc) = context.host.journaled_state.inner.state.get_mut(&target) && let Some(slot) = acc.storage.get_mut(&key) && slot.original_value != db_original { @@ -212,10 +217,14 @@ fn sstore_morph(context: InstructionContext<'_, MorphContext, // Morph fix: on cold access, restore original_value from the DB-committed value. // Mirrors sload_morph. Only fires on cold path; zero overhead on warm SSTOREs. if state_load.is_cold { - let db_original = context.host.journaled_state.database.storage(target, index); - if let Ok(db_original) = db_original - && state_load.data.original_value != db_original - { + let db_original = match context.host.journaled_state.database.storage(target, index) { + Ok(value) => value, + Err(_) => { + context.interpreter.halt_fatal(); + return; + } + }; + if state_load.data.original_value != db_original { state_load.data.original_value = db_original; if let Some(acc) = context.host.journaled_state.inner.state.get_mut(&target) && let Some(slot) = acc.storage.get_mut(&index) From b7ff09d4973e1cea1c1a5f0a447fc50ecfc17f26 Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 17 Jun 2026 23:29:22 +0800 Subject: [PATCH 13/19] chore: format audit fix code Apply rustfmt to earlier audit fixes so the branch remains format-clean. --- .../src/transaction/morph_transaction.rs | 5 ++++- crates/revm/src/handler.rs | 14 +++++--------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/crates/primitives/src/transaction/morph_transaction.rs b/crates/primitives/src/transaction/morph_transaction.rs index b92511e..00d4c84 100644 --- a/crates/primitives/src/transaction/morph_transaction.rs +++ b/crates/primitives/src/transaction/morph_transaction.rs @@ -241,7 +241,10 @@ impl TxMorph { } // Version 0 treats an all-zero Reference as absent, matching geth's // RPC normalization for backward-compatible V0 transactions. - if self.reference.is_some_and(|reference| reference != B256::ZERO) { + if self + .reference + .is_some_and(|reference| reference != B256::ZERO) + { return Err("version 0 MorphTx does not support Reference field"); } // Version 0 does not support Memo field diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 1ccd0cd..9467f8b 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -231,9 +231,7 @@ where // MorphTx maps to `TransactionType::Custom`, so revm's standard path // does not enforce EIP-1559 fee-cap rules. Those rules are independent // of which asset ultimately pays the fee, matching Morph geth's preCheck. - if evm.ctx_ref().tx().is_morph_tx() - && !evm.ctx_ref().cfg().is_fee_charge_disabled() - { + if evm.ctx_ref().tx().is_morph_tx() && !evm.ctx_ref().cfg().is_fee_charge_disabled() { let base_fee = Some(evm.ctx_ref().block().basefee() as u128); validation::validate_priority_fee_tx( evm.ctx_ref().tx().max_fee_per_gas(), @@ -1008,8 +1006,8 @@ mod tests { use super::*; use crate::MorphBlockEnv; use alloy_primitives::{Bytes, TxKind, address, keccak256}; - use morph_primitives::MORPH_TX_TYPE_ID; use morph_chainspec::hardfork::MorphHardfork; + use morph_primitives::MORPH_TX_TYPE_ID; use revm::{ context::{BlockEnv, TxEnv}, context_interface::result::InvalidTransaction, @@ -1063,11 +1061,9 @@ mod tests { ..Default::default() }; - let err = as Handler>::validate_env( - &MorphEvmHandler::default(), - &mut evm, - ) - .unwrap_err(); + let err = + as Handler>::validate_env(&MorphEvmHandler::default(), &mut evm) + .unwrap_err(); assert!(matches!( err, From 70016f1625a736b39019ac6e5080a880574a88b8 Mon Sep 17 00:00:00 2001 From: panos Date: Thu, 18 Jun 2026 08:39:18 +0800 Subject: [PATCH 14/19] fix(engine-api): read canonical head atomically Use reth's latest sealed header lookup so canonical head number, hash, and timestamp come from the same provider snapshot. --- crates/engine-api/src/builder.rs | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/crates/engine-api/src/builder.rs b/crates/engine-api/src/builder.rs index 5a4603b..390e5a1 100644 --- a/crates/engine-api/src/builder.rs +++ b/crates/engine-api/src/builder.rs @@ -22,7 +22,7 @@ use reth_payload_builder::{BuildNewPayload, PayloadBuilderHandle}; #[cfg(test)] use reth_primitives_traits::RecoveredBlock; use reth_primitives_traits::{FastInstant as Instant, SealedBlock, SealedHeader}; -use reth_provider::{BlockNumReader, CanonChainTracker, HeaderProvider}; +use reth_provider::{BlockNumReader, BlockReaderIdExt, CanonChainTracker, HeaderProvider}; use std::sync::Arc; // ============================================================================= @@ -140,6 +140,7 @@ impl MorphL2EngineApi for RealMorphL2EngineApi where Provider: HeaderProvider
+ BlockNumReader + + BlockReaderIdExt
+ CanonChainTracker
+ Clone + Send @@ -644,8 +645,13 @@ impl RealMorphL2EngineApi { parent_override: Option, ) -> EngineApiResult where - Provider: - HeaderProvider
+ BlockNumReader + Clone + Send + Sync + 'static, + Provider: HeaderProvider
+ + BlockNumReader + + BlockReaderIdExt
+ + Clone + + Send + + Sync + + 'static, { tracing::debug!( target: "morph::engine", @@ -926,26 +932,19 @@ impl RealMorphL2EngineApi { fn current_head(&self) -> EngineApiResult where - Provider: HeaderProvider + BlockNumReader, + Provider: BlockReaderIdExt
, { - let info = self - .provider - .chain_info() - .map_err(|e| MorphEngineApiError::Database(e.to_string()))?; let header = self .provider - .sealed_header_by_hash(info.best_hash) + .latest_header() .map_err(|e| MorphEngineApiError::Database(e.to_string()))? .ok_or_else(|| { - MorphEngineApiError::Internal(format!( - "canonical head header {} ({}) not found", - info.best_number, info.best_hash - )) + MorphEngineApiError::Internal("canonical head header not found".to_string()) })?; Ok(CanonicalHead { - number: info.best_number, - hash: info.best_hash, + number: header.number(), + hash: header.hash(), timestamp: header.timestamp(), }) } From 78fe01fb36f7590d27a6af22bc8481f24ed8a48f Mon Sep 17 00:00:00 2001 From: panos Date: Thu, 18 Jun 2026 08:43:34 +0800 Subject: [PATCH 15/19] Revert "fix(revm): halt on storage original read errors" This reverts commit dd98bc93e0f1bfb7f147fc69c8f0ab6c996fd25f. --- crates/revm/src/evm.rs | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index 003c06c..d8ba55b 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -111,14 +111,9 @@ fn sload_morph(context: InstructionContext<'_, MorphContext, E if storage.is_cold { // Read the true committed value from DB (hits State cache, O(1)). // This matches go-eth's GetCommittedState() returning the un-modified DB value. - let db_original = match context.host.journaled_state.database.storage(target, key) { - Ok(value) => value, - Err(_) => { - context.interpreter.halt_fatal(); - return; - } - }; - if let Some(acc) = context.host.journaled_state.inner.state.get_mut(&target) + let db_original = context.host.journaled_state.database.storage(target, key); + if let Ok(db_original) = db_original + && let Some(acc) = context.host.journaled_state.inner.state.get_mut(&target) && let Some(slot) = acc.storage.get_mut(&key) && slot.original_value != db_original { @@ -217,14 +212,10 @@ fn sstore_morph(context: InstructionContext<'_, MorphContext, // Morph fix: on cold access, restore original_value from the DB-committed value. // Mirrors sload_morph. Only fires on cold path; zero overhead on warm SSTOREs. if state_load.is_cold { - let db_original = match context.host.journaled_state.database.storage(target, index) { - Ok(value) => value, - Err(_) => { - context.interpreter.halt_fatal(); - return; - } - }; - if state_load.data.original_value != db_original { + let db_original = context.host.journaled_state.database.storage(target, index); + if let Ok(db_original) = db_original + && state_load.data.original_value != db_original + { state_load.data.original_value = db_original; if let Some(acc) = context.host.journaled_state.inner.state.get_mut(&target) && let Some(slot) = acc.storage.get_mut(&index) From c85bb0267faebdffd20b8eb9ff735b5cdd1609f2 Mon Sep 17 00:00:00 2001 From: panos Date: Thu, 18 Jun 2026 08:43:34 +0800 Subject: [PATCH 16/19] Revert "fix(rpc): validate MorphTx simulation fields" This reverts commit 0b761eb01d6a6bd796141c66a96509c51d25a451. --- crates/rpc/src/eth/transaction.rs | 44 ------------------------------- 1 file changed, 44 deletions(-) diff --git a/crates/rpc/src/eth/transaction.rs b/crates/rpc/src/eth/transaction.rs index 1718f03..ddaf361 100644 --- a/crates/rpc/src/eth/transaction.rs +++ b/crates/rpc/src/eth/transaction.rs @@ -134,7 +134,6 @@ impl TryIntoTxEnv for MorphTransactionReq if is_morph_tx { tx_env.inner.tx_type = morph_primitives::MORPH_TX_TYPE_ID; tx_env.version = Some(morph_tx_version(reference.as_ref(), memo.as_ref())); - validate_morph_tx_env(&tx_env, evm_env.cfg_env.chain_id)?; } // Required by `MorphEthApi::caller_gas_allowance` (eth/call.rs) to @@ -251,31 +250,6 @@ fn is_nonzero_reference(reference: Option<&B256>) -> bool { reference.is_some_and(|reference| *reference != B256::ZERO) } -fn validate_morph_tx_env(tx_env: &MorphTxEnv, fallback_chain_id: u64) -> Result<(), EthApiError> { - let morph_tx = TxMorph { - chain_id: tx_env.inner.chain_id.unwrap_or(fallback_chain_id), - nonce: tx_env.inner.nonce, - gas_limit: tx_env.inner.gas_limit, - max_fee_per_gas: tx_env.inner.gas_price, - max_priority_fee_per_gas: tx_env.inner.gas_priority_fee.unwrap_or_default(), - to: tx_env.inner.kind, - value: tx_env.inner.value, - access_list: tx_env.inner.access_list.clone(), - input: tx_env.inner.data.clone(), - fee_token_id: tx_env.fee_token_id.unwrap_or_default(), - fee_limit: tx_env.fee_limit.unwrap_or_default(), - version: tx_env - .version - .unwrap_or(morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1), - reference: tx_env.reference, - memo: tx_env.memo.clone(), - }; - - morph_tx - .validate() - .map_err(|reason| EthApiError::InvalidParams(reason.to_string())) -} - #[cfg(test)] mod tests { use super::*; @@ -508,24 +482,6 @@ mod tests { assert_eq!(tx_env.inner.chain_id, Some(2818)); } - #[test] - fn test_morph_tx_env_rejects_invalid_morph_fields() { - let request = MorphTransactionRequest { - inner: create_morph_transaction_request(), - fee_token_id: None, - fee_limit: Some(U256::from(1)), - reference: None, - memo: Some(Bytes::from_static(b"memo")), - }; - - let evm_env = create_evm_env(false); - let err = request - .try_into_tx_env(&evm_env) - .expect_err("tx-env conversion should validate MorphTx structural fields"); - - assert!(err.to_string().contains("FeeLimit")); - } - /// MorphTx converted on the `eth_call` path still carries RLP bytes. /// /// Before reth v2.0.0 this test asserted `rlp_bytes.is_none()` on the From 83bc9233144059cf73c38921a1f55f10c2318d1d Mon Sep 17 00:00:00 2001 From: panos Date: Thu, 18 Jun 2026 11:06:50 +0800 Subject: [PATCH 17/19] fix: address audit PR review comments Align MorphTx simulation validation with signing, canonicalize zero references, extend zero-byte V0 decode routing, and remove unused txpool validation input. --- .../src/transaction/morph_transaction.rs | 34 ++++++++++++-- crates/rpc/src/eth/transaction.rs | 46 ++++++++++++++++--- crates/txpool/src/maintain.rs | 1 - crates/txpool/src/morph_tx_validation.rs | 9 ---- crates/txpool/src/validator.rs | 1 - 5 files changed, 69 insertions(+), 22 deletions(-) diff --git a/crates/primitives/src/transaction/morph_transaction.rs b/crates/primitives/src/transaction/morph_transaction.rs index 00d4c84..e60943d 100644 --- a/crates/primitives/src/transaction/morph_transaction.rs +++ b/crates/primitives/src/transaction/morph_transaction.rs @@ -756,11 +756,11 @@ impl RlpEcdsaDecodableTx for TxMorph { // Detect version: // - V1: first byte is version byte (0x01), skip it - // - V0: first byte is RLP list prefix (>= 0xC0), no version prefix + // - V0: first byte is 0 or RLP list prefix (>= 0xC0), no version prefix let version = if first_byte == MORPH_TX_VERSION_1 { *buf = &buf[1..]; // skip version byte MORPH_TX_VERSION_1 - } else if first_byte >= 0xC0 { + } else if first_byte == MORPH_TX_VERSION_0 || first_byte >= 0xC0 { MORPH_TX_VERSION_0 } else { return Err(alloy_rlp::Error::Custom("unsupported morph tx version")); @@ -856,11 +856,11 @@ impl Decodable for TxMorph { if first_byte == MORPH_TX_VERSION_1 { // V1: skip version byte, then decode RLP *buf = &buf[1..]; - } else if first_byte < 0xC0 { + } else if first_byte != MORPH_TX_VERSION_0 && first_byte < 0xC0 { // Invalid: not a version we support and not an RLP list return Err(alloy_rlp::Error::Custom("unsupported morph tx version")); } - // V0: first_byte is RLP list prefix (>= 0xC0) + // V0: first_byte is 0 or RLP list prefix (>= 0xC0) let header = Header::decode(buf)?; if !header.list { @@ -1509,6 +1509,32 @@ mod tests { ); } + #[test] + fn test_signed_decode_accepts_zero_byte_as_v0() { + let mut buf: &[u8] = &[0x00]; + let result = TxMorph::rlp_decode_with_signature(&mut buf); + + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!( + !err_msg.contains("unsupported"), + "expected RLP-level error, got: {err_msg}" + ); + } + + #[test] + fn test_decodable_accepts_zero_byte_as_v0() { + let mut buf: &[u8] = &[0x00]; + let result = ::decode(&mut buf); + + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!( + !err_msg.contains("unsupported"), + "expected RLP-level error, got: {err_msg}" + ); + } + #[test] fn test_morph_transaction_size() { let tx = TxMorph { diff --git a/crates/rpc/src/eth/transaction.rs b/crates/rpc/src/eth/transaction.rs index ddaf361..6b3793d 100644 --- a/crates/rpc/src/eth/transaction.rs +++ b/crates/rpc/src/eth/transaction.rs @@ -105,13 +105,25 @@ impl TryIntoTxEnv for MorphTransactionReq ) -> Result { let fee_token_id = self.fee_token_id; let fee_limit = self.fee_limit; - let reference = self.reference; + let reference = normalize_reference(self.reference); let memo = self.memo; let mut inner = self.inner; if inner.chain_id.is_none() { inner.chain_id = Some(evm_env.cfg_env.chain_id); } + // Determine MorphTx intent before delegating to the standard conversion so + // simulation rejects the same invalid request shape as signing/building. + let is_morph_tx = fee_token_id.is_some_and(|id| id.to::() > 0) + || is_nonzero_reference(reference.as_ref()) + || memo.as_ref().is_some_and(|m| !m.is_empty()); + + if is_morph_tx && inner.gas_price.is_some() { + return Err(EthApiError::InvalidParams( + "gas_price cannot be used with Morph transaction fields".to_string(), + )); + } + let inner_tx_env = inner.try_into_tx_env(evm_env).map_err(EthApiError::from)?; let mut tx_env = MorphTxEnv::new(inner_tx_env); @@ -126,11 +138,6 @@ impl TryIntoTxEnv for MorphTransactionReq tx_env.reference = reference; tx_env.memo = memo.clone(); - // Determine if this is a MorphTx based on Morph-specific fields - let is_morph_tx = fee_token_id.is_some_and(|id| id.to::() > 0) - || is_nonzero_reference(reference.as_ref()) - || memo.as_ref().is_some_and(|m| !m.is_empty()); - if is_morph_tx { tx_env.inner.tx_type = morph_primitives::MORPH_TX_TYPE_ID; tx_env.version = Some(morph_tx_version(reference.as_ref(), memo.as_ref())); @@ -176,6 +183,7 @@ fn try_build_morph_tx_from_request( reference: Option, memo: Option, ) -> Result, &'static str> { + let reference = normalize_reference(reference); let fee_token_id_u16 = u16::try_from(fee_token_id.to::()).map_err(|_| "invalid token")?; // Check if this should be a MorphTx @@ -250,6 +258,10 @@ fn is_nonzero_reference(reference: Option<&B256>) -> bool { reference.is_some_and(|reference| *reference != B256::ZERO) } +fn normalize_reference(reference: Option) -> Option { + reference.filter(|reference| *reference != B256::ZERO) +} + #[cfg(test)] mod tests { use super::*; @@ -462,6 +474,26 @@ mod tests { ); } + #[test] + fn test_morph_tx_env_rejects_gas_price_with_morph_fields() { + let request = MorphTransactionRequest { + inner: create_basic_transaction_request(), + fee_token_id: Some(U64::from(1)), + fee_limit: Some(U256::from(1000000)), + reference: None, + memo: None, + }; + + let evm_env = create_evm_env(false); + let err = request + .try_into_tx_env(&evm_env) + .expect_err("gas_price with Morph fields should be rejected"); + + assert!( + matches!(err, EthApiError::InvalidParams(message) if message == "gas_price cannot be used with Morph transaction fields") + ); + } + #[test] fn test_morph_tx_env_defaults_missing_chain_id_from_evm_env() { let mut inner = create_morph_transaction_request(); @@ -719,7 +751,7 @@ mod tests { tx.version, morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_0 ); - assert_eq!(tx.reference, Some(B256::ZERO)); + assert_eq!(tx.reference, None); } #[test] diff --git a/crates/txpool/src/maintain.rs b/crates/txpool/src/maintain.rs index 808f248..7f54f55 100644 --- a/crates/txpool/src/maintain.rs +++ b/crates/txpool/src/maintain.rs @@ -258,7 +258,6 @@ where sender, eth_balance: budget.eth_balance, l1_data_fee, - base_fee_per_gas: new_tip.base_fee_per_gas(), hardfork, }; diff --git a/crates/txpool/src/morph_tx_validation.rs b/crates/txpool/src/morph_tx_validation.rs index 5a9e139..e666c29 100644 --- a/crates/txpool/src/morph_tx_validation.rs +++ b/crates/txpool/src/morph_tx_validation.rs @@ -25,8 +25,6 @@ pub struct MorphTxValidationInput<'a> { pub eth_balance: U256, /// L1 data fee (pre-calculated) pub l1_data_fee: U256, - /// Current block base fee used to derive the effective gas price. - pub base_fee_per_gas: Option, /// Current hardfork pub hardfork: MorphHardfork, } @@ -202,7 +200,6 @@ mod tests { sender, eth_balance: U256::from(1_000_000_000_000_000_000u128), // 1 ETH l1_data_fee: U256::from(100_000), - base_fee_per_gas: Some(1_000_000_000), hardfork: MorphHardfork::Viridian, }; @@ -241,7 +238,6 @@ mod tests { 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::Jade, }; let mut db = EmptyDB::default(); @@ -283,7 +279,6 @@ mod tests { 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(); @@ -321,7 +316,6 @@ mod tests { 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(); @@ -363,7 +357,6 @@ mod tests { sender, 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::Jade, }; let mut db = EmptyDB::default(); @@ -406,7 +399,6 @@ mod tests { 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(); @@ -445,7 +437,6 @@ mod tests { 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(); diff --git a/crates/txpool/src/validator.rs b/crates/txpool/src/validator.rs index 442a729..1afea64 100644 --- a/crates/txpool/src/validator.rs +++ b/crates/txpool/src/validator.rs @@ -445,7 +445,6 @@ where sender, eth_balance, l1_data_fee, - base_fee_per_gas: self.block_info.base_fee_per_gas(), hardfork, }; From 1f41fdc3a215f5c0a3f21cc2aec213ea08046508 Mon Sep 17 00:00:00 2001 From: panos Date: Thu, 18 Jun 2026 11:34:56 +0800 Subject: [PATCH 18/19] fix(rpc): align gas price MorphTx requests with geth Treat requests that include legacy gas_price as standard transactions even when Morph-specific fields are present, matching geth's transaction type precedence. --- crates/rpc/src/eth/transaction.rs | 69 ++++++++++++++----------------- 1 file changed, 32 insertions(+), 37 deletions(-) diff --git a/crates/rpc/src/eth/transaction.rs b/crates/rpc/src/eth/transaction.rs index 6b3793d..dc3b5ce 100644 --- a/crates/rpc/src/eth/transaction.rs +++ b/crates/rpc/src/eth/transaction.rs @@ -112,33 +112,27 @@ impl TryIntoTxEnv for MorphTransactionReq inner.chain_id = Some(evm_env.cfg_env.chain_id); } - // Determine MorphTx intent before delegating to the standard conversion so - // simulation rejects the same invalid request shape as signing/building. - let is_morph_tx = fee_token_id.is_some_and(|id| id.to::() > 0) - || is_nonzero_reference(reference.as_ref()) - || memo.as_ref().is_some_and(|m| !m.is_empty()); - - if is_morph_tx && inner.gas_price.is_some() { - return Err(EthApiError::InvalidParams( - "gas_price cannot be used with Morph transaction fields".to_string(), - )); - } + // Match geth: legacy gas_price takes precedence over Morph-specific + // fields, so the request is treated as a standard transaction. + let is_morph_tx = inner.gas_price.is_none() + && (fee_token_id.is_some_and(|id| id.to::() > 0) + || is_nonzero_reference(reference.as_ref()) + || memo.as_ref().is_some_and(|m| !m.is_empty())); let inner_tx_env = inner.try_into_tx_env(evm_env).map_err(EthApiError::from)?; let mut tx_env = MorphTxEnv::new(inner_tx_env); - tx_env.fee_token_id = match fee_token_id { - Some(id) => Some( - u16::try_from(id.to::()) - .map_err(|_| EthApiError::InvalidParams("invalid token".to_string()))?, - ), - None => None, - }; - tx_env.fee_limit = fee_limit; - tx_env.reference = reference; - tx_env.memo = memo.clone(); - if is_morph_tx { + tx_env.fee_token_id = match fee_token_id { + Some(id) => Some( + u16::try_from(id.to::()) + .map_err(|_| EthApiError::InvalidParams("invalid token".to_string()))?, + ), + None => None, + }; + tx_env.fee_limit = fee_limit; + tx_env.reference = reference; + tx_env.memo = memo.clone(); tx_env.inner.tx_type = morph_primitives::MORPH_TX_TYPE_ID; tx_env.version = Some(morph_tx_version(reference.as_ref(), memo.as_ref())); } @@ -184,6 +178,10 @@ fn try_build_morph_tx_from_request( memo: Option, ) -> Result, &'static str> { let reference = normalize_reference(reference); + if req.gas_price.is_some() { + return Ok(None); + } + let fee_token_id_u16 = u16::try_from(fee_token_id.to::()).map_err(|_| "invalid token")?; // Check if this should be a MorphTx @@ -196,10 +194,6 @@ fn try_build_morph_tx_from_request( return Ok(None); } - if req.gas_price.is_some() { - return Err("gas_price cannot be used with Morph transaction fields"); - } - let version = morph_tx_version(reference.as_ref(), memo.as_ref()); // Now build the MorphTx @@ -475,7 +469,7 @@ mod tests { } #[test] - fn test_morph_tx_env_rejects_gas_price_with_morph_fields() { + fn test_morph_tx_env_treats_gas_price_with_morph_fields_as_standard_tx() { let request = MorphTransactionRequest { inner: create_basic_transaction_request(), fee_token_id: Some(U64::from(1)), @@ -485,13 +479,16 @@ mod tests { }; let evm_env = create_evm_env(false); - let err = request + let tx_env = request .try_into_tx_env(&evm_env) - .expect_err("gas_price with Morph fields should be rejected"); + .expect("gas_price should force the standard transaction path"); - assert!( - matches!(err, EthApiError::InvalidParams(message) if message == "gas_price cannot be used with Morph transaction fields") - ); + assert_ne!(tx_env.inner.tx_type, morph_primitives::MORPH_TX_TYPE_ID); + assert!(tx_env.fee_token_id.is_none()); + assert!(tx_env.fee_limit.is_none()); + assert!(tx_env.reference.is_none()); + assert!(tx_env.memo.is_none()); + assert!(tx_env.version.is_none()); } #[test] @@ -716,15 +713,13 @@ mod tests { } #[test] - fn try_build_morph_tx_rejects_gas_price_with_morph_fields() { + fn try_build_morph_tx_treats_gas_price_with_morph_fields_as_standard_tx() { 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_eq!( - result.unwrap_err(), - "gas_price cannot be used with Morph transaction fields" - ); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); } #[test] From f60c2b2bbde1aaee7ab02244c96a70a55e7aaf76 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Thu, 18 Jun 2026 14:32:44 +0800 Subject: [PATCH 19/19] fix(rpc): honor explicit MorphTx version --- crates/rpc/src/eth/transaction.rs | 192 ++++++++++++++++++++++++++---- crates/rpc/src/types/request.rs | 17 ++- 2 files changed, 186 insertions(+), 23 deletions(-) diff --git a/crates/rpc/src/eth/transaction.rs b/crates/rpc/src/eth/transaction.rs index dc3b5ce..3029d5a 100644 --- a/crates/rpc/src/eth/transaction.rs +++ b/crates/rpc/src/eth/transaction.rs @@ -23,6 +23,7 @@ impl TryIntoSimTx for MorphTransactionRequest { &self.inner, self.fee_token_id.unwrap_or_default(), self.fee_limit.unwrap_or_default(), + self.version, self.reference, self.memo.clone(), ); @@ -40,6 +41,7 @@ impl TryIntoSimTx for MorphTransactionRequest { inner, fee_token_id: self.fee_token_id, fee_limit: self.fee_limit, + version: self.version, reference: self.reference, memo: self.memo.clone(), }) @@ -66,6 +68,7 @@ impl SignableTxRequest for MorphTransactionRequest { &self.inner, self.fee_token_id.unwrap_or_default(), self.fee_limit.unwrap_or_default(), + self.version, self.reference, self.memo, ); @@ -105,6 +108,8 @@ impl TryIntoTxEnv for MorphTransactionReq ) -> Result { let fee_token_id = self.fee_token_id; let fee_limit = self.fee_limit; + let explicit_version = explicit_morph_tx_version(self.version) + .map_err(|err| EthApiError::InvalidParams(err.to_string()))?; let reference = normalize_reference(self.reference); let memo = self.memo; let mut inner = self.inner; @@ -115,7 +120,8 @@ impl TryIntoTxEnv for MorphTransactionReq // Match geth: legacy gas_price takes precedence over Morph-specific // fields, so the request is treated as a standard transaction. let is_morph_tx = inner.gas_price.is_none() - && (fee_token_id.is_some_and(|id| id.to::() > 0) + && (explicit_version.is_some() + || fee_token_id.is_some_and(|id| id.to::() > 0) || is_nonzero_reference(reference.as_ref()) || memo.as_ref().is_some_and(|m| !m.is_empty())); @@ -134,7 +140,11 @@ impl TryIntoTxEnv for MorphTransactionReq tx_env.reference = reference; tx_env.memo = memo.clone(); tx_env.inner.tx_type = morph_primitives::MORPH_TX_TYPE_ID; - tx_env.version = Some(morph_tx_version(reference.as_ref(), memo.as_ref())); + tx_env.version = Some(morph_tx_version( + explicit_version, + reference.as_ref(), + memo.as_ref(), + )); } // Required by `MorphEthApi::caller_gas_allowance` (eth/call.rs) to @@ -167,6 +177,7 @@ fn morph_envelope_from_ethereum( /// or `Err(...)` if there's a validation error. /// /// A MorphTx is constructed when any of these conditions are met: +/// - `version` is present /// - `feeTokenID > 0` (ERC20 gas payment) /// - `reference` is present /// - `memo` is present and non-empty @@ -174,6 +185,7 @@ fn try_build_morph_tx_from_request( req: &alloy_rpc_types_eth::TransactionRequest, fee_token_id: U64, fee_limit: U256, + version: Option, reference: Option, memo: Option, ) -> Result, &'static str> { @@ -183,18 +195,20 @@ fn try_build_morph_tx_from_request( } let fee_token_id_u16 = u16::try_from(fee_token_id.to::()).map_err(|_| "invalid token")?; + let explicit_version = explicit_morph_tx_version(version)?; // Check if this should be a MorphTx + let has_explicit_version = explicit_version.is_some(); let has_fee_token = fee_token_id_u16 > 0; let has_reference = is_nonzero_reference(reference.as_ref()); let has_memo = memo.as_ref().is_some_and(|m| !m.is_empty()); - if !has_fee_token && !has_reference && !has_memo { + if !has_explicit_version && !has_fee_token && !has_reference && !has_memo { // No Morph-specific fields → standard Ethereum tx return Ok(None); } - let version = morph_tx_version(reference.as_ref(), memo.as_ref()); + let version = morph_tx_version(explicit_version, reference.as_ref(), memo.as_ref()); // Now build the MorphTx let chain_id = req @@ -240,7 +254,29 @@ fn try_build_morph_tx_from_request( Ok(Some(morph_tx)) } -fn morph_tx_version(reference: Option<&B256>, memo: Option<&Bytes>) -> u8 { +fn explicit_morph_tx_version(version: Option) -> Result, &'static str> { + let Some(version) = version else { + return Ok(None); + }; + + match u8::try_from(version.to::()) { + Ok( + version @ (morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_0 + | morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1), + ) => Ok(Some(version)), + _ => Err("unsupported MorphTx version"), + } +} + +fn morph_tx_version( + explicit_version: Option, + reference: Option<&B256>, + memo: Option<&Bytes>, +) -> u8 { + if let Some(version) = explicit_version { + return version; + } + if is_nonzero_reference(reference) || memo.is_some_and(|m| !m.is_empty()) { morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1 } else { @@ -324,6 +360,7 @@ mod tests { inner: create_basic_transaction_request(), fee_token_id: None, fee_limit: None, + version: None, reference: None, memo: None, }; @@ -355,6 +392,7 @@ mod tests { inner: create_basic_transaction_request(), fee_token_id: None, fee_limit: None, + version: None, reference: None, memo: None, }; @@ -396,6 +434,7 @@ mod tests { inner: create_morph_transaction_request(), fee_token_id: Some(U64::from(1)), // Triggers MorphTx (use U64, not U256) fee_limit: Some(U256::from(1000000)), + version: None, reference: Some(reference), memo: Some(memo.clone()), }; @@ -453,6 +492,7 @@ mod tests { inner: create_morph_transaction_request(), fee_token_id: Some(U64::from(1)), fee_limit: Some(U256::from(1000000)), + version: None, reference: None, memo: None, }; @@ -468,12 +508,67 @@ mod tests { ); } + #[test] + fn test_explicit_version_tx_env_triggers_morph_tx() { + let request = MorphTransactionRequest { + inner: create_morph_transaction_request(), + fee_token_id: None, + fee_limit: None, + version: Some(U64::from(1)), + reference: None, + memo: None, + }; + + let evm_env = create_evm_env(false); + let tx_env = request + .try_into_tx_env(&evm_env) + .expect("explicit version should trigger MorphTx tx env"); + + assert_eq!(tx_env.inner.tx_type, morph_primitives::MORPH_TX_TYPE_ID); + assert_eq!( + tx_env.version, + Some(morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1) + ); + } + + #[test] + fn try_into_sim_tx_explicit_version_triggers_morph_tx() { + let request: MorphTransactionRequest = serde_json::from_value(serde_json::json!({ + "from": "0x0000000000000000000000000000000000000001", + "to": "0x0000000000000000000000000000000000000002", + "gas": "0x186a0", + "maxFeePerGas": "0x3b9aca00", + "maxPriorityFeePerGas": "0x5f5e100", + "value": "0x0", + "nonce": "0x1", + "chainId": "0xb02", + "version": "0x1" + })) + .expect("request should deserialize"); + + let envelope = request + .try_into_sim_tx() + .expect("explicit version should build a MorphTx"); + + match envelope { + MorphTxEnvelope::Morph(signed) => { + assert_eq!( + signed.tx().version, + morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1 + ); + assert_eq!(signed.tx().fee_token_id, 0); + } + other => panic!("expected Morph variant, got {other:?}"), + } + } + #[test] fn test_morph_tx_env_treats_gas_price_with_morph_fields_as_standard_tx() { let request = MorphTransactionRequest { inner: create_basic_transaction_request(), fee_token_id: Some(U64::from(1)), fee_limit: Some(U256::from(1000000)), + version: None, reference: None, memo: None, }; @@ -499,6 +594,7 @@ mod tests { inner, fee_token_id: Some(U64::from(1)), fee_limit: Some(U256::from(1000000)), + version: None, reference: None, memo: None, }; @@ -527,6 +623,7 @@ mod tests { inner: create_morph_transaction_request(), fee_token_id: Some(U64::from(1)), fee_limit: Some(U256::from(1000000)), + version: None, reference: Some(B256::random()), memo: Some(Bytes::from("test")), }; @@ -566,6 +663,7 @@ mod tests { inner: create_basic_transaction_request(), fee_token_id: None, fee_limit: None, + version: None, reference: None, memo: None, }; @@ -692,7 +790,7 @@ mod 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); + let result = try_build_morph_tx_from_request(&req, U64::ZERO, U256::ZERO, None, None, None); assert!(result.is_ok()); assert!(result.unwrap().is_none()); } @@ -700,8 +798,14 @@ mod tests { #[test] fn try_build_morph_tx_with_fee_token_id() { let req = create_morph_transaction_request(); - let result = - try_build_morph_tx_from_request(&req, U64::from(1), U256::from(1_000_000), None, None); + let result = try_build_morph_tx_from_request( + &req, + U64::from(1), + U256::from(1_000_000), + None, + None, + None, + ); assert!(result.is_ok()); let tx = result.unwrap().unwrap(); assert_eq!(tx.fee_token_id, 1); @@ -715,8 +819,14 @@ mod tests { #[test] fn try_build_morph_tx_treats_gas_price_with_morph_fields_as_standard_tx() { 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); + let result = try_build_morph_tx_from_request( + &req, + U64::from(1), + U256::from(1_000_000), + None, + None, + None, + ); assert!(result.is_ok()); assert!(result.unwrap().is_none()); @@ -726,8 +836,14 @@ mod tests { fn try_build_morph_tx_with_reference_only() { let req = create_morph_transaction_request(); let reference = B256::random(); - let result = - try_build_morph_tx_from_request(&req, U64::ZERO, U256::ZERO, Some(reference), None); + let result = try_build_morph_tx_from_request( + &req, + U64::ZERO, + U256::ZERO, + None, + Some(reference), + None, + ); assert!(result.is_ok()); let tx = result.unwrap().unwrap(); assert_eq!(tx.reference, Some(reference)); @@ -737,8 +853,14 @@ mod tests { #[test] fn try_build_morph_tx_treats_zero_reference_as_absent() { let req = create_morph_transaction_request(); - let result = - try_build_morph_tx_from_request(&req, U64::from(1), U256::ZERO, Some(B256::ZERO), None); + let result = try_build_morph_tx_from_request( + &req, + U64::from(1), + U256::ZERO, + None, + Some(B256::ZERO), + None, + ); assert!(result.is_ok()); let tx = result.unwrap().unwrap(); @@ -753,8 +875,14 @@ mod tests { fn try_build_morph_tx_with_memo_only() { let req = create_morph_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())); + let result = try_build_morph_tx_from_request( + &req, + U64::ZERO, + U256::ZERO, + None, + None, + Some(memo.clone()), + ); assert!(result.is_ok()); let tx = result.unwrap().unwrap(); assert_eq!(tx.memo, Some(memo)); @@ -763,19 +891,40 @@ mod tests { #[test] fn try_build_morph_tx_empty_memo_is_not_trigger() { let req = create_morph_transaction_request(); - let result = - try_build_morph_tx_from_request(&req, U64::ZERO, U256::ZERO, None, Some(Bytes::new())); + let result = try_build_morph_tx_from_request( + &req, + U64::ZERO, + U256::ZERO, + None, + 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_rejects_unsupported_explicit_version() { + let req = create_morph_transaction_request(); + let result = try_build_morph_tx_from_request( + &req, + U64::ZERO, + U256::ZERO, + Some(U64::from(2)), + None, + None, + ); + + assert_eq!(result.unwrap_err(), "unsupported MorphTx version"); + } + #[test] fn try_build_morph_tx_requires_chain_id() { let mut req = create_morph_transaction_request(); req.chain_id = None; let result = - try_build_morph_tx_from_request(&req, U64::from(1), U256::from(100), None, None); + try_build_morph_tx_from_request(&req, U64::from(1), U256::from(100), None, None, None); assert!(result.is_err()); assert!(result.unwrap_err().contains("chain_id")); } @@ -789,7 +938,7 @@ mod tests { }; let result = - try_build_morph_tx_from_request(&req, U64::from(1), U256::from(100), None, None); + try_build_morph_tx_from_request(&req, U64::from(1), U256::from(100), None, None, None); assert_eq!(result.unwrap_err(), "data and input fields must match"); } @@ -800,7 +949,7 @@ mod tests { req.to = None; let result = - try_build_morph_tx_from_request(&req, U64::from(1), U256::from(100), None, None); + try_build_morph_tx_from_request(&req, U64::from(1), U256::from(100), None, None, None); assert_eq!(result.unwrap_err(), "contract creation requires initcode"); } @@ -812,6 +961,7 @@ mod tests { &req, U64::from(2), U256::from(500_000), + None, Some(B256::random()), Some(Bytes::from("memo")), ); diff --git a/crates/rpc/src/types/request.rs b/crates/rpc/src/types/request.rs index f08c36b..4b541a0 100644 --- a/crates/rpc/src/types/request.rs +++ b/crates/rpc/src/types/request.rs @@ -9,10 +9,11 @@ use serde::{Deserialize, Serialize}; /// Extends standard Ethereum transaction request with: /// - `feeTokenID`: Token ID for ERC20 gas payment /// - `feeLimit`: Maximum token amount willing to pay for fees +/// - `version`: Explicit MorphTx version selector /// - `reference`: 32-byte reference key for transaction indexing /// - `memo`: Arbitrary memo data (up to 64 bytes) /// -/// All MorphTx transactions are constructed as Version 1 (the latest format). +/// When omitted, MorphTx version is inferred from Morph-specific fields. #[derive( Debug, Clone, @@ -44,6 +45,10 @@ pub struct MorphTransactionRequest { #[serde(default, skip_serializing_if = "Option::is_none")] pub fee_limit: Option, + /// Explicit MorphTx version selector (only for MorphTx type 0x7F). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + /// Reference key for transaction indexing (32 bytes). /// Used for looking up transactions by external systems. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -71,13 +76,14 @@ impl AsMut for MorphTransactionRequest { /// Creates a [`MorphTransactionRequest`] from a standard [`TransactionRequest`]. /// -/// Sets `fee_token_id`, `fee_limit`, `reference`, and `memo` to `None`. +/// Sets `fee_token_id`, `fee_limit`, `version`, `reference`, and `memo` to `None`. impl From for MorphTransactionRequest { fn from(value: TransactionRequest) -> Self { Self { inner: value, fee_token_id: None, fee_limit: None, + version: None, reference: None, memo: None, } @@ -114,6 +120,7 @@ mod tests { assert_eq!(morph_req.inner, inner); assert!(morph_req.fee_token_id.is_none()); assert!(morph_req.fee_limit.is_none()); + assert!(morph_req.version.is_none()); assert!(morph_req.reference.is_none()); assert!(morph_req.memo.is_none()); } @@ -124,6 +131,7 @@ mod tests { inner: basic_inner_request(), fee_token_id: Some(U64::from(1)), fee_limit: Some(U256::from(500)), + version: Some(U64::from(1)), reference: Some(b256!( "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" )), @@ -178,6 +186,7 @@ mod tests { inner: basic_inner_request(), fee_token_id: Some(U64::from(5)), fee_limit: Some(U256::from(999)), + version: Some(U64::from(1)), reference: Some(b256!( "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" )), @@ -194,11 +203,13 @@ mod tests { inner: basic_inner_request(), fee_token_id: Some(U64::from(1)), fee_limit: Some(U256::from(100)), + version: Some(U64::from(1)), ..Default::default() }; let json = serde_json::to_string(&req).unwrap(); assert!(json.contains("\"feeTokenID\"")); assert!(json.contains("\"feeLimit\"")); + assert!(json.contains("\"version\"")); } #[test] @@ -210,6 +221,7 @@ mod tests { let json = serde_json::to_string(&req).unwrap(); assert!(!json.contains("feeTokenID")); assert!(!json.contains("feeLimit")); + assert!(!json.contains("version")); assert!(!json.contains("reference")); assert!(!json.contains("memo")); } @@ -220,6 +232,7 @@ mod tests { assert_eq!(req.inner, TransactionRequest::default()); assert!(req.fee_token_id.is_none()); assert!(req.fee_limit.is_none()); + assert!(req.version.is_none()); assert!(req.reference.is_none()); assert!(req.memo.is_none()); }