arbos/
parse_l2.rs

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