arbos/
parse_l2.rs

1use alloy_eips::eip2718::{Decodable2718, Typed2718};
2use alloy_primitives::{keccak256, Address, Bytes, B256, U256};
3use arb_primitives::{
4    signed_tx::ArbTransactionSigned,
5    tx_types::{ArbContractTx, ArbDepositTx, ArbSubmitRetryableTx, ArbUnsignedTx},
6};
7use std::io::{self, Cursor, Read};
8
9use crate::{
10    arbos_types::{
11        L1_MESSAGE_TYPE_BATCH_FOR_GAS_ESTIMATION, L1_MESSAGE_TYPE_BATCH_POSTING_REPORT,
12        L1_MESSAGE_TYPE_END_OF_BLOCK, L1_MESSAGE_TYPE_ETH_DEPOSIT, L1_MESSAGE_TYPE_INITIALIZE,
13        L1_MESSAGE_TYPE_L2_FUNDED_BY_L1, L1_MESSAGE_TYPE_L2_MESSAGE, L1_MESSAGE_TYPE_ROLLUP_EVENT,
14        L1_MESSAGE_TYPE_SUBMIT_RETRYABLE,
15    },
16    util::{
17        address_from_256_from_reader, address_from_reader, bytestring_from_reader,
18        hash_from_reader, uint256_from_reader, uint64_from_reader,
19    },
20};
21
22/// L2 message kind constants.
23pub const L2_MESSAGE_KIND_UNSIGNED_USER_TX: u8 = 0;
24pub const L2_MESSAGE_KIND_CONTRACT_TX: u8 = 1;
25pub const L2_MESSAGE_KIND_NON_MUTATING_CALL: u8 = 2;
26pub const L2_MESSAGE_KIND_BATCH: u8 = 3;
27pub const L2_MESSAGE_KIND_SIGNED_TX: u8 = 4;
28pub const L2_MESSAGE_KIND_HEARTBEAT: u8 = 6;
29pub const L2_MESSAGE_KIND_SIGNED_COMPRESSED_TX: u8 = 7;
30
31/// The ArbOS version at which heartbeat messages were disabled.
32pub const HEARTBEATS_DISABLED_AT: u64 = 6;
33
34/// Maximum size of an L2 message segment (256 KB).
35pub const MAX_L2_MESSAGE_SIZE: usize = 256 * 1024;
36
37/// Represents a parsed L2 transaction from an L1 message.
38#[derive(Debug, Clone)]
39pub enum ParsedTransaction {
40    /// A signed Ethereum transaction (RLP-encoded).
41    Signed(Vec<u8>),
42    /// An unsigned user transaction (Arbitrum-specific).
43    UnsignedUserTx {
44        from: Address,
45        to: Option<Address>,
46        value: U256,
47        gas: u64,
48        gas_fee_cap: U256,
49        nonce: u64,
50        data: Vec<u8>,
51    },
52    /// A contract transaction (L1→L2 call).
53    ContractTx {
54        from: Address,
55        to: Option<Address>,
56        value: U256,
57        gas: u64,
58        gas_fee_cap: U256,
59        data: Vec<u8>,
60        request_id: B256,
61    },
62    /// An ETH deposit from L1.
63    EthDeposit {
64        from: Address,
65        to: Address,
66        value: U256,
67        request_id: B256,
68    },
69    /// A submit retryable transaction.
70    SubmitRetryable {
71        request_id: B256,
72        l1_base_fee: U256,
73        deposit: U256,
74        callvalue: U256,
75        gas_feature_cap: U256,
76        gas_limit: u64,
77        max_submission_fee: U256,
78        from: Address,
79        to: Option<Address>,
80        fee_refund_addr: Address,
81        beneficiary: Address,
82        data: Vec<u8>,
83    },
84    /// A batch posting report (internal tx).
85    BatchPostingReport {
86        batch_timestamp: u64,
87        batch_poster: Address,
88        data_hash: B256,
89        batch_number: u64,
90        l1_base_fee_estimate: U256,
91        extra_gas: u64,
92    },
93    /// An internal start-block transaction.
94    InternalStartBlock {
95        l1_block_number: u64,
96        l1_timestamp: u64,
97    },
98}
99
100/// Parse L2 transactions from an L1 incoming message.
101pub fn parse_l2_transactions(
102    kind: u8,
103    poster: Address,
104    l2_msg: &[u8],
105    request_id: Option<B256>,
106    l1_base_fee: Option<U256>,
107    chain_id: u64,
108) -> Result<Vec<ParsedTransaction>, io::Error> {
109    match kind {
110        L1_MESSAGE_TYPE_L2_MESSAGE => parse_l2_message(l2_msg, poster, request_id, 0, chain_id),
111        L1_MESSAGE_TYPE_END_OF_BLOCK => Ok(vec![]),
112        L1_MESSAGE_TYPE_L2_FUNDED_BY_L1 => {
113            let request_id = request_id.ok_or_else(|| {
114                io::Error::new(
115                    io::ErrorKind::InvalidData,
116                    "cannot issue L2 funded by L1 tx without L1 request id",
117                )
118            })?;
119            parse_l2_funded_by_l1(l2_msg, poster, request_id)
120        }
121        L1_MESSAGE_TYPE_SUBMIT_RETRYABLE => {
122            let request_id = request_id.ok_or_else(|| {
123                io::Error::new(
124                    io::ErrorKind::InvalidData,
125                    "cannot issue submit retryable tx without L1 request id",
126                )
127            })?;
128            let l1_base_fee = l1_base_fee.unwrap_or(U256::ZERO);
129            parse_submit_retryable_message(l2_msg, poster, request_id, l1_base_fee)
130        }
131        L1_MESSAGE_TYPE_ETH_DEPOSIT => {
132            let request_id = request_id.ok_or_else(|| {
133                io::Error::new(
134                    io::ErrorKind::InvalidData,
135                    "cannot issue deposit tx without L1 request id",
136                )
137            })?;
138            parse_eth_deposit_message(l2_msg, poster, request_id)
139        }
140        L1_MESSAGE_TYPE_BATCH_POSTING_REPORT => {
141            let request_id = request_id.unwrap_or(B256::ZERO);
142            parse_batch_posting_report(l2_msg, poster, request_id)
143        }
144        L1_MESSAGE_TYPE_BATCH_FOR_GAS_ESTIMATION => Err(io::Error::new(
145            io::ErrorKind::InvalidData,
146            "L1 message type BatchForGasEstimation is unimplemented",
147        )),
148        L1_MESSAGE_TYPE_INITIALIZE | L1_MESSAGE_TYPE_ROLLUP_EVENT => Ok(vec![]),
149        _ => Ok(vec![]),
150    }
151}
152
153/// Batch-nesting limit matching Nitro (`depth >= 16` → error).
154const MAX_L2_MESSAGE_BATCH_DEPTH: u32 = 16;
155
156#[allow(clippy::only_used_in_recursion)]
157fn parse_l2_message(
158    data: &[u8],
159    poster: Address,
160    request_id: Option<B256>,
161    depth: u32,
162    chain_id: u64,
163) -> Result<Vec<ParsedTransaction>, io::Error> {
164    if data.is_empty() {
165        return Err(io::Error::new(
166            io::ErrorKind::UnexpectedEof,
167            "L2 message is empty (missing kind byte)",
168        ));
169    }
170
171    let kind = data[0];
172    let payload = &data[1..];
173
174    match kind {
175        L2_MESSAGE_KIND_SIGNED_COMPRESSED_TX => Err(io::Error::new(
176            io::ErrorKind::InvalidData,
177            "L2 message kind SignedCompressedTx is unimplemented",
178        )),
179        L2_MESSAGE_KIND_SIGNED_TX => {
180            // Reject Arbitrum internal types and blob txs. Chain ID is not
181            // checked here — legacy txs with `v = 27/28` (no EIP-155 chain
182            // ID) are valid (e.g. deterministic deploy txs).
183            match ArbTransactionSigned::decode_2718(&mut &payload[..]) {
184                Ok(tx) => {
185                    let ty = tx.ty();
186                    if ty >= 0x64 || ty == 3 {
187                        return Err(io::Error::new(
188                            io::ErrorKind::InvalidData,
189                            format!("unsupported tx type: {ty}"),
190                        ));
191                    }
192                    Ok(vec![ParsedTransaction::Signed(payload.to_vec())])
193                }
194                Err(_) => Err(io::Error::new(
195                    io::ErrorKind::InvalidData,
196                    "failed to decode signed transaction",
197                )),
198            }
199        }
200        L2_MESSAGE_KIND_UNSIGNED_USER_TX => {
201            let tx = parse_unsigned_tx(payload, poster, request_id, kind)?;
202            Ok(vec![tx])
203        }
204        L2_MESSAGE_KIND_CONTRACT_TX => {
205            let tx = parse_unsigned_tx(payload, poster, request_id, kind)?;
206            Ok(vec![tx])
207        }
208        L2_MESSAGE_KIND_BATCH => {
209            if depth >= MAX_L2_MESSAGE_BATCH_DEPTH {
210                return Err(io::Error::new(
211                    io::ErrorKind::InvalidData,
212                    "L2 message batches have a max depth of 16",
213                ));
214            }
215            let mut reader = Cursor::new(payload);
216            let mut txs = Vec::new();
217            let mut index: u64 = 0;
218            while let Ok(segment) = bytestring_from_reader(&mut reader, MAX_L2_MESSAGE_SIZE as u64)
219            {
220                if segment.len() > MAX_L2_MESSAGE_SIZE {
221                    break;
222                }
223                let sub_request_id = request_id.map(|parent_id| {
224                    let mut preimage = [0u8; 64];
225                    preimage[..32].copy_from_slice(parent_id.as_slice());
226                    preimage[32..].copy_from_slice(&U256::from(index).to_be_bytes::<32>());
227                    B256::from(keccak256(preimage))
228                });
229                index += 1;
230                let mut sub_txs =
231                    parse_l2_message(&segment, poster, sub_request_id, depth + 1, chain_id)?;
232                txs.append(&mut sub_txs);
233            }
234            Ok(txs)
235        }
236        L2_MESSAGE_KIND_HEARTBEAT => Ok(vec![]),
237        L2_MESSAGE_KIND_NON_MUTATING_CALL => Ok(vec![]),
238        _ => Ok(vec![]),
239    }
240}
241
242/// Parse an unsigned tx or contract tx from the binary format.
243///
244/// Field format (all 32-byte big-endian):
245///   gasLimit: Hash (32 bytes) → u64
246///   maxFeePerGas: Hash (32 bytes) → U256
247///   nonce: Hash (32 bytes) → u64 (only for UnsignedUserTx kind)
248///   to: AddressFrom256 (32 bytes) → Address
249///   value: Hash (32 bytes) → U256
250///   calldata: remaining bytes (ReadAll)
251fn parse_unsigned_tx(
252    data: &[u8],
253    poster: Address,
254    request_id: Option<B256>,
255    kind: u8,
256) -> Result<ParsedTransaction, io::Error> {
257    let mut reader = Cursor::new(data);
258
259    let gas_limit = uint256_from_reader(&mut reader)?;
260    let gas_limit: u64 = gas_limit.try_into().map_err(|_| {
261        io::Error::new(
262            io::ErrorKind::InvalidData,
263            "unsigned user tx gas limit >= 2^64",
264        )
265    })?;
266
267    let max_fee_per_gas = uint256_from_reader(&mut reader)?;
268
269    let nonce = if kind == L2_MESSAGE_KIND_UNSIGNED_USER_TX {
270        let nonce_u256 = uint256_from_reader(&mut reader)?;
271        let n: u64 = nonce_u256.try_into().map_err(|_| {
272            io::Error::new(io::ErrorKind::InvalidData, "unsigned user tx nonce >= 2^64")
273        })?;
274        n
275    } else {
276        0
277    };
278
279    let to = address_from_256_from_reader(&mut reader)?;
280    let destination = if to == Address::ZERO { None } else { Some(to) };
281
282    let value = uint256_from_reader(&mut reader)?;
283
284    let mut calldata = Vec::new();
285    reader.read_to_end(&mut calldata)?;
286
287    match kind {
288        L2_MESSAGE_KIND_UNSIGNED_USER_TX => Ok(ParsedTransaction::UnsignedUserTx {
289            from: poster,
290            to: destination,
291            value,
292            gas: gas_limit,
293            gas_fee_cap: max_fee_per_gas,
294            nonce,
295            data: calldata,
296        }),
297        L2_MESSAGE_KIND_CONTRACT_TX => {
298            let req_id = request_id.ok_or_else(|| {
299                io::Error::new(
300                    io::ErrorKind::InvalidData,
301                    "cannot issue contract tx without L1 request id",
302                )
303            })?;
304            Ok(ParsedTransaction::ContractTx {
305                from: poster,
306                to: destination,
307                value,
308                gas: gas_limit,
309                gas_fee_cap: max_fee_per_gas,
310                data: calldata,
311                request_id: req_id,
312            })
313        }
314        _ => Err(io::Error::new(
315            io::ErrorKind::InvalidData,
316            "invalid L2 tx type in parseUnsignedTx",
317        )),
318    }
319}
320
321fn parse_l2_funded_by_l1(
322    data: &[u8],
323    poster: Address,
324    request_id: B256,
325) -> Result<Vec<ParsedTransaction>, io::Error> {
326    if data.is_empty() {
327        return Err(io::Error::new(
328            io::ErrorKind::InvalidData,
329            "L2FundedByL1 message has no data",
330        ));
331    }
332
333    let kind = data[0];
334
335    // Derive sub-request IDs: keccak256(requestId ++ U256(0)) and keccak256(requestId ++ U256(1))
336    let mut deposit_preimage = [0u8; 64];
337    deposit_preimage[..32].copy_from_slice(request_id.as_slice());
338    // U256(0) is already zeroed
339    let deposit_request_id = B256::from(keccak256(deposit_preimage));
340
341    let mut unsigned_preimage = [0u8; 64];
342    unsigned_preimage[..32].copy_from_slice(request_id.as_slice());
343    unsigned_preimage[63] = 1; // U256(1) in big-endian
344    let unsigned_request_id = B256::from(keccak256(unsigned_preimage));
345
346    let tx = parse_unsigned_tx(&data[1..], poster, Some(unsigned_request_id), kind)?;
347
348    // Extract value from the parsed tx for the deposit.
349    let tx_value = match &tx {
350        ParsedTransaction::UnsignedUserTx { value, .. } => *value,
351        ParsedTransaction::ContractTx { value, .. } => *value,
352        _ => U256::ZERO,
353    };
354
355    // L2FundedByL1 deposit: `from` is zero and `to` is the poster.
356    let deposit = ParsedTransaction::EthDeposit {
357        from: Address::ZERO,
358        to: poster,
359        value: tx_value,
360        request_id: deposit_request_id,
361    };
362
363    Ok(vec![deposit, tx])
364}
365
366fn parse_eth_deposit_message(
367    data: &[u8],
368    poster: Address,
369    request_id: B256,
370) -> Result<Vec<ParsedTransaction>, io::Error> {
371    let mut reader = Cursor::new(data);
372    let to = address_from_reader(&mut reader)?;
373    let value = uint256_from_reader(&mut reader)?;
374    Ok(vec![ParsedTransaction::EthDeposit {
375        from: poster,
376        to,
377        value,
378        request_id,
379    }])
380}
381
382fn parse_submit_retryable_message(
383    data: &[u8],
384    poster: Address,
385    request_id: B256,
386    l1_base_fee: U256,
387) -> Result<Vec<ParsedTransaction>, io::Error> {
388    let mut reader = Cursor::new(data);
389
390    // Field order matches parseSubmitRetryableMessage exactly.
391    let retry_to = address_from_256_from_reader(&mut reader)?;
392    let callvalue = uint256_from_reader(&mut reader)?;
393    let deposit = uint256_from_reader(&mut reader)?;
394    let max_submission_fee = uint256_from_reader(&mut reader)?;
395    let fee_refund_addr = address_from_256_from_reader(&mut reader)?;
396    let beneficiary = address_from_256_from_reader(&mut reader)?;
397    let gas_limit_u256 = uint256_from_reader(&mut reader)?;
398    let gas_limit = gas_limit_u256
399        .try_into()
400        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "gas limit too large"))?;
401    let gas_feature_cap = uint256_from_reader(&mut reader)?;
402
403    // Data length is encoded as a 32-byte hash, then raw bytes follow.
404    // Cap the declared length at MAX_L2_MESSAGE_SIZE to prevent an
405    // attacker from triggering a huge allocation (DoS).
406    let data_length_hash = hash_from_reader(&mut reader)?;
407    let data_length: usize = U256::from_be_bytes(data_length_hash.0)
408        .try_into()
409        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "data length too large"))?;
410    if data_length > MAX_L2_MESSAGE_SIZE {
411        return Err(io::Error::new(
412            io::ErrorKind::InvalidData,
413            format!("data length {data_length} exceeds MAX_L2_MESSAGE_SIZE {MAX_L2_MESSAGE_SIZE}"),
414        ));
415    }
416    let mut calldata = vec![0u8; data_length];
417    if data_length > 0 {
418        io::Read::read_exact(&mut reader, &mut calldata)?;
419    }
420
421    let to = if retry_to == Address::ZERO {
422        None
423    } else {
424        Some(retry_to)
425    };
426
427    Ok(vec![ParsedTransaction::SubmitRetryable {
428        request_id,
429        l1_base_fee,
430        deposit,
431        callvalue,
432        gas_feature_cap,
433        gas_limit,
434        max_submission_fee,
435        from: poster,
436        to,
437        fee_refund_addr,
438        beneficiary,
439        data: calldata,
440    }])
441}
442
443fn parse_batch_posting_report(
444    data: &[u8],
445    _poster: Address,
446    _request_id: B256,
447) -> Result<Vec<ParsedTransaction>, io::Error> {
448    let mut reader = Cursor::new(data);
449
450    // All fields use 32-byte Hash format except batchPosterAddr (20 bytes)
451    // and extraGas (8-byte uint64, optional).
452    let batch_timestamp_u256 = uint256_from_reader(&mut reader)?;
453    let batch_timestamp: u64 = batch_timestamp_u256
454        .try_into()
455        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "batch timestamp too large"))?;
456
457    let batch_poster = address_from_reader(&mut reader)?;
458
459    let data_hash = hash_from_reader(&mut reader)?;
460
461    let batch_number_u256 = uint256_from_reader(&mut reader)?;
462    let batch_number: u64 = batch_number_u256
463        .try_into()
464        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "batch number too large"))?;
465
466    let l1_base_fee_estimate = uint256_from_reader(&mut reader)?;
467
468    // extraGas is optional — defaults to 0 on EOF.
469    let extra_gas = match uint64_from_reader(&mut reader) {
470        Ok(v) => v,
471        Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => 0,
472        Err(e) => return Err(e),
473    };
474
475    Ok(vec![ParsedTransaction::BatchPostingReport {
476        batch_timestamp,
477        batch_poster,
478        data_hash,
479        batch_number,
480        l1_base_fee_estimate,
481        extra_gas,
482    }])
483}
484
485// =====================================================================
486// Conversion to ArbTransactionSigned
487// =====================================================================
488
489/// Convert a `ParsedTransaction` into an `ArbTransactionSigned`.
490///
491/// The `chain_id` is needed for constructing Arbitrum-specific tx envelopes.
492/// Returns `None` for batch posting reports and internal start-block txs that
493/// are constructed separately by the internal tx module.
494pub fn parsed_tx_to_signed(
495    parsed: &ParsedTransaction,
496    chain_id: u64,
497) -> Option<ArbTransactionSigned> {
498    use arb_primitives::signed_tx::ArbTypedTransaction;
499
500    let chain_id_u256 = U256::from(chain_id);
501
502    let tx = match parsed {
503        ParsedTransaction::Signed(rlp_bytes) => {
504            // Standard signed Ethereum tx — decode via Decodable2718.
505            use alloy_eips::Decodable2718;
506            return ArbTransactionSigned::decode_2718(&mut rlp_bytes.as_slice()).ok();
507        }
508        ParsedTransaction::UnsignedUserTx {
509            from,
510            to,
511            value,
512            gas,
513            gas_fee_cap,
514            nonce,
515            data,
516        } => ArbTypedTransaction::Unsigned(ArbUnsignedTx {
517            chain_id: chain_id_u256,
518            from: *from,
519            nonce: *nonce,
520            gas_fee_cap: *gas_fee_cap,
521            gas: *gas,
522            to: *to,
523            value: *value,
524            data: Bytes::copy_from_slice(data),
525        }),
526        ParsedTransaction::ContractTx {
527            from,
528            to,
529            value,
530            gas,
531            gas_fee_cap,
532            data,
533            request_id,
534        } => ArbTypedTransaction::Contract(ArbContractTx {
535            chain_id: chain_id_u256,
536            request_id: *request_id,
537            from: *from,
538            gas_fee_cap: *gas_fee_cap,
539            gas: *gas,
540            to: *to,
541            value: *value,
542            data: Bytes::copy_from_slice(data),
543        }),
544        ParsedTransaction::EthDeposit {
545            from,
546            to,
547            value,
548            request_id,
549        } => ArbTypedTransaction::Deposit(ArbDepositTx {
550            chain_id: chain_id_u256,
551            l1_request_id: *request_id,
552            from: *from,
553            to: *to,
554            value: *value,
555        }),
556        ParsedTransaction::SubmitRetryable {
557            request_id,
558            l1_base_fee,
559            deposit,
560            callvalue,
561            gas_feature_cap,
562            gas_limit,
563            max_submission_fee,
564            from,
565            to,
566            fee_refund_addr,
567            beneficiary,
568            data,
569        } => ArbTypedTransaction::SubmitRetryable(ArbSubmitRetryableTx {
570            chain_id: chain_id_u256,
571            request_id: *request_id,
572            from: *from,
573            l1_base_fee: *l1_base_fee,
574            deposit_value: *deposit,
575            gas_fee_cap: *gas_feature_cap,
576            gas: *gas_limit,
577            retry_to: *to,
578            retry_value: *callvalue,
579            beneficiary: *beneficiary,
580            max_submission_fee: *max_submission_fee,
581            fee_refund_addr: *fee_refund_addr,
582            retry_data: Bytes::copy_from_slice(data),
583        }),
584        ParsedTransaction::BatchPostingReport { .. } => {
585            // Batch posting reports become internal txs with ABI-encoded data.
586            // These are constructed by the block producer, not this function.
587            return None;
588        }
589        ParsedTransaction::InternalStartBlock { .. } => {
590            // Start-block txs are constructed by the block producer.
591            return None;
592        }
593    };
594
595    let sig = alloy_primitives::Signature::new(U256::ZERO, U256::ZERO, false);
596    Some(ArbTransactionSigned::new_unhashed(tx, sig))
597}