fix(primitives): emit nonce/v/r/s placeholders for L1 message RPC#115
Conversation
There was a problem hiding this comment.
Code review is billed via overage credits. To resume reviews, an organization admin can raise the monthly limit at claude.ai/admin-settings/claude-code.
Once credits are available, reopen this pull request to trigger a review.
📝 WalkthroughWalkthroughThis PR refactors L1 message transaction serialization to enforce JSON-RPC compatibility. ChangesL1 Message RPC Serialization
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
1a2e061 to
2b0fe8b
Compare
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/primitives/src/transaction/l1_transaction.rs`:
- Around line 35-42: The current serde from/into on TxL1Msg using
msg_serde::L1MsgSerdeHelper injects an RPC-only nonce field and RPC-style
renames for all formats; restrict that behavior to human-readable (JSON/RPC)
only by removing the global #[cfg_attr(... serde(from/into =
"msg_serde::L1MsgSerdeHelper"))] from TxL1Msg and instead implement a manual
impl Serialize and impl Deserialize for TxL1Msg that check
serializer.is_human_readable()/deserializer.is_human_readable() and delegate to
L1MsgSerdeHelper only when human-readable, or alternatively create a separate
RPC wrapper type (e.g., L1MsgRpc or similar) that uses
msg_serde::L1MsgSerdeHelper for RPC transport while leaving TxL1Msg’s default
serde untouched.
- Around line 403-424: The helper always flattens TxL1Msg::signature() into
every serde format; change serde_l1_message_tx_rpc to check
serializer.is_human_readable() and only emit the flattened signature for
human-readable serializers (JSON-RPC) — for non-human-readable serializers
serialize just the inner value without the signature. Concretely, inside
serde_l1_message_tx_rpc inspect S::is_human_readable
(serializer.is_human_readable()), and branch: when true use the existing
SerdeHelper struct that #[serde(flatten)]s signature, otherwise serialize value
alone (e.g., call value.serialize(serializer) or use a helper struct with only
value) so v/r/s/yParity are not emitted for binary formats.
- Around line 669-790: The serde-dependent tests
(test_l1_transaction_json_includes_nonce_placeholder,
test_l1_transaction_json_round_trip,
test_serde_l1_message_tx_rpc_flattens_zero_signature,
test_l1_envelope_rpc_json_includes_geth_parity_fields) must be compiled only
when the serde feature is enabled; add #[cfg(feature = "serde")] to each of
these test functions or wrap them in a single #[cfg(feature = "serde")] mod
serde_tests { ... } inside mod tests so they are excluded when building with
--no-default-features.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: d05c2d95-27a8-4f94-9f35-75ca72601558
📒 Files selected for processing (2)
crates/primitives/src/transaction/envelope.rscrates/primitives/src/transaction/l1_transaction.rs
| #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] | ||
| #[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] | ||
| #[cfg_attr( | ||
| feature = "serde", | ||
| serde( | ||
| from = "msg_serde::L1MsgSerdeHelper", | ||
| into = "msg_serde::L1MsgSerdeHelper" | ||
| ) | ||
| )] |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate TxL1Msg and surrounding serde attrs
fd -a "l1_transaction.rs" crates/primitives/src/transaction -g'*' > /tmp/l1_transaction_paths.txt || true
echo "== l1_transaction.rs match =="
cat /tmp/l1_transaction_paths.txt || true
# Grep for TxL1Msg declaration
rg -n "struct\s+TxL1Msg|enum\s+TxL1Msg|TxL1Msg" crates/primitives/src/transaction/l1_transaction.rs
# Dump the exact serde attribute block region for context
sed -n '1,120p' crates/primitives/src/transaction/l1_transaction.rs | nl -ba | sed -n '20,80p'
# Find L1MsgSerdeHelper definition/impls
echo "== Searching for L1MsgSerdeHelper =="
rg -n "L1MsgSerdeHelper" crates/primitives/src
# Print the helper implementation file sections
helper_file="$(rg -n "struct\s+L1MsgSerdeHelper|enum\s+L1MsgSerdeHelper" crates/primitives/src -l | head -n 1 || true)"
echo "== helper_file =="
echo "$helper_file"
if [[ -n "${helper_file:-}" ]]; then
wc -l "$helper_file"
sed -n '1,220p' "$helper_file" | nl -ba | head -n 220
fi
# Also look for where helper decides human_readable
if [[ -n "${helper_file:-}" ]]; then
echo "== human_readable gating checks =="
rg -n "is_human_readable|human_readable" "$helper_file"
rg -n "nonce" "$helper_file" | head -n 50
rg -n "queueIndex|gas" "$helper_file" | head -n 50
fi
# If helper impl is in a different file/module, print those too
echo "== Checking for serde helper module msg_serde =="
rg -n "mod\s+msg_serde|msg_serde::L1MsgSerdeHelper" crates/primitives/src/transaction/l1_transaction.rs crates/primitives/src -SRepository: morph-l2/morph-reth
Length of output: 2494
🏁 Script executed:
#!/bin/bash
set -euo pipefail
file="crates/primitives/src/transaction/l1_transaction.rs"
echo "== File: $file =="
test -f "$file" && echo "exists" || { echo "missing"; exit 1; }
echo "== TxL1Msg serde attributes near lines 30-60 =="
sed -n '25,60p' "$file" | cat -n
echo "== TxL1Msg serde attribute block requested (around 35-42) =="
sed -n '32,48p' "$file" | cat -n
echo "== L1MsgSerdeHelper module/impl region (around 320-460) =="
sed -n '300,470p' "$file" | cat -n
echo "== Look for is_human_readable gating and field mapping inside helper =="
rg -n "is_human_readable|human_readable" "$file" || true
rg -n "nonce|queueIndex|gas" "$file" | head -n 80
echo "== show where serde helper is used in From impls =="
rg -n "From<.*L1MsgSerdeHelper>|impl From<.*TxL1Msg.*L1MsgSerdeHelper>" "$file"Repository: morph-l2/morph-reth
Length of output: 13309
Scope the nonce/RPC-shaped L1MsgSerdeHelper to human-readable serde only
TxL1Msg is configured with serde(from/into = "msg_serde::L1MsgSerdeHelper"), and that helper unconditionally injects an extra nonce field and applies RPC-oriented renames (queueIndex, gas, camelCase, plus the data/input alias). There’s no serializer.is_human_readable()/deserializer.is_human_readable() gating, so this changes the serde representation for all formats—not just the JSON-RPC layer—broadening the public contract beyond the stated intent. Move the behavior into a human-readable-only manual Serialize/Deserialize, or into an RPC-specific wrapper rather than the base TxL1Msg serde attributes.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/primitives/src/transaction/l1_transaction.rs` around lines 35 - 42,
The current serde from/into on TxL1Msg using msg_serde::L1MsgSerdeHelper injects
an RPC-only nonce field and RPC-style renames for all formats; restrict that
behavior to human-readable (JSON/RPC) only by removing the global #[cfg_attr(...
serde(from/into = "msg_serde::L1MsgSerdeHelper"))] from TxL1Msg and instead
implement a manual impl Serialize and impl Deserialize for TxL1Msg that check
serializer.is_human_readable()/deserializer.is_human_readable() and delegate to
L1MsgSerdeHelper only when human-readable, or alternatively create a separate
RPC wrapper type (e.g., L1MsgRpc or similar) that uses
msg_serde::L1MsgSerdeHelper for RPC transport while leaving TxL1Msg’s default
serde untouched.
| #[cfg(feature = "serde")] | ||
| pub fn serde_l1_message_tx_rpc<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error> | ||
| where | ||
| T: serde::Serialize, | ||
| S: serde::Serializer, | ||
| { | ||
| use serde::Serialize; | ||
|
|
||
| #[derive(Serialize)] | ||
| struct SerdeHelper<'a, T> { | ||
| #[serde(flatten)] | ||
| value: &'a T, | ||
| #[serde(flatten)] | ||
| signature: Signature, | ||
| } | ||
|
|
||
| SerdeHelper { | ||
| value, | ||
| signature: TxL1Msg::signature(), | ||
| } | ||
| .serialize(serializer) | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate the helper and related envelope wiring
rg -n "serde_l1_message_tx_rpc" crates/primitives/src/transaction -S
rg -n "MorphTxEnvelope::L1Msg|serialize_with\\s*=\\s*\"crate::transaction::l1_transaction::serde_l1_message_tx_rpc|serialize_with\\s*=\\s*serde_l1_message_tx_rpc" crates/primitives/src/transaction -S
# Inspect the helper implementation around the reported line range
FILE="crates/primitives/src/transaction/l1_transaction.rs"
echo "---- context from $FILE around 380-470 ----"
sed -n '380,470p' "$FILE"
# Inspect TxL1Msg::signature implementation + any docs/comments
rg -n "fn signature\\(|TxL1Msg::signature\\(" crates/primitives/src/transaction/l1_transaction.rs -S
rg -n "signature\\(\\).*U256::ZERO|yParity|v\\b|r\\b|s\\b|yParity" crates/primitives/src/transaction/l1_transaction.rs -S
sed -n '1,260p' "$FILE" | rg -n "signature\\(" -n || true
# If signature impl is elsewhere, locate it broadly in the transaction module
rg -n "struct Signature|type Signature|Signature\\b" crates/primitives/src/transaction/l1_transaction.rs crates/primitives/src/transaction -S
# Search for serializer.is_human_readable usage in the crate
rg -n "is_human_readable\\(" crates -S
# Check for tests asserting injected fields for JSON/RPC vs other formats
rg -n "serde_l1_message_tx_rpc|yParity|\"yParity\"|\"v\"|\"r\"|\"s\"|MorphTxEnvelope::L1Msg" crates/primitives/src/transaction/l1_transaction.rs crates/primitives/src/transaction -S
# Show nearby test sections if they exist
rg -n "#\\[test\\]" crates/primitives/src/transaction/l1_transaction.rs -SRepository: morph-l2/morph-reth
Length of output: 22867
Gate the flattened zero signature to human-readable (JSON-RPC) serializers only
serde_l1_message_tx_rpc unconditionally #[serde(flatten)]s TxL1Msg::signature() (emitting v/r/s/yParity), and MorphTxEnvelope::L1Msg wires it via serde(serialize_with = ...), so these RPC-parity placeholder fields will be included for any serde format, not just JSON-RPC.
Suggested fix
pub fn serde_l1_message_tx_rpc<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
where
T: serde::Serialize,
S: serde::Serializer,
{
use serde::Serialize;
+
+ if !serializer.is_human_readable() {
+ return value.serialize(serializer);
+ }
#[derive(Serialize)]
struct SerdeHelper<'a, T> {
#[serde(flatten)]
value: &'a T,
#[serde(flatten)]
signature: Signature,
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| #[cfg(feature = "serde")] | |
| pub fn serde_l1_message_tx_rpc<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error> | |
| where | |
| T: serde::Serialize, | |
| S: serde::Serializer, | |
| { | |
| use serde::Serialize; | |
| #[derive(Serialize)] | |
| struct SerdeHelper<'a, T> { | |
| #[serde(flatten)] | |
| value: &'a T, | |
| #[serde(flatten)] | |
| signature: Signature, | |
| } | |
| SerdeHelper { | |
| value, | |
| signature: TxL1Msg::signature(), | |
| } | |
| .serialize(serializer) | |
| } | |
| #[cfg(feature = "serde")] | |
| pub fn serde_l1_message_tx_rpc<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error> | |
| where | |
| T: serde::Serialize, | |
| S: serde::Serializer, | |
| { | |
| use serde::Serialize; | |
| if !serializer.is_human_readable() { | |
| return value.serialize(serializer); | |
| } | |
| #[derive(Serialize)] | |
| struct SerdeHelper<'a, T> { | |
| #[serde(flatten)] | |
| value: &'a T, | |
| #[serde(flatten)] | |
| signature: Signature, | |
| } | |
| SerdeHelper { | |
| value, | |
| signature: TxL1Msg::signature(), | |
| } | |
| .serialize(serializer) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/primitives/src/transaction/l1_transaction.rs` around lines 403 - 424,
The helper always flattens TxL1Msg::signature() into every serde format; change
serde_l1_message_tx_rpc to check serializer.is_human_readable() and only emit
the flattened signature for human-readable serializers (JSON-RPC) — for
non-human-readable serializers serialize just the inner value without the
signature. Concretely, inside serde_l1_message_tx_rpc inspect
S::is_human_readable (serializer.is_human_readable()), and branch: when true use
the existing SerdeHelper struct that #[serde(flatten)]s signature, otherwise
serialize value alone (e.g., call value.serialize(serializer) or use a helper
struct with only value) so v/r/s/yParity are not emitted for binary formats.
| /// JSON serialization must include the geth/scroll RPC parity placeholder | ||
| /// `nonce: "0x0"` so downstream clients (ethers v5/v6, viem) can parse L1 | ||
| /// message tx objects returned by `eth_getBlockByNumber` etc. | ||
| #[test] | ||
| fn test_l1_transaction_json_includes_nonce_placeholder() { | ||
| let tx = TxL1Msg { | ||
| queue_index: 7, | ||
| gas_limit: 0x1e8480, | ||
| to: address!("5300000000000000000000000000000000000007"), | ||
| value: U256::ZERO, | ||
| input: Bytes::from(vec![0x8e, 0xf1, 0x33, 0x2e]), | ||
| sender: address!("ed82366effa760804dcfc3edf87fa2a6f1624415"), | ||
| }; | ||
|
|
||
| let json = serde_json::to_value(&tx).expect("serialization must succeed"); | ||
|
|
||
| assert_eq!(json["queueIndex"], "0x7"); | ||
| assert_eq!(json["gas"], "0x1e8480"); | ||
| assert_eq!(json["input"], "0x8ef1332e"); | ||
| assert_eq!(json["nonce"], "0x0"); | ||
| // v / r / s / yParity are emitted by `serde_l1_message_tx_rpc` via the | ||
| // envelope, not by `TxL1Msg` itself, so they are absent here. | ||
| assert!(json.get("v").is_none()); | ||
| assert!(json.get("r").is_none()); | ||
| assert!(json.get("s").is_none()); | ||
| } | ||
|
|
||
| /// Round-trip through `serde_json` must be lossless: real fields preserved, | ||
| /// the `nonce` placeholder must be tolerated on the way back in. | ||
| #[test] | ||
| fn test_l1_transaction_json_round_trip() { | ||
| let original = TxL1Msg { | ||
| queue_index: 42, | ||
| gas_limit: 100_000, | ||
| to: address!("0000000000000000000000000000000000000002"), | ||
| value: U256::from(1_000u64), | ||
| input: Bytes::from(vec![0x01, 0x02]), | ||
| sender: address!("0000000000000000000000000000000000000001"), | ||
| }; | ||
|
|
||
| let json = serde_json::to_value(&original).unwrap(); | ||
| let decoded: TxL1Msg = serde_json::from_value(json).expect("round-trip must succeed"); | ||
|
|
||
| assert_eq!(decoded, original); | ||
| } | ||
|
|
||
| /// `serde_l1_message_tx_rpc` adds a flattened zero signature on top of any | ||
| /// serializable inner value. This is the function the envelope uses to emit | ||
| /// `v`/`r`/`s`/`yParity` for L1 message transactions in RPC responses. | ||
| #[test] | ||
| fn test_serde_l1_message_tx_rpc_flattens_zero_signature() { | ||
| use serde::Serialize; | ||
|
|
||
| // Serialize a TxL1Msg through `serde_l1_message_tx_rpc` and inspect the | ||
| // resulting JSON object. We attach it to a wrapper field then read that | ||
| // field out — the function flattens the signature into the same object | ||
| // as the inner value. | ||
| #[derive(Serialize)] | ||
| struct WrapperOut<'a> { | ||
| #[serde(serialize_with = "serde_l1_message_tx_rpc")] | ||
| inner: &'a TxL1Msg, | ||
| } | ||
|
|
||
| let tx = TxL1Msg { | ||
| queue_index: 1, | ||
| gas_limit: 21_000, | ||
| to: address!("0000000000000000000000000000000000000002"), | ||
| value: U256::ZERO, | ||
| input: Bytes::new(), | ||
| sender: address!("0000000000000000000000000000000000000001"), | ||
| }; | ||
|
|
||
| let outer = serde_json::to_value(WrapperOut { inner: &tx }) | ||
| .expect("wrapper serialization must succeed"); | ||
| let inner = &outer["inner"]; | ||
|
|
||
| // Real TxL1Msg fields preserved (nonce comes from the helper struct). | ||
| assert_eq!(inner["queueIndex"], "0x1"); | ||
| assert_eq!(inner["nonce"], "0x0"); | ||
| // Signature fields injected by `serde_l1_message_tx_rpc`. | ||
| assert_eq!(inner["v"], "0x0"); | ||
| assert_eq!(inner["r"], "0x0"); | ||
| assert_eq!(inner["s"], "0x0"); | ||
| assert_eq!(inner["yParity"], "0x0"); | ||
| } | ||
|
|
||
| /// End-to-end check: serializing a [`MorphTxEnvelope::L1Msg`] (the type | ||
| /// actually emitted by RPC) must include all the morph-geth parity fields | ||
| /// at the top level: type, queueIndex, sender, nonce, v, r, s, yParity, hash. | ||
| #[test] | ||
| fn test_l1_envelope_rpc_json_includes_geth_parity_fields() { | ||
| use crate::MorphTxEnvelope; | ||
| use alloy_consensus::Sealable; | ||
|
|
||
| let tx = TxL1Msg { | ||
| queue_index: 7, | ||
| gas_limit: 0x1e8480, | ||
| to: address!("5300000000000000000000000000000000000007"), | ||
| value: U256::ZERO, | ||
| input: Bytes::from(vec![0x8e, 0xf1, 0x33, 0x2e]), | ||
| sender: address!("ed82366effa760804dcfc3edf87fa2a6f1624415"), | ||
| }; | ||
| let envelope = MorphTxEnvelope::L1Msg(tx.seal_slow()); | ||
|
|
||
| let json = serde_json::to_value(&envelope).expect("envelope serialization must succeed"); | ||
|
|
||
| assert_eq!(json["type"], "0x7e"); | ||
| assert_eq!(json["queueIndex"], "0x7"); | ||
| assert_eq!(json["gas"], "0x1e8480"); | ||
| assert_eq!( | ||
| json["sender"].as_str().unwrap().to_lowercase(), | ||
| "0xed82366effa760804dcfc3edf87fa2a6f1624415" | ||
| ); | ||
| // morph-geth RPC parity placeholders, must all be present at the top level. | ||
| assert_eq!(json["nonce"], "0x0"); | ||
| assert_eq!(json["v"], "0x0"); | ||
| assert_eq!(json["r"], "0x0"); | ||
| assert_eq!(json["s"], "0x0"); | ||
| assert_eq!(json["yParity"], "0x0"); | ||
| // Sealed<T> contributes the precomputed hash. | ||
| assert!(json["hash"].is_string()); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="crates/primitives/src/transaction/l1_transaction.rs"
# Show the relevant area around the reported line range
echo "=== Context around lines 620-840 ==="
nl -ba "$FILE" | sed -n '620,840p'
# Find any cfg/feature gating around the tests in this region
echo
echo "=== Search for cfg(feature = \"serde\") near tests ==="
rg -n --no-heading 'cfg\(feature *= *"serde"\)|cfg_attr\(feature *= *"serde"' "$FILE"
# Check the serde helper function gating
echo
echo "=== Search for serde_l1_message_tx_rpc definition and its cfg ==="
rg -n --no-heading 'serde_l1_message_tx_rpc' "$FILE"
# Check whether serde_json is gated/imported behind serde
echo
echo "=== Search for serde_json usage and related cfg ==="
rg -n --no-heading 'serde_json::|use .*serde_json' "$FILE"Repository: morph-l2/morph-reth
Length of output: 161
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="crates/primitives/src/transaction/l1_transaction.rs"
echo "=== Context around lines 620-840 (with line numbers via python) ==="
python3 - <<'PY'
import itertools
path="crates/primitives/src/transaction/l1_transaction.rs"
start, end = 620, 840
with open(path, 'r', encoding='utf-8') as f:
for i, line in enumerate(f, start=1):
if start <= i <= end:
print(f"{i:5d}\t{line.rstrip()}")
elif i > end:
break
PY
echo
echo "=== Search for cfg(feature = \"serde\") / cfg_attr in file ==="
rg -n --no-heading 'cfg(feature *= *"serde")|cfg_attr\(feature *= *"serde"' "$FILE" || true
echo
echo "=== Search for serde_l1_message_tx_rpc definition and its cfg ==="
rg -n --no-heading 'serde_l1_message_tx_rpc' "$FILE" || true
echo
echo "=== Search for serde_json usage and related imports in file ==="
rg -n --no-heading 'serde_json::|use .*serde_json|extern crate serde_json' "$FILE" || trueRepository: morph-l2/morph-reth
Length of output: 9291
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate the Cargo.toml for the primitives crate
# (Using fd to avoid guessing path.)
fd -a -t f "Cargo.toml" crates/primitives | head -n 20
# Dump features/deps area for the primitives crate
# Prefer rg to narrow quickly.
FILE="$(fd -a -t f "Cargo.toml" crates/primitives | head -n 1)"
echo "=== Using Cargo.toml: $FILE ==="
python3 - <<'PY'
import re, itertools, pathlib, os, sys
path=os.environ.get("FILE")
text=pathlib.Path(path).read_text(encoding="utf-8")
# Print the [features] section and nearby [dependencies] entries for serde/serde_json.
m=re.search(r'(?ms)^\[features\].*$', text)
print("HAS [features]:", bool(m))
PY
echo
echo "=== Search for serde feature and serde_json dependency in $FILE ==="
rg -n --no-heading '^\[features\]|\bserde\b|serde_json' "$FILE" || true
echo
echo "=== Show the serde/serde_json related block (features+deps context) ==="
python3 - <<'PY'
import os, pathlib, re
path=os.environ.get("FILE")
text=pathlib.Path(path).read_text(encoding="utf-8").splitlines()
# Print lines around feature "serde" and any serde_json deps
targets=[]
for i,line in enumerate(text, start=1):
if re.search(r'\bserde_json\b', line) or re.search(r'^\s*serde\s*=\s*\[', line) or re.search(r'"serde"', line) and 'features' not in line:
targets.append(i)
# Also include [features] section start
for i,line in enumerate(text, start=1):
if line.strip()=="[features]":
targets.append(i)
if not targets:
targets=[1]
lo=max(min(targets)-30,1)
hi=min(max(targets)+30, len(text))
for i in range(lo, min(hi, lo+260)):
print(f"{i:5d}\t{text[i-1]}")
PYRepository: morph-l2/morph-reth
Length of output: 737
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="crates/primitives/Cargo.toml"
echo "=== Using Cargo.toml: $FILE ==="
echo
echo "=== Search for serde/serde_json in features + dependencies ==="
rg -n --no-heading '^\[features\]|serde_json|(?<!\w)serde(?!_json)|"serde"' "$FILE" || true
echo
echo "=== Print the [features] section (serde-related window) ==="
python3 - <<'PY'
import pathlib, re
path=pathlib.Path("crates/primitives/Cargo.toml")
lines=path.read_text(encoding="utf-8").splitlines()
# find [features]
idx=None
for i,l in enumerate(lines):
if l.strip()=="[features]":
idx=i
break
if idx is None:
print("No [features] section found")
raise SystemExit(0)
# print until next top-level section
start=idx
end=len(lines)
for j in range(idx+1, len(lines)):
if re.match(r'^\s*\[.+\]\s*$', lines[j]) and not lines[j].strip().startswith("[features"):
end=j
break
for k in range(start, min(end, start+250)):
print(f"{k+1:5d}\t{lines[k]}")
PY
echo
echo "=== Print serde_json and serde dependency declarations ==="
rg -n --no-heading 'serde_json\s*=\s*|serde\s*=\s*\{|\[dependencies\]|\[dependencies\.serde_json\]|\[dependencies\.serde\]' "$FILE" || true
echo
echo "=== Extract exact blocks for any serde/serde_json dependency entries ==="
python3 - <<'PY'
import pathlib, re
path=pathlib.Path("crates/primitives/Cargo.toml")
text=path.read_text(encoding="utf-8")
def show_block(name):
# match lines like `serde_json = { ... }` or `serde_json = "..."` within [dependencies] or elsewhere.
pat = rf'(?m)^([ \t]*{re.escape(name)}[ \t]*=[^\n]*$)'
m=re.finditer(pat, text)
found=False
for mm in m:
found=True
line=mm.group(1)
print(line)
return found
show_block("serde_json")
show_block("serde")
PYRepository: morph-l2/morph-reth
Length of output: 1823
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="crates/primitives/Cargo.toml"
echo "=== Using Cargo.toml: $FILE ==="
echo
echo "=== Search for serde_json and feature serde in the whole Cargo.toml ==="
rg -n --no-heading 'serde_json|^serde\s*=|^\s*\[dev-dependencies\]|\[dependencies\]|\[dev-dependencies\]|\[build-dependencies\]|\bserde\b' "$FILE" || true
echo
echo "=== Show [dependencies], [dev-dependencies], and [features] sections (serde_json/serde context) ==="
python3 - <<'PY'
import pathlib, re
path=pathlib.Path("crates/primitives/Cargo.toml")
lines=path.read_text(encoding="utf-8").splitlines()
sections=[]
for i,l in enumerate(lines):
if re.match(r'^\[.+\]\s*$', l.strip()):
sections.append((i, l.strip()))
# Extract a window around each relevant section
targets = {"[dependencies]", "[dev-dependencies]", "[features]"}
for si, header in sections:
if header in targets:
start=si
# end at next top-level section or EOF
end=len(lines)
for sj, _h in sections:
if sj>si:
end=sj
break
for k in range(start, min(end, start+260)):
print(f"{k+1:5d}\t{lines[k]}")
print()
PYRepository: morph-l2/morph-reth
Length of output: 2663
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="crates/primitives/src/transaction/l1_transaction.rs"
echo "=== Context near the cfg_attr derive (around line 1-120) ==="
python3 - <<'PY'
path="crates/primitives/src/transaction/l1_transaction.rs"
for i in range(1,121):
with open(path,'r',encoding='utf-8') as f:
lines=f.read().splitlines()
break
PYRepository: morph-l2/morph-reth
Length of output: 124
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="crates/primitives/src/transaction/l1_transaction.rs"
python3 - <<'PY'
path="crates/primitives/src/transaction/l1_transaction.rs"
start,end=1,120
with open(path,'r',encoding='utf-8') as f:
for i,line in enumerate(f, start=1):
if start<=i<=end:
print(f"{i:5d}\t{line.rstrip()}")
elif i>end:
break
PY
echo
echo "=== Search for cfg_attr(feature = \"serde\", derive(...)) and serde gating ==="
rg -n --no-heading 'cfg_attr\(feature *= *"serde".*derive\(|cfg\(feature *= *"serde"\)|cfg!\(feature *= *"serde"\)|mod .*serde' "$FILE" || trueRepository: morph-l2/morph-reth
Length of output: 5502
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="crates/primitives/src/transaction/l1_transaction.rs"
echo "=== Search for test module boundaries near the serde JSON tests ==="
rg -n --no-heading 'cfg\(test\)|mod tests|mod serde|serde_tests' "$FILE"
echo
echo "=== Show a small context around the first cfg(test) occurrence ==="
line=$(rg -n --no-heading 'cfg\(test\)' "$FILE" | head -n 1 | cut -d: -f1 || true)
if [ -n "${line:-}" ]; then
start=$((line-10))
end=$((line+30))
python3 - <<PY
path="$FILE"
start=$start
end=$end
with open(path,'r',encoding='utf-8') as f:
for i,line in enumerate(f, start=1):
if start<=i<=end:
print(f"{i:5d}\t{line.rstrip()}")
elif i>end:
break
PY
else
echo "No cfg(test) found"
fiRepository: morph-l2/morph-reth
Length of output: 1412
Gate the serde JSON/RPC tests behind feature = "serde"
These tests call serde_json::{to_value, from_value} on TxL1Msg and use serde_l1_message_tx_rpc, but TxL1Msg only gets serde::Serialize/Deserialize via #[cfg_attr(feature = "serde", ...)] and serde_l1_message_tx_rpc is #[cfg(feature = "serde")]. With --no-default-features (disabling serde), this block won’t compile.
Suggested fix
+#[cfg(feature = "serde")]
#[test]
fn test_l1_transaction_json_includes_nonce_placeholder() {Apply the same #[cfg(feature = "serde")] guard to the other serde-dependent tests in this block (lines ~669-790) or wrap them in a dedicated #[cfg(feature = "serde")] mod ... inside mod tests.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/primitives/src/transaction/l1_transaction.rs` around lines 669 - 790,
The serde-dependent tests (test_l1_transaction_json_includes_nonce_placeholder,
test_l1_transaction_json_round_trip,
test_serde_l1_message_tx_rpc_flattens_zero_signature,
test_l1_envelope_rpc_json_includes_geth_parity_fields) must be compiled only
when the serde feature is enabled; add #[cfg(feature = "serde")] to each of
these test functions or wrap them in a single #[cfg(feature = "serde")] mod
serde_tests { ... } inside mod tests so they are excluded when building with
--no-default-features.
L1 message transactions (type 0x7E) have no signature or nonce at the consensus layer, but every standard EVM JSON-RPC client (ethers v5/v6, viem, web3.js — including the @morph-network/* SDK adapters that wrap them) expects every transaction object to expose `nonce`, `v`, `r`, `s` and `yParity`. Without these fields, calling `eth_getBlockByNumber(num, true)` or `eth_getTransactionByHash(l1MsgHash)` against a morph-reth RPC endpoint causes downstream deserialization to fail; the prover landed an ad-hoc tolerance patch on its own `TransactionTrace` for the same reason. Fix it on the producing side, mirroring morph-geth's RPC contract: TxL1Msg now serializes through L1MsgSerdeHelper (`serde(from = ..., into = ...)`), which carries five extra placeholder fields — `nonce`, `v`, `r`, `s`, `yParity` — all rendered as `"0x0"` and silently dropped on deserialize so geth-shaped JSON also round-trips. Because `alloy_consensus::Sealed` already flattens its inner T into the surrounding object, these top-level fields naturally appear at the envelope's RPC root with no envelope-level wiring required. RLP, EIP-2718, signing, and reth-codec Compact (DB) encodings are unchanged.
2b0fe8b to
b734aaf
Compare
Summary
L1 message transactions (type
0x7E) have no signature or nonce at the consensus layer, but every standard EVM JSON-RPC client — ethers v5/v6, viem, web3.js, and the@morph-network/*SDK adapters that wrap them — expects every transaction object returned byeth_getBlockByNumber(num, true)/eth_getTransactionByHash(...)to exposenonce,v,r,s,yParity. Today morph-reth omits all five for L1 messages, so anything that reads a block or fetches an L1-message tx hash via these libraries fails to deserialize. The prover already shipped an ad-hoc tolerance patch on its ownTransactionTracefor the same reason (morph#fix/reth-l1msg-tx-deserialize).This PR fixes it on the producing side so the RPC output matches morph-geth's existing JSON contract.
Approach
Single-file change in
l1_transaction.rson the serde JSON path only — RLP, EIP-2718, signing, and reth-codec Compact (DB) encodings are unchanged.TxL1Msgnow serializes through a privateL1MsgSerdeHelper(serde(from = ..., into = ...)), which carries five extra placeholder fields —nonce,v,r,s,yParity— all rendered as"0x0"and silently dropped on deserialize so geth-shaped JSON also round-trips.alloy_consensus::Sealedalready flattens its innerTinto the surrounding object, so these top-level fields naturally appear at the envelope's RPC root with no envelope-level wiring required.Resulting envelope JSON:
{ "type":"0x7e","queueIndex":"0x7","gas":"0x1e8480", "to":"0x...","value":"0x0","sender":"0x...","input":"0x...", "nonce":"0x0","v":"0x0","r":"0x0","s":"0x0","yParity":"0x0", "hash":"0x..." }This matches morph-geth's
RPCTransactionshape exactly for L1 messages.Test plan
l1_transaction.rs:test_l1_transaction_json_includes_geth_parity_fields— verifies all five placeholders are emitted by the helper struct.test_l1_transaction_json_round_trip—serde_jsonround-trip preserves real fields, drops placeholders.test_l1_envelope_rpc_json_includes_geth_parity_fields— verifies the full envelope JSON hastype / queueIndex / gas / to / value / sender / input / nonce / v / r / s / yParity / hashat the top level (the actual shapeeth_*returns).cargo nextest run -p morph-primitives -p morph-rpc -p morph-consensus -p morph-txpool— 253/253 pass.cargo check --workspace --all-targets— clean.cargo clippy --all --all-targets -- -D warnings— clean.cargo fmt --all -- --check— clean.cargo test --doc -p morph-primitives— clean.