alloy_zksync/network/
tx_envelope.rs

1use alloy::consensus::{Signed, Typed2718};
2use alloy::network::eip2718::{Decodable2718, Encodable2718};
3use alloy::rlp::{Encodable, Header};
4use serde::{Deserialize, Serialize};
5
6use super::tx_type::TxType;
7use super::unsigned_tx::eip712::TxEip712;
8/// Transaction envelope is a wrapper around the transaction data.
9/// See [`alloy::consensus::TxEnvelope`](https://docs.rs/alloy/latest/alloy/consensus/enum.TxEnvelope.html) for more details.
10#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(
12    into = "serde_from::TaggedTxEnvelope",
13    from = "serde_from::MaybeTaggedTxEnvelope"
14)]
15pub enum TxEnvelope {
16    /// Ethereum-native transaction.
17    Native(alloy::consensus::TxEnvelope),
18    /// ZKsync-native EIP712 transaction.
19    Eip712(Signed<TxEip712>),
20}
21
22impl TxEnvelope {
23    /// Returns true if the transaction is a legacy transaction.
24    #[inline]
25    pub const fn is_legacy(&self) -> bool {
26        match self {
27            Self::Native(inner) => inner.is_legacy(),
28            Self::Eip712(_) => false,
29        }
30    }
31
32    /// Returns true if the transaction is an EIP-2930 transaction.
33    #[inline]
34    pub const fn is_eip2930(&self) -> bool {
35        match self {
36            Self::Native(inner) => inner.is_eip2930(),
37            Self::Eip712(_) => false,
38        }
39    }
40
41    /// Returns true if the transaction is an EIP-1559 transaction.
42    #[inline]
43    pub const fn is_eip1559(&self) -> bool {
44        match self {
45            Self::Native(inner) => inner.is_eip1559(),
46            Self::Eip712(_) => false,
47        }
48    }
49
50    /// Returns true if the transaction is an EIP-4844 transaction.
51    #[inline]
52    pub const fn is_eip4844(&self) -> bool {
53        match self {
54            Self::Native(inner) => inner.is_eip4844(),
55            Self::Eip712(_) => false,
56        }
57    }
58
59    /// Returns true if the transaction is an EIP-7702 transaction.
60    #[inline]
61    pub const fn is_eip7702(&self) -> bool {
62        match self {
63            Self::Native(inner) => inner.is_eip7702(),
64            Self::Eip712(_) => false,
65        }
66    }
67
68    /// Returns true if the transaction is an EIP-712 transaction.
69    #[inline]
70    pub const fn is_eip712(&self) -> bool {
71        matches!(self, Self::Eip712(_))
72    }
73
74    /// Returns true if the transaction is replay protected.
75    ///
76    /// All non-legacy transactions are replay protected, as the chain id is
77    /// included in the transaction body. Legacy transactions are considered
78    /// replay protected if the `v` value is not 27 or 28, according to the
79    /// rules of [EIP-155].
80    ///
81    /// [EIP-155]: https://eips.ethereum.org/EIPS/eip-155
82    #[inline]
83    pub const fn is_replay_protected(&self) -> bool {
84        match self {
85            Self::Native(inner) => inner.is_replay_protected(),
86            Self::Eip712(_) => true,
87        }
88    }
89
90    /// Returns the [`TxLegacy`] variant if the transaction is a legacy transaction.
91    pub const fn as_legacy(&self) -> Option<&Signed<alloy::consensus::TxLegacy>> {
92        match self {
93            Self::Native(inner) => inner.as_legacy(),
94            Self::Eip712(_) => None,
95        }
96    }
97
98    /// Returns the [`TxEip2930`] variant if the transaction is an EIP-2930 transaction.
99    pub const fn as_eip2930(&self) -> Option<&Signed<alloy::consensus::TxEip2930>> {
100        match self {
101            Self::Native(inner) => inner.as_eip2930(),
102            Self::Eip712(_) => None,
103        }
104    }
105
106    /// Returns the [`TxEip1559`] variant if the transaction is an EIP-1559 transaction.
107    pub const fn as_eip1559(&self) -> Option<&Signed<alloy::consensus::TxEip1559>> {
108        match self {
109            Self::Native(inner) => inner.as_eip1559(),
110            Self::Eip712(_) => None,
111        }
112    }
113
114    /// Returns the [`TxEip4844`] variant if the transaction is an EIP-4844 transaction.
115    pub const fn as_eip4844(&self) -> Option<&Signed<alloy::consensus::TxEip4844Variant>> {
116        match self {
117            Self::Native(inner) => inner.as_eip4844(),
118            Self::Eip712(_) => None,
119        }
120    }
121
122    /// Returns the [`TxEip7702`] variant if the transaction is an EIP-7702 transaction.
123    pub const fn as_eip7702(&self) -> Option<&Signed<alloy::consensus::TxEip7702>> {
124        match self {
125            Self::Native(inner) => inner.as_eip7702(),
126            Self::Eip712(_) => None,
127        }
128    }
129
130    /// Returns the [`TxEip712`] variant if the transaction is an EIP-712 transaction.
131    pub const fn as_eip712(&self) -> Option<&Signed<TxEip712>> {
132        match self {
133            Self::Native(_) => None,
134            Self::Eip712(inner) => Some(inner),
135        }
136    }
137
138    /// Calculate the signing hash for the transaction.
139    pub fn signature_hash(&self) -> alloy::primitives::B256 {
140        match self {
141            Self::Native(inner) => inner.signature_hash(),
142            Self::Eip712(inner) => inner.signature_hash(),
143        }
144    }
145
146    /// Return the reference to signature.
147    pub const fn signature(&self) -> &alloy::primitives::Signature {
148        match self {
149            Self::Native(inner) => inner.signature(),
150            Self::Eip712(inner) => inner.signature(),
151        }
152    }
153
154    /// Return the hash of the inner Signed.
155    #[doc(alias = "transaction_hash")]
156    pub fn tx_hash(&self) -> &alloy::primitives::B256 {
157        match self {
158            Self::Native(inner) => inner.tx_hash(),
159            Self::Eip712(inner) => inner.hash(),
160        }
161    }
162
163    /// Return the [`TxType`] of the inner txn.
164    #[doc(alias = "transaction_type")]
165    pub const fn tx_type(&self) -> crate::network::tx_type::TxType {
166        match self {
167            Self::Native(inner) => match inner.tx_type() {
168                alloy::consensus::TxType::Legacy => crate::network::tx_type::TxType::Legacy,
169                alloy::consensus::TxType::Eip2930 => crate::network::tx_type::TxType::Eip2930,
170                alloy::consensus::TxType::Eip1559 => crate::network::tx_type::TxType::Eip1559,
171                alloy::consensus::TxType::Eip4844 => crate::network::tx_type::TxType::Eip4844,
172                alloy::consensus::TxType::Eip7702 => crate::network::tx_type::TxType::Eip7702,
173            },
174            Self::Eip712(_) => crate::network::tx_type::TxType::Eip712,
175        }
176    }
177
178    /// Return the length of the inner txn, including type byte length
179    pub fn eip2718_encoded_length(&self) -> usize {
180        match self {
181            Self::Native(inner) => inner.eip2718_encoded_length(),
182            Self::Eip712(inner) => inner.tx().encoded_length(inner.signature()),
183        }
184    }
185}
186
187impl Typed2718 for TxEnvelope {
188    fn ty(&self) -> u8 {
189        match self {
190            Self::Native(inner) => inner.ty(),
191            Self::Eip712(inner) => inner.tx().tx_type() as u8,
192        }
193    }
194}
195
196impl Encodable2718 for TxEnvelope {
197    fn type_flag(&self) -> Option<u8> {
198        match self {
199            Self::Native(inner) => inner.type_flag(),
200            Self::Eip712(inner) => Some(inner.tx().tx_type() as u8),
201        }
202    }
203
204    fn encode_2718_len(&self) -> usize {
205        match self {
206            Self::Native(inner) => inner.encode_2718_len(),
207            Self::Eip712(inner) => {
208                let payload_length = inner.tx().fields_len()
209                    + inner.signature().rlp_rs_len()
210                    + inner.signature().v().length();
211                Header {
212                    list: true,
213                    payload_length,
214                }
215                .length()
216                    + payload_length
217            }
218        }
219    }
220
221    fn encode_2718(&self, out: &mut dyn alloy::primitives::bytes::BufMut) {
222        match self {
223            Self::Native(inner) => inner.encode_2718(out),
224            Self::Eip712(tx) => {
225                tx.tx().encode_with_signature(tx.signature(), out);
226            }
227        }
228    }
229}
230
231impl Decodable2718 for TxEnvelope {
232    fn typed_decode(ty: u8, buf: &mut &[u8]) -> alloy::network::eip2718::Eip2718Result<Self> {
233        match ty {
234            _ if ty == (TxType::Eip712 as u8) => {
235                let tx = TxEip712::decode_signed_fields(buf)?;
236                Ok(Self::Eip712(tx))
237            }
238            _ => {
239                let inner = alloy::consensus::TxEnvelope::typed_decode(ty, buf)?;
240                Ok(Self::Native(inner))
241            }
242        }
243    }
244
245    fn fallback_decode(buf: &mut &[u8]) -> alloy::network::eip2718::Eip2718Result<Self> {
246        let inner = alloy::consensus::TxEnvelope::fallback_decode(buf)?;
247        Ok(Self::Native(inner))
248    }
249}
250
251impl AsRef<dyn alloy::consensus::Transaction> for TxEnvelope {
252    fn as_ref(&self) -> &dyn alloy::consensus::Transaction {
253        match self {
254            TxEnvelope::Native(inner) => inner,
255            TxEnvelope::Eip712(signed_inner) => signed_inner.tx(),
256        }
257    }
258}
259
260impl alloy::consensus::Transaction for TxEnvelope {
261    fn chain_id(&self) -> Option<alloy::primitives::ChainId> {
262        self.as_ref().chain_id()
263    }
264
265    fn nonce(&self) -> u64 {
266        self.as_ref().nonce()
267    }
268
269    fn gas_limit(&self) -> u64 {
270        self.as_ref().gas_limit()
271    }
272
273    fn gas_price(&self) -> Option<u128> {
274        self.as_ref().gas_price()
275    }
276
277    fn max_fee_per_gas(&self) -> u128 {
278        self.as_ref().max_fee_per_gas()
279    }
280
281    fn max_priority_fee_per_gas(&self) -> Option<u128> {
282        self.as_ref().max_priority_fee_per_gas()
283    }
284
285    fn max_fee_per_blob_gas(&self) -> Option<u128> {
286        self.as_ref().max_fee_per_blob_gas()
287    }
288
289    fn priority_fee_or_price(&self) -> u128 {
290        self.as_ref().priority_fee_or_price()
291    }
292
293    fn effective_gas_price(&self, base_fee: Option<u64>) -> u128 {
294        self.as_ref().effective_gas_price(base_fee)
295    }
296
297    fn is_dynamic_fee(&self) -> bool {
298        self.as_ref().is_dynamic_fee()
299    }
300
301    fn kind(&self) -> alloy::primitives::TxKind {
302        self.as_ref().kind()
303    }
304
305    fn is_create(&self) -> bool {
306        self.as_ref().is_create()
307    }
308
309    fn value(&self) -> alloy::primitives::U256 {
310        self.as_ref().value()
311    }
312
313    fn input(&self) -> &alloy::primitives::Bytes {
314        self.as_ref().input()
315    }
316
317    fn access_list(&self) -> Option<&alloy::rpc::types::AccessList> {
318        self.as_ref().access_list()
319    }
320
321    fn blob_versioned_hashes(&self) -> Option<&[alloy::primitives::B256]> {
322        self.as_ref().blob_versioned_hashes()
323    }
324
325    fn authorization_list(&self) -> Option<&[alloy::eips::eip7702::SignedAuthorization]> {
326        self.as_ref().authorization_list()
327    }
328}
329
330mod serde_from {
331    //! NB: Why do we need this?
332    //!
333    //! We are following the same approach as [`alloy::consensus::TxEnvelope`] but with an additional
334    //! ZKsync-specific transaction type (`type: "0x71"`).
335    //!
336    //! Because the tag may be missing, we need an abstraction over tagged (with
337    //! type) and untagged (always legacy). This is [`MaybeTaggedTxEnvelope`].
338    //!
339    //! The tagged variant is [`TaggedTxEnvelope`], which always has a type tag.
340    //!
341    //! We serialize via [`TaggedTxEnvelope`] and deserialize via
342    //! [`MaybeTaggedTxEnvelope`].
343    //!
344    //! zkSync RPC responses may omit `accessList` on type-2 (EIP-1559) transactions.
345    //! The deserialization path handles this by defaulting it to an empty list.
346    use crate::network::tx_envelope::TxEnvelope;
347    use crate::network::unsigned_tx::eip712::TxEip712;
348    use alloy::consensus::{Signed, TxEip1559, TxEip2930, TxEip4844Variant, TxEip7702, TxLegacy};
349
350    #[derive(Debug, serde::Deserialize)]
351    #[serde(untagged)]
352    pub(crate) enum MaybeTaggedTxEnvelope {
353        Tagged(TaggedTxEnvelopeDe),
354        Untagged {
355            #[serde(
356                default,
357                rename = "type",
358                deserialize_with = "alloy::serde::reject_if_some"
359            )]
360            _ty: Option<()>,
361            #[serde(flatten, with = "alloy::consensus::transaction::signed_legacy_serde")]
362            tx: Signed<TxLegacy>,
363        },
364    }
365
366    /// Serialization-only tagged envelope.
367    ///
368    /// Keep only the canonical type tags here. Alternate tag aliases matter
369    /// only on deserialization and live on [`TaggedTxEnvelopeDe`].
370    #[derive(Debug, serde::Serialize)]
371    #[serde(tag = "type")]
372    pub(crate) enum TaggedTxEnvelope {
373        #[serde(
374            rename = "0x0",
375            with = "alloy::consensus::transaction::signed_legacy_serde"
376        )]
377        Legacy(Signed<TxLegacy>),
378        #[serde(rename = "0x1")]
379        Eip2930(Signed<TxEip2930>),
380        #[serde(rename = "0x2")]
381        Eip1559(Signed<TxEip1559>),
382        #[serde(rename = "0x3")]
383        Eip4844(Signed<TxEip4844Variant>),
384        #[serde(rename = "0x4")]
385        Eip7702(Signed<TxEip7702>),
386        #[serde(rename = "0x71")]
387        Eip712(Signed<TxEip712>),
388    }
389
390    /// Deserialization-only tagged envelope.
391    ///
392    /// Separate from [`TaggedTxEnvelope`] because zkSync RPC responses may omit
393    /// `accessList` on type-2 (EIP-1559) transactions. This type uses a permissive
394    /// deserializer that defaults the field to an empty list when missing.
395    #[derive(Debug, serde::Deserialize)]
396    #[serde(tag = "type")]
397    pub(crate) enum TaggedTxEnvelopeDe {
398        #[serde(
399            rename = "0x0",
400            alias = "0x00",
401            with = "alloy::consensus::transaction::signed_legacy_serde"
402        )]
403        Legacy(Signed<TxLegacy>),
404        #[serde(rename = "0x1", alias = "0x01")]
405        Eip2930(Signed<TxEip2930>),
406        #[serde(
407            rename = "0x2",
408            alias = "0x02",
409            deserialize_with = "deserialize_eip1559_permissive"
410        )]
411        Eip1559(Signed<TxEip1559>),
412        #[serde(rename = "0x3", alias = "0x03")]
413        Eip4844(Signed<TxEip4844Variant>),
414        #[serde(rename = "0x4", alias = "0x04")]
415        Eip7702(Signed<TxEip7702>),
416        #[serde(rename = "0x71")]
417        Eip712(Signed<TxEip712>),
418    }
419
420    /// Deserializes `Signed<TxEip1559>`, defaulting `accessList` to `[]` when missing.
421    ///
422    /// Uses a mirror of `TxEip1559` with `#[serde(default)]` on `access_list`
423    /// to avoid an intermediate `serde_json::Value` round-trip.
424    fn deserialize_eip1559_permissive<'de, D>(
425        deserializer: D,
426    ) -> Result<Signed<TxEip1559>, D::Error>
427    where
428        D: serde::Deserializer<'de>,
429    {
430        use alloy::eips::eip2930::AccessList;
431        use alloy::primitives::{B256, Bytes, ChainId, Signature, TxKind, U256};
432        use serde::Deserialize;
433
434        /// Mirrors [`TxEip1559`] but defaults `access_list` to empty when missing.
435        ///
436        /// Keep this in lockstep with alloy's `TxEip1559` serde shape. The
437        /// conversion below will fail to compile on field/type additions, but
438        /// serde attribute changes upstream still need manual review here.
439        #[derive(Deserialize)]
440        #[serde(rename_all = "camelCase")]
441        struct TxEip1559Permissive {
442            #[serde(with = "alloy::serde::quantity")]
443            chain_id: ChainId,
444            #[serde(with = "alloy::serde::quantity")]
445            nonce: u64,
446            #[serde(with = "alloy::serde::quantity", rename = "gas", alias = "gasLimit")]
447            gas_limit: u64,
448            #[serde(with = "alloy::serde::quantity")]
449            max_fee_per_gas: u128,
450            #[serde(with = "alloy::serde::quantity")]
451            max_priority_fee_per_gas: u128,
452            #[serde(default)]
453            to: TxKind,
454            value: U256,
455            #[serde(default, deserialize_with = "alloy::serde::null_as_default")]
456            access_list: AccessList,
457            input: Bytes,
458        }
459
460        /// Mirrors alloy's internal `Signed` serde helper with the permissive
461        /// transaction type flattened alongside the signature.
462        #[derive(Deserialize)]
463        struct SignedHelper {
464            #[serde(flatten)]
465            tx: TxEip1559Permissive,
466            #[serde(flatten)]
467            signature: Signature,
468            hash: B256,
469        }
470
471        let helper = SignedHelper::deserialize(deserializer)?;
472        let tx = TxEip1559 {
473            chain_id: helper.tx.chain_id,
474            nonce: helper.tx.nonce,
475            gas_limit: helper.tx.gas_limit,
476            max_fee_per_gas: helper.tx.max_fee_per_gas,
477            max_priority_fee_per_gas: helper.tx.max_priority_fee_per_gas,
478            to: helper.tx.to,
479            value: helper.tx.value,
480            access_list: helper.tx.access_list,
481            input: helper.tx.input,
482        };
483        // Match alloy's RPC deserialization path: trust the RPC-provided hash
484        // and signature material instead of recomputing or verifying here.
485        Ok(Signed::new_unchecked(tx, helper.signature, helper.hash))
486    }
487
488    impl From<MaybeTaggedTxEnvelope> for TxEnvelope {
489        fn from(value: MaybeTaggedTxEnvelope) -> Self {
490            match value {
491                MaybeTaggedTxEnvelope::Tagged(tagged) => tagged.into(),
492                MaybeTaggedTxEnvelope::Untagged { tx, .. } => {
493                    Self::Native(alloy::consensus::TxEnvelope::Legacy(tx))
494                }
495            }
496        }
497    }
498
499    impl From<TaggedTxEnvelopeDe> for TxEnvelope {
500        fn from(value: TaggedTxEnvelopeDe) -> Self {
501            match value {
502                TaggedTxEnvelopeDe::Legacy(signed) => {
503                    Self::Native(alloy::consensus::TxEnvelope::Legacy(signed))
504                }
505                TaggedTxEnvelopeDe::Eip2930(signed) => {
506                    Self::Native(alloy::consensus::TxEnvelope::Eip2930(signed))
507                }
508                TaggedTxEnvelopeDe::Eip1559(signed) => {
509                    Self::Native(alloy::consensus::TxEnvelope::Eip1559(signed))
510                }
511                TaggedTxEnvelopeDe::Eip4844(signed) => {
512                    Self::Native(alloy::consensus::TxEnvelope::Eip4844(signed))
513                }
514                TaggedTxEnvelopeDe::Eip7702(signed) => {
515                    Self::Native(alloy::consensus::TxEnvelope::Eip7702(signed))
516                }
517                TaggedTxEnvelopeDe::Eip712(signed) => Self::Eip712(signed),
518            }
519        }
520    }
521
522    impl From<TxEnvelope> for TaggedTxEnvelope {
523        fn from(value: TxEnvelope) -> Self {
524            match value {
525                TxEnvelope::Native(alloy::consensus::TxEnvelope::Legacy(signed)) => {
526                    Self::Legacy(signed)
527                }
528                TxEnvelope::Native(alloy::consensus::TxEnvelope::Eip2930(signed)) => {
529                    Self::Eip2930(signed)
530                }
531                TxEnvelope::Native(alloy::consensus::TxEnvelope::Eip1559(signed)) => {
532                    Self::Eip1559(signed)
533                }
534                TxEnvelope::Native(alloy::consensus::TxEnvelope::Eip4844(signed)) => {
535                    Self::Eip4844(signed)
536                }
537                TxEnvelope::Native(alloy::consensus::TxEnvelope::Eip7702(signed)) => {
538                    Self::Eip7702(signed)
539                }
540                TxEnvelope::Eip712(signed) => Self::Eip712(signed),
541            }
542        }
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549    use crate::network::transaction_response::TransactionResponse;
550    use alloy::consensus::Transaction;
551
552    /// Round-trip test: serialize a canonical `TxEip1559` via alloy's own
553    /// `Serialize`, then deserialize through our `TxEnvelope` permissive path.
554    ///
555    /// Guards against silent serde attribute drift between alloy's `TxEip1559`
556    /// and our `TxEip1559Permissive` mirror struct.
557    #[test]
558    fn eip1559_roundtrip_through_permissive_path() {
559        use alloy::consensus::{Signed, TxEip1559};
560        use alloy::eips::eip2930::AccessList;
561        use alloy::primitives::{Address, B256, Bytes, Signature, U256};
562
563        let tx = TxEip1559 {
564            chain_id: 324,
565            nonce: 42,
566            gas_limit: 21_000,
567            max_fee_per_gas: 4_000_000_000,
568            max_priority_fee_per_gas: 1_000_000,
569            to: Address::repeat_byte(0xab).into(),
570            value: U256::from(1_000_000_000_000_000_000u128),
571            access_list: AccessList::default(),
572            input: Bytes::from_static(b"\xde\xad"),
573        };
574
575        let sig = Signature::test_signature();
576        let hash = B256::repeat_byte(0xff);
577        let signed = Signed::new_unchecked(tx.clone(), sig, hash);
578
579        // Wrap in TaggedTxEnvelope (our serialization type) to get `"type": "0x2"`.
580        let envelope = TxEnvelope::Native(alloy::consensus::TxEnvelope::Eip1559(signed));
581        let json = serde_json::to_string(&envelope).expect("serialize");
582
583        // Deserialize back through our permissive path.
584        let roundtripped: TxEnvelope = serde_json::from_str(&json).expect("deserialize");
585        let inner = roundtripped.as_eip1559().expect("should be EIP-1559").tx();
586
587        assert_eq!(inner.chain_id, tx.chain_id);
588        assert_eq!(inner.nonce, tx.nonce);
589        assert_eq!(inner.gas_limit, tx.gas_limit);
590        assert_eq!(inner.max_fee_per_gas, tx.max_fee_per_gas);
591        assert_eq!(inner.max_priority_fee_per_gas, tx.max_priority_fee_per_gas);
592        assert_eq!(inner.to, tx.to);
593        assert_eq!(inner.value, tx.value);
594        assert_eq!(inner.access_list, tx.access_list);
595        assert_eq!(inner.input, tx.input);
596    }
597
598    /// Regression test: zkSync RPC responses may omit `accessList` on type-2
599    /// (EIP-1559) transactions. This reproduces the failure reported in
600    /// <https://github.com/matter-labs/alloy-zksync/issues/66>.
601    #[test]
602    fn deserialize_eip1559_without_access_list() {
603        let json = r#"
604        {
605            "type": "0x2",
606            "chainId": "0x144",
607            "nonce": "0x5",
608            "gas": "0x5208",
609            "maxFeePerGas": "0xee6b2800",
610            "maxPriorityFeePerGas": "0x0",
611            "to": "0x1234567890abcdef1234567890abcdef12345678",
612            "value": "0xde0b6b3a7640000",
613            "input": "0x",
614            "r": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
615            "s": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
616            "v": "0x0",
617            "yParity": "0x0",
618            "hash": "0x09d047b22ceb10d30bd1a36969e45eb9f63b6d01f16439f4fd0b9f0114177cff"
619        }
620        "#;
621
622        let envelope: TxEnvelope = serde_json::from_str(json).unwrap();
623        assert!(envelope.is_eip1559());
624        let access_list = envelope
625            .access_list()
626            .expect("should have defaulted to empty");
627        assert!(access_list.is_empty());
628    }
629
630    /// Verify that EIP-1559 transactions WITH `accessList` still deserialize.
631    #[test]
632    fn deserialize_eip1559_with_access_list() {
633        let json = r#"
634        {
635            "type": "0x2",
636            "chainId": "0x144",
637            "nonce": "0x5",
638            "gas": "0x5208",
639            "maxFeePerGas": "0xee6b2800",
640            "maxPriorityFeePerGas": "0x0",
641            "to": "0x1234567890abcdef1234567890abcdef12345678",
642            "value": "0xde0b6b3a7640000",
643            "input": "0x",
644            "accessList": [],
645            "r": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
646            "s": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
647            "v": "0x0",
648            "yParity": "0x0",
649            "hash": "0x09d047b22ceb10d30bd1a36969e45eb9f63b6d01f16439f4fd0b9f0114177cff"
650        }
651        "#;
652
653        let envelope: TxEnvelope = serde_json::from_str(json).unwrap();
654        assert!(envelope.is_eip1559());
655    }
656
657    /// zkSync has also been observed to return `accessList: null`; accept it
658    /// the same way alloy does for other permissive RPC providers.
659    #[test]
660    fn deserialize_eip1559_with_null_access_list() {
661        let json = r#"
662        {
663            "type": "0x2",
664            "chainId": "0x144",
665            "nonce": "0x5",
666            "gas": "0x5208",
667            "maxFeePerGas": "0xee6b2800",
668            "maxPriorityFeePerGas": "0x0",
669            "to": "0x1234567890abcdef1234567890abcdef12345678",
670            "value": "0xde0b6b3a7640000",
671            "input": "0x",
672            "accessList": null,
673            "r": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
674            "s": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
675            "v": "0x0",
676            "yParity": "0x0",
677            "hash": "0x09d047b22ceb10d30bd1a36969e45eb9f63b6d01f16439f4fd0b9f0114177cff"
678        }
679        "#;
680
681        let envelope: TxEnvelope = serde_json::from_str(json).unwrap();
682        assert!(envelope.is_eip1559());
683        let access_list = envelope
684            .access_list()
685            .expect("should have defaulted null to empty");
686        assert!(access_list.is_empty());
687    }
688
689    /// EIP-712 transactions without `accessList` should also deserialize.
690    #[test]
691    fn deserialize_eip712_without_access_list() {
692        let json = r#"
693        {
694            "type": "0x71",
695            "blockHash": "0x05a9dc0814c1bf7dc96c464663bf24db8e1306e2814c848c87de2ea0684dadb5",
696            "blockNumber": "0x41e64c0",
697            "chainId": "0x144",
698            "from": "0xaede212cda8fc657ac7b4ad5e98fd510624638b3",
699            "gas": "0x388d8",
700            "gasPrice": "0x2b275d0",
701            "hash": "0xfb0fac3fccaaec0a1863e87941ba68752e92f9190f036f1af2b2f485e5829d28",
702            "input": "0x51cff8d9000000000000000000000000aede212cda8fc657ac7b4ad5e98fd510624638b3",
703            "l1BatchNumber": "0x7c0c8",
704            "l1BatchTxIndex": "0x140",
705            "maxFeePerGas": "0x2b275d0",
706            "maxPriorityFeePerGas": "0x2b275d0",
707            "nonce": "0x31db5",
708            "r": "0x3e6d7b9325a8ef7b707a02f287c91d7d07b06bb1ad16d645ba35703bc5804936",
709            "s": "0x6a8a76d975aec0c560233be60e13bfa2233a95ee7d2262d8c119ec6589e1bc26",
710            "to": "0x000000000000000000000000000000000000800a",
711            "transactionIndex": "0x0",
712            "v": "0x0",
713            "value": "0x1"
714        }
715        "#;
716
717        let envelope: TxEnvelope = serde_json::from_str(json).unwrap();
718        assert!(envelope.is_eip712());
719    }
720
721    /// Regression test at the `TransactionResponse` level: a full RPC response
722    /// for a type-2 transaction without `accessList` should deserialize through
723    /// the `TransactionEither` untagged enum path.
724    #[test]
725    fn deserialize_transaction_response_without_access_list() {
726        let json = r#"
727        {
728            "type": "0x2",
729            "blockHash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
730            "blockNumber": "0x41a3a9b",
731            "transactionIndex": "0x0",
732            "hash": "0x09d047b22ceb10d30bd1a36969e45eb9f63b6d01f16439f4fd0b9f0114177cff",
733            "from": "0x1234567890abcdef1234567890abcdef12345678",
734            "to": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd",
735            "value": "0xde0b6b3a7640000",
736            "nonce": "0x5",
737            "gas": "0x5208",
738            "maxFeePerGas": "0xee6b2800",
739            "maxPriorityFeePerGas": "0x0",
740            "input": "0x",
741            "chainId": "0x144",
742            "r": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
743            "s": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
744            "v": "0x0",
745            "yParity": "0x0"
746        }
747        "#;
748
749        let response: TransactionResponse = serde_json::from_str(json).unwrap();
750        assert_eq!(response.nonce(), 5);
751        assert_eq!(response.gas_limit(), 21000);
752        assert_eq!(response.max_fee_per_gas(), 0xee6b2800);
753        let access_list = response
754            .access_list()
755            .expect("should have defaulted to empty");
756        assert!(access_list.is_empty());
757    }
758}