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