Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 14 additions & 15 deletions crates/engine-api/src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

// =============================================================================
Expand Down Expand Up @@ -140,6 +140,7 @@ impl<Provider> MorphL2EngineApi for RealMorphL2EngineApi<Provider>
where
Provider: HeaderProvider<Header = MorphHeader>
+ BlockNumReader
+ BlockReaderIdExt<Header = MorphHeader>
+ CanonChainTracker<Header = MorphHeader>
+ Clone
+ Send
Expand Down Expand Up @@ -644,8 +645,13 @@ impl<Provider> RealMorphL2EngineApi<Provider> {
parent_override: Option<B256>,
) -> EngineApiResult<MorphBuiltPayload>
where
Provider:
HeaderProvider<Header = MorphHeader> + BlockNumReader + Clone + Send + Sync + 'static,
Provider: HeaderProvider<Header = MorphHeader>
+ BlockNumReader
+ BlockReaderIdExt<Header = MorphHeader>
+ Clone
+ Send
+ Sync
+ 'static,
{
tracing::debug!(
target: "morph::engine",
Expand Down Expand Up @@ -926,26 +932,19 @@ impl<Provider> RealMorphL2EngineApi<Provider> {

fn current_head(&self) -> EngineApiResult<CanonicalHead>
where
Provider: HeaderProvider + BlockNumReader,
Provider: BlockReaderIdExt<Header = MorphHeader>,
{
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(),
})
}
Expand Down
75 changes: 67 additions & 8 deletions crates/primitives/src/transaction/morph_transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = <TxMorph as Decodable>::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 {
Expand Down
54 changes: 45 additions & 9 deletions crates/revm/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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},
Expand All @@ -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 =
<MorphEvmHandler<_, _> 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");
Expand Down
2 changes: 1 addition & 1 deletion crates/revm/src/precompiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: <https://github.com/morph-l2/go-ethereum/blob/main/core/vm/contracts.go#L136-L148>
/// Matches: <https://github.com/morph-l2/go-ethereum/blob/main/core/vm/contracts.go#L124-L134>
pub fn bernoulli() -> &'static Precompiles {
static INSTANCE: OnceLock<Precompiles> = OnceLock::new();
INSTANCE.get_or_init(|| {
Expand Down
Loading