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(), }) } diff --git a/crates/primitives/src/transaction/morph_transaction.rs b/crates/primitives/src/transaction/morph_transaction.rs index 49187a3..e60943d 100644 --- a/crates/primitives/src/transaction/morph_transaction.rs +++ b/crates/primitives/src/transaction/morph_transaction.rs @@ -239,8 +239,12 @@ 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 @@ -384,9 +388,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 { @@ -752,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")); @@ -852,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 { @@ -1145,6 +1149,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, @@ -1476,6 +1491,50 @@ 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_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/revm/src/handler.rs b/crates/revm/src/handler.rs index 02a7822..9467f8b 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -228,13 +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. - if evm.ctx_ref().tx().is_morph_tx() - && !evm.ctx_ref().tx().uses_token_fee() - && !evm.ctx_ref().cfg().is_fee_charge_disabled() - { + // 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() { 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,10 +1005,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_chainspec::hardfork::MorphHardfork; + use morph_primitives::MORPH_TX_TYPE_ID; use revm::{ - context::BlockEnv, + context::{BlockEnv, TxEnv}, + context_interface::result::InvalidTransaction, database::{CacheDB, EmptyDB}, inspector::NoOpInspector, state::{AccountInfo, Bytecode}, @@ -1037,6 +1036,43 @@ 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"); 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(|| { diff --git a/crates/rpc/src/eth/transaction.rs b/crates/rpc/src/eth/transaction.rs index 6727d3f..3029d5a 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 @@ -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(), }) @@ -55,7 +57,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, @@ -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, ); @@ -95,7 +98,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; @@ -105,33 +108,43 @@ impl TryIntoTxEnv for MorphTransactionReq ) -> Result { let fee_token_id = self.fee_token_id; let fee_limit = self.fee_limit; - let reference = self.reference; + 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 inner = self.inner; + let mut inner = self.inner; + if inner.chain_id.is_none() { + inner.chain_id = Some(evm_env.cfg_env.chain_id); + } + + // 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() + && (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())); 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(); - - // 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() - || memo.as_ref().is_some_and(|m| !m.is_empty()); - 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_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1); + 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 @@ -159,11 +172,12 @@ 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. /// /// 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 @@ -171,23 +185,30 @@ 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> { + 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")?; + 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 = 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 { + if !has_explicit_version && !has_fee_token && !has_reference && !has_memo { // No Morph-specific fields → standard Ethereum tx 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(explicit_version, reference.as_ref(), memo.as_ref()); // Now build the MorphTx let chain_id = req @@ -195,11 +216,19 @@ 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(); + 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, @@ -225,12 +254,50 @@ fn try_build_morph_tx_from_request( Ok(Some(morph_tx)) } +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 { + 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) +} + +fn normalize_reference(reference: Option) -> Option { + reference.filter(|reference| *reference != B256::ZERO) +} + #[cfg(test)] 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}; @@ -249,6 +316,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(); @@ -284,6 +360,7 @@ mod tests { inner: create_basic_transaction_request(), fee_token_id: None, fee_limit: None, + version: None, reference: None, memo: None, }; @@ -315,6 +392,7 @@ mod tests { inner: create_basic_transaction_request(), fee_token_id: None, fee_limit: None, + version: None, reference: None, memo: None, }; @@ -353,9 +431,10 @@ 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)), + version: None, reference: Some(reference), memo: Some(memo.clone()), }; @@ -407,6 +486,127 @@ mod tests { ); } + #[test] + fn test_fee_token_only_tx_env_uses_morph_tx_version_0() { + let request = MorphTransactionRequest { + inner: create_morph_transaction_request(), + fee_token_id: Some(U64::from(1)), + fee_limit: Some(U256::from(1000000)), + version: None, + 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) + ); + } + + #[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, + }; + + let evm_env = create_evm_env(false); + let tx_env = request + .try_into_tx_env(&evm_env) + .expect("gas_price should force the standard transaction path"); + + 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] + 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)), + version: None, + 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 @@ -420,9 +620,10 @@ 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)), + version: None, reference: Some(B256::random()), memo: Some(Bytes::from("test")), }; @@ -462,6 +663,7 @@ mod tests { inner: create_basic_transaction_request(), fee_token_id: None, fee_limit: None, + version: None, reference: None, memo: None, }; @@ -588,44 +790,99 @@ 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()); } #[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); + 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, + 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 + morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_0 ); } #[test] - fn try_build_morph_tx_with_reference_only() { + 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, + None, + ); + + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + #[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); + 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)); 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, + None, + 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, None); + } + #[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())); + 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)); @@ -633,31 +890,78 @@ mod tests { #[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())); + let req = create_morph_transaction_request(); + 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_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); + 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")); } + #[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, 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, None); + + assert_eq!(result.unwrap_err(), "contract creation requires initcode"); + } + #[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), U256::from(500_000), + None, Some(B256::random()), Some(Bytes::from("memo")), ); @@ -665,7 +969,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)); } 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()); } diff --git a/crates/txpool/src/maintain.rs b/crates/txpool/src/maintain.rs index 545e517..7f54f55 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. @@ -240,7 +258,6 @@ where sender, eth_balance: budget.eth_balance, l1_data_fee, - base_fee_per_gas: new_tip.base_fee_per_gas(), hardfork, }; @@ -453,4 +470,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)); + } } diff --git a/crates/txpool/src/morph_tx_validation.rs b/crates/txpool/src/morph_tx_validation.rs index 3e05b15..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, } @@ -89,7 +87,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 +130,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); @@ -201,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, }; @@ -240,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(); @@ -282,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(); @@ -320,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(); @@ -362,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(); @@ -405,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(); @@ -444,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 e76aedb..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, }; @@ -885,7 +884,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 +914,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 +943,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 +951,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) + )); } }