diff --git a/Cargo.lock b/Cargo.lock index 4eff2e44..9149d393 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 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/primitives/src/transaction/morph_transaction.rs b/crates/primitives/src/transaction/morph_transaction.rs index 509cca82..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))] @@ -38,7 +43,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, @@ -78,7 +91,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 @@ -113,13 +129,28 @@ 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. /// 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", + alias = "feeTokenId" + ) + )] pub fee_token_id: u16, /// Maximum amount of tokens the sender is willing to pay as fee. @@ -2142,6 +2173,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() { diff --git a/crates/rpc/src/eth/receipt.rs b/crates/rpc/src/eth/receipt.rs index def41fc2..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,32 +72,30 @@ 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), } }); 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, @@ -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, @@ -133,7 +147,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 +158,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 +249,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 +276,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 @@ -320,7 +373,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))); @@ -339,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`. @@ -391,13 +515,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(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)); + assert_eq!(rpc.fee_limit, Some(U256::ZERO)); assert!(rpc.reference.is_none()); assert!(rpc.memo.is_none()); } diff --git a/crates/rpc/src/eth/transaction.rs b/crates/rpc/src/eth/transaction.rs index 725ce418..014081ce 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,68 @@ 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("\"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); + assert_eq!(json.matches("\"feeLimit\"").count(), 1); + assert_eq!(json.matches("\"reference\"").count(), 1); + 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] @@ -838,14 +836,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 +878,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/receipt.rs b/crates/rpc/src/types/receipt.rs index 93dd2fd3..b631b41b 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, + 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, } @@ -159,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, @@ -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] @@ -244,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"))); + } } 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;