From 044ef8ab2ea38099b2e6b2ff868555190263f67e Mon Sep 17 00:00:00 2001 From: chengwenxi <22697326+chengwenxi@users.noreply.github.com> Date: Mon, 2 Feb 2026 15:19:35 +0800 Subject: [PATCH 1/5] feat: Enhance MorphTransaction for reference key --- crates/primitives/src/receipt/mod.rs | 50 +- crates/primitives/src/receipt/receipt.rs | 78 +- crates/primitives/src/transaction/mod.rs | 4 +- .../src/transaction/morph_transaction.rs | 926 ++++++++++++++++-- crates/revm/src/tx.rs | 132 ++- 5 files changed, 1060 insertions(+), 130 deletions(-) diff --git a/crates/primitives/src/receipt/mod.rs b/crates/primitives/src/receipt/mod.rs index bad6ebf..9054ce5 100644 --- a/crates/primitives/src/receipt/mod.rs +++ b/crates/primitives/src/receipt/mod.rs @@ -447,8 +447,8 @@ mod compact { /// Note: `tx_type` must be the last field because it's not a known fixed-size type /// for the CompactZstd derive macro. /// - /// Note: `fee_token_id` is stored as `u64` instead of `u16` because `u16` doesn't implement - /// `Compact` in reth_codecs. The conversion is lossless since `u16` fits in `u64`. + /// Note: `fee_token_id` and `version` are stored as `u64` instead of `u16`/`u8` because + /// they don't implement `Compact` in reth_codecs. The conversion is lossless. #[derive(reth_codecs::CompactZstd)] #[reth_zstd( compressor = reth_zstd_compressors::RECEIPT_COMPRESSOR, @@ -460,41 +460,53 @@ mod compact { #[allow(clippy::owned_cow)] logs: Cow<'a, Vec>, l1_fee: Option, + /// Stored as u64 for Compact compatibility (u8 doesn't implement Compact) + version: Option, /// Stored as u64 for Compact compatibility (u16 doesn't implement Compact) fee_token_id: Option, fee_rate: Option, token_scale: Option, fee_limit: Option, + reference: Option, + #[allow(clippy::owned_cow)] + memo: Option>, /// Must be the last field - not a known fixed-size type tx_type: MorphTxType, } impl<'a> From<&'a MorphReceipt> for CompactMorphReceipt<'a> { fn from(receipt: &'a MorphReceipt) -> Self { - let (l1_fee, fee_token_id, fee_rate, token_scale, fee_limit) = match receipt { - MorphReceipt::Legacy(r) - | MorphReceipt::Eip2930(r) - | MorphReceipt::Eip1559(r) - | MorphReceipt::Eip7702(r) - | MorphReceipt::Morph(r) => ( - r.l1_fee, - r.fee_token_id.map(u64::from), - r.fee_rate, - r.token_scale, - r.fee_limit, - ), - MorphReceipt::L1Msg(_) => (None, None, None, None, None), - }; + let (l1_fee, version, fee_token_id, fee_rate, token_scale, fee_limit, reference, memo) = + match receipt { + MorphReceipt::Legacy(r) + | MorphReceipt::Eip2930(r) + | MorphReceipt::Eip1559(r) + | MorphReceipt::Eip7702(r) + | MorphReceipt::Morph(r) => ( + r.l1_fee, + r.version.map(u64::from), + r.fee_token_id.map(u64::from), + r.fee_rate, + r.token_scale, + r.fee_limit, + r.reference, + r.memo.as_ref().map(Cow::Borrowed), + ), + MorphReceipt::L1Msg(_) => (None, None, None, None, None, None, None, None), + }; Self { success: receipt.status(), cumulative_gas_used: receipt.cumulative_gas_used(), logs: Cow::Borrowed(&receipt.as_receipt().logs), l1_fee, + version, fee_token_id, fee_rate, token_scale, fee_limit, + reference, + memo, tx_type: receipt.tx_type(), } } @@ -507,10 +519,13 @@ mod compact { cumulative_gas_used, logs, l1_fee, + version, fee_token_id, fee_rate, token_scale, fee_limit, + reference, + memo, tx_type, } = receipt; @@ -528,10 +543,13 @@ mod compact { let morph_receipt = MorphTransactionReceipt { inner, l1_fee, + version: version.map(|v| v as u8), fee_token_id: fee_token_id.map(|id| id as u16), fee_rate, token_scale, fee_limit, + reference, + memo: memo.map(|m| m.into_owned()), }; match tx_type { diff --git a/crates/primitives/src/receipt/receipt.rs b/crates/primitives/src/receipt/receipt.rs index fd5751e..279f4c2 100644 --- a/crates/primitives/src/receipt/receipt.rs +++ b/crates/primitives/src/receipt/receipt.rs @@ -12,10 +12,15 @@ use alloy_rlp::{BufMut, Decodable, Encodable, Header}; /// /// This receipt extends the standard Ethereum receipt with: /// - `l1_fee`: The L1 data fee charged for posting transaction data to L1 +/// - `version`: The version of the Morph transaction format /// - `fee_token_id`: The ERC20 token ID used for fee payment (TxMorph) /// - `fee_rate`: The exchange rate for the fee token /// - `token_scale`: The scale factor for the token /// - `fee_limit`: The fee limit for TxMorph +/// - `reference`: The reference key for the transaction +/// - `memo`: The memo field for arbitrary data +/// +/// Reference: #[derive(Clone, Debug, Default, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] @@ -32,6 +37,14 @@ pub struct MorphTransactionReceipt { )] pub l1_fee: Option, + /// The version of the Morph transaction format. + /// Only present for TxMorph. + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub version: Option, + /// The ERC20 token ID used for fee payment (TxMorph feature). /// Only present for TxMorph. #[cfg_attr( @@ -63,6 +76,23 @@ pub struct MorphTransactionReceipt { serde(default, skip_serializing_if = "Option::is_none") )] pub fee_limit: Option, + + /// Reference key for the transaction. + /// Used for indexing and looking up transactions by external systems. + /// Only present for TxMorph. + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub reference: Option, + + /// Memo field for arbitrary data. + /// Only present for TxMorph. + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub memo: Option, } impl MorphTransactionReceipt { @@ -71,10 +101,13 @@ impl MorphTransactionReceipt { Self { inner, l1_fee: None, + version: None, fee_token_id: None, fee_rate: None, token_scale: None, fee_limit: None, + reference: None, + memo: None, } } @@ -83,14 +116,17 @@ impl MorphTransactionReceipt { Self { inner, l1_fee: Some(l1_fee), + version: None, fee_token_id: None, fee_rate: None, token_scale: None, fee_limit: None, + reference: None, + memo: None, } } - /// Creates a new receipt with TxMorph fields. + /// Creates a new receipt with TxMorph fields (legacy version without reference/memo). pub const fn with_morph_tx( inner: Receipt, l1_fee: U256, @@ -102,22 +138,60 @@ impl MorphTransactionReceipt { Self { inner, l1_fee: Some(l1_fee), + version: None, + fee_token_id: Some(fee_token_id), + fee_rate: Some(fee_rate), + token_scale: Some(token_scale), + fee_limit: Some(fee_limit), + reference: None, + memo: None, + } + } + + /// Creates a new receipt with all TxMorph fields including version, reference, and memo. + pub const fn with_morph_tx_full( + inner: Receipt, + l1_fee: U256, + version: u8, + fee_token_id: u16, + fee_rate: U256, + token_scale: U256, + fee_limit: U256, + reference: Option, + memo: Option, + ) -> Self { + Self { + inner, + l1_fee: Some(l1_fee), + version: Some(version), fee_token_id: Some(fee_token_id), fee_rate: Some(fee_rate), token_scale: Some(token_scale), fee_limit: Some(fee_limit), + reference, + memo, } } /// Returns true if this receipt is for a TxMorph. pub const fn is_morph_tx(&self) -> bool { - self.fee_token_id.is_some() + self.fee_token_id.is_some() || self.version.is_some() || self.reference.is_some() + } + + /// Returns true if this receipt has a reference. + pub const fn has_reference(&self) -> bool { + self.reference.is_some() } /// Returns the L1 fee, defaulting to zero if not set. pub fn l1_fee_or_zero(&self) -> U256 { self.l1_fee.unwrap_or(U256::ZERO) } + + /// Returns the version, defaulting to 0 if not set. + pub fn version_or_zero(&self) -> u8 { + self.version.unwrap_or(0) + } } impl MorphTransactionReceipt { diff --git a/crates/primitives/src/transaction/mod.rs b/crates/primitives/src/transaction/mod.rs index edd18f2..9386a93 100644 --- a/crates/primitives/src/transaction/mod.rs +++ b/crates/primitives/src/transaction/mod.rs @@ -6,4 +6,6 @@ pub mod morph_transaction; pub use envelope::{MorphTxEnvelope, MorphTxType}; pub use l1_transaction::{L1_TX_TYPE_ID, TxL1Msg}; -pub use morph_transaction::{MORPH_TX_TYPE_ID, TxMorph, TxMorphExt}; +pub use morph_transaction::{ + MAX_MEMO_LENGTH, MORPH_TX_TYPE_ID, MORPH_TX_VERSION_0, MORPH_TX_VERSION_1, TxMorph, TxMorphExt, +}; diff --git a/crates/primitives/src/transaction/morph_transaction.rs b/crates/primitives/src/transaction/morph_transaction.rs index 227c861..28f7c4a 100644 --- a/crates/primitives/src/transaction/morph_transaction.rs +++ b/crates/primitives/src/transaction/morph_transaction.rs @@ -1,9 +1,12 @@ //! Morph Transaction type for Morph L2. //! -//! This module defines the TxMorph type which represents transactions that -//! use ERC20 tokens for gas payment instead of native ETH. +//! This module defines the TxMorph type which represents Morph-specific transactions +//! that support: +//! - ERC20 tokens for gas payment instead of native ETH +//! - Transaction reference for indexing/lookup +//! - Memo field for arbitrary data //! -//! Reference: +//! Reference: use alloy_consensus::{ SignableTransaction, Transaction, @@ -19,13 +22,23 @@ use core::mem; /// Morph Transaction type ID (0x7F). pub const MORPH_TX_TYPE_ID: u8 = 0x7F; +/// MorphTx version 0: original format without Version, Reference, Memo fields. +pub const MORPH_TX_VERSION_0: u8 = 0; + +/// MorphTx version 1: includes Version, Reference, Memo fields. +pub const MORPH_TX_VERSION_1: u8 = 1; + +/// Maximum length of the memo field in bytes. +pub const MAX_MEMO_LENGTH: usize = 64; + /// Morph Transaction for Morph L2. /// -/// This transaction type allows users to pay gas fees using ERC20 tokens -/// instead of native ETH. It extends EIP-1559 style transactions with -/// additional fields for token-based fee payment. +/// This transaction type extends EIP-1559 style transactions with Morph-specific fields: +/// - Token-based fee payment (ERC20 tokens instead of native ETH) +/// - Transaction reference for indexing/lookup by external systems +/// - Memo field for arbitrary data /// -/// Reference: +/// Reference: #[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] @@ -74,9 +87,38 @@ pub struct TxMorph { /// accessing outside the list. pub access_list: AccessList, + /// Version of the Morph transaction format. + /// Used for future extensibility. + #[cfg_attr(feature = "serde", serde(with = "alloy_serde::quantity"))] + 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(with = "alloy_serde::quantity"))] + pub fee_token_id: u16, + /// Maximum amount of tokens the sender is willing to pay as fee. pub fee_limit: U256, + /// Reference key for the transaction (optional, v1 only). + /// Used for indexing and looking up transactions by external systems. + /// This is a 32-byte value that can be used to group related transactions. + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub reference: Option, + + /// Memo field for arbitrary data (optional, v1 only). + /// Can be used to attach additional information to the transaction. + /// Maximum length is 64 bytes. + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub memo: Option, + /// Input has two uses depending if transaction is Create or Call (if `to` /// field is None or Some). /// - init: An unlimited size byte array specifying the EVM-code for the @@ -85,11 +127,6 @@ pub struct TxMorph { /// message call. #[cfg_attr(feature = "serde", serde(default, alias = "data"))] pub input: Bytes, - - /// Token ID for alternative fee payment. - /// This corresponds to the token registered in the L2 Token Registry. - #[cfg_attr(feature = "serde", serde(with = "alloy_serde::quantity"))] - pub fee_token_id: u16, } impl TxMorph { @@ -122,9 +159,51 @@ impl TxMorph { if self.max_priority_fee_per_gas > self.max_fee_per_gas { return Err("max priority fee per gas exceeds max fee per gas"); } + // Validate memo length + if let Some(memo) = &self.memo { + if memo.len() > MAX_MEMO_LENGTH { + return Err("memo exceeds maximum length of 64 bytes"); + } + } + // Validate version-specific rules + self.validate_version() + } + + /// Validates the MorphTx version and its associated field requirements. + /// + /// Rules: + /// - Version 0 (legacy format): FeeTokenID must be > 0 + /// - Version 1 (with Reference/Memo): FeeTokenID, FeeLimit, Reference, Memo are all optional + /// - Other versions: not supported + pub fn validate_version(&self) -> Result<(), &'static str> { + match self.version { + MORPH_TX_VERSION_0 => { + // Version 0 requires FeeTokenID > 0 (legacy format used for alt-fee transactions) + if self.fee_token_id == 0 { + return Err("version 0 MorphTx requires FeeTokenID > 0"); + } + } + MORPH_TX_VERSION_1 => { + // Version 1: FeeTokenID, FeeLimit, Reference, Memo are all optional + // No additional validation needed + } + _ => { + return Err("unsupported MorphTx version"); + } + } Ok(()) } + /// Returns true if this is a version 0 (legacy) MorphTx. + pub const fn is_v0(&self) -> bool { + self.version == MORPH_TX_VERSION_0 + } + + /// Returns true if this is a version 1 MorphTx (with Reference/Memo). + pub const fn is_v1(&self) -> bool { + self.version >= MORPH_TX_VERSION_1 + } + /// Calculate the in-memory size of this transaction. pub fn size(&self) -> usize { mem::size_of::() + // chain_id @@ -135,15 +214,25 @@ impl TxMorph { self.to.size() + // to mem::size_of::() + // value self.access_list.size() + // access_list - self.input.len() + // input + mem::size_of::() + // version mem::size_of::() + // fee_token_id - mem::size_of::() // fee_limit + mem::size_of::() + // fee_limit + mem::size_of::>() + // reference + self.memo.as_ref().map_or(0, |m| m.len()) + // memo + self.input.len() // input } - /// Outputs the length of the transaction's fields, without a RLP header. + /// Outputs the length of the transaction's RLP fields, without a RLP header. + /// + /// Note: For V1, the version byte is NOT included here - it's encoded as a prefix byte + /// before the RLP data, similar to txType. + /// + /// V0 format: ChainID, Nonce, GasTipCap, GasFeeCap, Gas, To, Value, Data, AccessList, FeeTokenID, FeeLimit + /// V1 format: ChainID, Nonce, GasTipCap, GasFeeCap, Gas, To, Value, Data, AccessList, FeeTokenID, FeeLimit, Reference, Memo #[doc(hidden)] pub fn fields_len(&self) -> usize { let mut len = 0; + // Common fields len += self.chain_id.length(); len += self.nonce.length(); len += self.max_priority_fee_per_gas.length(); @@ -153,13 +242,33 @@ impl TxMorph { len += self.value.length(); len += self.input.0.length(); len += self.access_list.length(); + + // FeeTokenID and FeeLimit are always present len += self.fee_token_id.length(); len += self.fee_limit.length(); + + if !self.is_v0() { + // V1 format: adds Reference, Memo (Version is prefix byte, not in RLP) + // Reference is Option - encoded as 32 bytes or empty bytes + len += self + .reference + .as_ref() + .map_or(0usize.length(), |r| r.0.length()); + // Memo is Option - encoded as RLP bytes or empty + len += self.memo.as_ref().map_or(0usize.length(), |m| m.0.length()); + } len } - /// Encodes only the transaction's fields into the desired buffer, without a RLP header. + /// Encodes only the transaction's RLP fields into the desired buffer, without a RLP header. + /// + /// Note: For V1, the version byte is NOT included here - it's encoded as a prefix byte + /// before the RLP data by the caller (encode_2718). + /// + /// V0 format: ChainID, Nonce, GasTipCap, GasFeeCap, Gas, To, Value, Data, AccessList, FeeTokenID, FeeLimit + /// V1 format: ChainID, Nonce, GasTipCap, GasFeeCap, Gas, To, Value, Data, AccessList, FeeTokenID, FeeLimit, Reference, Memo pub fn encode_fields(&self, out: &mut dyn BufMut) { + // Common fields self.chain_id.encode(out); self.nonce.encode(out); self.max_priority_fee_per_gas.encode(out); @@ -169,48 +278,270 @@ impl TxMorph { self.value.encode(out); self.input.0.encode(out); self.access_list.encode(out); + + // FeeTokenID and FeeLimit are always present self.fee_token_id.encode(out); self.fee_limit.encode(out); + + if !self.is_v0() { + // V1 format: adds Reference, Memo (Version is prefix byte, not in RLP) + // Reference is Option - encode as 32 bytes or empty bytes + if let Some(ref r) = self.reference { + r.0.encode(out); + } else { + 0usize.encode(out); // Encode empty bytes for None + } + // Memo is Option - encode inner bytes or empty + if let Some(ref memo) = self.memo { + memo.0.encode(out); + } else { + 0usize.encode(out); // Encode empty bytes for None + } + } } - /// Decodes the inner fields from RLP bytes. + /// Decodes the inner fields from RLP bytes (after txType byte is consumed). /// - /// NOTE: This assumes a RLP header has already been decoded, and _just_ decodes the following - /// RLP fields in the following order: + /// Version detection based on first byte: + /// - V0 format: first byte is 0 or RLP list prefix (>= 0xC0) → direct RLP decode + /// - V1+ format: first byte is version (0x01, 0x02, ...) → skip version byte, then RLP decode /// - /// - `chain_id` - /// - `nonce` - /// - `max_priority_fee_per_gas` - /// - `max_fee_per_gas` - /// - `gas_limit` - /// - `to` - /// - `value` - /// - `data` (`input`) - /// - `access_list` - /// - `fee_token_id` - /// - `fee_limit` + /// V0 RLP: ChainID, Nonce, GasTipCap, GasFeeCap, Gas, To, Value, Data, AccessList, FeeTokenID, FeeLimit + /// V1 RLP: ChainID, Nonce, GasTipCap, GasFeeCap, Gas, To, Value, Data, AccessList, FeeTokenID, FeeLimit, Reference, Memo pub fn decode_fields(buf: &mut &[u8]) -> alloy_rlp::Result { + if buf.is_empty() { + return Err(alloy_rlp::Error::InputTooShort); + } + + let first_byte = buf[0]; + + // Check first byte to determine version: + // - V0 format (legacy AltFeeTx): first byte is 0 or RLP list prefix (0xC0-0xFF) + // - V1+ format: first byte is version (0x01, 0x02, ...) followed by RLP + if first_byte == 0 || first_byte >= 0xC0 { + // V0 format: direct RLP decode (legacy compatible) + Self::decode_fields_v0(buf) + } else if first_byte == MORPH_TX_VERSION_1 { + // V1 format: first byte is version, rest is RLP + // Skip the version byte + *buf = &buf[1..]; + Self::decode_fields_v1(buf) + } else { + Err(alloy_rlp::Error::Custom("unsupported morph tx version")) + } + } + + /// Decodes V0 format fields (for decode_fields, includes RLP header handling). + /// + /// V0 format: ChainID, Nonce, GasTipCap, GasFeeCap, Gas, To, Value, Data, AccessList, FeeTokenID, FeeLimit + fn decode_fields_v0(buf: &mut &[u8]) -> alloy_rlp::Result { + // Need to decode RLP header first + let header = Header::decode(buf)?; + if !header.list { + return Err(alloy_rlp::Error::UnexpectedString); + } + + Self::decode_fields_v0_inner(buf) + } + + /// Decodes V1 format fields (for decode_fields, includes RLP header handling). + /// + /// V1 format (after version byte is consumed): + /// ChainID, Nonce, GasTipCap, GasFeeCap, Gas, To, Value, Data, AccessList, FeeTokenID, FeeLimit, Reference, Memo + /// + /// Note: Version is NOT in the RLP - it was already consumed as a prefix byte. + fn decode_fields_v1(buf: &mut &[u8]) -> alloy_rlp::Result { + // Need to decode RLP header first + let header = Header::decode(buf)?; + if !header.list { + return Err(alloy_rlp::Error::UnexpectedString); + } + + Self::decode_fields_v1_inner(buf) + } + + /// Decodes V1 format fields (inner, assumes RLP header already consumed). + fn decode_fields_v1_inner(buf: &mut &[u8]) -> alloy_rlp::Result { + let chain_id = Decodable::decode(buf)?; + let nonce = Decodable::decode(buf)?; + let max_priority_fee_per_gas = Decodable::decode(buf)?; + let max_fee_per_gas = Decodable::decode(buf)?; + let gas_limit = Decodable::decode(buf)?; + let to = Decodable::decode(buf)?; + let value = Decodable::decode(buf)?; + let input = Decodable::decode(buf)?; + let access_list = Decodable::decode(buf)?; + let fee_token_id = Decodable::decode(buf)?; + let fee_limit = Decodable::decode(buf)?; + + // Decode reference: empty bytes means None, 32 bytes means Some(B256) + let reference_bytes: Bytes = Decodable::decode(buf)?; + let reference = if reference_bytes.is_empty() { + None + } else if reference_bytes.len() == 32 { + Some(B256::from_slice(&reference_bytes)) + } else { + return Err(alloy_rlp::Error::Custom("invalid reference length")); + }; + + // Decode memo: bytes -> Option + let memo_bytes: Bytes = Decodable::decode(buf)?; + let memo = if memo_bytes.is_empty() { + None + } else { + Some(memo_bytes) + }; + Ok(Self { - chain_id: Decodable::decode(buf)?, - nonce: Decodable::decode(buf)?, - max_priority_fee_per_gas: Decodable::decode(buf)?, - max_fee_per_gas: Decodable::decode(buf)?, - gas_limit: Decodable::decode(buf)?, - to: Decodable::decode(buf)?, - value: Decodable::decode(buf)?, - input: Decodable::decode(buf)?, - access_list: Decodable::decode(buf)?, - fee_token_id: Decodable::decode(buf)?, - fee_limit: Decodable::decode(buf)?, + chain_id, + nonce, + max_priority_fee_per_gas, + max_fee_per_gas, + gas_limit, + to, + value, + input, + access_list, + version: MORPH_TX_VERSION_1, + fee_token_id, + fee_limit, + reference, + memo, + }) + } + + /// Decodes V0 format fields (inner, assumes RLP header already consumed). + fn decode_fields_v0_inner(buf: &mut &[u8]) -> alloy_rlp::Result { + let chain_id = Decodable::decode(buf)?; + let nonce = Decodable::decode(buf)?; + let max_priority_fee_per_gas = Decodable::decode(buf)?; + let max_fee_per_gas = Decodable::decode(buf)?; + let gas_limit = Decodable::decode(buf)?; + let to = Decodable::decode(buf)?; + let value = Decodable::decode(buf)?; + let input = Decodable::decode(buf)?; + let access_list = Decodable::decode(buf)?; + let fee_token_id: u16 = Decodable::decode(buf)?; + let fee_limit = Decodable::decode(buf)?; + + // V0 requires FeeTokenID > 0 + if fee_token_id == 0 { + return Err(alloy_rlp::Error::Custom( + "invalid fee token id, expected non-zero for V0", + )); + } + + Ok(Self { + chain_id, + nonce, + max_priority_fee_per_gas, + max_fee_per_gas, + gas_limit, + to, + value, + input, + access_list, + version: MORPH_TX_VERSION_0, + fee_token_id, + fee_limit, + reference: None, + memo: None, }) } /// Computes the hash used for signing the transaction. + /// + /// Note: The sigHash encoding differs from transaction encoding for V1: + /// - Transaction encoding: `[version byte] + RLP([..., FeeTokenID, FeeLimit, Reference, Memo])` + /// - SigHash encoding: `TxType + RLP([..., FeeTokenID, FeeLimit, Version, Reference, Memo])` + /// + /// For V1, Version is included IN the RLP for signing, not as a prefix. pub fn signature_hash(&self) -> B256 { - let mut buf = Vec::with_capacity(self.encode_2718_len()); - self.encode_2718(&mut buf); + let mut buf = Vec::new(); + self.encode_for_sig_hash(&mut buf); keccak256(&buf) } + + /// Encodes the transaction for signature hash calculation. + /// + /// V0 format: TxType + RLP([..., FeeTokenID, FeeLimit]) + /// V1 format: TxType + RLP([..., FeeTokenID, FeeLimit, Version, Reference, Memo]) + /// + /// Note: For V1, Version is included in the RLP (after FeeLimit), not as a prefix byte. + fn encode_for_sig_hash(&self, out: &mut dyn BufMut) { + // Write txType + out.put_u8(MORPH_TX_TYPE_ID); + + // Write RLP header and fields for signing + let payload_length = self.sig_hash_fields_len(); + let header = Header { + list: true, + payload_length, + }; + header.encode(out); + self.encode_sig_hash_fields(out); + } + + /// Outputs the length of fields for signature hash encoding. + fn sig_hash_fields_len(&self) -> usize { + let mut len = 0; + // Common fields + len += self.chain_id.length(); + len += self.nonce.length(); + len += self.max_priority_fee_per_gas.length(); + len += self.max_fee_per_gas.length(); + len += self.gas_limit.length(); + len += self.to.length(); + len += self.value.length(); + len += self.input.0.length(); + len += self.access_list.length(); + len += self.fee_token_id.length(); + len += self.fee_limit.length(); + + if !self.is_v0() { + // V1 sigHash: includes Version, Reference, Memo IN the RLP + len += self.version.length(); + len += self + .reference + .as_ref() + .map_or(0usize.length(), |r| r.0.length()); + len += self.memo.as_ref().map_or(0usize.length(), |m| m.0.length()); + } + len + } + + /// Encodes fields for signature hash calculation. + /// + /// V0 format: ChainID, Nonce, GasTipCap, GasFeeCap, Gas, To, Value, Data, AccessList, FeeTokenID, FeeLimit + /// V1 format: ChainID, Nonce, GasTipCap, GasFeeCap, Gas, To, Value, Data, AccessList, FeeTokenID, FeeLimit, Version, Reference, Memo + fn encode_sig_hash_fields(&self, out: &mut dyn BufMut) { + self.chain_id.encode(out); + self.nonce.encode(out); + self.max_priority_fee_per_gas.encode(out); + self.max_fee_per_gas.encode(out); + self.gas_limit.encode(out); + self.to.encode(out); + self.value.encode(out); + self.input.0.encode(out); + self.access_list.encode(out); + self.fee_token_id.encode(out); + self.fee_limit.encode(out); + + if !self.is_v0() { + // V1 sigHash: includes Version, Reference, Memo IN the RLP + self.version.encode(out); + if let Some(ref r) = self.reference { + r.0.encode(out); + } else { + 0usize.encode(out); + } + if let Some(ref memo) = self.memo { + memo.0.encode(out); + } else { + 0usize.encode(out); + } + } + } } impl Typed2718 for TxMorph { @@ -314,27 +645,68 @@ impl SignableTransaction for TxMorph { } fn encode_for_signing(&self, out: &mut dyn alloy_rlp::BufMut) { - out.put_u8(Self::tx_type()); - self.encode(out) + // Use the dedicated sigHash encoding which includes Version IN the RLP for V1 + self.encode_for_sig_hash(out); } fn payload_len_for_signature(&self) -> usize { - self.length() + 1 + // txType (1 byte) + RLP header + sig hash fields + let payload_length = self.sig_hash_fields_len(); + 1 + Header { + list: true, + payload_length, + } + .length() + + payload_length } } impl Encodable for TxMorph { + /// Encodes TxMorph to RLP. + /// + /// For V0: RLP([fields...]) + /// For V1: [version byte] + RLP([fields...]) fn encode(&self, out: &mut dyn BufMut) { + if !self.is_v0() { + // V1+: write version byte before RLP + out.put_u8(self.version); + } self.rlp_encode(out); } fn length(&self) -> usize { - self.rlp_encoded_length() + if self.is_v0() { + self.rlp_encoded_length() + } else { + // V1+: version byte + RLP + 1 + self.rlp_encoded_length() + } } } impl Decodable for TxMorph { + /// Decodes TxMorph from RLP bytes (after txType byte is consumed). + /// + /// This handles both V0 and V1 formats: + /// - V0: RLP list directly + /// - V1: version byte + RLP list fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { + if buf.is_empty() { + return Err(alloy_rlp::Error::InputTooShort); + } + + let first_byte = buf[0]; + + // Check if this is a version prefix (V1+) or RLP list header (V0) + if first_byte == MORPH_TX_VERSION_1 { + // V1: skip version byte, then decode RLP + *buf = &buf[1..]; + } else if first_byte != 0 && first_byte < 0xC0 { + // Invalid: not a version we support and not an RLP list + return Err(alloy_rlp::Error::Custom("unsupported morph tx version")); + } + // V0: first_byte is 0 or >= 0xC0 (RLP list prefix) + let header = Header::decode(buf)?; if !header.list { return Err(alloy_rlp::Error::UnexpectedString); @@ -345,7 +717,12 @@ impl Decodable for TxMorph { return Err(alloy_rlp::Error::InputTooShort); } - Self::decode_fields(buf) + // Determine version based on what we saw + if first_byte == MORPH_TX_VERSION_1 { + Self::decode_fields_v1_inner(buf) + } else { + Self::decode_fields_v0_inner(buf) + } } } @@ -355,23 +732,15 @@ impl Encodable2718 for TxMorph { } fn encode_2718_len(&self) -> usize { - let payload_length = self.fields_len(); - 1 + Header { - list: true, - payload_length, - } - .length() - + payload_length + // txType (1 byte) + encode() (which includes version prefix for V1) + 1 + self.length() } fn encode_2718(&self, out: &mut dyn BufMut) { - MORPH_TX_TYPE_ID.encode(out); - let header = Header { - list: true, - payload_length: self.fields_len(), - }; - header.encode(out); - self.encode_fields(out); + // Write txType first + out.put_u8(MORPH_TX_TYPE_ID); + // encode() now includes version prefix for V1 + self.encode(out); } } @@ -396,9 +765,14 @@ impl reth_codecs::Compact for TxMorph { len += self.to.to_compact(buf); len += self.value.to_compact(buf); len += self.access_list.to_compact(buf); + len += (self.version as u64).to_compact(buf); + len += (self.fee_token_id as u64).to_compact(buf); len += self.fee_limit.to_compact(buf); + len += self.reference.to_compact(buf); + // Memo is Option, convert to Bytes for Compact + let memo_bytes = self.memo.clone().unwrap_or_default(); + len += memo_bytes.to_compact(buf); len += self.input.to_compact(buf); - len += (self.fee_token_id as u64).to_compact(buf); len } @@ -411,9 +785,19 @@ impl reth_codecs::Compact for TxMorph { let (to, buf) = TxKind::from_compact(buf, len); let (value, buf) = U256::from_compact(buf, len); let (access_list, buf) = AccessList::from_compact(buf, len); + let (version, buf) = u64::from_compact(buf, len); + let (fee_token_id, buf) = u64::from_compact(buf, len); let (fee_limit, buf) = U256::from_compact(buf, len); + let (reference, buf) = Option::::from_compact(buf, len); + let (memo_bytes, buf) = Bytes::from_compact(buf, len); let (input, buf) = Bytes::from_compact(buf, len); - let (fee_token_id, buf) = u64::from_compact(buf, len); + + // Convert Bytes to Option (empty = None) + let memo = if memo_bytes.is_empty() { + None + } else { + Some(memo_bytes) + }; ( Self { @@ -425,30 +809,56 @@ impl reth_codecs::Compact for TxMorph { to, value, access_list, + version: version as u8, + fee_token_id: fee_token_id as u16, fee_limit, + reference, + memo, input, - fee_token_id: fee_token_id as u16, }, buf, ) } } -/// Extension trait for [`TxMorph`] to access token fee fields. +/// Extension trait for [`TxMorph`] to access Morph-specific fields. pub trait TxMorphExt { + /// Returns the version of the Morph transaction format. + fn version(&self) -> u8; + /// Returns the token ID used for fee payment. fn fee_token_id(&self) -> u16; /// Returns the maximum token amount for fee payment. fn fee_limit(&self) -> U256; + /// Returns the reference key for the transaction. + fn reference(&self) -> Option; + + /// Returns the memo field. + fn memo(&self) -> Option<&Bytes>; + /// Returns true if this transaction uses token-based fee payment. fn uses_token_fee(&self) -> bool { self.fee_token_id() > 0 } + + /// Returns true if this transaction has a reference. + fn has_reference(&self) -> bool { + self.reference().is_some() + } + + /// Returns true if this transaction has a memo. + fn has_memo(&self) -> bool { + self.memo().is_some_and(|m| !m.is_empty()) + } } impl TxMorphExt for TxMorph { + fn version(&self) -> u8 { + self.version + } + fn fee_token_id(&self) -> u16 { self.fee_token_id } @@ -456,6 +866,14 @@ impl TxMorphExt for TxMorph { fn fee_limit(&self) -> U256 { self.fee_limit } + + fn reference(&self) -> Option { + self.reference + } + + fn memo(&self) -> Option<&Bytes> { + self.memo.as_ref() + } } #[cfg(test)] @@ -472,8 +890,13 @@ mod tests { assert_eq!(tx.max_fee_per_gas, 0); assert_eq!(tx.max_priority_fee_per_gas, 0); assert_eq!(tx.value, U256::ZERO); + assert_eq!(tx.version, 0); assert_eq!(tx.fee_token_id, 0); assert_eq!(tx.fee_limit, U256::ZERO); + assert_eq!(tx.reference, None); + assert_eq!(tx.memo, None); + assert!(tx.is_v0()); + assert!(!tx.is_v1()); } #[test] @@ -484,19 +907,60 @@ mod tests { #[test] fn test_morph_transaction_validate() { - let valid_tx = TxMorph { + // Valid V1 tx (no fee token required) + let valid_v1 = TxMorph { max_fee_per_gas: 100, max_priority_fee_per_gas: 50, + version: MORPH_TX_VERSION_1, ..Default::default() }; - assert!(valid_tx.validate().is_ok()); + assert!(valid_v1.validate().is_ok()); - let invalid_tx = TxMorph { + // Valid V0 tx (requires fee_token_id > 0) + let valid_v0 = TxMorph { + max_fee_per_gas: 100, + max_priority_fee_per_gas: 50, + version: MORPH_TX_VERSION_0, + fee_token_id: 1, // Required for V0 + ..Default::default() + }; + assert!(valid_v0.validate().is_ok()); + + // Invalid: priority fee > max fee + let invalid_priority = TxMorph { max_fee_per_gas: 50, max_priority_fee_per_gas: 100, + version: MORPH_TX_VERSION_1, ..Default::default() }; - assert!(invalid_tx.validate().is_err()); + assert!(invalid_priority.validate().is_err()); + + // Invalid: V0 without fee_token_id + let invalid_v0 = TxMorph { + max_fee_per_gas: 100, + max_priority_fee_per_gas: 50, + version: MORPH_TX_VERSION_0, + fee_token_id: 0, // Invalid for V0 + ..Default::default() + }; + assert!(invalid_v0.validate().is_err()); + assert_eq!( + invalid_v0.validate().unwrap_err(), + "version 0 MorphTx requires FeeTokenID > 0" + ); + + // Invalid: unsupported version + let invalid_version = TxMorph { + max_fee_per_gas: 100, + max_priority_fee_per_gas: 50, + version: 99, + ..Default::default() + }; + assert!(invalid_version.validate().is_err()); + assert_eq!( + invalid_version.validate().unwrap_err(), + "unsupported MorphTx version" + ); } #[test] @@ -519,6 +983,8 @@ mod tests { #[test] fn test_morph_transaction_trait_methods() { + let reference = B256::from([0x42u8; 32]); + let memo = Bytes::from(vec![0xde, 0xad, 0xbe, 0xef]); let tx = TxMorph { chain_id: 1, nonce: 42, @@ -529,8 +995,11 @@ mod tests { value: U256::from(100u64), access_list: AccessList::default(), input: Bytes::from(vec![1, 2, 3, 4]), + version: 1, fee_token_id: 1, fee_limit: U256::from(1000u64), + reference: Some(reference), + memo: Some(memo.clone()), }; // Test Transaction trait methods @@ -556,9 +1025,15 @@ mod tests { assert!(tx.authorization_list().is_none()); // Test TxMorphExt trait methods + assert_eq!(tx.version(), 1); assert_eq!(tx.fee_token_id(), 1); assert_eq!(tx.fee_limit(), U256::from(1000u64)); + assert_eq!(tx.reference(), Some(reference)); + assert_eq!(tx.memo(), Some(&memo)); assert!(tx.uses_token_fee()); + assert!(tx.has_reference()); + assert!(tx.has_memo()); + assert!(tx.is_v1()); } #[test] @@ -577,7 +1052,9 @@ mod tests { } #[test] - fn test_morph_transaction_rlp_roundtrip() { + fn test_morph_transaction_rlp_roundtrip_v1() { + let reference = B256::from([0xab; 32]); + let memo = Bytes::from(vec![0xca, 0xfe]); let tx = TxMorph { chain_id: 1, nonce: 42, @@ -588,8 +1065,11 @@ mod tests { value: U256::from(1_000_000_000_000_000_000u128), access_list: AccessList::default(), input: Bytes::from(vec![0x12, 0x34]), + version: 1, // V1 format fee_token_id: 1, fee_limit: U256::from(1000u64), + reference: Some(reference), + memo: Some(memo.clone()), }; // Encode @@ -597,7 +1077,7 @@ mod tests { tx.encode(&mut buf); // Decode - let decoded = TxMorph::decode(&mut buf.as_slice()).expect("Should decode"); + let decoded = TxMorph::decode(&mut buf.as_slice()).expect("Should decode V1"); assert_eq!(tx.chain_id, decoded.chain_id); assert_eq!(tx.nonce, decoded.nonce); @@ -610,8 +1090,57 @@ mod tests { assert_eq!(tx.to, decoded.to); assert_eq!(tx.value, decoded.value); assert_eq!(tx.input, decoded.input); + assert_eq!(tx.version, decoded.version); assert_eq!(tx.fee_token_id, decoded.fee_token_id); assert_eq!(tx.fee_limit, decoded.fee_limit); + assert_eq!(tx.reference, decoded.reference); + assert_eq!(tx.memo, decoded.memo); + assert!(decoded.is_v1()); + } + + #[test] + fn test_morph_transaction_rlp_roundtrip_v0() { + let tx = TxMorph { + chain_id: 1, + nonce: 42, + gas_limit: 21_000, + max_fee_per_gas: 100_000_000_000, + max_priority_fee_per_gas: 2_000_000_000, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + value: U256::from(1_000_000_000_000_000_000u128), + access_list: AccessList::default(), + input: Bytes::from(vec![0x12, 0x34]), + version: 0, // V0 format (legacy) + fee_token_id: 1, + fee_limit: U256::from(1000u64), + reference: None, // V0 has no reference + memo: None, // V0 has no memo + }; + + // Encode + let mut buf = Vec::new(); + tx.encode(&mut buf); + + // Decode + let decoded = TxMorph::decode(&mut buf.as_slice()).expect("Should decode V0"); + + assert_eq!(tx.chain_id, decoded.chain_id); + assert_eq!(tx.nonce, decoded.nonce); + assert_eq!(tx.gas_limit, decoded.gas_limit); + assert_eq!(tx.max_fee_per_gas, decoded.max_fee_per_gas); + assert_eq!( + tx.max_priority_fee_per_gas, + decoded.max_priority_fee_per_gas + ); + assert_eq!(tx.to, decoded.to); + assert_eq!(tx.value, decoded.value); + assert_eq!(tx.input, decoded.input); + assert_eq!(decoded.version, MORPH_TX_VERSION_0); + assert_eq!(tx.fee_token_id, decoded.fee_token_id); + assert_eq!(tx.fee_limit, decoded.fee_limit); + assert_eq!(decoded.reference, None); + assert_eq!(decoded.memo, None); + assert!(decoded.is_v0()); } #[test] @@ -626,8 +1155,11 @@ mod tests { value: U256::ZERO, access_list: AccessList::default(), input: Bytes::from(vec![0x60, 0x80, 0x60, 0x40]), + version: 0, fee_token_id: 1, fee_limit: U256::from(1000u64), + reference: None, + memo: None, }; // Encode @@ -652,8 +1184,11 @@ mod tests { value: U256::from(100u64), access_list: AccessList::default(), input: Bytes::new(), + version: 0, fee_token_id: 1, fee_limit: U256::from(1000u64), + reference: None, + memo: None, }; let mut buf = Vec::new(); @@ -681,8 +1216,11 @@ mod tests { value: U256::from(1_000_000_000_000_000_000u128), access_list: AccessList::default(), input: Bytes::from(vec![0x12, 0x34]), + version: 0, fee_token_id: 1, fee_limit: U256::from(1000u64), + reference: None, + memo: None, }; // Encode the transaction @@ -712,8 +1250,11 @@ mod tests { value: U256::ZERO, access_list: AccessList::default(), input: Bytes::new(), + version: 0, fee_token_id: 0, fee_limit: U256::ZERO, + reference: None, + memo: None, }; let size = tx.size(); @@ -732,8 +1273,11 @@ mod tests { value: U256::from(100u64), access_list: AccessList::default(), input: Bytes::from(vec![1, 2, 3, 4]), + version: 0, fee_token_id: 1, fee_limit: U256::from(1000u64), + reference: None, + memo: None, }; let fields_len = tx.fields_len(); @@ -756,8 +1300,11 @@ mod tests { value: U256::from(100u64), access_list: AccessList::default(), input: Bytes::new(), + version: 0, fee_token_id: 1, fee_limit: U256::from(1000u64), + reference: None, + memo: None, }; let mut buf = Vec::new(); @@ -795,11 +1342,246 @@ mod tests { value: U256::from(100u64), access_list: AccessList::default(), input: Bytes::new(), + version: 0, fee_token_id: 1, fee_limit: U256::from(1000u64), + reference: None, + memo: None, }; let hash = tx.signature_hash(); assert_ne!(hash, B256::ZERO); } + + #[test] + fn test_morph_transaction_with_reference_and_memo() { + let reference = B256::from([0x42u8; 32]); + let memo = Bytes::from(vec![0xde, 0xad, 0xbe, 0xef]); + + let tx = TxMorph { + chain_id: 1, + nonce: 1, + gas_limit: 21_000, + max_fee_per_gas: 100_000_000_000, + max_priority_fee_per_gas: 2_000_000_000, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + value: U256::from(100u64), + access_list: AccessList::default(), + input: Bytes::new(), + version: 1, + fee_token_id: 0, // No token fee, but still a MorphTx + fee_limit: U256::ZERO, + reference: Some(reference), + memo: Some(memo.clone()), + }; + + // Test trait methods + assert_eq!(tx.version(), 1); + assert_eq!(tx.reference(), Some(reference)); + assert_eq!(tx.memo(), Some(&memo)); + assert!(!tx.uses_token_fee()); // fee_token_id is 0 + assert!(tx.has_reference()); + assert!(tx.has_memo()); + assert!(tx.is_v1()); + + // Test RLP roundtrip + let mut buf = Vec::new(); + tx.encode(&mut buf); + let decoded = TxMorph::decode(&mut buf.as_slice()).expect("Should decode"); + + assert_eq!(decoded.version, 1); + assert_eq!(decoded.reference, Some(reference)); + assert_eq!(decoded.memo, Some(memo)); + assert!(decoded.is_v1()); + } + + #[test] + fn test_morph_transaction_memo_validation() { + // Valid memo (under 64 bytes) - use V1 since it doesn't require fee_token_id + let valid_tx = TxMorph { + version: MORPH_TX_VERSION_1, + memo: Some(Bytes::from(vec![0u8; 64])), + ..Default::default() + }; + assert!(valid_tx.validate().is_ok()); + + // Invalid memo (over 64 bytes) + let invalid_tx = TxMorph { + version: MORPH_TX_VERSION_1, + memo: Some(Bytes::from(vec![0u8; 65])), + ..Default::default() + }; + assert!(invalid_tx.validate().is_err()); + assert_eq!( + invalid_tx.validate().unwrap_err(), + "memo exceeds maximum length of 64 bytes" + ); + } + + #[test] + fn test_morph_transaction_v0_v1_encoding_difference() { + // V0 transaction + let v0_tx = TxMorph { + chain_id: 1, + nonce: 1, + gas_limit: 21_000, + max_fee_per_gas: 100, + max_priority_fee_per_gas: 10, + to: TxKind::Call(address!("0000000000000000000000000000000000000001")), + value: U256::from(100u64), + version: 0, // V0 + fee_token_id: 1, + fee_limit: U256::from(1000u64), + ..Default::default() + }; + + // V1 transaction with same base fields but with reference/memo + let v1_tx = TxMorph { + chain_id: 1, + nonce: 1, + gas_limit: 21_000, + max_fee_per_gas: 100, + max_priority_fee_per_gas: 10, + to: TxKind::Call(address!("0000000000000000000000000000000000000001")), + value: U256::from(100u64), + version: 1, // V1 + fee_token_id: 1, + fee_limit: U256::from(1000u64), + reference: Some(B256::from([0xab; 32])), + memo: Some(Bytes::from(vec![0xca, 0xfe])), + ..Default::default() + }; + + let mut v0_buf = Vec::new(); + v0_tx.encode(&mut v0_buf); + + let mut v1_buf = Vec::new(); + v1_tx.encode(&mut v1_buf); + + // V1 should be longer due to version byte prefix, Reference, and Memo fields + assert!( + v1_buf.len() > v0_buf.len(), + "V1 encoding ({}) should be longer than V0 ({})", + v1_buf.len(), + v0_buf.len() + ); + + // Both should decode correctly + let decoded_v0 = TxMorph::decode(&mut v0_buf.as_slice()).expect("V0 decode"); + let decoded_v1 = TxMorph::decode(&mut v1_buf.as_slice()).expect("V1 decode"); + + assert!(decoded_v0.is_v0()); + assert!(decoded_v1.is_v1()); + assert_eq!(decoded_v1.reference, Some(B256::from([0xab; 32]))); + assert_eq!(decoded_v1.memo, Some(Bytes::from(vec![0xca, 0xfe]))); + } + + #[test] + fn test_morph_transaction_encode_2718_v1_with_version_prefix() { + // V1 transaction + let tx = TxMorph { + chain_id: 1, + nonce: 1, + gas_limit: 21_000, + max_fee_per_gas: 100_000_000_000, + max_priority_fee_per_gas: 2_000_000_000, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + value: U256::from(100u64), + access_list: AccessList::default(), + input: Bytes::new(), + version: 1, // V1 + fee_token_id: 0, + fee_limit: U256::ZERO, + reference: Some(B256::from([0xab; 32])), + memo: Some(Bytes::from(vec![0xca, 0xfe])), + }; + + let mut buf = Vec::new(); + tx.encode_2718(&mut buf); + + // First byte should be txType (0x7F) + assert_eq!(buf[0], MORPH_TX_TYPE_ID); + + // Second byte should be version (0x01) for V1 + assert_eq!(buf[1], MORPH_TX_VERSION_1); + + // Third byte should be RLP list prefix (>= 0xC0) + assert!( + buf[2] >= 0xC0, + "Third byte should be RLP list prefix, got 0x{:02x}", + buf[2] + ); + + // Verify length consistency + assert_eq!(buf.len(), tx.encode_2718_len()); + } + + #[test] + fn test_morph_transaction_encode_2718_v0_no_version_prefix() { + // V0 transaction + let tx = TxMorph { + chain_id: 1, + nonce: 1, + gas_limit: 21_000, + max_fee_per_gas: 100_000_000_000, + max_priority_fee_per_gas: 2_000_000_000, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + value: U256::from(100u64), + access_list: AccessList::default(), + input: Bytes::new(), + version: 0, // V0 + fee_token_id: 1, + fee_limit: U256::from(1000u64), + reference: None, + memo: None, + }; + + let mut buf = Vec::new(); + tx.encode_2718(&mut buf); + + // First byte should be txType (0x7F) + assert_eq!(buf[0], MORPH_TX_TYPE_ID); + + // Second byte should be RLP list prefix (>= 0xC0) - NO version byte for V0 + assert!( + buf[1] >= 0xC0, + "Second byte should be RLP list prefix for V0, got 0x{:02x}", + buf[1] + ); + + // Verify length consistency + assert_eq!(buf.len(), tx.encode_2718_len()); + } + + #[test] + fn test_morph_transaction_v0_requires_fee_token_id() { + // V0 with fee_token_id = 0 should fail to decode + let tx = TxMorph { + chain_id: 1, + nonce: 1, + gas_limit: 21_000, + max_fee_per_gas: 100, + max_priority_fee_per_gas: 10, + to: TxKind::Call(address!("0000000000000000000000000000000000000001")), + value: U256::from(100u64), + version: 0, + fee_token_id: 0, // Invalid for V0 + fee_limit: U256::ZERO, + ..Default::default() + }; + + // Validation should fail + assert!(tx.validate_version().is_err()); + + // We can still encode it (encoding doesn't validate) + let mut buf = Vec::new(); + tx.encode(&mut buf); + + // But decoding should fail because V0 requires fee_token_id > 0 + let result = TxMorph::decode(&mut buf.as_slice()); + assert!( + result.is_err(), + "V0 with fee_token_id=0 should fail to decode" + ); + } } diff --git a/crates/revm/src/tx.rs b/crates/revm/src/tx.rs index d0e32bb..062339b 100644 --- a/crates/revm/src/tx.rs +++ b/crates/revm/src/tx.rs @@ -25,6 +25,9 @@ use alloy_consensus::transaction::Either; /// - L1 message detection (tx_type 0x7E) /// - TxMorph with token-based gas payment (tx_type 0x7F) /// - RLP encoded transaction bytes for L1 data fee calculation +/// - Version, reference, and memo fields for extended MorphTx functionality +/// +/// Reference: #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct MorphTxEnv { /// Inner transaction environment. @@ -32,11 +35,18 @@ pub struct MorphTxEnv { /// RLP encoded transaction bytes. /// Used only for L1 data fee calculation. pub rlp_bytes: Option, - /// Maximum amount of tokens the sender is willing to pay as fee. - pub fee_limit: Option, + /// Version of the Morph transaction format. + pub version: Option, /// Token ID for fee payment (only for TxMorph type 0x7F). /// 0 means ETH payment, > 0 means ERC20 token payment. pub fee_token_id: Option, + /// Maximum amount of tokens the sender is willing to pay as fee. + pub fee_limit: Option, + /// Reference key for the transaction. + /// Used for indexing and looking up transactions by external systems. + pub reference: Option, + /// Memo field for arbitrary data. + pub memo: Option, } impl MorphTxEnv { @@ -45,8 +55,11 @@ impl MorphTxEnv { Self { inner, rlp_bytes: None, - fee_limit: None, + version: None, fee_token_id: None, + fee_limit: None, + reference: None, + memo: None, } } @@ -56,9 +69,9 @@ impl MorphTxEnv { self } - /// Set the fee limit. - pub fn with_fee_limit(mut self, fee_limit: U256) -> Self { - self.fee_limit = Some(fee_limit); + /// Set the version. + pub fn with_version(mut self, version: u8) -> Self { + self.version = Some(version); self } @@ -68,6 +81,24 @@ impl MorphTxEnv { self } + /// Set the fee limit. + pub fn with_fee_limit(mut self, fee_limit: U256) -> Self { + self.fee_limit = Some(fee_limit); + self + } + + /// Set the reference. + pub fn with_reference(mut self, reference: alloy_primitives::B256) -> Self { + self.reference = Some(reference); + self + } + + /// Set the memo. + pub fn with_memo(mut self, memo: Bytes) -> Self { + self.memo = Some(memo); + self + } + /// Create a new Morph transaction environment from a recovered transaction. /// /// This method: @@ -86,14 +117,11 @@ impl MorphTxEnv { fn from_tx_with_rlp_bytes(tx: &MorphTxEnvelope, signer: Address, rlp_bytes: Bytes) -> Self { let tx_type: u8 = tx.tx_type().into(); - // Extract fee_token_id for TxMorph (type 0x7F) - let fee_token_info = if tx_type == MORPH_TX_TYPE_ID { - ( - Some(extract_fee_token_id_from_rlp(&rlp_bytes)), - Some(extract_fee_limit_from_rlp(&rlp_bytes)), - ) + // Extract MorphTx fields for TxMorph (type 0x7F) + let morph_tx_info = if tx_type == MORPH_TX_TYPE_ID { + extract_morph_tx_fields_from_rlp(&rlp_bytes) } else { - (None, None) + None }; // Build TxEnv from the transaction @@ -132,46 +160,52 @@ impl MorphTxEnv { // Use builder pattern to set Morph-specific fields let mut env = Self::new(inner).with_rlp_bytes(rlp_bytes); - if let Some(fee_token_id) = fee_token_info.0 { - env = env.with_fee_token_id(fee_token_id); - }; - if let Some(fee_limit) = fee_token_info.1 { - env = env.with_fee_limit(fee_limit); - }; + if let Some(info) = morph_tx_info { + env = env.with_version(info.version); + env = env.with_fee_token_id(info.fee_token_id); + env = env.with_fee_limit(info.fee_limit); + if let Some(reference) = info.reference { + env = env.with_reference(reference); + } + if let Some(memo) = info.memo { + if !memo.is_empty() { + env = env.with_memo(memo); + } + } + } env } } -/// Extract fee_token_id from RLP-encoded TxMorph bytes. -/// -/// The bytes should be EIP-2718 encoded (type byte + RLP payload). -/// Returns 0 if decoding fails. -fn extract_fee_token_id_from_rlp(rlp_bytes: &Bytes) -> u16 { - if rlp_bytes.is_empty() { - return 0; - } - - // Skip the type byte (0x7F) and decode the TxMorph - let payload = &rlp_bytes[1..]; - TxMorph::decode(&mut &payload[..]) - .map(|tx| tx.fee_token_id) - .unwrap_or(0) +/// Extracted MorphTx fields from RLP-encoded bytes. +struct MorphTxFields { + version: u8, + fee_token_id: u16, + fee_limit: U256, + reference: Option, + memo: Option, } -/// Extract fee_limit from RLP-encoded TxMorph bytes. +/// Extract all MorphTx fields from RLP-encoded TxMorph bytes. /// /// The bytes should be EIP-2718 encoded (type byte + RLP payload). -/// Returns 0 if decoding fails. -fn extract_fee_limit_from_rlp(rlp_bytes: &Bytes) -> U256 { +/// Returns None if decoding fails. +fn extract_morph_tx_fields_from_rlp(rlp_bytes: &Bytes) -> Option { if rlp_bytes.is_empty() { - return U256::default(); + return None; } // Skip the type byte (0x7F) and decode the TxMorph let payload = &rlp_bytes[1..]; TxMorph::decode(&mut &payload[..]) - .map(|tx| tx.fee_limit) - .unwrap_or_default() + .map(|tx| MorphTxFields { + version: tx.version, + fee_token_id: tx.fee_token_id, + fee_limit: tx.fee_limit, + reference: tx.reference, + memo: tx.memo, + }) + .ok() } impl Deref for MorphTxEnv { @@ -356,6 +390,16 @@ pub trait MorphTxExt { /// Returns whether this transaction is a TxMorph (type 0x7F). /// TxMorph supports ERC20 token-based gas payment. fn is_morph_tx(&self) -> bool; + + /// Returns whether this transaction uses token-based gas payment. + fn uses_token_fee(&self) -> bool { + false + } + + /// Returns whether this transaction has a reference. + fn has_reference(&self) -> bool { + false + } } impl MorphTxExt for MorphTxEnv { @@ -368,6 +412,16 @@ impl MorphTxExt for MorphTxEnv { fn is_morph_tx(&self) -> bool { self.inner.tx_type == MORPH_TX_TYPE_ID } + + #[inline] + fn uses_token_fee(&self) -> bool { + self.fee_token_id.map_or(false, |id| id > 0) + } + + #[inline] + fn has_reference(&self) -> bool { + self.reference.is_some() + } } impl MorphTxExt for TxEnv { From e87a571092e9084b650e50330e63907ed0a496ac Mon Sep 17 00:00:00 2001 From: chengwenxi <22697326+chengwenxi@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:05:08 +0800 Subject: [PATCH 2/5] fix clippy --- crates/primitives/src/receipt/receipt.rs | 1 + crates/primitives/src/transaction/morph_transaction.rs | 8 ++++---- crates/revm/src/tx.rs | 10 +++++----- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/primitives/src/receipt/receipt.rs b/crates/primitives/src/receipt/receipt.rs index 279f4c2..cc345dc 100644 --- a/crates/primitives/src/receipt/receipt.rs +++ b/crates/primitives/src/receipt/receipt.rs @@ -149,6 +149,7 @@ impl MorphTransactionReceipt { } /// Creates a new receipt with all TxMorph fields including version, reference, and memo. + #[allow(clippy::too_many_arguments)] pub const fn with_morph_tx_full( inner: Receipt, l1_fee: U256, diff --git a/crates/primitives/src/transaction/morph_transaction.rs b/crates/primitives/src/transaction/morph_transaction.rs index 28f7c4a..a2d1251 100644 --- a/crates/primitives/src/transaction/morph_transaction.rs +++ b/crates/primitives/src/transaction/morph_transaction.rs @@ -160,10 +160,10 @@ impl TxMorph { return Err("max priority fee per gas exceeds max fee per gas"); } // Validate memo length - if let Some(memo) = &self.memo { - if memo.len() > MAX_MEMO_LENGTH { - return Err("memo exceeds maximum length of 64 bytes"); - } + if let Some(memo) = &self.memo + && memo.len() > MAX_MEMO_LENGTH + { + return Err("memo exceeds maximum length of 64 bytes"); } // Validate version-specific rules self.validate_version() diff --git a/crates/revm/src/tx.rs b/crates/revm/src/tx.rs index 062339b..b31602e 100644 --- a/crates/revm/src/tx.rs +++ b/crates/revm/src/tx.rs @@ -167,10 +167,10 @@ impl MorphTxEnv { if let Some(reference) = info.reference { env = env.with_reference(reference); } - if let Some(memo) = info.memo { - if !memo.is_empty() { - env = env.with_memo(memo); - } + if let Some(memo) = info.memo + && !memo.is_empty() + { + env = env.with_memo(memo); } } env @@ -415,7 +415,7 @@ impl MorphTxExt for MorphTxEnv { #[inline] fn uses_token_fee(&self) -> bool { - self.fee_token_id.map_or(false, |id| id > 0) + self.fee_token_id.is_some_and(|id| id > 0) } #[inline] From 22257f9dea2263ccd649205080b8f66b45d346d3 Mon Sep 17 00:00:00 2001 From: chengwenxi <22697326+chengwenxi@users.noreply.github.com> Date: Mon, 2 Feb 2026 18:14:41 +0800 Subject: [PATCH 3/5] docs: Update README to reflect changes in Morph Transaction --- README.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7d7267e..8969404 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,8 @@ Morph Reth is the next-generation execution client for [Morph](https://www.morph ### Key Features -- **L1 Message Support**: Native handling of L1-to-L2 deposit messages with queue index validation -- **Alt Fee Tokens**: Support for paying transaction fees in alternative ERC-20 tokens -- **Custom Receipt Types**: Extended receipt format with L1 fee tracking +- **L1 Message Support**: Seamless bridging of assets and messages from Ethereum L1 to Morph L2 +- **Morph Transaction**: EVM+ transaction enabling alternative token fees, reference key indexing, and memo attachment - **Morph Hardforks**: Full support for Morph's upgrade schedule (Bernoulli, Curie, Morph203, Viridian, Emerald) ## Architecture @@ -91,7 +90,7 @@ Morph supports the following transaction types: | EIP-1559 | `0x02` | Dynamic fee transactions | | EIP-7702 | `0x04` | Account abstraction transactions | | L1 Message | `0x7e` | L1-to-L2 deposit messages | -| Alt Fee | `0x7f` | Alternative fee token transactions | +| Morph Transaction | `0x7f` | EVM+ transaction with enhanced features | ### L1 Messages @@ -102,6 +101,16 @@ L1 messages are special deposit transactions that originate from Ethereum L1: - Gas is prepaid on L1, so no L2 gas fee is charged - Cannot be sent via the mempool (sequencer only) +### Morph Transaction + +Morph Transaction (`0x7f`) is Morph's EVM+ transaction type, extending standard EVM transactions for better user experience and enterprise integration: + +| Feature | Description | +|---------|-------------| +| **Alternative Fee Tokens** | Pay gas in stablecoins (USDT, USDC) or other ERC-20 tokens — no ETH required | +| **Transaction Reference** | Tag transactions with a 32-byte key for order tracking and payment reconciliation | +| **Memo Field** | Attach notes or invoice numbers (up to 64 bytes) for auditing and record-keeping | + ### Hardforks | Hardfork | Description | From 4f7b0c3a3aecd48753ee8a20f60a29f63fee0767 Mon Sep 17 00:00:00 2001 From: chengwenxi <22697326+chengwenxi@users.noreply.github.com> Date: Mon, 2 Feb 2026 18:52:25 +0800 Subject: [PATCH 4/5] update readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8969404..8864fcd 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Morph Reth is the next-generation execution client for [Morph](https://www.morph ### Key Features - **L1 Message Support**: Seamless bridging of assets and messages from Ethereum L1 to Morph L2 -- **Morph Transaction**: EVM+ transaction enabling alternative token fees, reference key indexing, and memo attachment +- **Morph Transaction**: Morph EVM+ transaction enabling alternative token fees, reference key indexing, and memo attachment - **Morph Hardforks**: Full support for Morph's upgrade schedule (Bernoulli, Curie, Morph203, Viridian, Emerald) ## Architecture @@ -90,7 +90,7 @@ Morph supports the following transaction types: | EIP-1559 | `0x02` | Dynamic fee transactions | | EIP-7702 | `0x04` | Account abstraction transactions | | L1 Message | `0x7e` | L1-to-L2 deposit messages | -| Morph Transaction | `0x7f` | EVM+ transaction with enhanced features | +| Morph Transaction | `0x7f` | Morph EVM+ transaction with enhanced features | ### L1 Messages From b5ccdf9091b2277bf3ccdf357c379c16d7bf3e8c Mon Sep 17 00:00:00 2001 From: chengwenxi <22697326+chengwenxi@users.noreply.github.com> Date: Mon, 2 Feb 2026 18:58:13 +0800 Subject: [PATCH 5/5] fix clippy --- crates/primitives/src/transaction/morph_transaction.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/primitives/src/transaction/morph_transaction.rs b/crates/primitives/src/transaction/morph_transaction.rs index a2d1251..e55a396 100644 --- a/crates/primitives/src/transaction/morph_transaction.rs +++ b/crates/primitives/src/transaction/morph_transaction.rs @@ -1069,7 +1069,7 @@ mod tests { fee_token_id: 1, fee_limit: U256::from(1000u64), reference: Some(reference), - memo: Some(memo.clone()), + memo: Some(memo), }; // Encode