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