From 615ddbecb8031e627e54b686c6d2e863c55eec4f Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 13 May 2026 11:36:32 +0800 Subject: [PATCH 1/8] fix(rpc): align receipt extension field serialization Expose Morph receipt extension keys consistently with morph-geth so RPC parity does not report schema-only missing-field diffs for absent metadata. Constraint: Preserve morph-geth-compatible receipt JSON contract Confidence: high Scope-risk: narrow --- crates/rpc/src/eth/receipt.rs | 76 +++++++++++++++++++++++++-------- crates/rpc/src/types/receipt.rs | 35 ++++++++------- 2 files changed, 75 insertions(+), 36 deletions(-) diff --git a/crates/rpc/src/eth/receipt.rs b/crates/rpc/src/eth/receipt.rs index def41fc2..67344932 100644 --- a/crates/rpc/src/eth/receipt.rs +++ b/crates/rpc/src/eth/receipt.rs @@ -133,7 +133,9 @@ struct MorphTxReceiptFields { /// Extracts Morph-specific fee fields from a receipt. /// -/// L1 message receipts return zero/None for all fee fields. +/// morph-geth's `eth_` RPC keeps Morph receipt extension keys present for +/// every receipt. Numeric metadata that is absent in storage is exposed as +/// zero, while `reference` / `memo` remain null unless populated by MorphTx v1. fn morph_tx_receipt_fields(receipt: &MorphReceipt) -> MorphTxReceiptFields { match receipt { MorphReceipt::Legacy(r) @@ -142,15 +144,22 @@ fn morph_tx_receipt_fields(receipt: &MorphReceipt) -> MorphTxReceiptFields { | MorphReceipt::Eip7702(r) | MorphReceipt::Morph(r) => MorphTxReceiptFields { l1_fee: r.l1_fee, - version: r.version, - fee_token_id: r.fee_token_id, - fee_rate: r.fee_rate, - token_scale: r.token_scale, - fee_limit: r.fee_limit, + version: Some(r.version.unwrap_or_default()), + fee_token_id: Some(r.fee_token_id.unwrap_or_default()), + fee_rate: Some(r.fee_rate.unwrap_or_default()), + token_scale: Some(r.token_scale.unwrap_or_default()), + fee_limit: Some(r.fee_limit.unwrap_or_default()), reference: r.reference, memo: r.memo.clone(), }, - MorphReceipt::L1Msg(_) => MorphTxReceiptFields::default(), + MorphReceipt::L1Msg(_) => MorphTxReceiptFields { + version: Some(0), + fee_token_id: Some(0), + fee_rate: Some(U256::ZERO), + token_scale: Some(U256::ZERO), + fee_limit: Some(U256::ZERO), + ..Default::default() + }, } } @@ -226,11 +235,11 @@ mod tests { let fields = morph_tx_receipt_fields(&receipt); assert_eq!(fields.l1_fee, U256::ZERO); - assert!(fields.version.is_none()); - assert!(fields.fee_token_id.is_none()); - assert!(fields.fee_rate.is_none()); - assert!(fields.token_scale.is_none()); - assert!(fields.fee_limit.is_none()); + assert_eq!(fields.version, Some(0)); + assert_eq!(fields.fee_token_id, Some(0)); + assert_eq!(fields.fee_rate, Some(U256::ZERO)); + assert_eq!(fields.token_scale, Some(U256::ZERO)); + assert_eq!(fields.fee_limit, Some(U256::ZERO)); assert!(fields.reference.is_none()); assert!(fields.memo.is_none()); } @@ -253,6 +262,36 @@ mod tests { assert_eq!(fields.reference, r.reference); } + #[test] + fn morph_tx_receipt_fields_defaults_absent_numeric_metadata_to_zero() { + let receipt = MorphReceipt::Eip1559(MorphTransactionReceipt { + inner: Receipt { + status: alloy_consensus::Eip658Value::Eip658(true), + cumulative_gas_used: 21_000, + logs: vec![], + }, + l1_fee: U256::from(123), + version: None, + fee_token_id: None, + fee_rate: None, + token_scale: None, + fee_limit: None, + reference: None, + memo: None, + }); + + let fields = morph_tx_receipt_fields(&receipt); + + assert_eq!(fields.l1_fee, U256::from(123)); + assert_eq!(fields.version, Some(0)); + assert_eq!(fields.fee_token_id, Some(0)); + assert_eq!(fields.fee_rate, Some(U256::ZERO)); + assert_eq!(fields.token_scale, Some(U256::ZERO)); + assert_eq!(fields.fee_limit, Some(U256::ZERO)); + assert!(fields.reference.is_none()); + assert!(fields.memo.is_none()); + } + /// Regression test for the `transactionReceipts` subscription wiring. /// /// reth v2.2.0 exposes a `transactionReceipts` pubsub topic that, in the @@ -391,13 +430,14 @@ mod tests { .pop() .expect("converter must produce one receipt per input"); - // L1 messages explicitly carry no Morph metadata. + // L1 messages have no MorphTx metadata, but RPC compatibility with + // morph-geth still exposes absent numeric extension fields as zero. assert_eq!(rpc.l1_fee, U256::ZERO); - assert!(rpc.version.is_none()); - assert!(rpc.fee_token_id.is_none()); - assert!(rpc.fee_rate.is_none()); - assert!(rpc.token_scale.is_none()); - assert!(rpc.fee_limit.is_none()); + assert_eq!(rpc.version, Some(0)); + assert_eq!(rpc.fee_token_id, Some(U64::ZERO)); + assert_eq!(rpc.fee_rate, Some(U256::ZERO)); + assert_eq!(rpc.token_scale, Some(U256::ZERO)); + assert_eq!(rpc.fee_limit, Some(U256::ZERO)); assert!(rpc.reference.is_none()); assert!(rpc.memo.is_none()); } diff --git a/crates/rpc/src/types/receipt.rs b/crates/rpc/src/types/receipt.rs index 93dd2fd3..fc082657 100644 --- a/crates/rpc/src/types/receipt.rs +++ b/crates/rpc/src/types/receipt.rs @@ -25,33 +25,27 @@ pub struct MorphRpcReceipt { /// MorphTx version (only for MorphTx type 0x7F). /// 0 = legacy format, 1 = with reference/memo support. - #[serde(skip_serializing_if = "Option::is_none")] pub version: Option, /// Token ID used for fee payment. - #[serde(rename = "feeTokenID", skip_serializing_if = "Option::is_none")] + #[serde(rename = "feeTokenID")] pub fee_token_id: Option, /// Fee rate used for token fee calculation. - #[serde(skip_serializing_if = "Option::is_none")] pub fee_rate: Option, /// Token scale factor. - #[serde(skip_serializing_if = "Option::is_none")] pub token_scale: Option, /// Fee limit specified in the transaction. - #[serde(skip_serializing_if = "Option::is_none")] pub fee_limit: Option, /// Reference key for transaction indexing (only for MorphTx type 0x7F). /// 32-byte key used for looking up transactions by external systems. - #[serde(skip_serializing_if = "Option::is_none")] pub reference: Option, /// Memo field for arbitrary data (only for MorphTx type 0x7F). /// Up to 64 bytes for notes, invoice numbers, or other metadata. - #[serde(skip_serializing_if = "Option::is_none")] pub memo: Option, } @@ -217,18 +211,23 @@ mod tests { } #[test] - fn receipt_serde_skips_none_fields() { + fn receipt_serde_keeps_morph_extension_keys_for_geth_compatibility() { let receipt = make_rpc_receipt(U256::from(100), None, None); - let json = serde_json::to_string(&receipt).unwrap(); - - // Optional fields should not appear in JSON when None - assert!(!json.contains("version")); - assert!(!json.contains("feeTokenID")); - assert!(!json.contains("feeRate")); - assert!(!json.contains("tokenScale")); - assert!(!json.contains("feeLimit")); - assert!(!json.contains("reference")); - assert!(!json.contains("memo")); + let json = serde_json::to_value(&receipt).unwrap(); + + // morph-geth exposes these extension keys for every receipt. When a + // field is not populated, raw JSON still carries an explicit null. + for field in [ + "version", + "feeTokenID", + "feeRate", + "tokenScale", + "feeLimit", + "reference", + "memo", + ] { + assert_eq!(json.get(field), Some(&serde_json::Value::Null)); + } } #[test] From 692ae78d394d9dea88299f585e7b1b9bd3ee23f9 Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 13 May 2026 13:20:15 +0800 Subject: [PATCH 2/8] fix(rpc): encode receipt version as quantity Use the same JSON-RPC quantity encoding as morph-geth for the Morph receipt version extension field so replay can parse deployed reth receipts. Constraint: Preserve morph-geth-compatible receipt JSON contract Confidence: high Scope-risk: narrow --- crates/node/tests/it/rpc.rs | 6 ++++-- crates/rpc/src/eth/receipt.rs | 6 +++--- crates/rpc/src/types/receipt.rs | 12 ++++++++++-- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/node/tests/it/rpc.rs b/crates/node/tests/it/rpc.rs index c848a667..3e96816c 100644 --- a/crates/node/tests/it/rpc.rs +++ b/crates/node/tests/it/rpc.rs @@ -367,7 +367,8 @@ async fn transaction_receipt_exposes_morph_fields_over_rpc() -> eyre::Result<()> .await?; assert_eq!(receipt["type"].as_str(), Some("0x7f")); - assert_eq!(receipt["version"].as_u64(), Some(1)); + // version is JSON-RPC quantity-encoded (string "0x1"), not a number. + assert_eq!(receipt["version"].as_str(), Some("0x1")); assert_eq!(receipt["feeTokenID"].as_str(), Some("0x1")); assert_eq!( receipt["reference"].as_str(), @@ -506,7 +507,8 @@ async fn transaction_by_hash_exposes_morph_fields_over_rpc() -> eyre::Result<()> assert_eq!(tx["hash"].as_str(), Some(tx_hash.to_string().as_str())); assert_eq!(tx["type"].as_str(), Some("0x7f")); - assert_eq!(tx["version"].as_u64(), Some(1)); + // version is JSON-RPC quantity-encoded (string "0x1"), not a number. + assert_eq!(tx["version"].as_str(), Some("0x1")); assert_eq!(tx["feeTokenID"].as_str(), Some("0x1")); assert!(tx["feeLimit"].as_str().is_some()); assert_eq!(tx["reference"].as_str(), Some(expected_reference.as_str())); diff --git a/crates/rpc/src/eth/receipt.rs b/crates/rpc/src/eth/receipt.rs index 67344932..9bfdb991 100644 --- a/crates/rpc/src/eth/receipt.rs +++ b/crates/rpc/src/eth/receipt.rs @@ -93,7 +93,7 @@ impl MorphReceiptBuilder { let receipt = MorphRpcReceipt { inner: core_receipt, l1_fee: tx_receipt_fields.l1_fee, - version: tx_receipt_fields.version, + version: tx_receipt_fields.version.map(U64::from), fee_token_id: tx_receipt_fields.fee_token_id.map(U64::from), fee_rate: tx_receipt_fields.fee_rate, token_scale: tx_receipt_fields.token_scale, @@ -359,7 +359,7 @@ mod tests { // Morph-specific top-level RPC fields must round-trip from the // primitive `MorphTransactionReceipt` into `MorphRpcReceipt`. assert_eq!(rpc.l1_fee, U256::from(5000)); - assert_eq!(rpc.version, Some(1)); + assert_eq!(rpc.version, Some(U64::from(1))); assert_eq!(rpc.fee_token_id, Some(U64::from(3))); assert_eq!(rpc.fee_rate, Some(U256::from(2_000_000))); assert_eq!(rpc.token_scale, Some(U256::from(1_000_000))); @@ -433,7 +433,7 @@ mod tests { // L1 messages have no MorphTx metadata, but RPC compatibility with // morph-geth still exposes absent numeric extension fields as zero. assert_eq!(rpc.l1_fee, U256::ZERO); - assert_eq!(rpc.version, Some(0)); + assert_eq!(rpc.version, Some(U64::ZERO)); assert_eq!(rpc.fee_token_id, Some(U64::ZERO)); assert_eq!(rpc.fee_rate, Some(U256::ZERO)); assert_eq!(rpc.token_scale, Some(U256::ZERO)); diff --git a/crates/rpc/src/types/receipt.rs b/crates/rpc/src/types/receipt.rs index fc082657..b631b41b 100644 --- a/crates/rpc/src/types/receipt.rs +++ b/crates/rpc/src/types/receipt.rs @@ -25,7 +25,7 @@ pub struct MorphRpcReceipt { /// MorphTx version (only for MorphTx type 0x7F). /// 0 = legacy format, 1 = with reference/memo support. - pub version: Option, + pub version: Option, /// Token ID used for fee payment. #[serde(rename = "feeTokenID")] @@ -153,7 +153,7 @@ mod tests { MorphRpcReceipt { inner: tx_receipt, l1_fee, - version, + version: version.map(U64::from), fee_token_id, fee_rate: None, token_scale: None, @@ -243,4 +243,12 @@ mod tests { let json = serde_json::to_string(&receipt).unwrap(); assert!(json.contains("\"feeTokenID\"")); } + + #[test] + fn receipt_serde_version_is_json_rpc_quantity() { + let receipt = make_rpc_receipt(U256::ZERO, None, Some(0)); + let json = serde_json::to_value(&receipt).unwrap(); + + assert_eq!(json.get("version"), Some(&serde_json::json!("0x0"))); + } } From 467c4486f499599dced66da170ece35eedb974e5 Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 13 May 2026 13:32:10 +0800 Subject: [PATCH 3/8] fix(rpc): derive receipt type from transaction envelope Use the transaction envelope type when rendering receipt RPC responses so historical receipts decoded through a legacy storage wrapper still report their canonical transaction type. Constraint: Preserve morph-geth-compatible receipt JSON contract Confidence: high Scope-risk: narrow --- crates/rpc/src/eth/receipt.rs | 125 ++++++++++++++++++++++++++++------ 1 file changed, 105 insertions(+), 20 deletions(-) diff --git a/crates/rpc/src/eth/receipt.rs b/crates/rpc/src/eth/receipt.rs index 9bfdb991..1355ef15 100644 --- a/crates/rpc/src/eth/receipt.rs +++ b/crates/rpc/src/eth/receipt.rs @@ -3,9 +3,12 @@ use crate::eth::{MorphEthApi, MorphNodeCore}; use crate::types::receipt::MorphRpcReceipt; use alloy_consensus::{Receipt, TxReceipt}; +use alloy_eips::Typed2718; use alloy_primitives::{B256, Bytes, U64, U256}; use alloy_rpc_types_eth::Log; -use morph_primitives::{MorphReceipt, MorphReceiptEnvelope}; +use morph_primitives::{ + L1_TX_TYPE_ID, MORPH_TX_TYPE_ID, MorphReceipt, MorphReceiptEnvelope, MorphTxType, +}; use reth_primitives_traits::NodePrimitives; use reth_rpc_convert::{ RpcConvert, @@ -52,6 +55,7 @@ impl MorphReceiptBuilder { N: NodePrimitives, { let tx_receipt_fields = morph_tx_receipt_fields(&input.receipt); + let tx_type = morph_tx_type_from_u8(input.tx.ty()); let core_receipt = build_receipt(input, None, |receipt, next_log_index, meta| { let map_logs = |receipt: Receipt| { @@ -68,25 +72,23 @@ impl MorphReceiptBuilder { } }; - match receipt { - MorphReceipt::Legacy(receipt) => { - MorphReceiptEnvelope::Legacy(map_logs(receipt.inner).into_with_bloom()) - } - MorphReceipt::Eip2930(receipt) => { - MorphReceiptEnvelope::Eip2930(map_logs(receipt.inner).into_with_bloom()) - } - MorphReceipt::Eip1559(receipt) => { - MorphReceiptEnvelope::Eip1559(map_logs(receipt.inner).into_with_bloom()) - } - MorphReceipt::Eip7702(receipt) => { - MorphReceiptEnvelope::Eip7702(map_logs(receipt.inner).into_with_bloom()) - } - MorphReceipt::L1Msg(receipt) => { - MorphReceiptEnvelope::L1Message(map_logs(receipt).into_with_bloom()) - } - MorphReceipt::Morph(receipt) => { - MorphReceiptEnvelope::Morph(map_logs(receipt.inner).into_with_bloom()) - } + let receipt = match receipt { + MorphReceipt::Legacy(receipt) + | MorphReceipt::Eip2930(receipt) + | MorphReceipt::Eip1559(receipt) + | MorphReceipt::Eip7702(receipt) + | MorphReceipt::Morph(receipt) => map_logs(receipt.inner), + MorphReceipt::L1Msg(receipt) => map_logs(receipt), + } + .into_with_bloom(); + + match tx_type { + MorphTxType::Legacy => MorphReceiptEnvelope::Legacy(receipt), + MorphTxType::Eip2930 => MorphReceiptEnvelope::Eip2930(receipt), + MorphTxType::Eip1559 => MorphReceiptEnvelope::Eip1559(receipt), + MorphTxType::Eip7702 => MorphReceiptEnvelope::Eip7702(receipt), + MorphTxType::L1Msg => MorphReceiptEnvelope::L1Message(receipt), + MorphTxType::Morph => MorphReceiptEnvelope::Morph(receipt), } }); @@ -111,6 +113,18 @@ impl MorphReceiptBuilder { } } +fn morph_tx_type_from_u8(tx_type: u8) -> MorphTxType { + match tx_type { + 0 => MorphTxType::Legacy, + 1 => MorphTxType::Eip2930, + 2 => MorphTxType::Eip1559, + 4 => MorphTxType::Eip7702, + L1_TX_TYPE_ID => MorphTxType::L1Msg, + MORPH_TX_TYPE_ID => MorphTxType::Morph, + _ => MorphTxType::Legacy, + } +} + impl LoadReceipt for MorphEthApi where N: MorphNodeCore, @@ -378,6 +392,77 @@ mod tests { assert_eq!(rpc.inner.transaction_index, Some(0)); } + #[test] + fn receipt_rpc_type_follows_transaction_type_for_historical_receipts() { + use alloy_consensus::{Signed, TxEip1559, transaction::Recovered}; + use alloy_primitives::{B256, Signature, U256, address}; + use morph_primitives::{MorphPrimitives, MorphTxEnvelope, MorphTxType}; + use reth_primitives_traits::TransactionMeta; + use reth_rpc_convert::transaction::{ConvertReceiptInput, ReceiptConverter}; + + let signer = address!("0000000000000000000000000000000000000099"); + let envelope = MorphTxEnvelope::Eip1559(Signed::new_unchecked( + TxEip1559 { + chain_id: 2818, + nonce: 0, + gas_limit: 21_000, + max_fee_per_gas: 2_000_000_000, + max_priority_fee_per_gas: 1_000_000, + ..Default::default() + }, + Signature::new(U256::ZERO, U256::ZERO, false), + B256::ZERO, + )); + let recovered: Recovered<&MorphTxEnvelope> = Recovered::new_unchecked(&envelope, signer); + + // Historical receipts may be decoded from storage without preserving + // the original typed receipt variant. RPC must follow the transaction + // envelope type, not the storage receipt wrapper. + let receipt = MorphReceipt::Legacy(MorphTransactionReceipt { + inner: Receipt { + status: alloy_consensus::Eip658Value::Eip658(true), + cumulative_gas_used: 21_000, + logs: vec![], + }, + l1_fee: U256::ZERO, + version: None, + fee_token_id: None, + fee_rate: None, + token_scale: None, + fee_limit: None, + reference: None, + memo: None, + }); + + let input = ConvertReceiptInput::<'_, MorphPrimitives> { + receipt, + tx: recovered, + gas_used: 21_000, + next_log_index: 0, + meta: TransactionMeta { + tx_hash: B256::ZERO, + index: 0, + block_hash: B256::ZERO, + block_number: 42, + base_fee: Some(1_000_000_000), + excess_blob_gas: None, + timestamp: 1_700_000_000, + }, + }; + + let rpc = MorphReceiptConverter + .convert_receipts(vec![input]) + .expect("morph converter should not fail") + .pop() + .expect("converter must produce one receipt per input"); + + assert_eq!(rpc.inner.inner.tx_type(), MorphTxType::Eip1559); + assert_eq!( + serde_json::to_value(&rpc).unwrap().get("type"), + Some(&serde_json::json!("0x2")) + ); + } + /// Companion test: L1 message receipts must come back from the /// pubsub-style converter path with default Morph fields and the /// L1Msg envelope variant, just like `eth_getBlockReceipts`. From 803fc9a72216880fc97c6504022a49c8e88c5dbf Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 13 May 2026 16:58:12 +0800 Subject: [PATCH 4/8] fix(rpc): remove duplicate transaction extension fields Use the consensus transaction envelope as the single RPC serialization source for Morph transaction extension fields. This prevents L1Msg and MorphTx responses from emitting duplicate flattened JSON keys while preserving geth-compatible feeTokenID casing. Constraint: Keep receipt RPC fixes on the current phase branch intact Rejected: Keep wrapper fields with serde skip | leaves duplicate in-memory state Confidence: high Scope-risk: moderate --- .../src/transaction/morph_transaction.rs | 5 +- crates/rpc/src/eth/transaction.rs | 150 ++++++---------- crates/rpc/src/types/transaction.rs | 164 +----------------- 3 files changed, 63 insertions(+), 256 deletions(-) diff --git a/crates/primitives/src/transaction/morph_transaction.rs b/crates/primitives/src/transaction/morph_transaction.rs index 509cca82..a08437c9 100644 --- a/crates/primitives/src/transaction/morph_transaction.rs +++ b/crates/primitives/src/transaction/morph_transaction.rs @@ -119,7 +119,10 @@ pub struct TxMorph { /// Token ID for alternative fee payment. /// This corresponds to the token registered in the L2 Token Registry. /// 0 means ETH payment, > 0 means ERC20 token payment. - #[cfg_attr(feature = "serde", serde(default, with = "alloy_serde::quantity"))] + #[cfg_attr( + feature = "serde", + serde(default, with = "alloy_serde::quantity", rename = "feeTokenID") + )] pub fee_token_id: u16, /// Maximum amount of tokens the sender is willing to pay as fee. diff --git a/crates/rpc/src/eth/transaction.rs b/crates/rpc/src/eth/transaction.rs index 725ce418..09105dda 100644 --- a/crates/rpc/src/eth/transaction.rs +++ b/crates/rpc/src/eth/transaction.rs @@ -1,77 +1,17 @@ //! Morph transaction conversion for `eth_` RPC responses. use crate::MorphTransactionRequest; -use crate::types::transaction::MorphRpcTransaction; -use alloy_consensus::{ - EthereumTxEnvelope, SignableTransaction, Transaction, TxEip4844, transaction::Recovered, -}; +use alloy_consensus::{EthereumTxEnvelope, SignableTransaction, TxEip4844}; use alloy_network::TxSigner; -use alloy_primitives::{Address, Signature, TxKind, U64, U256}; -use alloy_rpc_types_eth::{AccessList, Transaction as RpcTransaction, TransactionInfo}; -use reth_rpc_convert::{ - FromConsensusTx, SignTxRequestError, SignableTxRequest, TryIntoSimTx, TryIntoTxEnv, -}; +use alloy_primitives::{Signature, TxKind, U64, U256}; +use alloy_rpc_types_eth::AccessList; +use reth_rpc_convert::{SignTxRequestError, SignableTxRequest, TryIntoSimTx, TryIntoTxEnv}; use reth_rpc_eth_types::EthApiError; -use std::convert::Infallible; use morph_primitives::{MorphTxEnvelope, TxMorph}; use morph_revm::{MorphBlockEnv, MorphTxEnv}; use reth_evm::EvmEnv; -/// Converts a consensus [`MorphTxEnvelope`] to an RPC [`MorphRpcTransaction`]. -impl FromConsensusTx for MorphRpcTransaction { - type TxInfo = TransactionInfo; - type Err = Infallible; - - fn from_consensus_tx( - tx: MorphTxEnvelope, - signer: Address, - tx_info: Self::TxInfo, - ) -> Result { - let (sender, queue_index) = match &tx { - MorphTxEnvelope::L1Msg(msg) => (Some(msg.sender), Some(U64::from(msg.queue_index))), - _ => (None, None), - }; - - // Extract MorphTx-specific fields - let version = tx.version(); - let fee_token_id = tx.fee_token_id().map(U64::from); - let fee_limit = tx.fee_limit(); - let reference = tx.reference(); - let memo = tx.memo().cloned(); - - let effective_gas_price = tx_info.base_fee.map(|base_fee| { - tx.effective_tip_per_gas(base_fee) - .unwrap_or_default() - .saturating_add(base_fee as u128) - }); - - let inner = RpcTransaction { - inner: Recovered::new_unchecked(tx, signer), - block_hash: tx_info.block_hash, - block_number: tx_info.block_number, - // alloy 2.0 added an explicit `block_timestamp` to RPC transactions - // so receipts/transactions returned by `eth_*` align with engine API - // semantics. `TransactionInfo` already plumbs this through from the - // block header, so we just forward it. - block_timestamp: tx_info.block_timestamp, - transaction_index: tx_info.index, - effective_gas_price, - }; - - Ok(Self { - inner, - sender, - queue_index, - version, - fee_token_id, - fee_limit, - reference, - memo, - }) - } -} - /// Converts a [`MorphTransactionRequest`] into a simulated transaction envelope. /// /// Handles both standard Ethereum transactions and Morph-specific fee token transactions. @@ -288,9 +228,11 @@ fn try_build_morph_tx_from_request( #[cfg(test)] mod tests { use super::*; - use alloy_primitives::{B256, Bytes, address}; - use alloy_rpc_types_eth::TransactionRequest; + use crate::types::transaction::MorphRpcTransaction; + use alloy_primitives::{Address, B256, Bytes, address}; + use alloy_rpc_types_eth::{TransactionInfo, TransactionRequest}; use morph_chainspec::MorphHardfork; + use reth_rpc_convert::FromConsensusTx; use revm::context::{BlockEnv, CfgEnv}; /// Helper function to create a basic TransactionRequest for testing @@ -754,14 +696,20 @@ mod tests { }; let rpc_tx = MorphRpcTransaction::from_consensus_tx(tx, signer, tx_info).unwrap(); - assert_eq!( - rpc_tx.sender, - Some(address!("000000000000000000000000000000000000dead")) - ); - assert_eq!(rpc_tx.queue_index, Some(U64::from(42))); - // L1 messages don't have MorphTx-specific fields - assert!(rpc_tx.version.is_none()); - assert!(rpc_tx.fee_token_id.is_none()); + match rpc_tx.inner.inner() { + MorphTxEnvelope::L1Msg(msg) => { + assert_eq!( + msg.sender, + address!("000000000000000000000000000000000000dead") + ); + assert_eq!(msg.queue_index, 42); + } + other => panic!("expected L1Msg variant, got {other:?}"), + } + + let json = serde_json::to_string(&rpc_tx).unwrap(); + assert_eq!(json.matches("\"sender\"").count(), 1); + assert_eq!(json.matches("\"queueIndex\"").count(), 1); } #[test] @@ -797,18 +745,27 @@ mod tests { }; let rpc_tx = MorphRpcTransaction::from_consensus_tx(tx, signer, tx_info).unwrap(); - // MorphTx should NOT have L1 message fields - assert!(rpc_tx.sender.is_none()); - assert!(rpc_tx.queue_index.is_none()); - // Should have MorphTx-specific fields - assert_eq!( - rpc_tx.version, - Some(morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1) - ); - assert_eq!(rpc_tx.fee_token_id, Some(U64::from(3))); - assert_eq!(rpc_tx.fee_limit, Some(U256::from(100_000))); - assert!(rpc_tx.reference.is_some()); - assert_eq!(rpc_tx.memo, Some(Bytes::from("hello"))); + match rpc_tx.inner.inner() { + MorphTxEnvelope::Morph(signed) => { + assert_eq!( + signed.tx().version, + morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1 + ); + assert_eq!(signed.tx().fee_token_id, 3); + assert_eq!(signed.tx().fee_limit, U256::from(100_000)); + assert!(signed.tx().reference.is_some()); + assert_eq!(signed.tx().memo, Some(Bytes::from("hello"))); + } + other => panic!("expected Morph variant, got {other:?}"), + } + + let json = serde_json::to_string(&rpc_tx).unwrap(); + assert_eq!(json.matches("\"version\"").count(), 1); + assert_eq!(json.matches("\"feeTokenID\"").count(), 1); + assert_eq!(json.matches("\"feeTokenId\"").count(), 0); + assert_eq!(json.matches("\"feeLimit\"").count(), 1); + assert_eq!(json.matches("\"reference\"").count(), 1); + assert_eq!(json.matches("\"memo\"").count(), 1); } #[test] @@ -838,14 +795,17 @@ mod tests { }; let rpc_tx = MorphRpcTransaction::from_consensus_tx(tx, signer, tx_info).unwrap(); - // Standard tx should have no L1 message or MorphTx fields - assert!(rpc_tx.sender.is_none()); - assert!(rpc_tx.queue_index.is_none()); - assert!(rpc_tx.version.is_none()); - assert!(rpc_tx.fee_token_id.is_none()); - assert!(rpc_tx.fee_limit.is_none()); - assert!(rpc_tx.reference.is_none()); - assert!(rpc_tx.memo.is_none()); + assert!(matches!(rpc_tx.inner.inner(), MorphTxEnvelope::Eip1559(_))); + + let json = serde_json::to_string(&rpc_tx).unwrap(); + assert!(!json.contains("\"sender\"")); + assert!(!json.contains("\"queueIndex\"")); + assert!(!json.contains("\"version\"")); + assert!(!json.contains("\"feeTokenID\"")); + assert!(!json.contains("\"feeTokenId\"")); + assert!(!json.contains("\"feeLimit\"")); + assert!(!json.contains("\"reference\"")); + assert!(!json.contains("\"memo\"")); } #[test] @@ -877,6 +837,6 @@ mod tests { // effective_gas_price = min(max_priority_fee, max_fee - base_fee) + base_fee // = min(500_000_000, 3_000_000_000 - 1_000_000_000) + 1_000_000_000 // = 500_000_000 + 1_000_000_000 = 1_500_000_000 - assert_eq!(rpc_tx.inner.effective_gas_price, Some(1_500_000_000)); + assert_eq!(rpc_tx.effective_gas_price, Some(1_500_000_000)); } } diff --git a/crates/rpc/src/types/transaction.rs b/crates/rpc/src/types/transaction.rs index 5757aac8..6fda1fb9 100644 --- a/crates/rpc/src/types/transaction.rs +++ b/crates/rpc/src/types/transaction.rs @@ -1,167 +1,11 @@ //! Morph RPC transaction type. -use alloy_consensus::Transaction as ConsensusTransaction; -use alloy_consensus::Transaction as TransactionTrait; -use alloy_eips::Typed2718; -use alloy_network::TransactionResponse; -use alloy_primitives::{Address, B256, BlockHash, Bytes, TxKind, U64, U256}; use alloy_rpc_types_eth::Transaction as RpcTransaction; use morph_primitives::MorphTxEnvelope; -use serde::{Deserialize, Serialize}; /// Morph RPC transaction representation. /// -/// Wraps the standard RPC transaction and adds Morph-specific fields: -/// - L1 message sender/queue index -/// - Morph fee token fields (version, fee_token_id, fee_limit, reference, memo) -#[derive( - Clone, Debug, PartialEq, Eq, Serialize, Deserialize, derive_more::Deref, derive_more::DerefMut, -)] -#[serde(rename_all = "camelCase")] -pub struct MorphRpcTransaction { - /// Standard RPC transaction fields. - #[serde(flatten)] - #[deref] - #[deref_mut] - pub inner: RpcTransaction, - - /// L1 message sender (only for L1Message type 0x7E). - #[serde(skip_serializing_if = "Option::is_none")] - pub sender: Option
, - - /// L1 message queue index (only for L1Message type 0x7E). - #[serde(skip_serializing_if = "Option::is_none")] - pub queue_index: Option, - - /// MorphTx version (only for MorphTx type 0x7F). - /// 0 = legacy format, 1 = with reference/memo support. - #[serde(skip_serializing_if = "Option::is_none")] - pub version: Option, - - /// Token ID for fee payment (only for MorphTx type 0x7F). - #[serde(rename = "feeTokenID", skip_serializing_if = "Option::is_none")] - pub fee_token_id: Option, - - /// Maximum token amount willing to pay for fees (only for MorphTx type 0x7F). - #[serde(skip_serializing_if = "Option::is_none")] - pub fee_limit: Option, - - /// Reference key for transaction indexing (only for MorphTx type 0x7F). - /// 32-byte key used for looking up transactions by external systems. - #[serde(skip_serializing_if = "Option::is_none")] - pub reference: Option, - - /// Memo field for arbitrary data (only for MorphTx type 0x7F). - /// Up to 64 bytes for notes, invoice numbers, or other metadata. - #[serde(skip_serializing_if = "Option::is_none")] - pub memo: Option, -} - -/// Implementation of [`Typed2718`] for Morph RPC transactions. -impl Typed2718 for MorphRpcTransaction { - fn ty(&self) -> u8 { - self.inner.ty() - } -} - -/// Implementation of [`ConsensusTransaction`] for Morph RPC transactions. -/// -/// Delegates all consensus transaction methods to the inner transaction. -impl ConsensusTransaction for MorphRpcTransaction { - fn chain_id(&self) -> Option { - self.inner.chain_id() - } - - fn nonce(&self) -> u64 { - self.inner.nonce() - } - - fn gas_limit(&self) -> u64 { - self.inner.gas_limit() - } - - fn gas_price(&self) -> Option { - TransactionTrait::gas_price(&self.inner) - } - - fn max_fee_per_gas(&self) -> u128 { - TransactionTrait::max_fee_per_gas(&self.inner) - } - - fn max_priority_fee_per_gas(&self) -> Option { - self.inner.max_priority_fee_per_gas() - } - - fn max_fee_per_blob_gas(&self) -> Option { - self.inner.max_fee_per_blob_gas() - } - - fn priority_fee_or_price(&self) -> u128 { - self.inner.priority_fee_or_price() - } - - fn effective_gas_price(&self, base_fee: Option) -> u128 { - self.inner.effective_gas_price(base_fee) - } - - fn is_dynamic_fee(&self) -> bool { - self.inner.is_dynamic_fee() - } - - fn kind(&self) -> TxKind { - self.inner.kind() - } - - fn is_create(&self) -> bool { - self.inner.is_create() - } - - fn to(&self) -> Option
{ - self.inner.to() - } - - fn value(&self) -> U256 { - self.inner.value() - } - - fn input(&self) -> &alloy_primitives::Bytes { - self.inner.input() - } - - fn access_list(&self) -> Option<&alloy_eips::eip2930::AccessList> { - self.inner.access_list() - } - - fn blob_versioned_hashes(&self) -> Option<&[alloy_primitives::B256]> { - self.inner.blob_versioned_hashes() - } - - fn authorization_list(&self) -> Option<&[alloy_eips::eip7702::SignedAuthorization]> { - self.inner.authorization_list() - } -} - -/// Implementation of [`TransactionResponse`] for Morph RPC transactions. -/// -/// Provides RPC-specific transaction metadata like block hash and index. -impl TransactionResponse for MorphRpcTransaction { - fn tx_hash(&self) -> alloy_primitives::B256 { - self.inner.tx_hash() - } - - fn block_hash(&self) -> Option { - self.inner.block_hash() - } - - fn block_number(&self) -> Option { - self.inner.block_number() - } - - fn transaction_index(&self) -> Option { - self.inner.transaction_index() - } - - fn from(&self) -> Address { - self.inner.from() - } -} +/// Morph-specific fields are emitted by the `MorphTxEnvelope` variants' own +/// serde derives. Do not wrap this with extra extension fields: doing so +/// duplicates flattened keys such as `sender`, `queueIndex`, and `feeTokenID`. +pub type MorphRpcTransaction = RpcTransaction; From 6087362604473069e6116db39d5153386263e3e9 Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 13 May 2026 17:24:22 +0800 Subject: [PATCH 5/8] fix(rpc): emit MorphTx gas as rpc gas field Align MorphTx JSON with standard eth transaction responses by serializing gas_limit as gas while accepting gasLimit as an input alias. This lets replay parse MorphTx blocks using the same schema as morph-geth. Constraint: Preserve Rust/internal gas_limit field and RLP encoding semantics Rejected: Normalize gasLimit in the replay harness | hides an RPC schema divergence Confidence: high Scope-risk: narrow --- crates/primitives/src/transaction/morph_transaction.rs | 5 ++++- crates/rpc/src/eth/transaction.rs | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/primitives/src/transaction/morph_transaction.rs b/crates/primitives/src/transaction/morph_transaction.rs index a08437c9..cef2dc46 100644 --- a/crates/primitives/src/transaction/morph_transaction.rs +++ b/crates/primitives/src/transaction/morph_transaction.rs @@ -78,7 +78,10 @@ pub struct TxMorph { /// in executing this transaction. This is paid up-front, before any /// computation is done and may not be increased later. /// Matches go-ethereum's `AltFeeTx.Gas` (uint64). - #[cfg_attr(feature = "serde", serde(with = "alloy_serde::quantity"))] + #[cfg_attr( + feature = "serde", + serde(with = "alloy_serde::quantity", rename = "gas", alias = "gasLimit") + )] pub gas_limit: u64, /// A scalar value equal to the maximum amount of gas that should be used diff --git a/crates/rpc/src/eth/transaction.rs b/crates/rpc/src/eth/transaction.rs index 09105dda..d70a2e95 100644 --- a/crates/rpc/src/eth/transaction.rs +++ b/crates/rpc/src/eth/transaction.rs @@ -760,6 +760,8 @@ mod tests { } let json = serde_json::to_string(&rpc_tx).unwrap(); + assert_eq!(json.matches("\"gas\"").count(), 1); + assert_eq!(json.matches("\"gasLimit\"").count(), 0); assert_eq!(json.matches("\"version\"").count(), 1); assert_eq!(json.matches("\"feeTokenID\"").count(), 1); assert_eq!(json.matches("\"feeTokenId\"").count(), 0); From 669657fc0fd43b5739eba037cd0a34d3a83afca5 Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 13 May 2026 17:31:25 +0800 Subject: [PATCH 6/8] fix(rpc): align MorphTxFields fee token id key --- .../src/transaction/morph_transaction.rs | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/primitives/src/transaction/morph_transaction.rs b/crates/primitives/src/transaction/morph_transaction.rs index cef2dc46..d7986336 100644 --- a/crates/primitives/src/transaction/morph_transaction.rs +++ b/crates/primitives/src/transaction/morph_transaction.rs @@ -38,7 +38,15 @@ pub const MAX_MEMO_LENGTH: usize = 64; pub struct MorphTxFields { #[cfg_attr(feature = "serde", serde(default, with = "alloy_serde::quantity"))] pub version: u8, - #[cfg_attr(feature = "serde", serde(default, with = "alloy_serde::quantity"))] + #[cfg_attr( + feature = "serde", + serde( + default, + with = "alloy_serde::quantity", + rename = "feeTokenID", + alias = "feeTokenId" + ) + )] pub fee_token_id: u16, #[cfg_attr(feature = "serde", serde(default))] pub fee_limit: U256, @@ -124,7 +132,12 @@ pub struct TxMorph { /// 0 means ETH payment, > 0 means ERC20 token payment. #[cfg_attr( feature = "serde", - serde(default, with = "alloy_serde::quantity", rename = "feeTokenID") + serde( + default, + with = "alloy_serde::quantity", + rename = "feeTokenID", + alias = "feeTokenId" + ) )] pub fee_token_id: u16, @@ -2148,6 +2161,22 @@ mod tests { assert_eq!(fields.memo, None); } + #[cfg(feature = "serde")] + #[test] + fn test_morph_tx_fields_serde_uses_canonical_fee_token_id_key() { + let fields = MorphTxFields { + version: 1, + fee_token_id: 7, + fee_limit: U256::from(999u64), + reference: None, + memo: None, + }; + + let json = serde_json::to_value(fields).unwrap(); + assert_eq!(json.get("feeTokenID"), Some(&serde_json::json!("0x7"))); + assert!(json.get("feeTokenId").is_none()); + } + #[cfg(feature = "reth-codec")] #[test] fn test_compact_roundtrip_v1_with_memo() { From 070b5e839ac8193be151d2c3e5886dcf469d2508 Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 13 May 2026 17:47:20 +0800 Subject: [PATCH 7/8] fix(rpc): omit MorphTx v0 version field --- .../src/transaction/morph_transaction.rs | 14 ++++++- crates/rpc/src/eth/transaction.rs | 39 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/primitives/src/transaction/morph_transaction.rs b/crates/primitives/src/transaction/morph_transaction.rs index d7986336..49187a38 100644 --- a/crates/primitives/src/transaction/morph_transaction.rs +++ b/crates/primitives/src/transaction/morph_transaction.rs @@ -31,6 +31,11 @@ pub const MORPH_TX_VERSION_1: u8 = 1; /// Maximum length of the memo field in bytes. pub const MAX_MEMO_LENGTH: usize = 64; +#[cfg(feature = "serde")] +fn is_morph_tx_version_0(version: &u8) -> bool { + *version == MORPH_TX_VERSION_0 +} + /// Canonical MorphTx-specific fields shared across modules. #[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -124,7 +129,14 @@ pub struct TxMorph { /// Version of the Morph transaction format. /// Used for future extensibility. - #[cfg_attr(feature = "serde", serde(default, with = "alloy_serde::quantity"))] + #[cfg_attr( + feature = "serde", + serde( + default, + with = "alloy_serde::quantity", + skip_serializing_if = "is_morph_tx_version_0" + ) + )] pub version: u8, /// Token ID for alternative fee payment. diff --git a/crates/rpc/src/eth/transaction.rs b/crates/rpc/src/eth/transaction.rs index d70a2e95..014081ce 100644 --- a/crates/rpc/src/eth/transaction.rs +++ b/crates/rpc/src/eth/transaction.rs @@ -770,6 +770,45 @@ mod tests { assert_eq!(json.matches("\"memo\"").count(), 1); } + #[test] + fn from_consensus_tx_morph_tx_v0_omits_v1_fields() { + use alloy_consensus::Signed; + + let morph_tx = TxMorph { + chain_id: 2818, + nonce: 756, + gas_limit: 100_000, + max_fee_per_gas: 85_000_000, + max_priority_fee_per_gas: 83_000_000, + fee_token_id: 2, + fee_limit: U256::from(10_000_000), + ..Default::default() + }; + let tx = MorphTxEnvelope::Morph(Signed::new_unchecked( + morph_tx, + Signature::new(U256::ZERO, U256::ZERO, false), + Default::default(), + )); + let tx_info = TransactionInfo { + hash: Some(B256::ZERO), + block_hash: Some(B256::random()), + block_number: Some(19720219), + block_timestamp: None, + index: Some(0), + base_fee: Some(1_000_000), + }; + + let rpc_tx = MorphRpcTransaction::from_consensus_tx(tx, Address::ZERO, tx_info).unwrap(); + let json = serde_json::to_string(&rpc_tx).unwrap(); + + assert_eq!(json.matches("\"gas\"").count(), 1); + assert_eq!(json.matches("\"feeTokenID\"").count(), 1); + assert_eq!(json.matches("\"feeLimit\"").count(), 1); + assert!(!json.contains("\"version\"")); + assert!(!json.contains("\"reference\"")); + assert!(!json.contains("\"memo\"")); + } + #[test] fn from_consensus_tx_standard_eip1559() { use alloy_consensus::{Signed, TxEip1559}; From 11443be494b4cbafacb99b627b30e3899c27c8a3 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Tue, 26 May 2026 11:22:59 +0800 Subject: [PATCH 8/8] fix(rpc): set default eth_maxPriorityFeePerGas to match morph-geth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds MORPH_DEFAULT_PRIORITY_FEE constant (1_000_000 wei) and wires it into the reth GPO config as default_suggested_fee. Without this, reth falls back to its own 1 gwei default while morph-geth inherits 1_000_000 wei from upstream go-ethereum's miner.DefaultConfig.GasPrice — a 1000x divergence in eth_maxPriorityFeePerGas whenever the GPO has no usable block samples, which is the common case on Morph L2 (tip is typically 0 and gets filtered out by ignore_price). --- Cargo.lock | 1 + bin/morph-reth/Cargo.toml | 1 + bin/morph-reth/src/main.rs | 103 ++++++++++++++++++------------ crates/chainspec/src/constants.rs | 11 ++++ 4 files changed, 74 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c3afd5e6..3964b9b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5177,6 +5177,7 @@ dependencies = [ name = "morph-reth" version = "0.3.0" dependencies = [ + "alloy-primitives", "clap", "eyre", "morph-chainspec", diff --git a/bin/morph-reth/Cargo.toml b/bin/morph-reth/Cargo.toml index dce6ce87..b953ca54 100644 --- a/bin/morph-reth/Cargo.toml +++ b/bin/morph-reth/Cargo.toml @@ -31,6 +31,7 @@ reth-node-builder.workspace = true reth-rpc-server-types.workspace = true # Other +alloy-primitives.workspace = true clap.workspace = true eyre.workspace = true tracing.workspace = true diff --git a/bin/morph-reth/src/main.rs b/bin/morph-reth/src/main.rs index 31b8cf7f..45b7d24f 100644 --- a/bin/morph-reth/src/main.rs +++ b/bin/morph-reth/src/main.rs @@ -15,8 +15,9 @@ use reth_cli_util::allocator::tikv_jemalloc_sys as _; #[unsafe(export_name = "malloc_conf")] static MALLOC_CONF: &[u8] = b"prof:true,prof_active:true,lg_prof_sample:19\0"; +use alloy_primitives::U256; use clap::Parser; -use morph_chainspec::{MorphChainSpec, MorphChainSpecParser}; +use morph_chainspec::{MORPH_DEFAULT_PRIORITY_FEE, MorphChainSpec, MorphChainSpecParser}; use morph_consensus::MorphConsensus; use morph_evm::{MorphEvmConfig, evm::MorphEvmFactory}; use morph_node::{ @@ -26,12 +27,28 @@ use morph_node::{ use morph_reference_index::ReferenceIndexDb; use reth_chainspec::EthChainSpec; use reth_cli_util::sigsegv_handler; -use reth_ethereum_cli::Cli; +use reth_ethereum_cli::{Cli, Commands}; use reth_node_builder::Node; use reth_rpc_server_types::DefaultRpcModuleValidator; use std::sync::Arc; use tracing::info; +fn morph_default_suggested_fee() -> U256 { + U256::from_limbs([MORPH_DEFAULT_PRIORITY_FEE, 0, 0, 0]) +} + +fn apply_morph_cli_defaults( + cli: &mut Cli, +) { + if let Commands::Node(command) = &mut cli.command { + command + .rpc + .gas_price_oracle + .default_suggested_fee + .get_or_insert_with(morph_default_suggested_fee); + } +} + fn main() { // Override reth's default version info with morph-reth's own version, // commit SHA, and build timestamp. Must be called before CLI parsing. @@ -55,47 +72,49 @@ fn main() { ) }; - // Parse CLI arguments and run the node + let mut cli = Cli::::parse(); + apply_morph_cli_defaults(&mut cli); + + // Run the node if let Err(err) = - Cli::::parse() - .run_with_components::(components, async move |builder, morph_args| { - info!(target: "morph::cli", "Starting Morph-Reth node"); - - // Open the reference index DB before launching the node so we - // can wire it into both the ExEx and the add-ons. - let chain_spec = builder.config().chain.clone(); - let datadir = builder.config().datadir(); - let reference_index_path = datadir.data_dir().join("morph").join("reference_index"); - let chain_id = chain_spec.chain().id(); - let genesis_hash = chain_spec.genesis_hash(); // from EthChainSpec trait - - info!( - target: "morph::reference_index", - path = %reference_index_path.display(), - chain_id, - "opening Morph reference index database" - ); - let db = ReferenceIndexDb::open(&reference_index_path, chain_id, genesis_hash)?; - let (control, startup_rx) = ReferenceIndexControl::new(db); - - let exex_control = control.clone(); - let node = MorphNode::new(morph_args); - - let handle = builder - .with_types::() - .with_components(node.components_builder()) - .with_add_ons(MorphAddOns::new().with_reference_index(control)) - .install_exex("morph-reference-index", async move |ctx| { - Ok(reference_index_exex(ctx, exex_control, startup_rx)) - }) - .launch_with_debug_capabilities() - .await?; - - info!(target: "morph::cli", "Node started successfully"); - - // Wait for node exit - handle.node_exit_future.await - }) + cli.run_with_components::(components, async move |builder, morph_args| { + info!(target: "morph::cli", "Starting Morph-Reth node"); + + // Open the reference index DB before launching the node so we + // can wire it into both the ExEx and the add-ons. + let chain_spec = builder.config().chain.clone(); + let datadir = builder.config().datadir(); + let reference_index_path = datadir.data_dir().join("morph").join("reference_index"); + let chain_id = chain_spec.chain().id(); + let genesis_hash = chain_spec.genesis_hash(); // from EthChainSpec trait + + info!( + target: "morph::reference_index", + path = %reference_index_path.display(), + chain_id, + "opening Morph reference index database" + ); + let db = ReferenceIndexDb::open(&reference_index_path, chain_id, genesis_hash)?; + let (control, startup_rx) = ReferenceIndexControl::new(db); + + let exex_control = control.clone(); + let node = MorphNode::new(morph_args); + + let handle = builder + .with_types::() + .with_components(node.components_builder()) + .with_add_ons(MorphAddOns::new().with_reference_index(control)) + .install_exex("morph-reference-index", async move |ctx| { + Ok(reference_index_exex(ctx, exex_control, startup_rx)) + }) + .launch_with_debug_capabilities() + .await?; + + info!(target: "morph::cli", "Node started successfully"); + + // Wait for node exit + handle.node_exit_future.await + }) { eprintln!("Error: {err:?}"); std::process::exit(1); diff --git a/crates/chainspec/src/constants.rs b/crates/chainspec/src/constants.rs index efbc953a..56b98755 100644 --- a/crates/chainspec/src/constants.rs +++ b/crates/chainspec/src/constants.rs @@ -12,6 +12,17 @@ pub const MORPH_HOODI_CHAIN_ID: u64 = 2910; /// The sequencer has the right to set any base fee below `MORPH_MAX_BASE_FEE`. pub const MORPH_BASE_FEE: u64 = 1_000_000; +/// Default priority fee returned by `eth_maxPriorityFeePerGas` when the gas +/// price oracle has no usable block samples (cold start or empty/zero-tip +/// blocks, the common case on Morph L2). +/// +/// Matches morph-geth, which inherits this value from upstream go-ethereum's +/// `miner.DefaultConfig.GasPrice = params.GWei / 1000 = 1_000_000 wei` +/// (see `eth/backend.go`: `gpoParams.Default = config.Miner.GasPrice`). +/// Without this default, reth would fall back to its own 1 gwei default, +/// causing `eth_maxPriorityFeePerGas` to diverge from geth by 1000x. +pub const MORPH_DEFAULT_PRIORITY_FEE: u64 = 1_000_000; + /// Morph Mainnet genesis hash (computed with ZK-trie state root). /// /// Source: go-ethereum/params/config.go