Skip to main content

txgate_chain/
ethereum.rs

1//! Ethereum transaction parser implementation.
2//!
3//! This module provides the [`EthereumParser`] struct that implements the [`Chain`]
4//! trait for parsing Ethereum transactions.
5//!
6//! # Supported Transaction Types
7//!
8//! - **Legacy (Type 0)**: Pre-EIP-2718 transactions, RLP-encoded directly
9//! - **EIP-2930 (Type 1)**: Access list transactions, prefixed with `0x01`
10//! - **EIP-1559 (Type 2)**: Dynamic fee transactions, prefixed with `0x02`
11//!
12//! # Example
13//!
14//! ```
15//! use txgate_chain::{Chain, EthereumParser};
16//!
17//! let parser = EthereumParser::new();
18//! assert_eq!(parser.id(), "ethereum");
19//!
20//! // Parse a raw transaction (hex-decoded bytes)
21//! // let parsed = parser.parse(&raw_tx_bytes)?;
22//! ```
23//!
24//! # Transaction Hash
25//!
26//! The transaction hash is computed as the Keccak-256 hash of the raw transaction bytes.
27//! This includes the type prefix for typed transactions (EIP-2718+).
28//!
29//! # Chain ID Extraction
30//!
31//! - For typed transactions (EIP-2930, EIP-1559): Chain ID is explicitly in the transaction
32//! - For legacy transactions with EIP-155: `chain_id = (v - 35) / 2`
33//! - For pre-EIP-155 legacy transactions (v = 27 or 28): Assumes mainnet (`chain_id` = 1)
34
35use alloy_primitives::{keccak256, Address, U256};
36use txgate_core::{error::ParseError, ParsedTx, TxType};
37use txgate_crypto::CurveType;
38
39use crate::erc20::{parse_erc20_call, Erc20Call};
40use crate::rlp::{
41    decode_bytes, decode_list, decode_optional_address, decode_u256, decode_u64, detect_tx_type,
42    typed_tx_payload,
43};
44use crate::Chain;
45
46/// Ethereum transaction parser.
47///
48/// This struct implements the [`Chain`] trait for parsing Ethereum transactions
49/// into the unified [`ParsedTx`] format.
50///
51/// # Supported Transaction Types
52///
53/// - Legacy transactions (type 0 or no type prefix)
54/// - EIP-2930 transactions (type 1)
55/// - EIP-1559 transactions (type 2)
56///
57/// # Example
58///
59/// ```
60/// use txgate_chain::{Chain, EthereumParser};
61///
62/// let parser = EthereumParser::new();
63///
64/// // Check chain ID and curve
65/// assert_eq!(parser.id(), "ethereum");
66/// assert_eq!(parser.curve(), txgate_crypto::CurveType::Secp256k1);
67///
68/// // Check supported versions
69/// assert!(parser.supports_version(0));
70/// assert!(parser.supports_version(1));
71/// assert!(parser.supports_version(2));
72/// assert!(!parser.supports_version(3)); // EIP-4844 not yet supported
73/// ```
74#[derive(Debug, Clone, Copy, Default)]
75pub struct EthereumParser;
76
77impl EthereumParser {
78    /// Create a new Ethereum parser instance.
79    ///
80    /// # Example
81    ///
82    /// ```
83    /// use txgate_chain::EthereumParser;
84    ///
85    /// let parser = EthereumParser::new();
86    /// ```
87    #[must_use]
88    pub const fn new() -> Self {
89        Self
90    }
91
92    /// Parse a legacy transaction (type 0 or no type prefix).
93    ///
94    /// Legacy transactions are RLP-encoded as:
95    /// `[nonce, gasPrice, gasLimit, to, value, data, v, r, s]`
96    ///
97    /// # Chain ID Extraction (EIP-155)
98    ///
99    /// - If `v >= 35`: `chain_id = (v - 35) / 2`
100    /// - If `v = 27` or `v = 28`: Pre-EIP-155, assumes mainnet (`chain_id` = 1)
101    fn parse_legacy(raw: &[u8]) -> Result<ParsedTx, ParseError> {
102        // For true legacy transactions, the hash source is the same as the RLP payload
103        Self::parse_legacy_with_hash_source(raw, raw)
104    }
105
106    /// Determine whether a 9-item legacy RLP list is unsigned and extract the `chain_id`.
107    ///
108    /// Returns `(is_unsigned, chain_id)` where `chain_id` is `None` for pre-EIP-155.
109    ///
110    /// For 9-item transactions, unsigned EIP-155 format has `r=0, s=0` in items\[7\]
111    /// and items\[8\]. A valid ECDSA signature cannot have both r=0 and s=0.
112    fn extract_legacy_chain_info(items: &[&[u8]]) -> Result<(bool, Option<u64>), ParseError> {
113        if items.len() == 6 {
114            // Pre-EIP-155 unsigned: no chain_id available
115            return Ok((true, None));
116        }
117
118        // items.len() == 9 at this point (caller validates length)
119        // Safe: caller ensures items has 9 elements
120        let v_raw = decode_u64(
121            items
122                .get(6)
123                .ok_or_else(|| ParseError::MalformedTransaction {
124                    context: "missing v/chainId field".to_string(),
125                })?,
126        )?;
127        let r_val = decode_u256(
128            items
129                .get(7)
130                .ok_or_else(|| ParseError::MalformedTransaction {
131                    context: "missing r field".to_string(),
132                })?,
133        )?;
134        let s_val = decode_u256(
135            items
136                .get(8)
137                .ok_or_else(|| ParseError::MalformedTransaction {
138                    context: "missing s field".to_string(),
139                })?,
140        )?;
141
142        let is_unsigned = r_val.is_zero() && s_val.is_zero();
143
144        let chain_id = if is_unsigned {
145            // Unsigned EIP-155: v_raw IS the chain_id directly.
146            // chain_id=0 is invalid per EIP-155; reject it.
147            if v_raw == 0 {
148                return Err(ParseError::MalformedTransaction {
149                    context: "unsigned EIP-155 transaction has chain_id=0".to_string(),
150                });
151            }
152            Some(v_raw)
153        } else if v_raw >= 35 {
154            // Signed EIP-155: chain_id = (v - 35) / 2
155            Some((v_raw - 35) / 2)
156        } else if v_raw == 27 || v_raw == 28 {
157            // Pre-EIP-155 signed: no chain_id encoded
158            None
159        } else {
160            // v values in [0, 26] or [29, 34] are invalid for signed transactions
161            return Err(ParseError::MalformedTransaction {
162                context: format!("invalid v value for signed legacy transaction: {v_raw}"),
163            });
164        };
165
166        Ok((is_unsigned, chain_id))
167    }
168
169    /// Parse a legacy transaction with a separate hash source.
170    ///
171    /// This is used for type 0 transactions where the hash must be computed
172    /// over the full raw bytes (including type prefix), but the RLP payload
173    /// is without the type prefix.
174    ///
175    /// # Arguments
176    ///
177    /// * `hash_source` - The bytes to compute the transaction hash from (full raw for typed txs)
178    /// * `rlp_payload` - The RLP-encoded transaction data (without type prefix for typed txs)
179    fn parse_legacy_with_hash_source(
180        hash_source: &[u8],
181        rlp_payload: &[u8],
182    ) -> Result<ParsedTx, ParseError> {
183        // Decode the RLP list from the payload
184        let items = decode_list(rlp_payload)?;
185
186        // Legacy transaction:
187        // - Signed: 9 items [nonce, gasPrice, gasLimit, to, value, data, v, r, s]
188        // - Unsigned pre-EIP-155: 6 items [nonce, gasPrice, gasLimit, to, value, data]
189        // - Unsigned EIP-155: 9 items [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]
190        if items.len() != 9 && items.len() != 6 {
191            return Err(ParseError::MalformedTransaction {
192                context: format!(
193                    "legacy transaction expected 6 or 9 items, got {}",
194                    items.len()
195                ),
196            });
197        }
198
199        let (is_unsigned, chain_id_opt) = Self::extract_legacy_chain_info(&items)?;
200        // Default to mainnet (chain_id=1) when no chain_id is available (pre-EIP-155)
201        let chain_id = chain_id_opt.unwrap_or(1);
202
203        // Extract fields using safe indexing
204        let nonce_bytes = items
205            .first()
206            .ok_or_else(|| ParseError::MalformedTransaction {
207                context: "missing nonce field".to_string(),
208            })?;
209        let to_bytes = items
210            .get(3)
211            .ok_or_else(|| ParseError::MalformedTransaction {
212                context: "missing to field".to_string(),
213            })?;
214        let value_bytes = items
215            .get(4)
216            .ok_or_else(|| ParseError::MalformedTransaction {
217                context: "missing value field".to_string(),
218            })?;
219        let data_bytes = items
220            .get(5)
221            .ok_or_else(|| ParseError::MalformedTransaction {
222                context: "missing data field".to_string(),
223            })?;
224
225        // Decode nonce
226        let nonce = decode_u64(nonce_bytes)?;
227
228        // Decode recipient (can be empty for contract deployments)
229        let recipient = decode_optional_address(to_bytes)?;
230
231        // Decode value
232        let amount = decode_u256(value_bytes)?;
233
234        // Decode data
235        let data = decode_bytes(data_bytes)?;
236
237        // Check for ERC-20 token call
238        let erc20_info = recipient
239            .as_ref()
240            .and_then(|addr| Self::analyze_erc20(addr, &data));
241
242        // Determine transaction type (ERC-20 detection takes precedence)
243        let (final_tx_type, final_recipient, final_amount, token_address) =
244            erc20_info.as_ref().map_or_else(
245                || {
246                    (
247                        Self::determine_tx_type(recipient.as_ref(), &data, &amount),
248                        recipient.map(|addr| format!("{addr}")),
249                        Some(amount),
250                        None,
251                    )
252                },
253                |info| {
254                    (
255                        info.tx_type,
256                        Some(format!("{}", info.recipient)),
257                        Some(info.amount),
258                        Some(format!("{}", info.token_address)),
259                    )
260                },
261            );
262
263        // Compute transaction hash from the hash source (includes type prefix for typed txs)
264        let hash = keccak256(hash_source);
265
266        let mut metadata = std::collections::HashMap::new();
267        if is_unsigned {
268            metadata.insert("unsigned".to_string(), serde_json::Value::Bool(true));
269        }
270
271        Ok(ParsedTx {
272            hash: hash.into(),
273            recipient: final_recipient,
274            amount: final_amount,
275            token: None, // Token symbol lookup not implemented yet
276            token_address,
277            tx_type: final_tx_type,
278            chain: "ethereum".to_string(),
279            nonce: Some(nonce),
280            chain_id: Some(chain_id),
281            metadata,
282        })
283    }
284
285    /// Parse an EIP-2930 transaction (type 1).
286    ///
287    /// EIP-2930 transactions are encoded as:
288    /// `0x01 || RLP([chainId, nonce, gasPrice, gasLimit, to, value, data, accessList, signatureYParity, signatureR, signatureS])`
289    fn parse_eip2930(raw: &[u8], payload: &[u8]) -> Result<ParsedTx, ParseError> {
290        // Decode the RLP list from the payload (without type byte)
291        let items = decode_list(payload)?;
292
293        // EIP-2930: signed has 11 items, unsigned has 8 items
294        let is_unsigned = items.len() == 8;
295        if items.len() != 11 && items.len() != 8 {
296            return Err(ParseError::MalformedTransaction {
297                context: format!(
298                    "EIP-2930 transaction expected 8 or 11 items, got {}",
299                    items.len()
300                ),
301            });
302        }
303
304        // Extract fields using safe indexing
305        let chain_id_bytes = items
306            .first()
307            .ok_or_else(|| ParseError::MalformedTransaction {
308                context: "missing chainId field".to_string(),
309            })?;
310        let nonce_bytes = items
311            .get(1)
312            .ok_or_else(|| ParseError::MalformedTransaction {
313                context: "missing nonce field".to_string(),
314            })?;
315        let to_bytes = items
316            .get(4)
317            .ok_or_else(|| ParseError::MalformedTransaction {
318                context: "missing to field".to_string(),
319            })?;
320        let value_bytes = items
321            .get(5)
322            .ok_or_else(|| ParseError::MalformedTransaction {
323                context: "missing value field".to_string(),
324            })?;
325        let data_bytes = items
326            .get(6)
327            .ok_or_else(|| ParseError::MalformedTransaction {
328                context: "missing data field".to_string(),
329            })?;
330
331        // Decode fields
332        let chain_id = decode_u64(chain_id_bytes)?;
333        let nonce = decode_u64(nonce_bytes)?;
334        let recipient = decode_optional_address(to_bytes)?;
335        let amount = decode_u256(value_bytes)?;
336        let data = decode_bytes(data_bytes)?;
337
338        // Check for ERC-20 token call
339        let erc20_info = recipient
340            .as_ref()
341            .and_then(|addr| Self::analyze_erc20(addr, &data));
342
343        // Determine transaction type (ERC-20 detection takes precedence)
344        let (final_tx_type, final_recipient, final_amount, token_address) =
345            erc20_info.as_ref().map_or_else(
346                || {
347                    (
348                        Self::determine_tx_type(recipient.as_ref(), &data, &amount),
349                        recipient.map(|addr| format!("{addr}")),
350                        Some(amount),
351                        None,
352                    )
353                },
354                |info| {
355                    (
356                        info.tx_type,
357                        Some(format!("{}", info.recipient)),
358                        Some(info.amount),
359                        Some(format!("{}", info.token_address)),
360                    )
361                },
362            );
363
364        // Compute transaction hash (includes type byte)
365        let hash = keccak256(raw);
366
367        Ok(ParsedTx {
368            hash: hash.into(),
369            recipient: final_recipient,
370            amount: final_amount,
371            token: None, // Token symbol lookup not implemented yet
372            token_address,
373            tx_type: final_tx_type,
374            chain: "ethereum".to_string(),
375            nonce: Some(nonce),
376            chain_id: Some(chain_id),
377            metadata: {
378                let mut m = std::collections::HashMap::new();
379                if is_unsigned {
380                    m.insert("unsigned".to_string(), serde_json::Value::Bool(true));
381                }
382                m
383            },
384        })
385    }
386
387    /// Parse an EIP-1559 transaction (type 2).
388    ///
389    /// EIP-1559 transactions are encoded as:
390    /// `0x02 || RLP([chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, destination, amount, data, access_list, signature_y_parity, signature_r, signature_s])`
391    fn parse_eip1559(raw: &[u8], payload: &[u8]) -> Result<ParsedTx, ParseError> {
392        // Decode the RLP list from the payload (without type byte)
393        let items = decode_list(payload)?;
394
395        // EIP-1559: signed has 12 items, unsigned has 9 items
396        let is_unsigned = items.len() == 9;
397        if items.len() != 12 && items.len() != 9 {
398            return Err(ParseError::MalformedTransaction {
399                context: format!(
400                    "EIP-1559 transaction expected 9 or 12 items, got {}",
401                    items.len()
402                ),
403            });
404        }
405
406        // Extract fields using safe indexing
407        let chain_id_bytes = items
408            .first()
409            .ok_or_else(|| ParseError::MalformedTransaction {
410                context: "missing chainId field".to_string(),
411            })?;
412        let nonce_bytes = items
413            .get(1)
414            .ok_or_else(|| ParseError::MalformedTransaction {
415                context: "missing nonce field".to_string(),
416            })?;
417        let to_bytes = items
418            .get(5)
419            .ok_or_else(|| ParseError::MalformedTransaction {
420                context: "missing to field".to_string(),
421            })?;
422        let value_bytes = items
423            .get(6)
424            .ok_or_else(|| ParseError::MalformedTransaction {
425                context: "missing value field".to_string(),
426            })?;
427        let data_bytes = items
428            .get(7)
429            .ok_or_else(|| ParseError::MalformedTransaction {
430                context: "missing data field".to_string(),
431            })?;
432
433        // Decode fields
434        let chain_id = decode_u64(chain_id_bytes)?;
435        let nonce = decode_u64(nonce_bytes)?;
436        let recipient = decode_optional_address(to_bytes)?;
437        let amount = decode_u256(value_bytes)?;
438        let data = decode_bytes(data_bytes)?;
439
440        // Check for ERC-20 token call
441        let erc20_info = recipient
442            .as_ref()
443            .and_then(|addr| Self::analyze_erc20(addr, &data));
444
445        // Determine transaction type (ERC-20 detection takes precedence)
446        let (final_tx_type, final_recipient, final_amount, token_address) =
447            erc20_info.as_ref().map_or_else(
448                || {
449                    (
450                        Self::determine_tx_type(recipient.as_ref(), &data, &amount),
451                        recipient.map(|addr| format!("{addr}")),
452                        Some(amount),
453                        None,
454                    )
455                },
456                |info| {
457                    (
458                        info.tx_type,
459                        Some(format!("{}", info.recipient)),
460                        Some(info.amount),
461                        Some(format!("{}", info.token_address)),
462                    )
463                },
464            );
465
466        // Compute transaction hash (includes type byte)
467        let hash = keccak256(raw);
468
469        Ok(ParsedTx {
470            hash: hash.into(),
471            recipient: final_recipient,
472            amount: final_amount,
473            token: None, // Token symbol lookup not implemented yet
474            token_address,
475            tx_type: final_tx_type,
476            chain: "ethereum".to_string(),
477            nonce: Some(nonce),
478            chain_id: Some(chain_id),
479            metadata: {
480                let mut m = std::collections::HashMap::new();
481                if is_unsigned {
482                    m.insert("unsigned".to_string(), serde_json::Value::Bool(true));
483                }
484                m
485            },
486        })
487    }
488
489    /// Determine the transaction type based on recipient, data, and amount.
490    ///
491    /// - `Deployment`: No recipient (contract creation)
492    /// - `ContractCall`: Has data (non-empty calldata)
493    /// - `Transfer`: Simple ETH transfer (has recipient, no data)
494    const fn determine_tx_type(
495        recipient: Option<&alloy_primitives::Address>,
496        data: &[u8],
497        _amount: &U256,
498    ) -> TxType {
499        if recipient.is_none() {
500            // No recipient = contract deployment
501            TxType::Deployment
502        } else if !data.is_empty() {
503            // Has data = contract call
504            TxType::ContractCall
505        } else {
506            // Simple ETH transfer
507            TxType::Transfer
508        }
509    }
510
511    /// Analyze transaction data and return ERC-20 specific information if applicable.
512    ///
513    /// This function checks if the transaction is an ERC-20 token call and extracts
514    /// the relevant information for enriching the `ParsedTx`.
515    ///
516    /// # Arguments
517    ///
518    /// * `contract_address` - The address of the contract being called (the token contract)
519    /// * `data` - The transaction calldata
520    ///
521    /// # Returns
522    ///
523    /// Returns `Some(Erc20Info)` if this is an ERC-20 call, `None` otherwise.
524    fn analyze_erc20(contract_address: &Address, data: &[u8]) -> Option<Erc20Info> {
525        let erc20_call = parse_erc20_call(data)?;
526
527        let (tx_type, recipient_addr) = match &erc20_call {
528            Erc20Call::Transfer { to, .. } | Erc20Call::TransferFrom { to, .. } => {
529                (TxType::TokenTransfer, Address::from_slice(to))
530            }
531            Erc20Call::Approve { spender, .. } => {
532                (TxType::TokenApproval, Address::from_slice(spender))
533            }
534        };
535
536        Some(Erc20Info {
537            tx_type,
538            token_address: *contract_address,
539            recipient: recipient_addr,
540            amount: *erc20_call.amount(),
541        })
542    }
543
544    /// Assemble a fully signed transaction from raw bytes and a 65-byte signature.
545    ///
546    /// Takes the original raw transaction bytes (signed or unsigned) and the
547    /// signature (`r[32] || s[32] || v[1]`) and returns the RLP-encoded signed
548    /// transaction ready for broadcast.
549    ///
550    /// # Arguments
551    ///
552    /// * `raw` - The raw transaction bytes (as originally passed to `parse()`)
553    /// * `signature` - 65-byte signature: `r(32) || s(32) || recovery_id(1)`
554    ///
555    /// # Returns
556    ///
557    /// The fully assembled, RLP-encoded signed transaction bytes.
558    ///
559    /// # Errors
560    ///
561    /// Returns [`ParseError::AssemblyFailed`] if:
562    /// - The signature is not exactly 65 bytes
563    /// - The transaction cannot be decoded
564    /// - RLP encoding of the signed transaction fails
565    pub fn assemble_signed(raw: &[u8], signature: &[u8]) -> Result<Vec<u8>, ParseError> {
566        use alloy_primitives::Signature;
567
568        if signature.len() != 65 {
569            return Err(ParseError::assembly_failed(format!(
570                "expected 65-byte signature, got {}",
571                signature.len()
572            )));
573        }
574
575        // Extract signature components
576        let r = U256::from_be_slice(
577            signature
578                .get(0..32)
579                .ok_or_else(|| ParseError::assembly_failed("signature too short for r"))?,
580        );
581        let s = U256::from_be_slice(
582            signature
583                .get(32..64)
584                .ok_or_else(|| ParseError::assembly_failed("signature too short for s"))?,
585        );
586        let v_byte = *signature
587            .get(64)
588            .ok_or_else(|| ParseError::assembly_failed("signature too short for v"))?;
589        if v_byte > 1 {
590            return Err(ParseError::assembly_failed(format!(
591                "invalid recovery id: expected 0 or 1, got {v_byte}"
592            )));
593        }
594        let v_parity = v_byte != 0;
595
596        let sig = Signature::new(r, s, v_parity);
597
598        // Detect tx type and assemble
599        match detect_tx_type(raw) {
600            None => {
601                // Legacy transaction
602                if !crate::rlp::is_list(raw) {
603                    return Err(ParseError::assembly_failed("not a valid RLP list"));
604                }
605                Self::assemble_legacy(raw, raw, &sig, None)
606            }
607            Some(0x00) => {
608                // Typed legacy (type 0)
609                let payload = typed_tx_payload(raw)?;
610                Self::assemble_legacy(raw, payload, &sig, None)
611            }
612            Some(0x01) => {
613                // EIP-2930
614                let payload = typed_tx_payload(raw)?;
615                Self::assemble_eip2930(payload, &sig)
616            }
617            Some(0x02) => {
618                // EIP-1559
619                let payload = typed_tx_payload(raw)?;
620                Self::assemble_eip1559(payload, &sig)
621            }
622            Some(ty) => Err(ParseError::assembly_failed(format!(
623                "unsupported transaction type: 0x{ty:02x}"
624            ))),
625        }
626    }
627
628    /// Assemble a signed legacy transaction.
629    fn assemble_legacy(
630        _raw: &[u8],
631        rlp_payload: &[u8],
632        sig: &alloy_primitives::Signature,
633        _type_prefix: Option<u8>,
634    ) -> Result<Vec<u8>, ParseError> {
635        use alloy_consensus::transaction::RlpEcdsaEncodableTx;
636        use alloy_consensus::TxLegacy;
637        use alloy_primitives::{Bytes, TxKind};
638
639        let items = decode_list(rlp_payload)?;
640
641        // Accept 6 (unsigned pre-EIP-155) or 9 (signed / unsigned EIP-155)
642        if items.len() != 6 && items.len() != 9 {
643            return Err(ParseError::assembly_failed(format!(
644                "legacy tx expected 6 or 9 items, got {}",
645                items.len()
646            )));
647        }
648
649        let nonce = decode_u64(
650            items
651                .first()
652                .ok_or_else(|| ParseError::assembly_failed("missing nonce"))?,
653        )?;
654
655        let gas_price_u256 = decode_u256(
656            items
657                .get(1)
658                .ok_or_else(|| ParseError::assembly_failed("missing gasPrice"))?,
659        )?;
660        let gas_price: u128 = gas_price_u256
661            .try_into()
662            .map_err(|_| ParseError::assembly_failed("gasPrice overflow"))?;
663
664        let gas_limit = decode_u64(
665            items
666                .get(2)
667                .ok_or_else(|| ParseError::assembly_failed("missing gasLimit"))?,
668        )?;
669
670        let to_addr = decode_optional_address(
671            items
672                .get(3)
673                .ok_or_else(|| ParseError::assembly_failed("missing to"))?,
674        )?;
675
676        let value = decode_u256(
677            items
678                .get(4)
679                .ok_or_else(|| ParseError::assembly_failed("missing value"))?,
680        )?;
681
682        let data = decode_bytes(
683            items
684                .get(5)
685                .ok_or_else(|| ParseError::assembly_failed("missing data"))?,
686        )?;
687
688        // Extract chain_id using shared logic
689        let (_is_unsigned, chain_id) = Self::extract_legacy_chain_info(&items)?;
690
691        let tx = TxLegacy {
692            chain_id,
693            nonce,
694            gas_price,
695            gas_limit,
696            to: to_addr.map_or(TxKind::Create, TxKind::Call),
697            value,
698            input: Bytes::from(data),
699        };
700
701        let mut buf = Vec::new();
702        tx.rlp_encode_signed(sig, &mut buf);
703        Ok(buf)
704    }
705
706    /// Assemble a signed EIP-2930 transaction.
707    fn assemble_eip2930(
708        payload: &[u8],
709        sig: &alloy_primitives::Signature,
710    ) -> Result<Vec<u8>, ParseError> {
711        use alloy_consensus::transaction::RlpEcdsaEncodableTx;
712        use alloy_consensus::TxEip2930;
713        use alloy_eips::eip2930::AccessList;
714        use alloy_primitives::{Bytes, TxKind};
715        use alloy_rlp::Decodable;
716
717        let items = decode_list(payload)?;
718
719        // Accept 8 (unsigned) or 11 (signed)
720        if items.len() != 8 && items.len() != 11 {
721            return Err(ParseError::assembly_failed(format!(
722                "EIP-2930 tx expected 8 or 11 items, got {}",
723                items.len()
724            )));
725        }
726
727        let chain_id = decode_u64(
728            items
729                .first()
730                .ok_or_else(|| ParseError::assembly_failed("missing chainId"))?,
731        )?;
732
733        let nonce = decode_u64(
734            items
735                .get(1)
736                .ok_or_else(|| ParseError::assembly_failed("missing nonce"))?,
737        )?;
738
739        let gas_price_u256 = decode_u256(
740            items
741                .get(2)
742                .ok_or_else(|| ParseError::assembly_failed("missing gasPrice"))?,
743        )?;
744        let gas_price: u128 = gas_price_u256
745            .try_into()
746            .map_err(|_| ParseError::assembly_failed("gasPrice overflow"))?;
747
748        let gas_limit = decode_u64(
749            items
750                .get(3)
751                .ok_or_else(|| ParseError::assembly_failed("missing gasLimit"))?,
752        )?;
753
754        let to_addr = decode_optional_address(
755            items
756                .get(4)
757                .ok_or_else(|| ParseError::assembly_failed("missing to"))?,
758        )?;
759
760        let value = decode_u256(
761            items
762                .get(5)
763                .ok_or_else(|| ParseError::assembly_failed("missing value"))?,
764        )?;
765
766        let data = decode_bytes(
767            items
768                .get(6)
769                .ok_or_else(|| ParseError::assembly_failed("missing data"))?,
770        )?;
771
772        let access_list_bytes = items
773            .get(7)
774            .ok_or_else(|| ParseError::assembly_failed("missing accessList"))?;
775        let mut access_list_buf = *access_list_bytes;
776        let access_list = AccessList::decode(&mut access_list_buf).map_err(|e| {
777            ParseError::assembly_failed(format!("failed to decode access list: {e}"))
778        })?;
779
780        let tx = TxEip2930 {
781            chain_id,
782            nonce,
783            gas_price,
784            gas_limit,
785            to: to_addr.map_or(TxKind::Create, TxKind::Call),
786            value,
787            input: Bytes::from(data),
788            access_list,
789        };
790
791        let mut buf = vec![0x01]; // EIP-2930 type prefix
792        tx.rlp_encode_signed(sig, &mut buf);
793        Ok(buf)
794    }
795
796    /// Assemble a signed EIP-1559 transaction.
797    fn assemble_eip1559(
798        payload: &[u8],
799        sig: &alloy_primitives::Signature,
800    ) -> Result<Vec<u8>, ParseError> {
801        use alloy_consensus::transaction::RlpEcdsaEncodableTx;
802        use alloy_consensus::TxEip1559;
803        use alloy_eips::eip2930::AccessList;
804        use alloy_primitives::{Bytes, TxKind};
805        use alloy_rlp::Decodable;
806
807        let items = decode_list(payload)?;
808
809        // Accept 9 (unsigned) or 12 (signed)
810        if items.len() != 9 && items.len() != 12 {
811            return Err(ParseError::assembly_failed(format!(
812                "EIP-1559 tx expected 9 or 12 items, got {}",
813                items.len()
814            )));
815        }
816
817        let chain_id = decode_u64(
818            items
819                .first()
820                .ok_or_else(|| ParseError::assembly_failed("missing chainId"))?,
821        )?;
822
823        let nonce = decode_u64(
824            items
825                .get(1)
826                .ok_or_else(|| ParseError::assembly_failed("missing nonce"))?,
827        )?;
828
829        let max_priority_fee_u256 = decode_u256(
830            items
831                .get(2)
832                .ok_or_else(|| ParseError::assembly_failed("missing maxPriorityFeePerGas"))?,
833        )?;
834        let max_priority_fee_per_gas: u128 = max_priority_fee_u256
835            .try_into()
836            .map_err(|_| ParseError::assembly_failed("maxPriorityFeePerGas overflow"))?;
837
838        let max_fee_u256 = decode_u256(
839            items
840                .get(3)
841                .ok_or_else(|| ParseError::assembly_failed("missing maxFeePerGas"))?,
842        )?;
843        let max_fee_per_gas: u128 = max_fee_u256
844            .try_into()
845            .map_err(|_| ParseError::assembly_failed("maxFeePerGas overflow"))?;
846
847        let gas_limit = decode_u64(
848            items
849                .get(4)
850                .ok_or_else(|| ParseError::assembly_failed("missing gasLimit"))?,
851        )?;
852
853        let to_addr = decode_optional_address(
854            items
855                .get(5)
856                .ok_or_else(|| ParseError::assembly_failed("missing to"))?,
857        )?;
858
859        let value = decode_u256(
860            items
861                .get(6)
862                .ok_or_else(|| ParseError::assembly_failed("missing value"))?,
863        )?;
864
865        let data = decode_bytes(
866            items
867                .get(7)
868                .ok_or_else(|| ParseError::assembly_failed("missing data"))?,
869        )?;
870
871        let access_list_bytes = items
872            .get(8)
873            .ok_or_else(|| ParseError::assembly_failed("missing accessList"))?;
874        let mut access_list_buf = *access_list_bytes;
875        let access_list = AccessList::decode(&mut access_list_buf).map_err(|e| {
876            ParseError::assembly_failed(format!("failed to decode access list: {e}"))
877        })?;
878
879        let tx = TxEip1559 {
880            chain_id,
881            nonce,
882            max_priority_fee_per_gas,
883            max_fee_per_gas,
884            gas_limit,
885            to: to_addr.map_or(TxKind::Create, TxKind::Call),
886            value,
887            input: Bytes::from(data),
888            access_list,
889        };
890
891        let mut buf = vec![0x02]; // EIP-1559 type prefix
892        tx.rlp_encode_signed(sig, &mut buf);
893        Ok(buf)
894    }
895}
896
897/// Information extracted from an ERC-20 function call.
898///
899/// Used internally to enrich `ParsedTx` with token-specific data.
900struct Erc20Info {
901    /// The transaction type (`TokenTransfer` or `TokenApproval`).
902    tx_type: TxType,
903    /// The token contract address.
904    token_address: Address,
905    /// The actual recipient/spender address from the ERC-20 call.
906    recipient: Address,
907    /// The token amount from the ERC-20 call.
908    amount: U256,
909}
910
911impl Chain for EthereumParser {
912    /// Returns the chain identifier.
913    ///
914    /// # Returns
915    ///
916    /// Always returns `"ethereum"`.
917    fn id(&self) -> &'static str {
918        "ethereum"
919    }
920
921    /// Parse raw transaction bytes into a [`ParsedTx`].
922    ///
923    /// This method detects the transaction type and delegates to the
924    /// appropriate parser:
925    ///
926    /// - Legacy transactions (no type prefix or type 0)
927    /// - EIP-2930 transactions (type 1)
928    /// - EIP-1559 transactions (type 2)
929    ///
930    /// # Arguments
931    ///
932    /// * `raw` - The raw transaction bytes
933    ///
934    /// # Returns
935    ///
936    /// * `Ok(ParsedTx)` - Successfully parsed transaction
937    /// * `Err(ParseError)` - Parsing failed
938    ///
939    /// # Errors
940    ///
941    /// Returns a [`ParseError`] if:
942    /// - The transaction type is not supported
943    /// - The RLP encoding is invalid
944    /// - Required fields are missing or malformed
945    fn parse(&self, raw: &[u8]) -> Result<ParsedTx, ParseError> {
946        if raw.is_empty() {
947            return Err(ParseError::MalformedTransaction {
948                context: "empty transaction data".to_string(),
949            });
950        }
951
952        // Detect transaction type
953        match detect_tx_type(raw) {
954            None => {
955                // Legacy transaction (starts with RLP list prefix 0xc0-0xff)
956                // or potentially invalid data
957                if crate::rlp::is_list(raw) {
958                    Self::parse_legacy(raw)
959                } else {
960                    Err(ParseError::MalformedTransaction {
961                        context:
962                            "invalid transaction format: not a valid RLP list or typed transaction"
963                                .to_string(),
964                    })
965                }
966            }
967            Some(0) => {
968                // Type 0 - treat as legacy but skip the type byte for RLP parsing
969                // Hash must be computed over full raw bytes including type prefix
970                let payload = typed_tx_payload(raw)?;
971                Self::parse_legacy_with_hash_source(raw, payload)
972            }
973            Some(1) => {
974                // EIP-2930 (Access List)
975                let payload = typed_tx_payload(raw)?;
976                Self::parse_eip2930(raw, payload)
977            }
978            Some(2) => {
979                // EIP-1559 (Dynamic Fee)
980                let payload = typed_tx_payload(raw)?;
981                Self::parse_eip1559(raw, payload)
982            }
983            Some(_) => Err(ParseError::UnknownTxType),
984        }
985    }
986
987    /// Returns the elliptic curve used by Ethereum.
988    ///
989    /// # Returns
990    ///
991    /// Always returns [`CurveType::Secp256k1`].
992    fn curve(&self) -> CurveType {
993        CurveType::Secp256k1
994    }
995
996    /// Check if this parser supports a specific transaction version.
997    ///
998    /// # Arguments
999    ///
1000    /// * `version` - The transaction type byte
1001    ///
1002    /// # Returns
1003    ///
1004    /// * `true` for versions 0, 1, 2 (Legacy, EIP-2930, EIP-1559)
1005    /// * `false` for other versions (e.g., EIP-4844 blob transactions)
1006    fn supports_version(&self, version: u8) -> bool {
1007        matches!(version, 0..=2)
1008    }
1009
1010    fn assemble_signed(&self, raw: &[u8], signature: &[u8]) -> Result<Vec<u8>, ParseError> {
1011        Self::assemble_signed(raw, signature)
1012    }
1013}
1014
1015// ============================================================================
1016// Tests
1017// ============================================================================
1018
1019#[cfg(test)]
1020mod tests {
1021    #![allow(
1022        clippy::expect_used,
1023        clippy::unwrap_used,
1024        clippy::panic,
1025        clippy::indexing_slicing,
1026        clippy::similar_names,
1027        clippy::redundant_clone,
1028        clippy::manual_string_new,
1029        clippy::needless_raw_string_hashes,
1030        clippy::needless_collect,
1031        clippy::unreadable_literal,
1032        clippy::default_trait_access,
1033        clippy::too_many_arguments,
1034        clippy::default_constructed_unit_structs
1035    )]
1036
1037    use super::*;
1038    use alloy_consensus::{transaction::RlpEcdsaEncodableTx, TxEip1559, TxEip2930, TxLegacy};
1039    use alloy_primitives::{hex, Address, Bytes, Signature, TxKind};
1040
1041    /// Convert an alloy Signature into a 65-byte array: r(32) || s(32) || v(1).
1042    fn sig_to_bytes(sig: &Signature) -> [u8; 65] {
1043        let mut buf = [0u8; 65];
1044        buf[..32].copy_from_slice(&sig.r().to_be_bytes::<32>());
1045        buf[32..64].copy_from_slice(&sig.s().to_be_bytes::<32>());
1046        buf[64] = u8::from(sig.v());
1047        buf
1048    }
1049
1050    /// Helper to encode a legacy transaction with a fake signature
1051    fn encode_legacy_tx(
1052        nonce: u64,
1053        gas_price: u128,
1054        gas_limit: u64,
1055        to: Option<Address>,
1056        value: U256,
1057        data: Bytes,
1058        chain_id: Option<u64>,
1059    ) -> Vec<u8> {
1060        let tx = TxLegacy {
1061            chain_id,
1062            nonce,
1063            gas_price,
1064            gas_limit,
1065            to: to.map_or(TxKind::Create, TxKind::Call),
1066            value,
1067            input: data,
1068        };
1069
1070        // Create a fake signature
1071        let sig = Signature::new(
1072            U256::from(0xffff_ffff_ffff_ffffu64),
1073            U256::from(0xffff_ffff_ffff_ffffu64),
1074            false,
1075        );
1076
1077        let mut buf = Vec::new();
1078        tx.rlp_encode_signed(&sig, &mut buf);
1079        buf
1080    }
1081
1082    /// Helper to encode an EIP-2930 transaction with a fake signature
1083    fn encode_eip2930_tx(
1084        chain_id: u64,
1085        nonce: u64,
1086        gas_price: u128,
1087        gas_limit: u64,
1088        to: Option<Address>,
1089        value: U256,
1090        data: Bytes,
1091    ) -> Vec<u8> {
1092        let tx = TxEip2930 {
1093            chain_id,
1094            nonce,
1095            gas_price,
1096            gas_limit,
1097            to: to.map_or(TxKind::Create, TxKind::Call),
1098            value,
1099            input: data,
1100            access_list: Default::default(),
1101        };
1102
1103        // Create a fake signature
1104        let sig = Signature::new(
1105            U256::from(0xffff_ffff_ffff_ffffu64),
1106            U256::from(0xffff_ffff_ffff_ffffu64),
1107            false,
1108        );
1109
1110        // Build buffer with type prefix
1111        let mut buf = Vec::new();
1112        buf.push(0x01); // EIP-2930 type prefix
1113        tx.rlp_encode_signed(&sig, &mut buf);
1114        buf
1115    }
1116
1117    /// Helper to encode an EIP-1559 transaction with a fake signature
1118    fn encode_eip1559_tx(
1119        chain_id: u64,
1120        nonce: u64,
1121        max_priority_fee_per_gas: u128,
1122        max_fee_per_gas: u128,
1123        gas_limit: u64,
1124        to: Option<Address>,
1125        value: U256,
1126        data: Bytes,
1127    ) -> Vec<u8> {
1128        let tx = TxEip1559 {
1129            chain_id,
1130            nonce,
1131            max_priority_fee_per_gas,
1132            max_fee_per_gas,
1133            gas_limit,
1134            to: to.map_or(TxKind::Create, TxKind::Call),
1135            value,
1136            input: data,
1137            access_list: Default::default(),
1138        };
1139
1140        // Create a fake signature
1141        let sig = Signature::new(
1142            U256::from(0xffff_ffff_ffff_ffffu64),
1143            U256::from(0xffff_ffff_ffff_ffffu64),
1144            false,
1145        );
1146
1147        // Build buffer with type prefix
1148        let mut buf = Vec::new();
1149        buf.push(0x02); // EIP-1559 type prefix
1150        tx.rlp_encode_signed(&sig, &mut buf);
1151        buf
1152    }
1153
1154    // ------------------------------------------------------------------------
1155    // Constructor and Basic Tests
1156    // ------------------------------------------------------------------------
1157
1158    #[test]
1159    fn test_ethereum_parser_new() {
1160        let parser = EthereumParser::new();
1161        assert_eq!(parser.id(), "ethereum");
1162    }
1163
1164    #[test]
1165    fn test_ethereum_parser_default() {
1166        let parser = EthereumParser::default();
1167        assert_eq!(parser.id(), "ethereum");
1168    }
1169
1170    #[test]
1171    fn test_ethereum_parser_clone() {
1172        let parser = EthereumParser::new();
1173        let cloned = parser;
1174        assert_eq!(cloned.id(), "ethereum");
1175    }
1176
1177    #[test]
1178    fn test_ethereum_parser_debug() {
1179        let parser = EthereumParser::new();
1180        let debug_str = format!("{parser:?}");
1181        assert!(debug_str.contains("EthereumParser"));
1182    }
1183
1184    // ------------------------------------------------------------------------
1185    // Chain Trait Tests
1186    // ------------------------------------------------------------------------
1187
1188    #[test]
1189    fn test_chain_id() {
1190        let parser = EthereumParser::new();
1191        assert_eq!(parser.id(), "ethereum");
1192    }
1193
1194    #[test]
1195    fn test_chain_curve() {
1196        let parser = EthereumParser::new();
1197        assert_eq!(parser.curve(), CurveType::Secp256k1);
1198    }
1199
1200    #[test]
1201    fn test_supports_version() {
1202        let parser = EthereumParser::new();
1203
1204        // Supported versions
1205        assert!(parser.supports_version(0)); // Legacy
1206        assert!(parser.supports_version(1)); // EIP-2930
1207        assert!(parser.supports_version(2)); // EIP-1559
1208
1209        // Unsupported versions
1210        assert!(!parser.supports_version(3)); // EIP-4844 (blobs)
1211        assert!(!parser.supports_version(4));
1212        assert!(!parser.supports_version(255));
1213    }
1214
1215    // ------------------------------------------------------------------------
1216    // Empty Input Tests
1217    // ------------------------------------------------------------------------
1218
1219    #[test]
1220    fn test_parse_empty_input() {
1221        let parser = EthereumParser::new();
1222        let result = parser.parse(&[]);
1223
1224        assert!(result.is_err());
1225        assert!(matches!(
1226            result,
1227            Err(ParseError::MalformedTransaction { .. })
1228        ));
1229    }
1230
1231    // ------------------------------------------------------------------------
1232    // Legacy Transaction Tests
1233    // ------------------------------------------------------------------------
1234
1235    #[test]
1236    fn test_parse_legacy_transaction() {
1237        let parser = EthereumParser::new();
1238
1239        // Real legacy transaction from Ethereum mainnet
1240        // This is a simple ETH transfer
1241        // nonce=9, gasPrice=20gwei, gasLimit=21000, to=0x3535..., value=1ETH, data=empty
1242        let raw = hex::decode(
1243            "f86c098504a817c800825208943535353535353535353535353535353535353535880de0b6b3a76400008025a028ef61340bd939bc2195fe537567866003e1a15d3c71ff63e1590620aa636276a067cbe9d8997f761aecb703304b3800ccf555c9f3dc64214b297fb1966a3b6d83"
1244        ).expect("valid hex");
1245
1246        let result = parser.parse(&raw);
1247        assert!(result.is_ok(), "parsing failed: {result:?}");
1248
1249        let parsed = result.expect("should parse successfully");
1250
1251        // Verify fields
1252        assert_eq!(parsed.chain, "ethereum");
1253        assert_eq!(parsed.nonce, Some(9));
1254        assert_eq!(parsed.tx_type, TxType::Transfer);
1255        assert!(parsed.recipient.is_some());
1256        assert_eq!(
1257            parsed.recipient.as_ref().map(|s| s.to_lowercase()),
1258            Some("0x3535353535353535353535353535353535353535".to_string())
1259        );
1260
1261        // Verify amount is 1 ETH (10^18 wei)
1262        let expected_amount = U256::from(1_000_000_000_000_000_000u64);
1263        assert_eq!(parsed.amount, Some(expected_amount));
1264
1265        // Verify chain_id extraction from v=37 -> chain_id = (37-35)/2 = 1
1266        assert_eq!(parsed.chain_id, Some(1));
1267
1268        // Verify hash is computed
1269        assert_ne!(parsed.hash, [0u8; 32]);
1270    }
1271
1272    #[test]
1273    fn test_parse_legacy_transaction_pre_eip155() {
1274        let parser = EthereumParser::new();
1275
1276        // Legacy transaction without chain_id (pre-EIP-155)
1277        let to_addr = Address::from([0x12; 20]);
1278        let raw = encode_legacy_tx(
1279            0,                // nonce
1280            1_000_000_000,    // gas_price (1 gwei)
1281            21000,            // gas_limit
1282            Some(to_addr),    // to
1283            U256::ZERO,       // value
1284            Bytes::default(), // data
1285            None,             // chain_id (None = pre-EIP-155)
1286        );
1287
1288        let result = parser.parse(&raw);
1289        assert!(result.is_ok(), "parsing failed: {result:?}");
1290
1291        let parsed = result.expect("should parse successfully");
1292        // Pre-EIP-155 defaults to mainnet (chain_id = 1)
1293        assert_eq!(parsed.chain_id, Some(1));
1294    }
1295
1296    #[test]
1297    fn test_parse_legacy_contract_deployment() {
1298        let parser = EthereumParser::new();
1299
1300        // Contract deployment: to field is None (Create)
1301        let raw = encode_legacy_tx(
1302            0,                                         // nonce
1303            1_000_000_000,                             // gas_price
1304            100000,                                    // gas_limit
1305            None,                                      // to = None for deployment
1306            U256::ZERO,                                // value
1307            Bytes::from(vec![0x60, 0x80, 0x60, 0x40]), // data (some bytecode)
1308            Some(1),                                   // chain_id
1309        );
1310
1311        let result = parser.parse(&raw);
1312        assert!(result.is_ok(), "parsing failed: {result:?}");
1313
1314        let parsed = result.expect("should parse successfully");
1315        assert_eq!(parsed.tx_type, TxType::Deployment);
1316        assert!(parsed.recipient.is_none());
1317    }
1318
1319    #[test]
1320    fn test_parse_legacy_contract_call() {
1321        let parser = EthereumParser::new();
1322
1323        // Contract call: has recipient and non-empty data
1324        let to_addr = Address::from([0x12; 20]);
1325        let raw = encode_legacy_tx(
1326            1,                                         // nonce
1327            1_000_000_000,                             // gas_price
1328            100000,                                    // gas_limit
1329            Some(to_addr),                             // to
1330            U256::ZERO,                                // value
1331            Bytes::from(vec![0xa9, 0x05, 0x9c, 0xbb]), // data (transfer selector)
1332            Some(1),                                   // chain_id
1333        );
1334
1335        let result = parser.parse(&raw);
1336        assert!(result.is_ok(), "parsing failed: {result:?}");
1337
1338        let parsed = result.expect("should parse successfully");
1339        assert_eq!(parsed.tx_type, TxType::ContractCall);
1340        assert!(parsed.recipient.is_some());
1341    }
1342
1343    // ------------------------------------------------------------------------
1344    // EIP-2930 Transaction Tests
1345    // ------------------------------------------------------------------------
1346
1347    #[test]
1348    fn test_parse_eip2930_transaction() {
1349        let parser = EthereumParser::new();
1350
1351        // EIP-2930 transaction (type 1)
1352        let to_addr = Address::from([0x12; 20]);
1353        let raw = encode_eip2930_tx(
1354            1,                // chain_id
1355            0,                // nonce
1356            1_000_000_000,    // gas_price
1357            21000,            // gas_limit
1358            Some(to_addr),    // to
1359            U256::ZERO,       // value
1360            Bytes::default(), // data
1361        );
1362
1363        let result = parser.parse(&raw);
1364        assert!(result.is_ok(), "parsing failed: {result:?}");
1365
1366        let parsed = result.expect("should parse successfully");
1367
1368        assert_eq!(parsed.chain, "ethereum");
1369        assert_eq!(parsed.chain_id, Some(1));
1370        assert_eq!(parsed.nonce, Some(0));
1371        assert_eq!(parsed.tx_type, TxType::Transfer);
1372        assert!(parsed.recipient.is_some());
1373    }
1374
1375    #[test]
1376    fn test_parse_eip2930_contract_deployment() {
1377        let parser = EthereumParser::new();
1378
1379        // EIP-2930 contract deployment (to=None)
1380        let raw = encode_eip2930_tx(
1381            1,                                         // chain_id
1382            0,                                         // nonce
1383            1_000_000_000,                             // gas_price
1384            100000,                                    // gas_limit
1385            None,                                      // to = None for deployment
1386            U256::ZERO,                                // value
1387            Bytes::from(vec![0x60, 0x80, 0x60, 0x40]), // data
1388        );
1389
1390        let result = parser.parse(&raw);
1391        assert!(result.is_ok(), "parsing failed: {result:?}");
1392
1393        let parsed = result.expect("should parse successfully");
1394        assert_eq!(parsed.tx_type, TxType::Deployment);
1395        assert!(parsed.recipient.is_none());
1396    }
1397
1398    // ------------------------------------------------------------------------
1399    // EIP-1559 Transaction Tests
1400    // ------------------------------------------------------------------------
1401
1402    #[test]
1403    fn test_parse_eip1559_transaction() {
1404        let parser = EthereumParser::new();
1405
1406        // EIP-1559 transaction (type 2)
1407        let to_addr = Address::from([0x12; 20]);
1408        let raw = encode_eip1559_tx(
1409            1,                // chain_id
1410            0,                // nonce
1411            1_000_000_000,    // max_priority_fee_per_gas
1412            2_000_000_000,    // max_fee_per_gas
1413            21000,            // gas_limit
1414            Some(to_addr),    // to
1415            U256::ZERO,       // value
1416            Bytes::default(), // data
1417        );
1418
1419        let result = parser.parse(&raw);
1420        assert!(result.is_ok(), "parsing failed: {result:?}");
1421
1422        let parsed = result.expect("should parse successfully");
1423
1424        assert_eq!(parsed.chain, "ethereum");
1425        assert_eq!(parsed.chain_id, Some(1));
1426        assert_eq!(parsed.nonce, Some(0));
1427        assert_eq!(parsed.tx_type, TxType::Transfer);
1428        assert!(parsed.recipient.is_some());
1429    }
1430
1431    #[test]
1432    fn test_parse_eip1559_with_value() {
1433        let parser = EthereumParser::new();
1434
1435        // EIP-1559 transaction with value
1436        let to_addr = Address::from([0x12; 20]);
1437        let value = U256::from(1_000_000_000_000_000_000u64); // 1 ETH
1438        let raw = encode_eip1559_tx(
1439            1,                // chain_id
1440            5,                // nonce
1441            1_000_000_000,    // max_priority_fee_per_gas
1442            100_000_000_000,  // max_fee_per_gas
1443            21000,            // gas_limit
1444            Some(to_addr),    // to
1445            value,            // value
1446            Bytes::default(), // data
1447        );
1448
1449        let result = parser.parse(&raw);
1450        assert!(result.is_ok(), "parsing failed: {result:?}");
1451
1452        let parsed = result.expect("should parse successfully");
1453
1454        assert_eq!(parsed.chain, "ethereum");
1455        assert_eq!(parsed.chain_id, Some(1));
1456        assert_eq!(parsed.nonce, Some(5));
1457        assert_eq!(parsed.tx_type, TxType::Transfer);
1458        assert_eq!(parsed.amount, Some(value));
1459    }
1460
1461    #[test]
1462    fn test_parse_eip1559_contract_deployment() {
1463        let parser = EthereumParser::new();
1464
1465        // EIP-1559 contract deployment (to=None)
1466        let raw = encode_eip1559_tx(
1467            1,                                         // chain_id
1468            0,                                         // nonce
1469            1_000_000_000,                             // max_priority_fee_per_gas
1470            2_000_000_000,                             // max_fee_per_gas
1471            100000,                                    // gas_limit
1472            None,                                      // to = None for deployment
1473            U256::ZERO,                                // value
1474            Bytes::from(vec![0x60, 0x80, 0x60, 0x40]), // data
1475        );
1476
1477        let result = parser.parse(&raw);
1478        assert!(result.is_ok(), "parsing failed: {result:?}");
1479
1480        let parsed = result.expect("should parse successfully");
1481        assert_eq!(parsed.tx_type, TxType::Deployment);
1482        assert!(parsed.recipient.is_none());
1483    }
1484
1485    #[test]
1486    fn test_parse_eip1559_contract_call() {
1487        let parser = EthereumParser::new();
1488
1489        // EIP-1559 contract call (has data)
1490        let to_addr = Address::from([0x12; 20]);
1491        let raw = encode_eip1559_tx(
1492            1,                                         // chain_id
1493            0,                                         // nonce
1494            1_000_000_000,                             // max_priority_fee_per_gas
1495            2_000_000_000,                             // max_fee_per_gas
1496            100000,                                    // gas_limit
1497            Some(to_addr),                             // to
1498            U256::ZERO,                                // value
1499            Bytes::from(vec![0xa9, 0x05, 0x9c, 0xbb]), // data (transfer selector)
1500        );
1501
1502        let result = parser.parse(&raw);
1503        assert!(result.is_ok(), "parsing failed: {result:?}");
1504
1505        let parsed = result.expect("should parse successfully");
1506        assert_eq!(parsed.tx_type, TxType::ContractCall);
1507        assert!(parsed.recipient.is_some());
1508    }
1509
1510    // ------------------------------------------------------------------------
1511    // Hash Computation Tests
1512    // ------------------------------------------------------------------------
1513
1514    #[test]
1515    fn test_hash_is_correctly_computed() {
1516        let parser = EthereumParser::new();
1517
1518        let raw = hex::decode(
1519            "f86c098504a817c800825208943535353535353535353535353535353535353535880de0b6b3a76400008025a028ef61340bd939bc2195fe537567866003e1a15d3c71ff63e1590620aa636276a067cbe9d8997f761aecb703304b3800ccf555c9f3dc64214b297fb1966a3b6d83"
1520        ).expect("valid hex");
1521
1522        let result = parser.parse(&raw);
1523        assert!(result.is_ok());
1524
1525        let parsed = result.expect("should parse");
1526
1527        // Compute expected hash
1528        let expected_hash = keccak256(&raw);
1529
1530        assert_eq!(parsed.hash, *expected_hash);
1531    }
1532
1533    #[test]
1534    fn test_eip1559_hash_includes_type_prefix() {
1535        let parser = EthereumParser::new();
1536
1537        let to_addr = Address::from([0x12; 20]);
1538        let raw = encode_eip1559_tx(
1539            1,                // chain_id
1540            0,                // nonce
1541            1_000_000_000,    // max_priority_fee_per_gas
1542            2_000_000_000,    // max_fee_per_gas
1543            21000,            // gas_limit
1544            Some(to_addr),    // to
1545            U256::ZERO,       // value
1546            Bytes::default(), // data
1547        );
1548
1549        let result = parser.parse(&raw);
1550        assert!(result.is_ok());
1551
1552        let parsed = result.expect("should parse");
1553
1554        // Hash should be of the entire raw bytes (including type prefix)
1555        let expected_hash = keccak256(&raw);
1556        assert_eq!(parsed.hash, *expected_hash);
1557    }
1558
1559    #[test]
1560    fn test_type0_hash_includes_type_prefix() {
1561        let parser = EthereumParser::new();
1562
1563        // Create a legacy transaction and then prefix it with 0x00 (type 0)
1564        let to_addr = Address::from([0x12; 20]);
1565        let legacy_raw = encode_legacy_tx(
1566            0,                // nonce
1567            1_000_000_000,    // gas_price (1 gwei)
1568            21000,            // gas_limit
1569            Some(to_addr),    // to
1570            U256::ZERO,       // value
1571            Bytes::default(), // data
1572            Some(1),          // chain_id
1573        );
1574
1575        // Create type 0 transaction by prefixing with 0x00
1576        let mut type0_raw = vec![0x00];
1577        type0_raw.extend_from_slice(&legacy_raw);
1578
1579        let result = parser.parse(&type0_raw);
1580        assert!(result.is_ok(), "parsing failed: {result:?}");
1581
1582        let parsed = result.expect("should parse");
1583
1584        // Hash MUST be computed over the full raw bytes INCLUDING the type prefix
1585        let expected_hash = keccak256(&type0_raw);
1586        assert_eq!(
1587            parsed.hash, *expected_hash,
1588            "type 0 hash should include type prefix"
1589        );
1590
1591        // Verify it's NOT the hash of just the payload (without type byte)
1592        let wrong_hash = keccak256(&legacy_raw);
1593        assert_ne!(
1594            parsed.hash, *wrong_hash,
1595            "hash should NOT be computed without type prefix"
1596        );
1597    }
1598
1599    #[test]
1600    fn test_type0_vs_legacy_same_content_different_hash() {
1601        let parser = EthereumParser::new();
1602
1603        // Create a legacy transaction
1604        let to_addr = Address::from([0x12; 20]);
1605        let legacy_raw = encode_legacy_tx(
1606            5,                                        // nonce
1607            2_000_000_000,                            // gas_price (2 gwei)
1608            21000,                                    // gas_limit
1609            Some(to_addr),                            // to
1610            U256::from(1_000_000_000_000_000_000u64), // 1 ETH
1611            Bytes::default(),                         // data
1612            Some(1),                                  // chain_id
1613        );
1614
1615        // Parse as pure legacy (no type prefix)
1616        let legacy_result = parser.parse(&legacy_raw);
1617        assert!(legacy_result.is_ok());
1618        let legacy_parsed = legacy_result.expect("should parse legacy");
1619
1620        // Create type 0 version (same content, with 0x00 prefix)
1621        let mut type0_raw = vec![0x00];
1622        type0_raw.extend_from_slice(&legacy_raw);
1623
1624        // Parse as type 0
1625        let type0_result = parser.parse(&type0_raw);
1626        assert!(type0_result.is_ok());
1627        let type0_parsed = type0_result.expect("should parse type 0");
1628
1629        // Both should have the same transaction data
1630        assert_eq!(legacy_parsed.nonce, type0_parsed.nonce);
1631        assert_eq!(legacy_parsed.recipient, type0_parsed.recipient);
1632        assert_eq!(legacy_parsed.amount, type0_parsed.amount);
1633        assert_eq!(legacy_parsed.chain_id, type0_parsed.chain_id);
1634
1635        // But the hashes MUST be different because type 0 includes the prefix
1636        assert_ne!(
1637            legacy_parsed.hash, type0_parsed.hash,
1638            "legacy and type 0 hashes should differ due to type prefix"
1639        );
1640    }
1641
1642    // ------------------------------------------------------------------------
1643    // Error Handling Tests
1644    // ------------------------------------------------------------------------
1645
1646    #[test]
1647    fn test_parse_unsupported_tx_type() {
1648        let parser = EthereumParser::new();
1649
1650        // Type 3 (EIP-4844 blob tx) - not supported
1651        let raw = hex::decode("03f8c0").expect("valid hex");
1652
1653        let result = parser.parse(&raw);
1654        assert!(result.is_err());
1655        assert!(matches!(result, Err(ParseError::UnknownTxType)));
1656    }
1657
1658    #[test]
1659    fn test_parse_malformed_legacy_too_few_items() {
1660        let parser = EthereumParser::new();
1661
1662        // List with only 3 items instead of 9
1663        let raw = hex::decode("c3010203").expect("valid hex");
1664
1665        let result = parser.parse(&raw);
1666        assert!(result.is_err());
1667        assert!(matches!(
1668            result,
1669            Err(ParseError::MalformedTransaction { .. })
1670        ));
1671    }
1672
1673    #[test]
1674    fn test_parse_invalid_rlp() {
1675        let parser = EthereumParser::new();
1676
1677        // Invalid RLP (claims to be a list but truncated)
1678        let raw = hex::decode("f8ff").expect("valid hex");
1679
1680        let result = parser.parse(&raw);
1681        assert!(result.is_err());
1682    }
1683
1684    #[test]
1685    fn test_parse_not_list_not_typed() {
1686        let parser = EthereumParser::new();
1687
1688        // Single byte that's not a valid type prefix (0x04-0xbf range)
1689        // and not an RLP list (0xc0+)
1690        let raw = hex::decode("80").expect("valid hex");
1691
1692        let result = parser.parse(&raw);
1693        assert!(result.is_err());
1694        assert!(matches!(
1695            result,
1696            Err(ParseError::MalformedTransaction { .. })
1697        ));
1698    }
1699
1700    // ------------------------------------------------------------------------
1701    // Different Chain IDs Tests
1702    // ------------------------------------------------------------------------
1703
1704    #[test]
1705    fn test_parse_eip1559_polygon() {
1706        let parser = EthereumParser::new();
1707
1708        // EIP-1559 on Polygon (chainId=137)
1709        let to_addr = Address::from([0x12; 20]);
1710        let raw = encode_eip1559_tx(
1711            137,              // chain_id (Polygon)
1712            0,                // nonce
1713            1_000_000_000,    // max_priority_fee_per_gas
1714            2_000_000_000,    // max_fee_per_gas
1715            21000,            // gas_limit
1716            Some(to_addr),    // to
1717            U256::ZERO,       // value
1718            Bytes::default(), // data
1719        );
1720
1721        let result = parser.parse(&raw);
1722        assert!(result.is_ok(), "parsing failed: {result:?}");
1723
1724        let parsed = result.expect("should parse");
1725        assert_eq!(parsed.chain_id, Some(137));
1726    }
1727
1728    #[test]
1729    fn test_parse_legacy_with_high_chain_id() {
1730        let parser = EthereumParser::new();
1731
1732        // Legacy transaction with BSC chain_id (56)
1733        let to_addr = Address::from([0x12; 20]);
1734        let raw = encode_legacy_tx(
1735            9,                                        // nonce
1736            20_000_000_000,                           // gas_price (20 gwei)
1737            21000,                                    // gas_limit
1738            Some(to_addr),                            // to
1739            U256::from(1_000_000_000_000_000_000u64), // value (1 ETH equivalent)
1740            Bytes::default(),                         // data
1741            Some(56),                                 // chain_id (BSC)
1742        );
1743
1744        let result = parser.parse(&raw);
1745        assert!(result.is_ok(), "parsing failed: {result:?}");
1746
1747        let parsed = result.expect("should parse");
1748        assert_eq!(parsed.chain_id, Some(56));
1749    }
1750
1751    #[test]
1752    fn test_parse_unsigned_eip155_with_high_chain_id() {
1753        // Regression test: unsigned EIP-155 tx with high chain_id (Base Sepolia = 84532)
1754        // should be correctly identified as unsigned and the chain_id extracted directly
1755        // (not halved via the signed EIP-155 formula).
1756        let parser = EthereumParser::new();
1757        let recipient = Address::from([0x42; 20]);
1758        let chain_id: u64 = 84532;
1759
1760        let tx = TxLegacy {
1761            chain_id: Some(chain_id),
1762            nonce: 5,
1763            gas_price: 1_000_000_000,
1764            gas_limit: 21000,
1765            to: TxKind::Call(recipient),
1766            value: U256::from(1_000_000u64),
1767            input: Bytes::new(),
1768        };
1769
1770        // Encode as unsigned (RLP without signature = [nonce, gasPrice, gas, to, value, data, chainId, 0, 0])
1771        let mut unsigned_raw = Vec::new();
1772        alloy_rlp::Encodable::encode(&tx, &mut unsigned_raw);
1773
1774        let result = parser.parse(&unsigned_raw);
1775        assert!(
1776            result.is_ok(),
1777            "parsing unsigned EIP-155 failed: {result:?}"
1778        );
1779
1780        let parsed = result.expect("should parse");
1781        assert_eq!(
1782            parsed.chain_id,
1783            Some(chain_id),
1784            "chain_id should be {chain_id}, not halved"
1785        );
1786        // Verify it's marked as unsigned
1787        assert_eq!(
1788            parsed.metadata.get("unsigned"),
1789            Some(&serde_json::Value::Bool(true)),
1790            "unsigned EIP-155 tx should be flagged as unsigned"
1791        );
1792    }
1793
1794    #[test]
1795    fn test_parse_unsigned_eip155_with_chain_id_1() {
1796        // Edge case: unsigned EIP-155 with chain_id=1 (mainnet).
1797        // chain_id=1 is below 35, so the old code would fall through to the
1798        // catch-all branch. With the fix, r=0/s=0 detection should handle it.
1799        let parser = EthereumParser::new();
1800        let recipient = Address::from([0xAB; 20]);
1801
1802        let tx = TxLegacy {
1803            chain_id: Some(1),
1804            nonce: 0,
1805            gas_price: 20_000_000_000,
1806            gas_limit: 21000,
1807            to: TxKind::Call(recipient),
1808            value: U256::from(1_000_000_000_000_000_000u64),
1809            input: Bytes::new(),
1810        };
1811
1812        let mut unsigned_raw = Vec::new();
1813        alloy_rlp::Encodable::encode(&tx, &mut unsigned_raw);
1814
1815        let result = parser.parse(&unsigned_raw);
1816        assert!(
1817            result.is_ok(),
1818            "parsing unsigned EIP-155 chain_id=1 failed: {result:?}"
1819        );
1820
1821        let parsed = result.expect("should parse");
1822        assert_eq!(parsed.chain_id, Some(1));
1823        assert_eq!(
1824            parsed.metadata.get("unsigned"),
1825            Some(&serde_json::Value::Bool(true)),
1826        );
1827    }
1828
1829    // ------------------------------------------------------------------------
1830    // Thread Safety Tests
1831    // ------------------------------------------------------------------------
1832
1833    #[test]
1834    fn test_parser_is_send() {
1835        fn assert_send<T: Send>() {}
1836        assert_send::<EthereumParser>();
1837    }
1838
1839    #[test]
1840    fn test_parser_is_sync() {
1841        fn assert_sync<T: Sync>() {}
1842        assert_sync::<EthereumParser>();
1843    }
1844
1845    // ------------------------------------------------------------------------
1846    // Integration with Chain Registry
1847    // ------------------------------------------------------------------------
1848
1849    #[test]
1850    fn test_parser_as_trait_object() {
1851        let parser = EthereumParser::new();
1852        let chain: Box<dyn Chain> = Box::new(parser);
1853
1854        assert_eq!(chain.id(), "ethereum");
1855        assert_eq!(chain.curve(), CurveType::Secp256k1);
1856        assert!(chain.supports_version(0));
1857        assert!(chain.supports_version(1));
1858        assert!(chain.supports_version(2));
1859    }
1860
1861    // ------------------------------------------------------------------------
1862    // ERC-20 Detection Integration Tests
1863    // ------------------------------------------------------------------------
1864
1865    /// Helper to create ERC-20 transfer calldata
1866    fn erc20_transfer_calldata(to: Address, amount: U256) -> Bytes {
1867        let mut data = vec![0xa9, 0x05, 0x9c, 0xbb]; // transfer selector
1868                                                     // Address (32 bytes, left-padded)
1869        data.extend_from_slice(&[0u8; 12]);
1870        data.extend_from_slice(to.as_slice());
1871        // Amount (32 bytes, big-endian)
1872        data.extend_from_slice(&amount.to_be_bytes::<32>());
1873        Bytes::from(data)
1874    }
1875
1876    /// Helper to create ERC-20 approve calldata
1877    fn erc20_approve_calldata(spender: Address, amount: U256) -> Bytes {
1878        let mut data = vec![0x09, 0x5e, 0xa7, 0xb3]; // approve selector
1879                                                     // Spender address (32 bytes, left-padded)
1880        data.extend_from_slice(&[0u8; 12]);
1881        data.extend_from_slice(spender.as_slice());
1882        // Amount (32 bytes, big-endian)
1883        data.extend_from_slice(&amount.to_be_bytes::<32>());
1884        Bytes::from(data)
1885    }
1886
1887    /// Helper to create ERC-20 transferFrom calldata
1888    fn erc20_transfer_from_calldata(from: Address, to: Address, amount: U256) -> Bytes {
1889        let mut data = vec![0x23, 0xb8, 0x72, 0xdd]; // transferFrom selector
1890                                                     // From address (32 bytes, left-padded)
1891        data.extend_from_slice(&[0u8; 12]);
1892        data.extend_from_slice(from.as_slice());
1893        // To address (32 bytes, left-padded)
1894        data.extend_from_slice(&[0u8; 12]);
1895        data.extend_from_slice(to.as_slice());
1896        // Amount (32 bytes, big-endian)
1897        data.extend_from_slice(&amount.to_be_bytes::<32>());
1898        Bytes::from(data)
1899    }
1900
1901    #[test]
1902    fn test_erc20_transfer_detection_eip1559() {
1903        let parser = EthereumParser::new();
1904
1905        let token_contract = Address::from([0xaa; 20]); // Token contract address
1906        let recipient = Address::from([0xbb; 20]); // Actual recipient
1907        let token_amount = U256::from(1_000_000u64); // 1 USDC (6 decimals)
1908
1909        let calldata = erc20_transfer_calldata(recipient, token_amount);
1910
1911        let raw = encode_eip1559_tx(
1912            1,                    // chain_id
1913            0,                    // nonce
1914            1_000_000_000,        // max_priority_fee_per_gas
1915            2_000_000_000,        // max_fee_per_gas
1916            100_000,              // gas_limit
1917            Some(token_contract), // to (token contract)
1918            U256::ZERO,           // value (no ETH sent)
1919            calldata,             // data (ERC-20 transfer)
1920        );
1921
1922        let result = parser.parse(&raw);
1923        assert!(result.is_ok(), "parsing failed: {result:?}");
1924
1925        let parsed = result.expect("should parse");
1926
1927        // Should be detected as TokenTransfer
1928        assert_eq!(parsed.tx_type, TxType::TokenTransfer);
1929
1930        // Recipient should be the actual token recipient, not the contract
1931        assert_eq!(parsed.recipient, Some(format!("{recipient}")));
1932
1933        // Amount should be the token amount
1934        assert_eq!(parsed.amount, Some(token_amount));
1935
1936        // Token address should be set
1937        assert_eq!(parsed.token_address, Some(format!("{token_contract}")));
1938    }
1939
1940    #[test]
1941    fn test_erc20_approve_detection_eip1559() {
1942        let parser = EthereumParser::new();
1943
1944        let token_contract = Address::from([0xaa; 20]);
1945        let spender = Address::from([0xcc; 20]); // Spender (e.g., DEX router)
1946        let approval_amount = U256::MAX; // Unlimited approval
1947
1948        let calldata = erc20_approve_calldata(spender, approval_amount);
1949
1950        let raw = encode_eip1559_tx(
1951            1,                    // chain_id
1952            1,                    // nonce
1953            1_000_000_000,        // max_priority_fee_per_gas
1954            2_000_000_000,        // max_fee_per_gas
1955            60_000,               // gas_limit
1956            Some(token_contract), // to (token contract)
1957            U256::ZERO,           // value
1958            calldata,             // data (ERC-20 approve)
1959        );
1960
1961        let result = parser.parse(&raw);
1962        assert!(result.is_ok(), "parsing failed: {result:?}");
1963
1964        let parsed = result.expect("should parse");
1965
1966        // Should be detected as TokenApproval
1967        assert_eq!(parsed.tx_type, TxType::TokenApproval);
1968
1969        // Recipient should be the spender
1970        assert_eq!(parsed.recipient, Some(format!("{spender}")));
1971
1972        // Amount should be the approval amount
1973        assert_eq!(parsed.amount, Some(approval_amount));
1974
1975        // Token address should be set
1976        assert_eq!(parsed.token_address, Some(format!("{token_contract}")));
1977    }
1978
1979    #[test]
1980    fn test_erc20_transfer_from_detection_eip1559() {
1981        let parser = EthereumParser::new();
1982
1983        let token_contract = Address::from([0xaa; 20]);
1984        let from_addr = Address::from([0xdd; 20]); // Token owner
1985        let to_addr = Address::from([0xee; 20]); // Token recipient
1986        let token_amount = U256::from(500_000_000_000_000_000u64); // 0.5 tokens (18 decimals)
1987
1988        let calldata = erc20_transfer_from_calldata(from_addr, to_addr, token_amount);
1989
1990        let raw = encode_eip1559_tx(
1991            1,                    // chain_id
1992            2,                    // nonce
1993            1_000_000_000,        // max_priority_fee_per_gas
1994            2_000_000_000,        // max_fee_per_gas
1995            100_000,              // gas_limit
1996            Some(token_contract), // to (token contract)
1997            U256::ZERO,           // value
1998            calldata,             // data (ERC-20 transferFrom)
1999        );
2000
2001        let result = parser.parse(&raw);
2002        assert!(result.is_ok(), "parsing failed: {result:?}");
2003
2004        let parsed = result.expect("should parse");
2005
2006        // Should be detected as TokenTransfer
2007        assert_eq!(parsed.tx_type, TxType::TokenTransfer);
2008
2009        // Recipient should be the actual token recipient (to_addr)
2010        assert_eq!(parsed.recipient, Some(format!("{to_addr}")));
2011
2012        // Amount should be the token amount
2013        assert_eq!(parsed.amount, Some(token_amount));
2014
2015        // Token address should be set
2016        assert_eq!(parsed.token_address, Some(format!("{token_contract}")));
2017    }
2018
2019    #[test]
2020    fn test_erc20_transfer_detection_legacy() {
2021        let parser = EthereumParser::new();
2022
2023        let token_contract = Address::from([0xaa; 20]);
2024        let recipient = Address::from([0xbb; 20]);
2025        let token_amount = U256::from(2_000_000u64);
2026
2027        let calldata = erc20_transfer_calldata(recipient, token_amount);
2028
2029        let raw = encode_legacy_tx(
2030            5,                    // nonce
2031            20_000_000_000,       // gas_price (20 gwei)
2032            100_000,              // gas_limit
2033            Some(token_contract), // to (token contract)
2034            U256::ZERO,           // value
2035            calldata,             // data
2036            Some(1),              // chain_id
2037        );
2038
2039        let result = parser.parse(&raw);
2040        assert!(result.is_ok(), "parsing failed: {result:?}");
2041
2042        let parsed = result.expect("should parse");
2043
2044        assert_eq!(parsed.tx_type, TxType::TokenTransfer);
2045        assert_eq!(parsed.recipient, Some(format!("{recipient}")));
2046        assert_eq!(parsed.amount, Some(token_amount));
2047        assert_eq!(parsed.token_address, Some(format!("{token_contract}")));
2048    }
2049
2050    #[test]
2051    fn test_erc20_detection_eip2930() {
2052        let parser = EthereumParser::new();
2053
2054        let token_contract = Address::from([0xaa; 20]);
2055        let spender = Address::from([0xcc; 20]);
2056        let approval_amount = U256::from(1_000_000_000_000u64);
2057
2058        let calldata = erc20_approve_calldata(spender, approval_amount);
2059
2060        let raw = encode_eip2930_tx(
2061            1,                    // chain_id
2062            3,                    // nonce
2063            10_000_000_000,       // gas_price
2064            80_000,               // gas_limit
2065            Some(token_contract), // to (token contract)
2066            U256::ZERO,           // value
2067            calldata,             // data
2068        );
2069
2070        let result = parser.parse(&raw);
2071        assert!(result.is_ok(), "parsing failed: {result:?}");
2072
2073        let parsed = result.expect("should parse");
2074
2075        assert_eq!(parsed.tx_type, TxType::TokenApproval);
2076        assert_eq!(parsed.recipient, Some(format!("{spender}")));
2077        assert_eq!(parsed.amount, Some(approval_amount));
2078        assert_eq!(parsed.token_address, Some(format!("{token_contract}")));
2079    }
2080
2081    #[test]
2082    fn test_non_erc20_contract_call_unchanged() {
2083        let parser = EthereumParser::new();
2084
2085        let contract = Address::from([0x12; 20]);
2086        // Unknown function selector (not ERC-20)
2087        let calldata = Bytes::from(vec![0x12, 0x34, 0x56, 0x78, 0xab, 0xcd, 0xef, 0x00]);
2088
2089        let raw = encode_eip1559_tx(
2090            1,              // chain_id
2091            0,              // nonce
2092            1_000_000_000,  // max_priority_fee_per_gas
2093            2_000_000_000,  // max_fee_per_gas
2094            100_000,        // gas_limit
2095            Some(contract), // to
2096            U256::ZERO,     // value
2097            calldata,       // data (not ERC-20)
2098        );
2099
2100        let result = parser.parse(&raw);
2101        assert!(result.is_ok(), "parsing failed: {result:?}");
2102
2103        let parsed = result.expect("should parse");
2104
2105        // Should be a generic ContractCall
2106        assert_eq!(parsed.tx_type, TxType::ContractCall);
2107
2108        // Recipient should be the contract address
2109        assert_eq!(parsed.recipient, Some(format!("{contract}")));
2110
2111        // Token address should NOT be set
2112        assert!(parsed.token_address.is_none());
2113    }
2114
2115    #[test]
2116    fn test_simple_eth_transfer_unchanged() {
2117        let parser = EthereumParser::new();
2118
2119        let recipient = Address::from([0x12; 20]);
2120        let eth_amount = U256::from(1_000_000_000_000_000_000u64); // 1 ETH
2121
2122        let raw = encode_eip1559_tx(
2123            1,                // chain_id
2124            0,                // nonce
2125            1_000_000_000,    // max_priority_fee_per_gas
2126            2_000_000_000,    // max_fee_per_gas
2127            21_000,           // gas_limit
2128            Some(recipient),  // to
2129            eth_amount,       // value
2130            Bytes::default(), // data (empty)
2131        );
2132
2133        let result = parser.parse(&raw);
2134        assert!(result.is_ok(), "parsing failed: {result:?}");
2135
2136        let parsed = result.expect("should parse");
2137
2138        // Should be a simple Transfer
2139        assert_eq!(parsed.tx_type, TxType::Transfer);
2140
2141        // Recipient should be the ETH recipient
2142        assert_eq!(parsed.recipient, Some(format!("{recipient}")));
2143
2144        // Amount should be the ETH amount
2145        assert_eq!(parsed.amount, Some(eth_amount));
2146
2147        // Token address should NOT be set (native transfer)
2148        assert!(parsed.token_address.is_none());
2149    }
2150
2151    #[test]
2152    fn test_erc20_with_eth_value() {
2153        // Some exotic cases might send ETH value along with ERC-20 call
2154        // (e.g., WETH deposit with data, or payable token functions)
2155        let parser = EthereumParser::new();
2156
2157        let token_contract = Address::from([0xaa; 20]);
2158        let recipient = Address::from([0xbb; 20]);
2159        let token_amount = U256::from(1_000_000u64);
2160        let eth_value = U256::from(100_000_000_000_000_000u64); // 0.1 ETH
2161
2162        let calldata = erc20_transfer_calldata(recipient, token_amount);
2163
2164        let raw = encode_eip1559_tx(
2165            1,                    // chain_id
2166            0,                    // nonce
2167            1_000_000_000,        // max_priority_fee_per_gas
2168            2_000_000_000,        // max_fee_per_gas
2169            100_000,              // gas_limit
2170            Some(token_contract), // to (token contract)
2171            eth_value,            // value (some ETH too!)
2172            calldata,             // data (ERC-20 transfer)
2173        );
2174
2175        let result = parser.parse(&raw);
2176        assert!(result.is_ok(), "parsing failed: {result:?}");
2177
2178        let parsed = result.expect("should parse");
2179
2180        // Should still detect as TokenTransfer
2181        assert_eq!(parsed.tx_type, TxType::TokenTransfer);
2182
2183        // Amount should be the token amount (not ETH value)
2184        assert_eq!(parsed.amount, Some(token_amount));
2185
2186        // Token address should be set
2187        assert!(parsed.token_address.is_some());
2188    }
2189
2190    #[test]
2191    fn test_erc20_zero_amount() {
2192        let parser = EthereumParser::new();
2193
2194        let token_contract = Address::from([0xaa; 20]);
2195        let recipient = Address::from([0xbb; 20]);
2196        let token_amount = U256::ZERO;
2197
2198        let calldata = erc20_transfer_calldata(recipient, token_amount);
2199
2200        let raw = encode_eip1559_tx(
2201            1,                    // chain_id
2202            0,                    // nonce
2203            1_000_000_000,        // max_priority_fee_per_gas
2204            2_000_000_000,        // max_fee_per_gas
2205            60_000,               // gas_limit
2206            Some(token_contract), // to
2207            U256::ZERO,           // value
2208            calldata,             // data
2209        );
2210
2211        let result = parser.parse(&raw);
2212        assert!(result.is_ok(), "parsing failed: {result:?}");
2213
2214        let parsed = result.expect("should parse");
2215
2216        // Should still be detected as TokenTransfer
2217        assert_eq!(parsed.tx_type, TxType::TokenTransfer);
2218        assert_eq!(parsed.amount, Some(U256::ZERO));
2219    }
2220
2221    #[test]
2222    fn test_erc20_approve_zero_revoke() {
2223        // Approve with zero amount is a "revoke" pattern
2224        let parser = EthereumParser::new();
2225
2226        let token_contract = Address::from([0xaa; 20]);
2227        let spender = Address::from([0xcc; 20]);
2228        let approval_amount = U256::ZERO; // Revoke approval
2229
2230        let calldata = erc20_approve_calldata(spender, approval_amount);
2231
2232        let raw = encode_eip1559_tx(
2233            1,                    // chain_id
2234            0,                    // nonce
2235            1_000_000_000,        // max_priority_fee_per_gas
2236            2_000_000_000,        // max_fee_per_gas
2237            50_000,               // gas_limit
2238            Some(token_contract), // to
2239            U256::ZERO,           // value
2240            calldata,             // data
2241        );
2242
2243        let result = parser.parse(&raw);
2244        assert!(result.is_ok(), "parsing failed: {result:?}");
2245
2246        let parsed = result.expect("should parse");
2247
2248        // Should still be TokenApproval
2249        assert_eq!(parsed.tx_type, TxType::TokenApproval);
2250        assert_eq!(parsed.amount, Some(U256::ZERO));
2251    }
2252
2253    // ------------------------------------------------------------------------
2254    // Missing Field Error Tests
2255    // ------------------------------------------------------------------------
2256
2257    #[test]
2258    fn test_legacy_tx_truncated_data() {
2259        // Arrange: Create a truncated legacy transaction by encoding normally then truncating
2260        let parser = EthereumParser::new();
2261
2262        // First create a valid legacy transaction
2263        let valid_raw = encode_legacy_tx(
2264            9,
2265            20_000_000_000,
2266            21000,
2267            Some(Address::from([0x35; 20])),
2268            U256::from(1_000_000_000_000_000_000u64),
2269            Bytes::new(),
2270            Some(1),
2271        );
2272
2273        // Truncate it to simulate missing fields
2274        let truncated = &valid_raw[..valid_raw.len() / 2];
2275
2276        // Act
2277        let result = parser.parse(truncated);
2278
2279        // Assert: Should fail with InvalidRlp or MalformedTransaction
2280        assert!(result.is_err());
2281        assert!(matches!(
2282            result,
2283            Err(ParseError::InvalidRlp { .. } | ParseError::MalformedTransaction { .. })
2284        ));
2285    }
2286
2287    #[test]
2288    fn test_eip1559_tx_truncated_data() {
2289        // Arrange: Create a truncated EIP-1559 transaction
2290        let parser = EthereumParser::new();
2291
2292        // First create a valid EIP-1559 transaction
2293        let valid_raw = encode_eip1559_tx(
2294            1,                               // chain_id
2295            0,                               // nonce
2296            1_000_000_000,                   // max_priority_fee_per_gas
2297            2_000_000_000,                   // max_fee_per_gas
2298            21000,                           // gas_limit
2299            Some(Address::from([0x35; 20])), // to
2300            U256::ZERO,                      // value
2301            Bytes::new(),                    // data
2302        );
2303
2304        // Truncate it to simulate missing fields
2305        let truncated = &valid_raw[..valid_raw.len() / 2];
2306
2307        // Act
2308        let result = parser.parse(truncated);
2309
2310        // Assert: Should fail with InvalidRlp or MalformedTransaction
2311        assert!(result.is_err());
2312        assert!(matches!(
2313            result,
2314            Err(ParseError::InvalidRlp { .. } | ParseError::MalformedTransaction { .. })
2315        ));
2316    }
2317
2318    #[test]
2319    fn test_eip2930_tx_truncated_data() {
2320        // Arrange: Create a truncated EIP-2930 transaction
2321        let parser = EthereumParser::new();
2322
2323        // First create a valid EIP-2930 transaction
2324        let valid_raw = encode_eip2930_tx(
2325            1,                               // chain_id
2326            0,                               // nonce
2327            1_000_000_000,                   // gas_price
2328            21000,                           // gas_limit
2329            Some(Address::from([0x35; 20])), // to
2330            U256::ZERO,                      // value
2331            Bytes::new(),                    // data
2332        );
2333
2334        // Truncate it to simulate missing fields
2335        let truncated = &valid_raw[..valid_raw.len() / 2];
2336
2337        // Act
2338        let result = parser.parse(truncated);
2339
2340        // Assert: Should fail with InvalidRlp or MalformedTransaction
2341        assert!(result.is_err());
2342        assert!(matches!(
2343            result,
2344            Err(ParseError::InvalidRlp { .. } | ParseError::MalformedTransaction { .. })
2345        ));
2346    }
2347
2348    #[test]
2349    fn test_legacy_tx_invalid_rlp_structure() {
2350        // Arrange: Create invalid RLP data that doesn't represent a valid transaction
2351        let parser = EthereumParser::new();
2352
2353        // Invalid RLP: claim to be a long list but provide insufficient data
2354        let invalid_rlp = vec![0xf8, 0xff, 0x01, 0x02, 0x03];
2355
2356        // Act
2357        let result = parser.parse(&invalid_rlp);
2358
2359        // Assert: Should fail with InvalidRlp or MalformedTransaction
2360        assert!(result.is_err());
2361        assert!(matches!(
2362            result,
2363            Err(ParseError::InvalidRlp { .. } | ParseError::MalformedTransaction { .. })
2364        ));
2365    }
2366
2367    #[test]
2368    fn test_eip1559_tx_invalid_rlp_structure() {
2369        // Arrange: Create invalid EIP-1559 transaction with malformed RLP
2370        let parser = EthereumParser::new();
2371
2372        // Type 2 transaction with invalid RLP payload
2373        let invalid = vec![0x02, 0xf8, 0xff, 0x01, 0x02, 0x03];
2374
2375        // Act
2376        let result = parser.parse(&invalid);
2377
2378        // Assert: Should fail with InvalidRlp or MalformedTransaction
2379        assert!(result.is_err());
2380        assert!(matches!(
2381            result,
2382            Err(ParseError::InvalidRlp { .. } | ParseError::MalformedTransaction { .. })
2383        ));
2384    }
2385
2386    #[test]
2387    fn test_analyze_erc20_returns_none_for_non_erc20() {
2388        // This test exercises the None return path in analyze_erc20
2389        // by providing invalid ERC-20 calldata
2390        let parser = EthereumParser::new();
2391
2392        let contract = Address::from([0xaa; 20]);
2393        // Calldata with unknown selector (not ERC-20)
2394        let invalid_calldata = Bytes::from(vec![0x12, 0x34, 0x56, 0x78]);
2395
2396        let raw = encode_eip1559_tx(
2397            1,                // chain_id
2398            0,                // nonce
2399            1_000_000_000,    // max_priority_fee_per_gas
2400            2_000_000_000,    // max_fee_per_gas
2401            100_000,          // gas_limit
2402            Some(contract),   // to
2403            U256::ZERO,       // value
2404            invalid_calldata, // data (invalid ERC-20)
2405        );
2406
2407        let result = parser.parse(&raw);
2408        assert!(result.is_ok());
2409
2410        let parsed = result.unwrap();
2411
2412        // Should be ContractCall, not TokenTransfer/TokenApproval
2413        assert_eq!(parsed.tx_type, TxType::ContractCall);
2414        // Token address should NOT be set
2415        assert!(parsed.token_address.is_none());
2416    }
2417
2418    // ------------------------------------------------------------------------
2419    // Unsigned Transaction Parsing Tests
2420    // ------------------------------------------------------------------------
2421
2422    /// Helper to encode an unsigned EIP-1559 tx (9 items, no signature)
2423    fn encode_unsigned_eip1559_tx(
2424        chain_id: u64,
2425        nonce: u64,
2426        max_priority_fee_per_gas: u128,
2427        max_fee_per_gas: u128,
2428        gas_limit: u64,
2429        to: Option<Address>,
2430        value: U256,
2431        data: Bytes,
2432    ) -> Vec<u8> {
2433        use alloy_consensus::TxEip1559;
2434        use alloy_rlp::Encodable;
2435
2436        let tx = TxEip1559 {
2437            chain_id,
2438            nonce,
2439            max_priority_fee_per_gas,
2440            max_fee_per_gas,
2441            gas_limit,
2442            to: to.map_or(TxKind::Create, TxKind::Call),
2443            value,
2444            input: data,
2445            access_list: Default::default(),
2446        };
2447
2448        let mut payload = Vec::new();
2449        tx.encode(&mut payload);
2450
2451        let mut buf = vec![0x02]; // EIP-1559 type prefix
2452        buf.extend_from_slice(&payload);
2453        buf
2454    }
2455
2456    /// Helper to encode an unsigned EIP-2930 tx (8 items, no signature)
2457    fn encode_unsigned_eip2930_tx(
2458        chain_id: u64,
2459        nonce: u64,
2460        gas_price: u128,
2461        gas_limit: u64,
2462        to: Option<Address>,
2463        value: U256,
2464        data: Bytes,
2465    ) -> Vec<u8> {
2466        use alloy_consensus::TxEip2930;
2467        use alloy_rlp::Encodable;
2468
2469        let tx = TxEip2930 {
2470            chain_id,
2471            nonce,
2472            gas_price,
2473            gas_limit,
2474            to: to.map_or(TxKind::Create, TxKind::Call),
2475            value,
2476            input: data,
2477            access_list: Default::default(),
2478        };
2479
2480        let mut payload = Vec::new();
2481        tx.encode(&mut payload);
2482
2483        let mut buf = vec![0x01]; // EIP-2930 type prefix
2484        buf.extend_from_slice(&payload);
2485        buf
2486    }
2487
2488    /// Helper to encode an unsigned legacy tx (6 items, no signature)
2489    fn encode_unsigned_legacy_tx(
2490        nonce: u64,
2491        gas_price: u128,
2492        gas_limit: u64,
2493        to: Option<Address>,
2494        value: U256,
2495        data: Bytes,
2496    ) -> Vec<u8> {
2497        use alloy_consensus::TxLegacy;
2498        use alloy_rlp::Encodable;
2499
2500        let tx = TxLegacy {
2501            chain_id: None, // pre-EIP-155 unsigned: 6 items
2502            nonce,
2503            gas_price,
2504            gas_limit,
2505            to: to.map_or(TxKind::Create, TxKind::Call),
2506            value,
2507            input: data,
2508        };
2509
2510        let mut buf = Vec::new();
2511        tx.encode(&mut buf);
2512        buf
2513    }
2514
2515    #[test]
2516    fn test_parse_unsigned_eip1559() {
2517        let parser = EthereumParser::new();
2518        let recipient = Address::from(hex!("d8dA6BF26964aF9D7eEd9e03E53415D37aA96045"));
2519        let raw = encode_unsigned_eip1559_tx(
2520            1,
2521            0,
2522            1_000_000_000,
2523            2_000_000_000,
2524            21000,
2525            Some(recipient),
2526            U256::from(1_000_000_000_000_000_000u64),
2527            Bytes::new(),
2528        );
2529
2530        let result = parser.parse(&raw);
2531        assert!(
2532            result.is_ok(),
2533            "Should parse unsigned EIP-1559: {:?}",
2534            result.err()
2535        );
2536        let parsed = result.unwrap();
2537        assert_eq!(parsed.chain_id, Some(1));
2538        assert_eq!(parsed.nonce, Some(0));
2539        assert_eq!(
2540            parsed.metadata.get("unsigned"),
2541            Some(&serde_json::Value::Bool(true))
2542        );
2543    }
2544
2545    #[test]
2546    fn test_parse_unsigned_eip2930() {
2547        let parser = EthereumParser::new();
2548        let recipient = Address::from(hex!("d8dA6BF26964aF9D7eEd9e03E53415D37aA96045"));
2549        let raw = encode_unsigned_eip2930_tx(
2550            1,
2551            0,
2552            1_000_000_000,
2553            21000,
2554            Some(recipient),
2555            U256::from(1_000_000_000_000_000_000u64),
2556            Bytes::new(),
2557        );
2558
2559        let result = parser.parse(&raw);
2560        assert!(
2561            result.is_ok(),
2562            "Should parse unsigned EIP-2930: {:?}",
2563            result.err()
2564        );
2565        let parsed = result.unwrap();
2566        assert_eq!(parsed.chain_id, Some(1));
2567        assert_eq!(
2568            parsed.metadata.get("unsigned"),
2569            Some(&serde_json::Value::Bool(true))
2570        );
2571    }
2572
2573    #[test]
2574    fn test_parse_unsigned_legacy_pre_eip155() {
2575        let parser = EthereumParser::new();
2576        let recipient = Address::from(hex!("d8dA6BF26964aF9D7eEd9e03E53415D37aA96045"));
2577        let raw = encode_unsigned_legacy_tx(
2578            0,
2579            20_000_000_000,
2580            21000,
2581            Some(recipient),
2582            U256::from(1_000_000_000_000_000_000u64),
2583            Bytes::new(),
2584        );
2585
2586        let result = parser.parse(&raw);
2587        assert!(
2588            result.is_ok(),
2589            "Should parse unsigned legacy: {:?}",
2590            result.err()
2591        );
2592        let parsed = result.unwrap();
2593        assert_eq!(parsed.chain_id, Some(1)); // defaults to mainnet
2594        assert_eq!(
2595            parsed.metadata.get("unsigned"),
2596            Some(&serde_json::Value::Bool(true))
2597        );
2598    }
2599
2600    // ------------------------------------------------------------------------
2601    // assemble_signed() Tests
2602    // ------------------------------------------------------------------------
2603
2604    #[test]
2605    fn test_assemble_signed_legacy_roundtrip() {
2606        let recipient = Address::from(hex!("d8dA6BF26964aF9D7eEd9e03E53415D37aA96045"));
2607        let sig = Signature::new(
2608            U256::from(0xdeadbeef_u64),
2609            U256::from(0xcafebabe_u64),
2610            false,
2611        );
2612
2613        let tx = alloy_consensus::TxLegacy {
2614            chain_id: Some(1),
2615            nonce: 42,
2616            gas_price: 20_000_000_000,
2617            gas_limit: 21000,
2618            to: TxKind::Call(recipient),
2619            value: U256::from(1_000_000_000_000_000_000u64),
2620            input: Bytes::new(),
2621        };
2622
2623        // Encode the signed tx
2624        let mut expected = Vec::new();
2625        alloy_consensus::transaction::RlpEcdsaEncodableTx::rlp_encode_signed(
2626            &tx,
2627            &sig,
2628            &mut expected,
2629        );
2630
2631        // Now assemble from the same signed bytes (which is the input) + our signature
2632        // Build a 65-byte signature: r(32) || s(32) || v(1)
2633        let sig_bytes = sig_to_bytes(&sig);
2634
2635        let assembled = EthereumParser::assemble_signed(&expected, &sig_bytes);
2636        assert!(assembled.is_ok(), "Assembly failed: {:?}", assembled.err());
2637        assert_eq!(assembled.unwrap(), expected);
2638    }
2639
2640    #[test]
2641    fn test_assemble_signed_eip1559_roundtrip() {
2642        let recipient = Address::from(hex!("d8dA6BF26964aF9D7eEd9e03E53415D37aA96045"));
2643        let sig = Signature::new(U256::from(0xdeadbeef_u64), U256::from(0xcafebabe_u64), true);
2644
2645        let tx = alloy_consensus::TxEip1559 {
2646            chain_id: 1,
2647            nonce: 10,
2648            max_priority_fee_per_gas: 1_000_000_000,
2649            max_fee_per_gas: 2_000_000_000,
2650            gas_limit: 21000,
2651            to: TxKind::Call(recipient),
2652            value: U256::from(500_000_000_000_000_000u64),
2653            input: Bytes::new(),
2654            access_list: Default::default(),
2655        };
2656
2657        let mut expected = vec![0x02];
2658        alloy_consensus::transaction::RlpEcdsaEncodableTx::rlp_encode_signed(
2659            &tx,
2660            &sig,
2661            &mut expected,
2662        );
2663
2664        let sig_bytes = sig_to_bytes(&sig);
2665
2666        let assembled = EthereumParser::assemble_signed(&expected, &sig_bytes);
2667        assert!(assembled.is_ok(), "Assembly failed: {:?}", assembled.err());
2668        assert_eq!(assembled.unwrap(), expected);
2669    }
2670
2671    #[test]
2672    fn test_assemble_signed_eip2930_roundtrip() {
2673        let recipient = Address::from(hex!("d8dA6BF26964aF9D7eEd9e03E53415D37aA96045"));
2674        let sig = Signature::new(
2675            U256::from(0xdeadbeef_u64),
2676            U256::from(0xcafebabe_u64),
2677            false,
2678        );
2679
2680        let tx = alloy_consensus::TxEip2930 {
2681            chain_id: 1,
2682            nonce: 5,
2683            gas_price: 20_000_000_000,
2684            gas_limit: 21000,
2685            to: TxKind::Call(recipient),
2686            value: U256::from(1_000_000_000_000_000_000u64),
2687            input: Bytes::new(),
2688            access_list: Default::default(),
2689        };
2690
2691        let mut expected = vec![0x01];
2692        alloy_consensus::transaction::RlpEcdsaEncodableTx::rlp_encode_signed(
2693            &tx,
2694            &sig,
2695            &mut expected,
2696        );
2697
2698        let sig_bytes = sig_to_bytes(&sig);
2699
2700        let assembled = EthereumParser::assemble_signed(&expected, &sig_bytes);
2701        assert!(assembled.is_ok(), "Assembly failed: {:?}", assembled.err());
2702        assert_eq!(assembled.unwrap(), expected);
2703    }
2704
2705    #[test]
2706    fn test_assemble_signed_from_unsigned_eip1559() {
2707        let recipient = Address::from(hex!("d8dA6BF26964aF9D7eEd9e03E53415D37aA96045"));
2708        let sig = Signature::new(
2709            U256::from(0xdeadbeef_u64),
2710            U256::from(0xcafebabe_u64),
2711            false,
2712        );
2713
2714        let tx = alloy_consensus::TxEip1559 {
2715            chain_id: 1,
2716            nonce: 0,
2717            max_priority_fee_per_gas: 1_000_000_000,
2718            max_fee_per_gas: 2_000_000_000,
2719            gas_limit: 21000,
2720            to: TxKind::Call(recipient),
2721            value: U256::from(1_000_000_000_000_000_000u64),
2722            input: Bytes::new(),
2723            access_list: Default::default(),
2724        };
2725
2726        // Create unsigned raw bytes
2727        let unsigned_raw = encode_unsigned_eip1559_tx(
2728            1,
2729            0,
2730            1_000_000_000,
2731            2_000_000_000,
2732            21000,
2733            Some(recipient),
2734            U256::from(1_000_000_000_000_000_000u64),
2735            Bytes::new(),
2736        );
2737
2738        // Build expected signed output
2739        let mut expected = vec![0x02];
2740        alloy_consensus::transaction::RlpEcdsaEncodableTx::rlp_encode_signed(
2741            &tx,
2742            &sig,
2743            &mut expected,
2744        );
2745
2746        let sig_bytes = sig_to_bytes(&sig);
2747
2748        let assembled = EthereumParser::assemble_signed(&unsigned_raw, &sig_bytes);
2749        assert!(
2750            assembled.is_ok(),
2751            "Assembly from unsigned failed: {:?}",
2752            assembled.err()
2753        );
2754        assert_eq!(assembled.unwrap(), expected);
2755    }
2756
2757    #[test]
2758    fn test_assemble_signed_invalid_signature_length() {
2759        let raw = encode_eip1559_tx(
2760            1,
2761            0,
2762            1_000_000_000,
2763            2_000_000_000,
2764            21000,
2765            Some(Address::ZERO),
2766            U256::ZERO,
2767            Bytes::new(),
2768        );
2769
2770        let short_sig = [0u8; 32]; // too short
2771        let result = EthereumParser::assemble_signed(&raw, &short_sig);
2772        assert!(result.is_err());
2773        let err = result.unwrap_err();
2774        assert!(err.to_string().contains("expected 65-byte signature"));
2775    }
2776
2777    #[test]
2778    fn test_assemble_signed_invalid_recovery_id() {
2779        let raw = encode_eip1559_tx(
2780            1,
2781            0,
2782            1_000_000_000,
2783            2_000_000_000,
2784            21000,
2785            Some(Address::ZERO),
2786            U256::ZERO,
2787            Bytes::new(),
2788        );
2789
2790        let mut bad_sig = [0u8; 65];
2791        bad_sig[64] = 2; // invalid: must be 0 or 1
2792        let result = EthereumParser::assemble_signed(&raw, &bad_sig);
2793        assert!(result.is_err());
2794        let err = result.unwrap_err();
2795        assert!(err.to_string().contains("invalid recovery id"));
2796    }
2797
2798    #[test]
2799    fn test_assemble_signed_from_unsigned_legacy_eip155_high_chain_id() {
2800        // Regression test: unsigned EIP-155 legacy tx with a high chain_id
2801        // (Base Sepolia = 84532). Previously the code treated the raw chain_id
2802        // as a signed v value and applied (v-35)/2, halving it to 42248.
2803        let recipient = Address::from(hex!("d8dA6BF26964aF9D7eEd9e03E53415D37aA96045"));
2804        let chain_id: u64 = 84532; // Base Sepolia
2805
2806        let sig = Signature::new(
2807            U256::from_be_slice(&hex!(
2808                "f02452acc422c61ef96aa74d71c41ef272535b7bedb9a3cc923b0e5528558c6f"
2809            )),
2810            U256::from_be_slice(&hex!(
2811                "6b39d1e300572cbb0fdd4d5e4f866663bc2ad6caea0e41996033c90d8a97fedb"
2812            )),
2813            true, // recovery_id = 1
2814        );
2815
2816        // Build unsigned EIP-155 tx: [nonce, gasPrice, gas, to, value, data, chainId, 0, 0]
2817        let tx = alloy_consensus::TxLegacy {
2818            chain_id: Some(chain_id),
2819            nonce: 0,
2820            gas_price: 1_000_000_000,
2821            gas_limit: 21000,
2822            to: TxKind::Call(recipient),
2823            value: U256::ZERO,
2824            input: Bytes::new(),
2825        };
2826
2827        let mut unsigned_raw = Vec::new();
2828        alloy_rlp::Encodable::encode(&tx, &mut unsigned_raw);
2829
2830        // Build expected signed output using alloy directly
2831        let mut expected = Vec::new();
2832        alloy_consensus::transaction::RlpEcdsaEncodableTx::rlp_encode_signed(
2833            &tx,
2834            &sig,
2835            &mut expected,
2836        );
2837
2838        // Build 65-byte signature
2839        let sig_bytes = sig_to_bytes(&sig);
2840
2841        let assembled = EthereumParser::assemble_signed(&unsigned_raw, &sig_bytes);
2842        assert!(
2843            assembled.is_ok(),
2844            "Assembly from unsigned EIP-155 (chain_id={chain_id}) failed: {:?}",
2845            assembled.err()
2846        );
2847        let assembled_bytes = assembled.unwrap();
2848        assert_eq!(
2849            hex::encode(&assembled_bytes),
2850            hex::encode(&expected),
2851            "Assembled signed tx does not match expected for chain_id={chain_id}"
2852        );
2853    }
2854
2855    #[test]
2856    fn test_assemble_signed_from_unsigned_legacy_eip155_chain_id_1() {
2857        // Edge case: unsigned EIP-155 with chain_id=1 (mainnet).
2858        // items[6]=1, items[7]=0, items[8]=0. The r=0/s=0 check must
2859        // correctly identify this as unsigned and use chain_id=1 directly.
2860        let recipient = Address::from(hex!("d8dA6BF26964aF9D7eEd9e03E53415D37aA96045"));
2861        let chain_id: u64 = 1;
2862
2863        let sig = Signature::new(
2864            U256::from_be_slice(&hex!(
2865                "f02452acc422c61ef96aa74d71c41ef272535b7bedb9a3cc923b0e5528558c6f"
2866            )),
2867            U256::from_be_slice(&hex!(
2868                "6b39d1e300572cbb0fdd4d5e4f866663bc2ad6caea0e41996033c90d8a97fedb"
2869            )),
2870            true,
2871        );
2872
2873        let tx = TxLegacy {
2874            chain_id: Some(chain_id),
2875            nonce: 0,
2876            gas_price: 1_000_000_000,
2877            gas_limit: 21000,
2878            to: TxKind::Call(recipient),
2879            value: U256::ZERO,
2880            input: Bytes::new(),
2881        };
2882
2883        let mut unsigned_raw = Vec::new();
2884        alloy_rlp::Encodable::encode(&tx, &mut unsigned_raw);
2885
2886        let mut expected = Vec::new();
2887        alloy_consensus::transaction::RlpEcdsaEncodableTx::rlp_encode_signed(
2888            &tx,
2889            &sig,
2890            &mut expected,
2891        );
2892
2893        let sig_bytes = sig_to_bytes(&sig);
2894
2895        let assembled = EthereumParser::assemble_signed(&unsigned_raw, &sig_bytes);
2896        assert!(
2897            assembled.is_ok(),
2898            "Assembly from unsigned EIP-155 (chain_id={chain_id}) failed: {:?}",
2899            assembled.err()
2900        );
2901        assert_eq!(
2902            hex::encode(assembled.unwrap()),
2903            hex::encode(&expected),
2904            "Assembled signed tx does not match expected for chain_id={chain_id}"
2905        );
2906    }
2907
2908    #[test]
2909    fn test_assemble_signed_from_signed_legacy_high_chain_id() {
2910        // Verify that assemble_signed correctly handles a *signed* legacy tx
2911        // with a high chain_id. The v value will be recovery_id + chain_id*2 + 35,
2912        // so items[7] (r) and items[8] (s) will be non-zero.
2913        let recipient = Address::from(hex!("d8dA6BF26964aF9D7eEd9e03E53415D37aA96045"));
2914        let chain_id: u64 = 84532;
2915
2916        let sig = Signature::new(
2917            U256::from_be_slice(&hex!(
2918                "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
2919            )),
2920            U256::from_be_slice(&hex!(
2921                "1122334455667788112233445566778811223344556677881122334455667788"
2922            )),
2923            false, // recovery_id = 0
2924        );
2925
2926        let tx = TxLegacy {
2927            chain_id: Some(chain_id),
2928            nonce: 42,
2929            gas_price: 2_000_000_000,
2930            gas_limit: 100_000,
2931            to: TxKind::Call(recipient),
2932            value: U256::from(500_000_000_000_000_000u64),
2933            input: Bytes::new(),
2934        };
2935
2936        // Build signed tx bytes (what you'd normally broadcast)
2937        let mut signed_raw = Vec::new();
2938        alloy_consensus::transaction::RlpEcdsaEncodableTx::rlp_encode_signed(
2939            &tx,
2940            &sig,
2941            &mut signed_raw,
2942        );
2943
2944        // Also build unsigned for assembly
2945        let mut unsigned_raw = Vec::new();
2946        alloy_rlp::Encodable::encode(&tx, &mut unsigned_raw);
2947
2948        let sig_bytes = sig_to_bytes(&sig);
2949
2950        let assembled = EthereumParser::assemble_signed(&unsigned_raw, &sig_bytes);
2951        assert!(
2952            assembled.is_ok(),
2953            "Assembly from unsigned with high chain_id failed: {:?}",
2954            assembled.err()
2955        );
2956        assert_eq!(
2957            hex::encode(assembled.unwrap()),
2958            hex::encode(&signed_raw),
2959            "Assembled signed tx should match alloy's output for chain_id={chain_id}"
2960        );
2961    }
2962
2963    #[test]
2964    fn test_assemble_signed_from_unsigned_legacy_eip155_chain_id_35() {
2965        // Boundary test: chain_id=35 is the exact value where the old code's
2966        // `v >= 35` branch first triggers. Previously this would have computed
2967        // chain_id = (35-35)/2 = 0, which is wrong.
2968        let recipient = Address::from(hex!("d8dA6BF26964aF9D7eEd9e03E53415D37aA96045"));
2969        let chain_id: u64 = 35;
2970
2971        let sig = Signature::new(
2972            U256::from_be_slice(&hex!(
2973                "f02452acc422c61ef96aa74d71c41ef272535b7bedb9a3cc923b0e5528558c6f"
2974            )),
2975            U256::from_be_slice(&hex!(
2976                "6b39d1e300572cbb0fdd4d5e4f866663bc2ad6caea0e41996033c90d8a97fedb"
2977            )),
2978            false,
2979        );
2980
2981        let tx = TxLegacy {
2982            chain_id: Some(chain_id),
2983            nonce: 0,
2984            gas_price: 1_000_000_000,
2985            gas_limit: 21000,
2986            to: TxKind::Call(recipient),
2987            value: U256::ZERO,
2988            input: Bytes::new(),
2989        };
2990
2991        let mut unsigned_raw = Vec::new();
2992        alloy_rlp::Encodable::encode(&tx, &mut unsigned_raw);
2993
2994        let mut expected = Vec::new();
2995        RlpEcdsaEncodableTx::rlp_encode_signed(&tx, &sig, &mut expected);
2996
2997        let sig_bytes = sig_to_bytes(&sig);
2998
2999        let assembled = EthereumParser::assemble_signed(&unsigned_raw, &sig_bytes);
3000        assert!(
3001            assembled.is_ok(),
3002            "Assembly from unsigned EIP-155 (chain_id={chain_id}) failed: {:?}",
3003            assembled.err()
3004        );
3005        assert_eq!(
3006            hex::encode(assembled.unwrap()),
3007            hex::encode(&expected),
3008            "Assembled signed tx should match expected for chain_id={chain_id}"
3009        );
3010    }
3011
3012    #[test]
3013    fn test_parse_unsigned_eip155_chain_id_35() {
3014        // Boundary: chain_id=35 is where `v >= 35` first triggers in the old code.
3015        let parser = EthereumParser::new();
3016        let recipient = Address::from([0x42; 20]);
3017
3018        let tx = TxLegacy {
3019            chain_id: Some(35),
3020            nonce: 0,
3021            gas_price: 1_000_000_000,
3022            gas_limit: 21000,
3023            to: TxKind::Call(recipient),
3024            value: U256::ZERO,
3025            input: Bytes::new(),
3026        };
3027
3028        let mut unsigned_raw = Vec::new();
3029        alloy_rlp::Encodable::encode(&tx, &mut unsigned_raw);
3030
3031        let parsed = parser.parse(&unsigned_raw).expect("should parse");
3032        assert_eq!(parsed.chain_id, Some(35));
3033        assert_eq!(
3034            parsed.metadata.get("unsigned"),
3035            Some(&serde_json::Value::Bool(true)),
3036        );
3037    }
3038
3039    #[test]
3040    fn test_parse_unsigned_eip155_chain_id_0_is_error() {
3041        // chain_id=0 is invalid per EIP-155. Should return an error.
3042        let parser = EthereumParser::new();
3043
3044        // Manually build a 9-item RLP with items[6]=0, items[7]=0, items[8]=0.
3045        // We can't use TxLegacy with chain_id=Some(0) because alloy might not
3046        // encode it that way, so we build the RLP directly.
3047        use alloy_rlp::Encodable;
3048        let mut inner = Vec::new();
3049        0u64.encode(&mut inner); // nonce
3050        1_000_000_000u64.encode(&mut inner); // gasPrice
3051        21000u64.encode(&mut inner); // gasLimit
3052        Address::from([0x42; 20]).encode(&mut inner); // to
3053        U256::ZERO.encode(&mut inner); // value
3054        alloy_primitives::Bytes::new().encode(&mut inner); // data
3055        0u64.encode(&mut inner); // chainId = 0
3056        0u64.encode(&mut inner); // r = 0
3057        0u64.encode(&mut inner); // s = 0
3058
3059        let mut raw = Vec::new();
3060        alloy_rlp::Header {
3061            list: true,
3062            payload_length: inner.len(),
3063        }
3064        .encode(&mut raw);
3065        raw.extend_from_slice(&inner);
3066
3067        let result = parser.parse(&raw);
3068        assert!(
3069            result.is_err(),
3070            "chain_id=0 unsigned EIP-155 tx should be rejected"
3071        );
3072        assert!(result.unwrap_err().to_string().contains("chain_id=0"),);
3073    }
3074
3075    #[test]
3076    fn test_parse_signed_legacy_with_invalid_v_is_error() {
3077        // A signed tx (r != 0) with v=5 is invalid -- not pre-EIP-155 (27/28)
3078        // and not EIP-155 (>= 35). Should return an error.
3079        use alloy_rlp::Encodable;
3080        let parser = EthereumParser::new();
3081
3082        let mut inner = Vec::new();
3083        0u64.encode(&mut inner); // nonce
3084        1_000_000_000u64.encode(&mut inner); // gasPrice
3085        21000u64.encode(&mut inner); // gasLimit
3086        Address::from([0x42; 20]).encode(&mut inner); // to
3087        U256::ZERO.encode(&mut inner); // value
3088        alloy_primitives::Bytes::new().encode(&mut inner); // data
3089        5u64.encode(&mut inner); // v = 5 (invalid)
3090        0xdeadbeefu64.encode(&mut inner); // r != 0
3091        0xcafebabeu64.encode(&mut inner); // s != 0
3092
3093        let mut raw = Vec::new();
3094        alloy_rlp::Header {
3095            list: true,
3096            payload_length: inner.len(),
3097        }
3098        .encode(&mut raw);
3099        raw.extend_from_slice(&inner);
3100
3101        let result = parser.parse(&raw);
3102        assert!(
3103            result.is_err(),
3104            "signed legacy tx with v=5 should be rejected"
3105        );
3106        assert!(result.unwrap_err().to_string().contains("invalid v value"),);
3107    }
3108
3109    #[test]
3110    fn test_nine_item_legacy_with_only_r_zero_is_treated_as_signed() {
3111        // If only r=0 but s!=0, it should be treated as signed (not unsigned).
3112        // This verifies the unsigned detection requires BOTH r=0 AND s=0.
3113        use alloy_rlp::Encodable;
3114        let parser = EthereumParser::new();
3115
3116        let mut inner = Vec::new();
3117        0u64.encode(&mut inner); // nonce
3118        1_000_000_000u64.encode(&mut inner); // gasPrice
3119        21000u64.encode(&mut inner); // gasLimit
3120        Address::from([0x42; 20]).encode(&mut inner); // to
3121        U256::ZERO.encode(&mut inner); // value
3122        alloy_primitives::Bytes::new().encode(&mut inner); // data
3123        37u64.encode(&mut inner); // v = 37 (EIP-155 signed with chain_id=1)
3124        0u64.encode(&mut inner); // r = 0
3125        0xcafebabeu64.encode(&mut inner); // s != 0
3126
3127        let mut raw = Vec::new();
3128        alloy_rlp::Header {
3129            list: true,
3130            payload_length: inner.len(),
3131        }
3132        .encode(&mut raw);
3133        raw.extend_from_slice(&inner);
3134
3135        let parsed = parser.parse(&raw).expect("should parse as signed");
3136        // Not flagged as unsigned
3137        assert_eq!(parsed.metadata.get("unsigned"), None);
3138        // v=37 -> chain_id = (37 - 35) / 2 = 1
3139        assert_eq!(parsed.chain_id, Some(1));
3140    }
3141
3142    #[test]
3143    fn test_assemble_pre_eip155_signed_legacy() {
3144        // Verify that assembly from a pre-EIP-155 signed tx (v=27/28, no chain_id)
3145        // works correctly.
3146        let recipient = Address::from(hex!("d8dA6BF26964aF9D7eEd9e03E53415D37aA96045"));
3147        let sig = Signature::new(
3148            U256::from(0xdeadbeef_u64),
3149            U256::from(0xcafebabe_u64),
3150            false, // recovery_id = 0 -> v = 27
3151        );
3152
3153        let tx = TxLegacy {
3154            chain_id: None, // pre-EIP-155
3155            nonce: 0,
3156            gas_price: 1_000_000_000,
3157            gas_limit: 21000,
3158            to: TxKind::Call(recipient),
3159            value: U256::from(1_000_000u64),
3160            input: Bytes::new(),
3161        };
3162
3163        let mut expected = Vec::new();
3164        RlpEcdsaEncodableTx::rlp_encode_signed(&tx, &sig, &mut expected);
3165
3166        let sig_bytes = sig_to_bytes(&sig);
3167
3168        let assembled = EthereumParser::assemble_signed(&expected, &sig_bytes);
3169        assert!(
3170            assembled.is_ok(),
3171            "Assembly of pre-EIP-155 signed legacy failed: {:?}",
3172            assembled.err()
3173        );
3174        assert_eq!(assembled.unwrap(), expected);
3175    }
3176}