Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
dd88fda
fix(primitives): accept zero-prefixed MorphTx v0 payloads
panos-xyz Jun 17, 2026
6a8f84e
fix(revm): validate token-fee MorphTx fee caps
panos-xyz Jun 17, 2026
fc728e9
fix(rpc): infer MorphTx version from request fields
panos-xyz Jun 17, 2026
9d3ae51
fix(rpc): reject gas price on MorphTx requests
panos-xyz Jun 17, 2026
b106d61
fix(rpc): treat zero MorphTx reference as absent
panos-xyz Jun 17, 2026
8468a12
fix(txpool): drop MorphTx above block gas limit
panos-xyz Jun 17, 2026
cb19321
fix(txpool): require max-fee token budget
panos-xyz Jun 17, 2026
74ca9f1
fix(rpc): default MorphTx env chain id
panos-xyz Jun 17, 2026
921dbba
fix(rpc): validate MorphTx request input fields
panos-xyz Jun 17, 2026
2abb4ee
docs(revm): fix Bernoulli precompile reference link
panos-xyz Jun 17, 2026
0b761eb
fix(rpc): validate MorphTx simulation fields
panos-xyz Jun 17, 2026
dd98bc9
fix(revm): halt on storage original read errors
panos-xyz Jun 17, 2026
b7ff09d
chore: format audit fix code
panos-xyz Jun 17, 2026
70016f1
fix(engine-api): read canonical head atomically
panos-xyz Jun 18, 2026
78fe01f
Revert "fix(revm): halt on storage original read errors"
panos-xyz Jun 18, 2026
c85bb02
Revert "fix(rpc): validate MorphTx simulation fields"
panos-xyz Jun 18, 2026
83bc923
fix: address audit PR review comments
panos-xyz Jun 18, 2026
1f41fdc
fix(rpc): align gas price MorphTx requests with geth
panos-xyz Jun 18, 2026
f60c2b2
fix(rpc): honor explicit MorphTx version
panos-xyz Jun 18, 2026
674f0f6
Merge branch 'main' into audit/fix-feedback
chengwenxi Jun 24, 2026
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