diff --git a/Cargo.lock b/Cargo.lock index 78d122e9d..54f240bf8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5264,16 +5264,6 @@ dependencies = [ "rustc-hex", ] -[[package]] -name = "rlp" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa24e92bb2a83198bb76d661a71df9f7076b8c420b8696e4d3d97d50d94479e3" -dependencies = [ - "bytes", - "rustc-hex", -] - [[package]] name = "rocksdb" version = "0.24.0" @@ -5311,7 +5301,7 @@ dependencies = [ "proptest", "rand 0.8.5", "rand 0.9.4", - "rlp 0.5.2", + "rlp", "ruint-macro", "serde_core", "valuable", @@ -6188,6 +6178,7 @@ dependencies = [ "alloy-eips", "alloy-json-abi", "alloy-primitives", + "alloy-rlp", "alloy-rpc-types-eth", "alloy-rpc-types-trace", "alloy-sol-types", @@ -6249,7 +6240,6 @@ dependencies = [ "revm-inspectors", "revm-state", "ring", - "rlp 0.6.1", "rocksdb", "rustc-hash", "sasl2-sys", diff --git a/Cargo.toml b/Cargo.toml index 8cc0295b2..e2489c806 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -84,7 +84,7 @@ alloy-dyn-abi = "=1.6.1" alloy-json-abi = "=1.6.1" revm-inspectors = { version = "=0.42.0", features = ["js-tracer"] } revm-state = "42.0.0" -rlp = "=0.6.1" +alloy-rlp = { version = "=0.3.16", features = ["derive"] } # network jsonrpsee = { version = "=0.26.0", features = ["server", "client"] } diff --git a/src/eth/rpc/mod.rs b/src/eth/rpc/mod.rs index dd319a190..71a2864ad 100644 --- a/src/eth/rpc/mod.rs +++ b/src/eth/rpc/mod.rs @@ -26,6 +26,7 @@ pub use types::LogFilterInputTopic; pub use types::MulticallError; pub use types::RpcClientApp; pub use types::RpcError; +pub use types::TransactionDecodeError; // ----------------------------------------------------------------------------- // Tests diff --git a/src/eth/rpc/parser.rs b/src/eth/rpc/parser.rs index b5f6ec07f..3cc595cc7 100644 --- a/src/eth/rpc/parser.rs +++ b/src/eth/rpc/parser.rs @@ -1,13 +1,14 @@ //! Helper functions for parsing RPC requests and responses. +use alloy_rlp::Decodable; use jsonrpsee::Extensions; use jsonrpsee::types::ParamsSequence; -use rlp::Decodable; use tracing::Span; use super::middleware::Authentication; use crate::eth::rpc::RpcClientApp; use crate::eth::rpc::RpcError; +use crate::eth::rpc::TransactionDecodeError; use crate::ext::type_basename; use crate::infra::tracing::EnteredWrap; @@ -77,8 +78,11 @@ where /// /// https://ethereum.org/en/developers/docs/data-structures-and-encoding/rlp pub fn parse_rpc_rlp(value: &[u8]) -> Result { - match rlp::decode::(value) { + let mut buf = value; + match T::decode(&mut buf) { Ok(trx) => Ok(trx), - Err(e) => Err(RpcError::TransactionInvalid { decode_error: e.to_string() }), + Err(e) => Err(RpcError::TransactionInvalid { + decode_error: TransactionDecodeError::Custom(e.to_string()), + }), } } diff --git a/src/eth/rpc/types/error.rs b/src/eth/rpc/types/error.rs index 0e0d1ced8..220db3de9 100644 --- a/src/eth/rpc/types/error.rs +++ b/src/eth/rpc/types/error.rs @@ -3,6 +3,62 @@ use stratus_macros::ErrorCode; use crate::eth::rpc::BlockFilter; use crate::eth::types::ErrorCode; +/// Errors that can occur while decoding a raw transaction. +#[derive(Debug, thiserror::Error)] +pub enum TransactionDecodeError { + #[error("missing field: {0}")] + MissingField(&'static str), + + #[error("invalid to field")] + InvalidTo, + + #[error("failed to recover signer")] + SignerRecovery, + + #[error("unsupported transaction type")] + UnsupportedType, + + #[error("typed transaction has extra fields")] + ExtraFields, + + #[error("invalid transaction type byte")] + InvalidTypeByte, + + #[error("empty transaction bytes")] + EmptyBytes, + + #[error("legacy transaction type is not typed")] + LegacyNotTyped, + + #[error("invalid legacy v value")] + InvalidLegacyV, + + #[error("rlp decode error: {0}")] + RlpError(String), + + #[error("{0}")] + Custom(String), +} + +impl From for alloy_rlp::Error { + fn from(value: TransactionDecodeError) -> Self { + let message = match value { + TransactionDecodeError::MissingField(_) => "missing field", + TransactionDecodeError::InvalidTo => "invalid to field", + TransactionDecodeError::SignerRecovery => "failed to recover signer", + TransactionDecodeError::UnsupportedType => "unsupported transaction type", + TransactionDecodeError::ExtraFields => "typed transaction has extra fields", + TransactionDecodeError::InvalidTypeByte => "invalid transaction type byte", + TransactionDecodeError::EmptyBytes => "empty transaction bytes", + TransactionDecodeError::LegacyNotTyped => "legacy transaction type is not typed", + TransactionDecodeError::InvalidLegacyV => "invalid legacy v value", + TransactionDecodeError::RlpError(_) => "rlp decode error", + TransactionDecodeError::Custom(_) => "failed to decode transaction", + }; + alloy_rlp::Error::Custom(message) + } +} + #[derive(Debug, thiserror::Error, strum::EnumProperty, strum::IntoStaticStr, ErrorCode)] #[major_error_code = 1000] pub enum RpcError { @@ -38,9 +94,9 @@ pub enum RpcError { #[error_code = 7] SubscriptionLimit { max: u32 }, - #[error("failed to decode transaction RLP data.")] + #[error("failed to decode transaction RLP data: {decode_error}")] #[error_code = 8] - TransactionInvalid { decode_error: String }, + TransactionInvalid { decode_error: TransactionDecodeError }, #[error("miner mode param is invalid.")] #[error_code = 9] diff --git a/src/eth/rpc/types/mod.rs b/src/eth/rpc/types/mod.rs index 52bbeac38..673b94ed3 100644 --- a/src/eth/rpc/types/mod.rs +++ b/src/eth/rpc/types/mod.rs @@ -8,6 +8,7 @@ mod timestamp_filter; pub use block_filter::BlockFilter; pub use error::MulticallError; pub use error::RpcError; +pub use error::TransactionDecodeError; pub use log_filter::LogFilter; pub use log_filter_input::LogFilterInput; pub use log_filter_input::LogFilterInputTopic; diff --git a/src/eth/types/error.rs b/src/eth/types/error.rs index 2e355c3d5..24fe4ec6c 100644 --- a/src/eth/types/error.rs +++ b/src/eth/types/error.rs @@ -143,7 +143,7 @@ impl StratusError { Self::RPC(RpcError::ClientBlocked { client }) => to_json_value(client), // Transaction - Self::RPC(RpcError::TransactionInvalid { decode_error }) => to_json_value(decode_error), + Self::RPC(RpcError::TransactionInvalid { decode_error }) => to_json_value(decode_error.to_string()), Self::Executor(ExecutorError::EvmFailed(e)) => JsonValue::String(e.to_string()), Self::Executor(ExecutorError::RevertedCall { output }) => to_json_value(output), Self::Executor(ExecutorError::RevertedCallWithReason { reason }) => to_json_value(reason), diff --git a/src/eth/types/primitives/address.rs b/src/eth/types/primitives/address.rs index c6a0b823c..d4e9976b0 100644 --- a/src/eth/types/primitives/address.rs +++ b/src/eth/types/primitives/address.rs @@ -12,10 +12,25 @@ use fake::Faker; use hex_literal::hex; use crate::alias::RevmAddress; +use crate::eth::rpc::TransactionDecodeError; use crate::eth::types::LogTopic; /// Address of an Ethereum account (wallet or contract). -#[derive(DebugAsJson, Clone, Copy, Default, Eq, PartialEq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)] +#[derive( + DebugAsJson, + Clone, + Copy, + Default, + Eq, + PartialEq, + PartialOrd, + Ord, + Hash, + serde::Serialize, + serde::Deserialize, + alloy_rlp::RlpDecodableWrapper, + alloy_rlp::RlpEncodableWrapper, +)] pub struct Address(pub FixedBytes<20>); impl Address { @@ -109,6 +124,15 @@ impl TryFrom> for Address { } } +impl TryFrom<&[u8]> for Address { + type Error = TransactionDecodeError; + + fn try_from(value: &[u8]) -> Result { + let array = <[u8; 20]>::try_from(value).map_err(|_| TransactionDecodeError::InvalidTo)?; + Ok(Self(FixedBytes::from(array))) + } +} + // ----------------------------------------------------------------------------- // Conversions: Self -> Other // ----------------------------------------------------------------------------- diff --git a/src/eth/types/primitives/bytes.rs b/src/eth/types/primitives/bytes.rs index ee761d366..c56e68626 100644 --- a/src/eth/types/primitives/bytes.rs +++ b/src/eth/types/primitives/bytes.rs @@ -6,7 +6,7 @@ use display_json::DebugAsJson; use crate::alias::RevmBytes; use crate::alias::RevmOutput; -#[derive(DebugAsJson, Clone, Default, Eq, PartialEq)] +#[derive(DebugAsJson, Clone, Default, Eq, PartialEq, alloy_rlp::RlpDecodableWrapper, alloy_rlp::RlpEncodableWrapper)] pub struct Bytes(pub bytes::Bytes); impl Display for Bytes { diff --git a/src/eth/types/primitives/chain_id.rs b/src/eth/types/primitives/chain_id.rs index 131a1aaaf..a6177c55e 100644 --- a/src/eth/types/primitives/chain_id.rs +++ b/src/eth/types/primitives/chain_id.rs @@ -10,7 +10,9 @@ use fake::Faker; use crate::ext::RuintExt; -#[derive(DebugAsJson, derive_more::Display, Clone, Copy, Default, Eq, PartialEq, serde::Serialize)] +#[derive( + DebugAsJson, derive_more::Display, Clone, Copy, Default, Eq, PartialEq, serde::Serialize, alloy_rlp::RlpDecodableWrapper, alloy_rlp::RlpEncodableWrapper, +)] #[cfg_attr(test, derive(serde::Deserialize))] pub struct ChainId(pub U64); diff --git a/src/eth/types/primitives/gas.rs b/src/eth/types/primitives/gas.rs index afe568a57..ebc30e62a 100644 --- a/src/eth/types/primitives/gas.rs +++ b/src/eth/types/primitives/gas.rs @@ -9,7 +9,20 @@ use revm::context::result::ResultGas; use crate::ext::RuintExt; -#[derive(DebugAsJson, derive_more::Display, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, derive_more::Add, derive_more::AddAssign)] +#[derive( + DebugAsJson, + derive_more::Display, + Clone, + Copy, + Default, + PartialEq, + Eq, + serde::Serialize, + derive_more::Add, + derive_more::AddAssign, + alloy_rlp::RlpDecodableWrapper, + alloy_rlp::RlpEncodableWrapper, +)] #[serde(transparent)] #[cfg_attr(test, derive(serde::Deserialize))] pub struct Gas(U64); diff --git a/src/eth/types/primitives/nonce.rs b/src/eth/types/primitives/nonce.rs index 0cc3fe100..ee43af8b4 100644 --- a/src/eth/types/primitives/nonce.rs +++ b/src/eth/types/primitives/nonce.rs @@ -9,7 +9,19 @@ use fake::Faker; use crate::ext::RuintExt; -#[derive(DebugAsJson, derive_more::Display, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive( + DebugAsJson, + derive_more::Display, + Clone, + Copy, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + alloy_rlp::RlpDecodableWrapper, + alloy_rlp::RlpEncodableWrapper, +)] pub struct Nonce(U64); impl Nonce { diff --git a/src/eth/types/primitives/wei.rs b/src/eth/types/primitives/wei.rs index 65a0aa880..30bd3ab8a 100644 --- a/src/eth/types/primitives/wei.rs +++ b/src/eth/types/primitives/wei.rs @@ -7,7 +7,22 @@ use fake::Dummy; use fake::Faker; /// Native token amount in wei. -#[derive(DebugAsJson, derive_more::Display, Clone, Copy, Default, PartialOrd, Ord, PartialEq, Eq, derive_more::Sub, serde::Serialize, serde::Deserialize)] +#[derive( + DebugAsJson, + derive_more::Display, + Clone, + Copy, + Default, + PartialOrd, + Ord, + PartialEq, + Eq, + derive_more::Sub, + serde::Serialize, + serde::Deserialize, + alloy_rlp::RlpDecodableWrapper, + alloy_rlp::RlpEncodableWrapper, +)] pub struct Wei(pub U256); impl Wei { diff --git a/src/eth/types/transaction/transaction_input.rs b/src/eth/types/transaction/transaction_input.rs index 7aede9f8d..cf0263f15 100644 --- a/src/eth/types/transaction/transaction_input.rs +++ b/src/eth/types/transaction/transaction_input.rs @@ -7,19 +7,26 @@ use alloy_consensus::TxEip4844Variant; use alloy_consensus::TxEip7702; use alloy_consensus::TxEnvelope; use alloy_consensus::TxLegacy; +use alloy_consensus::TxType; use alloy_consensus::transaction::Recovered; -use alloy_eips::eip2718::Decodable2718; +use alloy_eips::eip7702::SignedAuthorization; +use alloy_primitives::B256; use alloy_primitives::Signature as AlloySignature; use alloy_primitives::TxKind; use alloy_primitives::U64; use alloy_primitives::U256; +use alloy_primitives::keccak256; +use alloy_rlp::Decodable as RlpDecodable; +use alloy_rlp::Encodable as RlpEncodable; +use alloy_rlp::Header as RlpHeader; +use alloy_rlp::length_of_length; use alloy_rpc_types_eth::AccessList; use anyhow::Context; use display_json::DebugAsJson; -use rlp::Decodable; use crate::alias::AlloyTransaction; use crate::eth::executor::TransactionExecutionInput; +use crate::eth::rpc::TransactionDecodeError; use crate::eth::types::Address; use crate::eth::types::Bytes; use crate::eth::types::ChainId; @@ -31,6 +38,36 @@ use crate::eth::types::SignatureComponent; use crate::eth::types::Wei; use crate::ext::RuintExt; +/// Legacy transaction `v` value constants (EIP-155). +const LEGACY_V_UNPROTECTED_EVEN: u64 = 27; +const LEGACY_V_UNPROTECTED_ODD: u64 = 28; +const EIP155_V_OFFSET: u64 = 35; + +/// Decodes the next RLP value, mapping a missing value to `MissingField` and an RLP error to `RlpError`. +fn decode_next(rlp: &mut alloy_rlp::Rlp<'_>, field: &'static str) -> Result { + rlp.get_next() + .map_err(|e| TransactionDecodeError::RlpError(e.to_string()))? + .ok_or(TransactionDecodeError::MissingField(field)) +} + +/// Decodes the `to` field: empty bytes mean contract creation, otherwise a 20-byte address. +fn decode_to_field(rlp: &mut alloy_rlp::Rlp<'_>) -> Result, TransactionDecodeError> { + let to_bytes = decode_next::(rlp, "to")?; + if to_bytes.is_empty() { + Ok(None) + } else { + Ok(Some(Address::try_from(to_bytes.as_ref())?)) + } +} + +/// Common fields shared by EIP-2930, EIP-1559, EIP-4844, and EIP-7702 transactions. +struct TypedTxCommonFields { + gas_limit: Gas, + to: Option
, + value: Wei, + input: Bytes, +} + #[derive(DebugAsJson, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)] #[cfg_attr(test, derive(serde::Deserialize, fake::Dummy))] pub struct TransactionInfo { @@ -143,10 +180,142 @@ impl TransactionInput { } } + /// Encodes a list of RLP items and returns the encoded bytes. + fn encode_rlp_list(items: &[&dyn RlpEncodable]) -> Vec { + let payload_length: usize = items.iter().map(|item| item.length()).sum(); + let mut out = Vec::with_capacity(payload_length + length_of_length(payload_length)); + RlpHeader { list: true, payload_length }.encode(&mut out); + for item in items { + item.encode(&mut out); + } + out + } + + /// Encodes a transaction for signature hash computation. + /// + /// For typed transactions, the type byte is prepended to the RLP list. + /// For legacy transactions, only the RLP list is encoded. + fn encode_transaction(tx_type: Option, fields: &[&dyn RlpEncodable]) -> B256 { + let encoded = Self::encode_rlp_list(fields); + let hash_input = match tx_type { + Some(tx_type) => { + let mut out = Vec::with_capacity(1 + encoded.len()); + out.push(tx_type as u8); + out.extend_from_slice(&encoded); + out + } + None => encoded, + }; + B256::from(keccak256(hash_input)) + } + + /// Returns the RLP encoding of the `to` field: empty bytes for contract creation, + /// or the 20-byte address for a call. + fn encode_to(&self) -> Vec { + self.execution_info.to.map(|addr| addr.0.to_vec()).unwrap_or_default() + } + + /// Returns the RLP encoding of the transaction `input` data. + fn encode_input(&self) -> Vec { + self.execution_info.input.0.to_vec() + } + + /// Computes the transaction signature hash from the fields stored in this input. + /// + /// Encodes the unsigned transaction directly via RLP. + fn signature_hash(&self) -> B256 { + let chain_id = self.execution_info.chain_id.map(|c| c.0.as_u64()).unwrap_or_default(); + let nonce = self.execution_info.nonce.as_u64(); + let gas_limit = self.execution_info.gas_limit.as_u64(); + let gas_price = self.execution_info.gas_price; + let value = self.execution_info.value.0; + let to = self.encode_to(); + let input = self.encode_input(); + + let tx_type = self + .transaction_info + .tx_type + .and_then(|t| TxType::try_from(t.as_u64()).ok()) + .unwrap_or(TxType::Legacy); + + match tx_type { + TxType::Eip2930 => Self::encode_transaction( + Some(TxType::Eip2930), + &[ + &chain_id, + &nonce, + &gas_price, + &gas_limit, + &to.as_slice(), + &value, + &input.as_slice(), + &AccessList::default(), + ], + ), + + TxType::Eip1559 => Self::encode_transaction( + Some(TxType::Eip1559), + &[ + &chain_id, + &nonce, + &gas_price, // max_priority_fee_per_gas + &gas_price, // max_fee_per_gas + &gas_limit, + &to.as_slice(), + &value, + &input.as_slice(), + &AccessList::default(), + ], + ), + + TxType::Eip4844 => Self::encode_transaction( + Some(TxType::Eip4844), + &[ + &chain_id, + &nonce, + &gas_price, // max_priority_fee_per_gas + &gas_price, // max_fee_per_gas + &gas_limit, + &to.as_slice(), + &value, + &input.as_slice(), + &AccessList::default(), + &0u128, // max_fee_per_blob_gas + &Vec::::new(), // blob_versioned_hashes + ], + ), + + TxType::Eip7702 => Self::encode_transaction( + Some(TxType::Eip7702), + &[ + &chain_id, + &nonce, + &gas_price, // max_priority_fee_per_gas + &gas_price, // max_fee_per_gas + &gas_limit, + &to.as_slice(), + &value, + &input.as_slice(), + &AccessList::default(), + &Vec::::new(), // authorization list placeholder + ], + ), + + TxType::Legacy => + if self.execution_info.chain_id.is_some() { + Self::encode_transaction( + None, + &[&nonce, &gas_price, &gas_limit, &to.as_slice(), &value, &input.as_slice(), &chain_id, &0u8, &0u8], + ) + } else { + Self::encode_transaction(None, &[&nonce, &gas_price, &gas_limit, &to.as_slice(), &value, &input.as_slice()]) + }, + } + } + /// Recovers the signer address from the transaction fields already stored in this input. fn recover_signer_address(&self) -> anyhow::Result
{ - let inner = self.to_tx_envelope(); - let prehash = inner.signature_hash(); + let prehash = self.signature_hash(); let signature: AlloySignature = self.signature.into(); let signer = signature .recover_address_from_prehash(&prehash) @@ -159,9 +328,14 @@ impl TransactionInput { let signature: AlloySignature = self.signature.into(); let tx_hash = self.transaction_info.hash.into(); - match self.transaction_info.tx_type.map(|t| t.as_u64()).unwrap_or(0) { - // EIP-2930 - 1 => TxEnvelope::Eip2930(Signed::new_unchecked( + let tx_type = self + .transaction_info + .tx_type + .and_then(|t| TxType::try_from(t.as_u64()).ok()) + .unwrap_or(TxType::Legacy); + + match tx_type { + TxType::Eip2930 => TxEnvelope::Eip2930(Signed::new_unchecked( TxEip2930 { chain_id: self.execution_info.chain_id.unwrap_or_default().into(), nonce: self.execution_info.nonce.into(), @@ -176,8 +350,7 @@ impl TransactionInput { tx_hash, )), - // EIP-1559 - 2 => TxEnvelope::Eip1559(Signed::new_unchecked( + TxType::Eip1559 => TxEnvelope::Eip1559(Signed::new_unchecked( TxEip1559 { chain_id: self.execution_info.chain_id.unwrap_or_default().into(), nonce: self.execution_info.nonce.into(), @@ -193,8 +366,7 @@ impl TransactionInput { tx_hash, )), - // EIP-4844 - 3 => TxEnvelope::Eip4844(Signed::new_unchecked( + TxType::Eip4844 => TxEnvelope::Eip4844(Signed::new_unchecked( TxEip4844Variant::TxEip4844(TxEip4844 { chain_id: self.execution_info.chain_id.unwrap_or_default().into(), nonce: self.execution_info.nonce.into(), @@ -212,8 +384,7 @@ impl TransactionInput { tx_hash, )), - // EIP-7702 - 4 => TxEnvelope::Eip7702(Signed::new_unchecked( + TxType::Eip7702 => TxEnvelope::Eip7702(Signed::new_unchecked( TxEip7702 { chain_id: self.execution_info.chain_id.unwrap_or_default().into(), nonce: self.execution_info.nonce.into(), @@ -230,8 +401,7 @@ impl TransactionInput { tx_hash, )), - // Legacy (default) - _ => TxEnvelope::Legacy(Signed::new_unchecked( + TxType::Legacy => TxEnvelope::Legacy(Signed::new_unchecked( TxLegacy { chain_id: self.execution_info.chain_id.map(Into::into), nonce: self.execution_info.nonce.into(), @@ -252,42 +422,225 @@ impl TransactionInput { // Serialization / Deserialization // ----------------------------------------------------------------------------- -impl Decodable for TransactionInput { - fn decode(rlp: &rlp::Rlp) -> Result { - fn convert_tx(envelope: TxEnvelope) -> Result { - let tx_input = try_from_alloy_transaction(alloy_rpc_types_eth::Transaction { - inner: Recovered::new_unchecked(envelope, alloy_primitives::Address::ZERO), - block_hash: None, - block_number: None, - block_timestamp: None, - transaction_index: None, - effective_gas_price: None, - }) - .map_err(|_| rlp::DecoderError::Custom("failed to convert transaction"))?; - - Ok(tx_input) +impl TransactionInput { + /// Derives the chain id and signature parity from a legacy `v` value. + fn decode_legacy_v(v: U64) -> Result<(Option, U64), TransactionDecodeError> { + let v_raw = v.as_u64(); + match v_raw { + LEGACY_V_UNPROTECTED_EVEN => Ok((None, U64::ZERO)), + LEGACY_V_UNPROTECTED_ODD => Ok((None, U64::ONE)), + v if v >= EIP155_V_OFFSET => { + let chain_id = (v - EIP155_V_OFFSET) / 2; + let parity = (v - LEGACY_V_UNPROTECTED_EVEN) % 2; + Ok((Some(ChainId::from(chain_id)), U64::from(parity))) + } + _ => Err(TransactionDecodeError::InvalidLegacyV), } + } - let raw_bytes = rlp.as_raw(); + /// Builds a `TransactionInput` from decoded legacy fields and recovers the signer. + #[allow(clippy::too_many_arguments)] + fn build_legacy( + nonce: Nonce, + gas_price: u128, + gas_limit: Gas, + to: Option
, + value: Wei, + input: Bytes, + v: U64, + r: U256, + s: U256, + hash: Hash, + ) -> Result { + let (chain_id, parity) = Self::decode_legacy_v(v)?; + + let mut tx = Self { + transaction_info: TransactionInfo { tx_type: None, hash }, + execution_info: ExecutionInfo { + chain_id, + nonce, + signer: Signer::Unrecovered, + to, + value, + input, + gas_limit, + gas_price, + }, + signature: Signature { v: parity, r, s }, + }; - if raw_bytes.is_empty() { - return Err(rlp::DecoderError::Custom("empty transaction bytes")); + let signer = tx.recover_signer_address().map_err(|_| TransactionDecodeError::SignerRecovery)?; + tx.execution_info.signer = Signer::Recovered(signer); + + Ok(tx) + } + + /// Decodes a legacy transaction from raw RLP bytes. + fn decode_legacy(raw_bytes: &[u8]) -> Result { + let mut rlp = alloy_rlp::Rlp::new(raw_bytes).map_err(|e| TransactionDecodeError::RlpError(e.to_string()))?; + + let nonce = decode_next::(&mut rlp, "nonce")?; + let gas_price = decode_next::(&mut rlp, "gasPrice")?; + let gas_limit = decode_next::(&mut rlp, "gasLimit")?; + let to = decode_to_field(&mut rlp)?; + let value = decode_next::(&mut rlp, "value")?; + let input = decode_next::(&mut rlp, "input")?; + let v = decode_next::(&mut rlp, "v")?; + let r = decode_next::(&mut rlp, "r")?; + let s = decode_next::(&mut rlp, "s")?; + + if rlp.get_next::().map_err(|e| TransactionDecodeError::RlpError(e.to_string()))?.is_some() { + return Err(TransactionDecodeError::ExtraFields); } - if rlp.is_list() { - // Legacy transaction - let mut bytes = raw_bytes; - TxEnvelope::fallback_decode(&mut bytes) - .map_err(|_| rlp::DecoderError::Custom("failed to decode legacy transaction")) - .and_then(convert_tx) - } else { - // Typed transaction (EIP-2718) - let first_byte = raw_bytes[0]; - let mut remaining_bytes = &raw_bytes[1..]; - TxEnvelope::typed_decode(first_byte, &mut remaining_bytes) - .map_err(|_| rlp::DecoderError::Custom("failed to decode transaction envelope")) - .and_then(convert_tx) + let hash = Hash::from(keccak256(raw_bytes)); + + Self::build_legacy(nonce, gas_price, gas_limit, to, value, input, v, r, s, hash) + } + + /// Decodes the common fields shared by access-list transaction types (EIP-2930, EIP-1559, EIP-4844, and EIP-7702). + fn decode_access_list_fields(rlp: &mut alloy_rlp::Rlp<'_>) -> Result { + let gas_limit = decode_next::(rlp, "gasLimit")?; + let to = decode_to_field(rlp)?; + let value = decode_next::(rlp, "value")?; + let input = decode_next::(rlp, "input")?; + let _: AccessList = decode_next(rlp, "accessList")?; + + Ok(TypedTxCommonFields { gas_limit, to, value, input }) + } + + /// Decodes the dynamic-fee gas price fields (`maxPriorityFeePerGas` and `maxFeePerGas`) + /// and returns `maxFeePerGas` as the effective gas price. + fn decode_dynamic_fee_gas_price(rlp: &mut alloy_rlp::Rlp<'_>) -> Result { + let max_priority_fee_per_gas = decode_next::(rlp, "maxPriorityFeePerGas")?; + let max_fee_per_gas = decode_next::(rlp, "maxFeePerGas")?; + let _ = max_priority_fee_per_gas; + Ok(max_fee_per_gas) + } + + /// Decodes the transaction signature fields (`v`, `r`, `s`). + fn decode_signature(rlp: &mut alloy_rlp::Rlp<'_>) -> Result<(U64, U256, U256), TransactionDecodeError> { + let v = decode_next::(rlp, "v")?; + let r = decode_next::(rlp, "r")?; + let s = decode_next::(rlp, "s")?; + Ok((v, r, s)) + } + + /// Decodes a typed transaction (EIP-2718) from raw bytes. + fn decode_typed(tx_type: u8, payload: &[u8], raw_bytes: &[u8]) -> Result { + let tx_type = TxType::try_from(tx_type).map_err(|_| TransactionDecodeError::UnsupportedType)?; + let mut rlp = alloy_rlp::Rlp::new(payload).map_err(|e| TransactionDecodeError::RlpError(e.to_string()))?; + + let chain_id = decode_next::(&mut rlp, "chainId")?; + let nonce = decode_next::(&mut rlp, "nonce")?; + let gas_price: u128; + let gas_limit: Gas; + let to: Option
; + let value: Wei; + let input: Bytes; + let v: U64; + let r: U256; + let s: U256; + + match tx_type { + TxType::Eip2930 => { + gas_price = decode_next::(&mut rlp, "gasPrice")?; + let fields = Self::decode_access_list_fields(&mut rlp)?; + gas_limit = fields.gas_limit; + to = fields.to; + value = fields.value; + input = fields.input; + (v, r, s) = Self::decode_signature(&mut rlp)?; + } + + TxType::Eip1559 => { + gas_price = Self::decode_dynamic_fee_gas_price(&mut rlp)?; + let fields = Self::decode_access_list_fields(&mut rlp)?; + gas_limit = fields.gas_limit; + to = fields.to; + value = fields.value; + input = fields.input; + (v, r, s) = Self::decode_signature(&mut rlp)?; + } + + TxType::Eip4844 => { + gas_price = Self::decode_dynamic_fee_gas_price(&mut rlp)?; + let fields = Self::decode_access_list_fields(&mut rlp)?; + gas_limit = fields.gas_limit; + to = fields.to; + value = fields.value; + input = fields.input; + let _: u128 = decode_next(&mut rlp, "maxFeePerBlobGas")?; + let _: Vec = decode_next(&mut rlp, "blobVersionedHashes")?; + (v, r, s) = Self::decode_signature(&mut rlp)?; + } + + TxType::Eip7702 => { + gas_price = Self::decode_dynamic_fee_gas_price(&mut rlp)?; + let fields = Self::decode_access_list_fields(&mut rlp)?; + gas_limit = fields.gas_limit; + to = fields.to; + value = fields.value; + input = fields.input; + let _: Vec = decode_next(&mut rlp, "authorizationList")?; + (v, r, s) = Self::decode_signature(&mut rlp)?; + } + + TxType::Legacy => return Err(TransactionDecodeError::LegacyNotTyped), } + + if rlp.get_next::().map_err(|e| TransactionDecodeError::RlpError(e.to_string()))?.is_some() { + return Err(TransactionDecodeError::ExtraFields); + } + + let hash = Hash::from(keccak256(raw_bytes)); + + let mut tx = Self { + transaction_info: TransactionInfo { + tx_type: Some(U64::from(tx_type as u8)), + hash, + }, + execution_info: ExecutionInfo { + chain_id: Some(chain_id), + nonce, + signer: Signer::Unrecovered, + to, + value, + input, + gas_limit, + gas_price, + }, + signature: Signature { v, r, s }, + }; + + let signer = tx.recover_signer_address().map_err(|_| TransactionDecodeError::SignerRecovery)?; + tx.execution_info.signer = Signer::Recovered(signer); + + Ok(tx) + } +} + +impl RlpDecodable for TransactionInput { + fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { + let raw_bytes = *buf; + + if raw_bytes.is_empty() { + return Err(TransactionDecodeError::EmptyBytes.into()); + } + + let tx = match raw_bytes[0] { + byte if byte >= 0xc0 => Self::decode_legacy(raw_bytes).map_err(alloy_rlp::Error::from)?, + byte if byte <= 0x7f => { + let tx_type = byte; + let payload = &raw_bytes[1..]; + Self::decode_typed(tx_type, payload, raw_bytes).map_err(alloy_rlp::Error::from)? + } + _ => return Err(TransactionDecodeError::InvalidTypeByte.into()), + }; + + // A raw transaction occupies the entire buffer. + *buf = &[]; + Ok(tx) } } @@ -302,17 +655,9 @@ impl TryFrom for TransactionInput { } } -impl TryFrom for TransactionInput { - type Error = anyhow::Error; - - fn try_from(value: AlloyTransaction) -> anyhow::Result { - try_from_alloy_transaction(value) - } -} - -fn try_from_alloy_transaction(value: alloy_rpc_types_eth::Transaction) -> anyhow::Result { +fn build_transaction_input_from_envelope(envelope: &TxEnvelope) -> anyhow::Result { // Get signature components from the envelope - let signature = value.inner.signature(); + let signature = envelope.signature(); let signature = Signature { r: signature.r(), s: signature.s(), @@ -321,36 +666,40 @@ fn try_from_alloy_transaction(value: alloy_rpc_types_eth::Transaction) -> anyhow // Build the TransactionInput from the fields we currently support, leaving the // signer unrecovered. We intentionally ignore any signer that may - // already be present in the source AlloyTransaction so that the leader and the follower always derive the same address + // already be present in the source transaction so that the leader and the follower always derive the same address // from the same set of saved fields. let mut tx_input = TransactionInput { transaction_info: TransactionInfo { - tx_type: Some(U64::from(value.inner.tx_type() as u8)), - hash: Hash::from(*value.inner.tx_hash()), + tx_type: Some(U64::from(envelope.tx_type() as u8)), + hash: Hash::from(*envelope.tx_hash()), }, execution_info: ExecutionInfo { - chain_id: value.inner.chain_id().map(Into::into), - nonce: Nonce::from(value.inner.nonce()), + chain_id: envelope.chain_id().map(Into::into), + nonce: Nonce::from(envelope.nonce()), signer: Signer::Unrecovered, - to: match value.inner.kind() { + to: match envelope.kind() { TxKind::Call(addr) => Some(Address::from(addr)), TxKind::Create => None, }, - value: Wei::from(value.inner.value()), - input: Bytes::from(value.inner.input().clone()), - gas_limit: Gas::from(value.inner.gas_limit()), - gas_price: value.inner.max_fee_per_gas(), + value: Wei::from(envelope.value()), + input: Bytes::from(envelope.input().clone()), + gas_limit: Gas::from(envelope.gas_limit()), + gas_price: envelope.max_fee_per_gas(), }, signature, }; - // Recover the signer from the envelope reconstructed using the saved fields. + // Recover the signer directly from the saved fields. let recovered_signer = tx_input.recover_signer_address()?; tx_input.execution_info.signer = Signer::Recovered(recovered_signer); Ok(tx_input) } +fn try_from_alloy_transaction(value: alloy_rpc_types_eth::Transaction) -> anyhow::Result { + build_transaction_input_from_envelope(value.inner.inner()) +} + impl From for ExecutionInfo { fn from(value: TransactionExecutionInput) -> Self { Self { @@ -385,3 +734,198 @@ impl From for AlloyTransaction { } } } + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use alloy_consensus::SignableTransaction; + use alloy_consensus::Signed; + use alloy_consensus::TxEip1559; + use alloy_consensus::TxEip2930; + use alloy_consensus::TxEip4844; + use alloy_consensus::TxEip7702; + use alloy_consensus::TxLegacy; + use alloy_eips::eip2718::Encodable2718; + use alloy_primitives::Address as AlloyAddress; + use alloy_primitives::Bytes as AlloyBytes; + use alloy_primitives::Signature as AlloySignature; + use alloy_primitives::TxKind; + use alloy_primitives::U64; + use alloy_primitives::U256; + use alloy_primitives::keccak256; + + use super::*; + + fn dummy_transaction_input(tx_type: u64) -> TransactionInput { + TransactionInput { + transaction_info: TransactionInfo { + tx_type: Some(U64::from(tx_type)), + hash: Hash::default(), + }, + execution_info: ExecutionInfo { + chain_id: Some(ChainId::from(1u64)), + nonce: Nonce::from(1u64), + signer: Signer::Unrecovered, + to: Some(Address::default()), + value: Wei::from(100u64), + input: Bytes::default(), + gas_limit: Gas::from(21000u64), + gas_price: 1_000_000_000, + }, + signature: Signature { + v: U64::ZERO, + r: U256::from(1u64), + s: U256::from(2u64), + }, + } + } + + #[test] + fn signature_hash_matches_to_tx_envelope() { + for tx_type in [0, 1, 2, 3, 4] { + let tx_input = dummy_transaction_input(tx_type); + let from_signature_hash = tx_input.signature_hash(); + let from_envelope = tx_input.to_tx_envelope().signature_hash(); + assert_eq!(from_signature_hash, from_envelope, "signature_hash mismatch for tx_type {tx_type}"); + } + } + + /// Builds raw EIP-2718 bytes for a transaction and decodes them directly into a + /// `TransactionInput`, comparing the recovered fields and signer with the source transaction. + fn assert_direct_decode(tx: T, tx_type: u8) + where + T: alloy_consensus::SignableTransaction + alloy_consensus::transaction::RlpEcdsaEncodableTx + alloy_eips::Typed2718, + { + let signature = AlloySignature::test_signature(); + let signing_hash = tx.signature_hash(); + let expected_signer = signature + .recover_address_from_prehash(&signing_hash) + .expect("test signature should be recoverable"); + + let signed = Signed::new_unchecked(tx, signature, B256::default()); + + let mut raw_bytes = Vec::new(); + signed.encode_2718(&mut raw_bytes); + let expected_hash = Hash::from(keccak256(&raw_bytes)); + + let decoded = TransactionInput::decode(&mut raw_bytes.as_slice()).expect("direct RLP decode should succeed"); + + assert_eq!(decoded.transaction_info.tx_type, Some(U64::from(tx_type))); + assert_eq!(decoded.transaction_info.hash, expected_hash); + assert_eq!(decoded.execution_info.chain_id, Some(ChainId::from(1u64))); + assert_eq!(decoded.execution_info.nonce, Nonce::from(1u64)); + assert_eq!(decoded.execution_info.gas_price, 1_000_000_000); + assert_eq!(decoded.execution_info.gas_limit, Gas::from(21000u64)); + assert_eq!(decoded.execution_info.to, Some(Address::default())); + assert_eq!(decoded.execution_info.value, Wei::from(100u64)); + assert_eq!(decoded.execution_info.input, Bytes::default()); + assert_eq!(decoded.signer(), Address::from(expected_signer)); + } + + #[test] + fn decode_legacy_from_raw_bytes() { + let tx = TxLegacy { + chain_id: Some(1), + nonce: 1, + gas_price: 1_000_000_000, + gas_limit: 21000, + to: TxKind::Call(AlloyAddress::default()), + value: U256::from(100), + input: AlloyBytes::new(), + }; + + let signature = AlloySignature::test_signature(); + let signing_hash = tx.signature_hash(); + let expected_signer = signature + .recover_address_from_prehash(&signing_hash) + .expect("test signature should be recoverable"); + + let signed = Signed::new_unchecked(tx, signature, B256::default()); + + let mut raw_bytes = Vec::new(); + signed.encode_2718(&mut raw_bytes); + let expected_hash = Hash::from(keccak256(&raw_bytes)); + + let decoded = TransactionInput::decode(&mut raw_bytes.as_slice()).expect("direct RLP decode should succeed"); + + assert_eq!(decoded.transaction_info.tx_type, None); + assert_eq!(decoded.transaction_info.hash, expected_hash); + assert_eq!(decoded.execution_info.chain_id, Some(ChainId::from(1u64))); + assert_eq!(decoded.execution_info.nonce, Nonce::from(1u64)); + assert_eq!(decoded.execution_info.gas_price, 1_000_000_000); + assert_eq!(decoded.execution_info.gas_limit, Gas::from(21000u64)); + assert_eq!(decoded.execution_info.to, Some(Address::default())); + assert_eq!(decoded.execution_info.value, Wei::from(100u64)); + assert_eq!(decoded.execution_info.input, Bytes::default()); + assert_eq!(decoded.signer(), Address::from(expected_signer)); + } + + #[test] + fn decode_eip2930_from_raw_bytes() { + let tx = TxEip2930 { + chain_id: 1, + nonce: 1, + gas_price: 1_000_000_000, + gas_limit: 21000, + to: TxKind::Call(AlloyAddress::default()), + value: U256::from(100), + input: AlloyBytes::new(), + access_list: AccessList::default(), + }; + assert_direct_decode(tx, 1); + } + + #[test] + fn decode_eip1559_from_raw_bytes() { + let tx = TxEip1559 { + chain_id: 1, + nonce: 1, + max_priority_fee_per_gas: 1_000_000_000, + max_fee_per_gas: 1_000_000_000, + gas_limit: 21000, + to: TxKind::Call(AlloyAddress::default()), + value: U256::from(100), + input: AlloyBytes::new(), + access_list: AccessList::default(), + }; + assert_direct_decode(tx, 2); + } + + #[test] + fn decode_eip4844_from_raw_bytes() { + let tx = TxEip4844 { + chain_id: 1, + nonce: 1, + max_priority_fee_per_gas: 1_000_000_000, + max_fee_per_gas: 1_000_000_000, + gas_limit: 21000, + to: AlloyAddress::default(), + value: U256::from(100), + input: AlloyBytes::new(), + access_list: AccessList::default(), + blob_versioned_hashes: Vec::new(), + max_fee_per_blob_gas: 0, + }; + assert_direct_decode(tx, 3); + } + + #[test] + fn decode_eip7702_from_raw_bytes() { + let tx = TxEip7702 { + chain_id: 1, + nonce: 1, + max_priority_fee_per_gas: 1_000_000_000, + max_fee_per_gas: 1_000_000_000, + gas_limit: 21000, + to: AlloyAddress::default(), + value: U256::from(100), + input: AlloyBytes::new(), + access_list: AccessList::default(), + authorization_list: Vec::new(), + }; + assert_direct_decode(tx, 4); + } +}