Skip to main content

eth_valkyoth_protocol/transaction/
envelope.rs

1use core::fmt;
2
3use eth_valkyoth_codec::{
4    DecodeError, DecodeErrorCategory, DecodeLimits, RlpList, decode_rlp_list,
5};
6use eth_valkyoth_primitives::TransactionType;
7
8/// EIP-2718 byte value reserved by this crate for the legacy transaction
9/// domain.
10pub const EIP_2718_TYPED_ZERO_PREFIX: u8 = 0x00;
11
12/// Largest single-byte EIP-2718 typed transaction prefix.
13pub const EIP_2718_MAX_TYPED_PREFIX: u8 = TransactionType::MAX_TYPED;
14
15/// First RLP scalar prefix that cannot be a typed transaction envelope or a
16/// legacy transaction list.
17pub const EIP_2718_SCALAR_PREFIX_START: u8 = 0x80;
18
19/// First byte used by canonical RLP short-list legacy transactions.
20pub const LEGACY_TRANSACTION_PREFIX_START: u8 = 0xc0;
21
22/// Prefix reserved by EIP-2718 as a future extension sentinel.
23pub const EIP_2718_RESERVED_PREFIX: u8 = 0xff;
24
25/// Borrowed transaction envelope shell.
26///
27/// This type classifies the outer transaction envelope only. It does not decode
28/// legacy fields, typed transaction payloads, signatures, sender addresses, or
29/// fork validity.
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub enum TransactionEnvelope<'a> {
32    /// A legacy transaction represented as one exact RLP list.
33    Legacy(RlpList<'a>),
34    /// An EIP-2718 typed transaction with an opaque payload.
35    Typed(TypedTransactionEnvelope<'a>),
36}
37
38/// Borrowed EIP-2718 typed transaction shell.
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40pub struct TypedTransactionEnvelope<'a> {
41    /// EIP-2718 transaction type byte.
42    pub transaction_type: TransactionType,
43    /// Opaque transaction payload. Later milestones decode this by type.
44    pub payload: &'a [u8],
45}
46
47/// Transaction envelope classification failure.
48#[non_exhaustive]
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
50pub enum TransactionEnvelopeError {
51    /// No bytes were supplied.
52    EmptyInput,
53    /// The first byte is a typed-prefix value this release does not admit.
54    ///
55    /// The envelope shell intentionally accepts every nonzero typed prefix in
56    /// `0x01..=0x7f` as opaque data. This error is reserved for prefix values
57    /// outside that admitted shell domain, such as legacy zero if it reaches
58    /// typed-prefix handling.
59    UnsupportedTransactionType {
60        /// Unsupported typed transaction prefix byte.
61        type_byte: u8,
62    },
63    /// The first byte is an RLP scalar prefix, not a transaction envelope.
64    ScalarPrefix {
65        /// Observed scalar prefix byte.
66        prefix: u8,
67    },
68    /// The first byte is the EIP-2718 reserved extension sentinel.
69    ReservedPrefix,
70    /// The legacy RLP list shell failed exact bounded decoding.
71    Decode(DecodeError),
72}
73
74impl TransactionEnvelopeError {
75    /// Stable machine-readable error code.
76    #[must_use]
77    pub const fn code(self) -> &'static str {
78        match self {
79            Self::EmptyInput => "ETH_TX_ENVELOPE_EMPTY_INPUT",
80            Self::UnsupportedTransactionType { .. } => "ETH_TX_ENVELOPE_UNSUPPORTED_TYPE",
81            Self::ScalarPrefix { .. } => "ETH_TX_ENVELOPE_SCALAR_PREFIX",
82            Self::ReservedPrefix => "ETH_TX_ENVELOPE_RESERVED_PREFIX",
83            Self::Decode(error) => error.code(),
84        }
85    }
86
87    /// Stable human-readable error message.
88    #[must_use]
89    pub const fn message(self) -> &'static str {
90        match self {
91            Self::EmptyInput => "transaction envelope input is empty",
92            Self::UnsupportedTransactionType { .. } => {
93                "transaction type is not supported by this envelope shell"
94            }
95            Self::ScalarPrefix { .. } => "transaction envelope starts with an RLP scalar prefix",
96            Self::ReservedPrefix => "transaction envelope starts with the reserved 0xff prefix",
97            Self::Decode(error) => error.message(),
98        }
99    }
100
101    /// Stable high-level category for policy decisions.
102    #[must_use]
103    pub const fn category(self) -> TransactionEnvelopeErrorCategory {
104        match self {
105            Self::EmptyInput
106            | Self::ScalarPrefix { .. }
107            | Self::ReservedPrefix
108            | Self::Decode(
109                DecodeError::TrailingBytes
110                | DecodeError::DecoderOverread
111                | DecodeError::Malformed
112                | DecodeError::UnexpectedList
113                | DecodeError::UnexpectedScalar
114                | DecodeError::LengthOverflow
115                | DecodeError::OffsetOutOfBounds,
116            ) => TransactionEnvelopeErrorCategory::MalformedInput,
117            Self::UnsupportedTransactionType { .. } => {
118                TransactionEnvelopeErrorCategory::Unsupported
119            }
120            Self::Decode(error) => match error.category() {
121                DecodeErrorCategory::MalformedInput => {
122                    TransactionEnvelopeErrorCategory::MalformedInput
123                }
124                DecodeErrorCategory::ResourceExhaustion => {
125                    TransactionEnvelopeErrorCategory::ResourceExhaustion
126                }
127                _ => TransactionEnvelopeErrorCategory::MalformedInput,
128            },
129        }
130    }
131}
132
133impl fmt::Display for TransactionEnvelopeError {
134    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
135        formatter.write_str(self.message())
136    }
137}
138
139#[cfg(feature = "std")]
140impl std::error::Error for TransactionEnvelopeError {}
141
142/// Stable high-level transaction envelope error categories.
143#[non_exhaustive]
144#[derive(Clone, Copy, Debug, Eq, PartialEq)]
145pub enum TransactionEnvelopeErrorCategory {
146    /// Input is malformed for an EIP-2718 or legacy transaction envelope.
147    MalformedInput,
148    /// A typed transaction prefix is intentionally unsupported by this shell.
149    Unsupported,
150    /// The active decode policy rejected the input as too large or too deep.
151    ResourceExhaustion,
152}
153
154/// Classifies a borrowed transaction envelope under explicit decode limits.
155///
156/// Typed transaction payloads are opaque in this release, but the full input is
157/// still checked against `limits.max_input_bytes`. Legacy transactions are
158/// required to be exactly one RLP list; field decoding is deferred to later
159/// milestones.
160pub fn decode_transaction_envelope<'a>(
161    input: &'a [u8],
162    limits: DecodeLimits,
163) -> Result<TransactionEnvelope<'a>, TransactionEnvelopeError> {
164    limits
165        .check_input_len(input.len())
166        .map_err(TransactionEnvelopeError::Decode)?;
167
168    let Some((&prefix, payload)) = input.split_first() else {
169        return Err(TransactionEnvelopeError::EmptyInput);
170    };
171
172    match prefix {
173        EIP_2718_TYPED_ZERO_PREFIX => {
174            Err(TransactionEnvelopeError::UnsupportedTransactionType { type_byte: prefix })
175        }
176        0x01..=EIP_2718_MAX_TYPED_PREFIX => {
177            let transaction_type = TransactionType::try_new_typed(prefix).map_err(|_| {
178                TransactionEnvelopeError::UnsupportedTransactionType { type_byte: prefix }
179            })?;
180            Ok(TransactionEnvelope::Typed(TypedTransactionEnvelope {
181                transaction_type,
182                payload,
183            }))
184        }
185        EIP_2718_SCALAR_PREFIX_START..=0xbf => {
186            Err(TransactionEnvelopeError::ScalarPrefix { prefix })
187        }
188        LEGACY_TRANSACTION_PREFIX_START..=0xfe => {
189            let list = decode_rlp_list(input, limits).map_err(TransactionEnvelopeError::Decode)?;
190            Ok(TransactionEnvelope::Legacy(list))
191        }
192        EIP_2718_RESERVED_PREFIX => Err(TransactionEnvelopeError::ReservedPrefix),
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    extern crate std;
200    use std::string::ToString;
201
202    const TEST_LIMITS: DecodeLimits = DecodeLimits {
203        max_input_bytes: 64,
204        max_list_items: 16,
205        max_nesting_depth: 8,
206        max_total_allocation: 64,
207        max_proof_nodes: 4,
208        max_total_items: 32,
209    };
210
211    #[test]
212    fn classifies_typed_transaction_without_decoding_payload() {
213        let envelope = decode_transaction_envelope(&[0x02, 0xc0, 0x01], TEST_LIMITS);
214
215        assert!(matches!(envelope, Ok(TransactionEnvelope::Typed(_))));
216        if let Ok(TransactionEnvelope::Typed(typed)) = envelope {
217            assert_eq!(u8::from(typed.transaction_type), 0x02);
218            assert_eq!(typed.payload, &[0xc0, 0x01]);
219        }
220    }
221
222    #[test]
223    fn classifies_legacy_transaction_as_exact_rlp_list() {
224        let envelope = decode_transaction_envelope(&[0xc0], TEST_LIMITS);
225
226        assert!(matches!(envelope, Ok(TransactionEnvelope::Legacy(_))));
227        if let Ok(TransactionEnvelope::Legacy(list)) = envelope {
228            assert_eq!(list.item_count(), 0);
229        }
230    }
231
232    #[test]
233    fn rejects_legacy_transaction_with_trailing_bytes() {
234        let envelope = decode_transaction_envelope(&[0xc0, 0x80], TEST_LIMITS);
235
236        assert_eq!(
237            envelope,
238            Err(TransactionEnvelopeError::Decode(DecodeError::TrailingBytes))
239        );
240    }
241
242    #[test]
243    fn rejects_empty_input_before_prefix_classification() {
244        assert_eq!(
245            decode_transaction_envelope(&[], TEST_LIMITS),
246            Err(TransactionEnvelopeError::EmptyInput)
247        );
248    }
249
250    #[test]
251    fn rejects_typed_zero_prefix_as_unsupported() {
252        assert_eq!(
253            decode_transaction_envelope(&[0x00], TEST_LIMITS),
254            Err(TransactionEnvelopeError::UnsupportedTransactionType { type_byte: 0 })
255        );
256    }
257
258    #[test]
259    fn rejects_rlp_scalar_prefixes() {
260        assert_eq!(
261            decode_transaction_envelope(&[0x80], TEST_LIMITS),
262            Err(TransactionEnvelopeError::ScalarPrefix { prefix: 0x80 })
263        );
264    }
265
266    #[test]
267    fn rejects_reserved_extension_prefix() {
268        assert_eq!(
269            decode_transaction_envelope(&[0xff], TEST_LIMITS),
270            Err(TransactionEnvelopeError::ReservedPrefix)
271        );
272    }
273
274    #[test]
275    fn enforces_input_budget_for_typed_payloads() {
276        let limits = DecodeLimits {
277            max_input_bytes: 1,
278            ..TEST_LIMITS
279        };
280
281        assert_eq!(
282            decode_transaction_envelope(&[0x02, 0x01], limits),
283            Err(TransactionEnvelopeError::Decode(DecodeError::InputTooLarge))
284        );
285    }
286
287    #[test]
288    fn envelope_errors_have_stable_codes_and_messages() {
289        let error = TransactionEnvelopeError::ReservedPrefix;
290
291        assert_eq!(error.code(), "ETH_TX_ENVELOPE_RESERVED_PREFIX");
292        assert_eq!(
293            error.message(),
294            "transaction envelope starts with the reserved 0xff prefix"
295        );
296        assert_eq!(
297            error.category(),
298            TransactionEnvelopeErrorCategory::MalformedInput
299        );
300        assert_eq!(
301            error.to_string(),
302            "transaction envelope starts with the reserved 0xff prefix"
303        );
304    }
305}