From eff7f51dfcb3013d5a5aea8de40e75497fecbfca Mon Sep 17 00:00:00 2001 From: panos Date: Mon, 7 Sep 2026 13:21:42 +0800 Subject: [PATCH 1/2] fix(rpc): emit null for absent Morph receipt fee fields morph-geth serializes unset feeTokenID/feeRate/tokenScale/feeLimit as JSON null, not "0x0". The previous unwrap_or_default made every standard tx look like MorphTx paying with token 0, which breaks viem/indexer checks of `feeTokenID != null`. Keep Some(0) for MorphTx v1 ETH-fee. --- crates/node/tests/it/rpc.rs | 48 +++++++++++- crates/rpc/src/eth/receipt.rs | 143 +++++++++++++++++++++++++++------- 2 files changed, 161 insertions(+), 30 deletions(-) diff --git a/crates/node/tests/it/rpc.rs b/crates/node/tests/it/rpc.rs index 0045244b..6a8ca1c8 100644 --- a/crates/node/tests/it/rpc.rs +++ b/crates/node/tests/it/rpc.rs @@ -9,7 +9,7 @@ use alloy_primitives::{Address, B256, Bytes, Sealable, TxKind, U256}; use alloy_signer::SignerSync; use jsonrpsee::core::client::ClientT; use morph_node::test_utils::{ - MorphTestNode, MorphTxBuilder, TEST_TOKEN_ID, TestNodeBuilder, advance_chain, + MorphTestNode, MorphTxBuilder, TEST_TOKEN_ID, TestNodeBuilder, advance_chain, make_transfer_tx, }; use morph_primitives::MorphTxEnvelope; use reth_payload_primitives::BuiltPayload; @@ -301,6 +301,52 @@ async fn transaction_receipt_exposes_morph_fields_over_rpc() -> eyre::Result<()> Ok(()) } +/// Standard (non-MorphTx) receipts must keep Morph fee pointers as JSON null. +/// +/// `@morph-network/viem` and indexers treat `feeTokenID != null` as "this is an +/// alt-fee transaction". Filling `"0x0"` would misclassify every EIP-1559 tx. +#[tokio::test(flavor = "multi_thread")] +async fn standard_transaction_receipt_exposes_null_fee_fields_over_rpc() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let raw_tx = make_transfer_tx(wallet.chain_id, wallet.inner.clone(), 0).await; + node.rpc.inject_tx(raw_tx).await?; + + let payload = node.advance_block().await?; + let tx_hash = *payload + .block() + .body() + .transactions + .first() + .unwrap() + .tx_hash(); + let client = node + .rpc_client() + .ok_or_else(|| eyre::eyre!("HTTP RPC client not available"))?; + + let receipt: Value = client + .request("eth_getTransactionReceipt", (tx_hash,)) + .await?; + + assert_ne!(receipt["type"].as_str(), Some("0x7f")); + // geth's version field is a value type, so unset receipts still emit "0x0". + assert_eq!(receipt["version"].as_str(), Some("0x0")); + for field in ["feeTokenID", "feeRate", "tokenScale", "feeLimit"] { + assert!( + receipt[field].is_null(), + "{field} must be JSON null for non-MorphTx receipts, got {}", + receipt[field] + ); + } + assert!(receipt["reference"].is_null()); + assert!(receipt["memo"].is_null()); + + Ok(()) +} + /// `eth_getBlockReceipts` uses the same Morph receipt converter as per-tx receipt RPCs. #[tokio::test(flavor = "multi_thread")] async fn block_receipts_expose_morph_fields_over_rpc() -> eyre::Result<()> { diff --git a/crates/rpc/src/eth/receipt.rs b/crates/rpc/src/eth/receipt.rs index 1355ef15..a1c64caf 100644 --- a/crates/rpc/src/eth/receipt.rs +++ b/crates/rpc/src/eth/receipt.rs @@ -148,8 +148,11 @@ struct MorphTxReceiptFields { /// Extracts Morph-specific fee fields from a receipt. /// /// 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. +/// every receipt. `version` is a value type there, so an unset version is +/// still `"0x0"`. Pointer metadata (`feeTokenID`, `feeRate`, `tokenScale`, +/// `feeLimit`, `reference`, `memo`) stays `null` unless storage populated it. +/// MorphTx v1 paying ETH stores `fee_token_id = Some(0)`, which must remain +/// `"0x0"` so clients can distinguish it from a non-MorphTx `null`. fn morph_tx_receipt_fields(receipt: &MorphReceipt) -> MorphTxReceiptFields { match receipt { MorphReceipt::Legacy(r) @@ -159,19 +162,15 @@ fn morph_tx_receipt_fields(receipt: &MorphReceipt) -> MorphTxReceiptFields { | MorphReceipt::Morph(r) => MorphTxReceiptFields { l1_fee: r.l1_fee, 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()), + fee_token_id: r.fee_token_id, + fee_rate: r.fee_rate, + token_scale: r.token_scale, + fee_limit: r.fee_limit, reference: r.reference, memo: r.memo.clone(), }, 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() }, } @@ -249,11 +248,13 @@ mod tests { let fields = morph_tx_receipt_fields(&receipt); assert_eq!(fields.l1_fee, U256::ZERO); + // geth's version field is a value type, so non-MorphTx receipts still + // expose "0x0". Pointer fee metadata is absent and must stay 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.fee_token_id.is_none()); + assert!(fields.fee_rate.is_none()); + assert!(fields.token_scale.is_none()); + assert!(fields.fee_limit.is_none()); assert!(fields.reference.is_none()); assert!(fields.memo.is_none()); } @@ -277,7 +278,7 @@ mod tests { } #[test] - fn morph_tx_receipt_fields_defaults_absent_numeric_metadata_to_zero() { + fn morph_tx_receipt_fields_keeps_absent_fee_metadata_none() { let receipt = MorphReceipt::Eip1559(MorphTransactionReceipt { inner: Receipt { status: alloy_consensus::Eip658Value::Eip658(true), @@ -297,11 +298,13 @@ mod tests { let fields = morph_tx_receipt_fields(&receipt); assert_eq!(fields.l1_fee, U256::from(123)); + // Match geth: version is a value type and always "0x0" when unset. 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)); + // Pointer fields stay None so JSON serializes as null, not "0x0". + 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!(fields.reference.is_none()); assert!(fields.memo.is_none()); } @@ -457,10 +460,86 @@ mod tests { .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")) - ); + let json = serde_json::to_value(&rpc).unwrap(); + assert_eq!(json.get("type"), Some(&serde_json::json!("0x2"))); + // geth emits version as a value type ("0x0") even for non-MorphTx. + assert_eq!(json.get("version"), Some(&serde_json::json!("0x0"))); + // Absent Morph fee pointers must be JSON null, not "0x0", so clients + // like `@morph-network/viem` can use `feeTokenID != null`. + for field in ["feeTokenID", "feeRate", "tokenScale", "feeLimit"] { + assert_eq!(json.get(field), Some(&serde_json::Value::Null), "{field}"); + } + } + + #[test] + fn morph_tx_v1_eth_fee_serializes_fee_token_id_zero_not_null() { + use alloy_consensus::{Signed, transaction::Recovered}; + use alloy_primitives::{B256, Signature, U256, address}; + use morph_primitives::{MorphPrimitives, MorphTxEnvelope, TxMorph}; + use reth_primitives_traits::TransactionMeta; + use reth_rpc_convert::transaction::{ConvertReceiptInput, ReceiptConverter}; + + let signer = address!("0000000000000000000000000000000000000099"); + let envelope = MorphTxEnvelope::Morph(Signed::new_unchecked( + TxMorph { + 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, + version: 1, + fee_token_id: 0, + ..Default::default() + }, + Signature::new(U256::ZERO, U256::ZERO, false), + B256::ZERO, + )); + let recovered: Recovered<&MorphTxEnvelope> = Recovered::new_unchecked(&envelope, signer); + + let receipt = MorphReceipt::Morph(MorphTransactionReceipt { + inner: Receipt { + status: alloy_consensus::Eip658Value::Eip658(true), + cumulative_gas_used: 21_000, + logs: vec![], + }, + l1_fee: U256::from(100), + version: Some(1), + fee_token_id: Some(0), + fee_rate: None, + token_scale: None, + fee_limit: Some(U256::ZERO), + reference: None, + memo: None, + }); + + let rpc = MorphReceiptConverter + .convert_receipts(vec![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, + }, + }]) + .expect("morph converter should not fail") + .pop() + .expect("converter must produce one receipt per input"); + + let json = serde_json::to_value(&rpc).unwrap(); + // MorphTx v1 paying ETH stores fee_token_id = 0, which must remain a + // quantity so it is distinguishable from a non-MorphTx null. + assert_eq!(json.get("feeTokenID"), Some(&serde_json::json!("0x0"))); + assert_eq!(json.get("version"), Some(&serde_json::json!("0x1"))); + assert_eq!(json.get("feeLimit"), Some(&serde_json::json!("0x0"))); + assert_eq!(json.get("feeRate"), Some(&serde_json::Value::Null)); + assert_eq!(json.get("tokenScale"), Some(&serde_json::Value::Null)); } /// Companion test: L1 message receipts must come back from the @@ -515,15 +594,21 @@ mod tests { .pop() .expect("converter must produce one receipt per input"); - // L1 messages have no MorphTx metadata, but RPC compatibility with - // morph-geth still exposes absent numeric extension fields as zero. + // L1 messages have no MorphTx metadata. geth leaves the pointer fee + // fields nil (JSON null) and still emits version as the value "0x0". assert_eq!(rpc.l1_fee, U256::ZERO); 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.fee_token_id.is_none()); + assert!(rpc.fee_rate.is_none()); + assert!(rpc.token_scale.is_none()); + assert!(rpc.fee_limit.is_none()); assert!(rpc.reference.is_none()); assert!(rpc.memo.is_none()); + + let json = serde_json::to_value(&rpc).unwrap(); + assert_eq!(json.get("version"), Some(&serde_json::json!("0x0"))); + for field in ["feeTokenID", "feeRate", "tokenScale", "feeLimit"] { + assert_eq!(json.get(field), Some(&serde_json::Value::Null), "{field}"); + } } } From 94fed50ad19b5e35d827904de10a8a269fb0af0c Mon Sep 17 00:00:00 2001 From: panos Date: Tue, 8 Sep 2026 11:22:42 +0800 Subject: [PATCH 2/2] fix(evm): omit token metadata for MorphTx ETH receipts --- crates/evm/src/block/mod.rs | 8 ++--- crates/evm/src/block/receipt.rs | 12 +++---- crates/node/tests/it/rpc.rs | 40 ++++++++++++++++++++++++ crates/primitives/src/receipt/receipt.rs | 8 ++--- 4 files changed, 54 insertions(+), 14 deletions(-) diff --git a/crates/evm/src/block/mod.rs b/crates/evm/src/block/mod.rs index 42ce3269..ae25208b 100644 --- a/crates/evm/src/block/mod.rs +++ b/crates/evm/src/block/mod.rs @@ -153,8 +153,8 @@ where return Ok(Some(MorphReceiptTxFields { version, fee_token_id: 0, - fee_rate: U256::ZERO, - token_scale: U256::ZERO, + fee_rate: None, + token_scale: None, fee_limit, reference, memo, @@ -174,8 +174,8 @@ where Ok(token_info.map(|info| MorphReceiptTxFields { version, fee_token_id, - fee_rate: info.price_ratio, - token_scale: info.scale, + fee_rate: Some(info.price_ratio), + token_scale: Some(info.scale), fee_limit, reference, memo, diff --git a/crates/evm/src/block/receipt.rs b/crates/evm/src/block/receipt.rs index 00c9a426..347c8265 100644 --- a/crates/evm/src/block/receipt.rs +++ b/crates/evm/src/block/receipt.rs @@ -92,10 +92,10 @@ pub(crate) struct MorphReceiptTxFields { pub version: u8, /// Token ID for fee payment pub fee_token_id: u16, - /// Exchange rate for the fee token - pub fee_rate: U256, - /// Scale factor for the token - pub token_scale: U256, + /// Exchange rate for the fee token, absent when paying with ETH + pub fee_rate: Option, + /// Scale factor for the token, absent when paying with ETH + pub token_scale: Option, /// Fee limit specified in the transaction pub fee_limit: U256, /// Reference key for transaction indexing @@ -422,8 +422,8 @@ mod tests { let fields = MorphReceiptTxFields { version: 0, fee_token_id: 1, - fee_rate: U256::from(2_000_000_000u64), - token_scale: U256::from(10u64).pow(U256::from(18u64)), + fee_rate: Some(U256::from(2_000_000_000u64)), + token_scale: Some(U256::from(10u64).pow(U256::from(18u64))), fee_limit: U256::from(1000u64), reference: None, memo: None, diff --git a/crates/node/tests/it/rpc.rs b/crates/node/tests/it/rpc.rs index 6a8ca1c8..72b8a03c 100644 --- a/crates/node/tests/it/rpc.rs +++ b/crates/node/tests/it/rpc.rs @@ -301,6 +301,46 @@ async fn transaction_receipt_exposes_morph_fields_over_rpc() -> eyre::Result<()> Ok(()) } +/// MorphTx v1 pays directly in ETH when `feeTokenID` is zero, so no token +/// exchange rate or scale applies to its receipt. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v1_eth_receipt_omits_token_exchange_metadata() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let raw_tx = MorphTxBuilder::new(wallet.chain_id, wallet.inner.clone(), 0) + .with_v1_eth_fee() + .build_signed()?; + node.rpc.inject_tx(raw_tx).await?; + + let payload = node.advance_block().await?; + let tx_hash = *payload + .block() + .body() + .transactions + .first() + .unwrap() + .tx_hash(); + let client = node + .rpc_client() + .ok_or_else(|| eyre::eyre!("HTTP RPC client not available"))?; + + let receipt: Value = client + .request("eth_getTransactionReceipt", (tx_hash,)) + .await?; + + assert_eq!(receipt["type"].as_str(), Some("0x7f")); + assert_eq!(receipt["version"].as_str(), Some("0x1")); + assert_eq!(receipt["feeTokenID"].as_str(), Some("0x0")); + assert_eq!(receipt["feeLimit"].as_str(), Some("0x0")); + assert!(receipt["feeRate"].is_null()); + assert!(receipt["tokenScale"].is_null()); + + Ok(()) +} + /// Standard (non-MorphTx) receipts must keep Morph fee pointers as JSON null. /// /// `@morph-network/viem` and indexers treat `feeTokenID != null` as "this is an diff --git a/crates/primitives/src/receipt/receipt.rs b/crates/primitives/src/receipt/receipt.rs index 3bf82a21..7ce39383 100644 --- a/crates/primitives/src/receipt/receipt.rs +++ b/crates/primitives/src/receipt/receipt.rs @@ -155,8 +155,8 @@ impl MorphTransactionReceipt { l1_fee: U256, version: u8, fee_token_id: u16, - fee_rate: U256, - token_scale: U256, + fee_rate: Option, + token_scale: Option, fee_limit: U256, reference: Option, memo: Option, @@ -166,8 +166,8 @@ impl MorphTransactionReceipt { l1_fee, version: Some(version), fee_token_id: Some(fee_token_id), - fee_rate: Some(fee_rate), - token_scale: Some(token_scale), + fee_rate, + token_scale, fee_limit: Some(fee_limit), reference, memo,