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.unwrap_or(B256::ZERO);
114            parse_l2_funded_by_l1(l2_msg, poster, request_id)
115        }
116        L1_MESSAGE_TYPE_SUBMIT_RETRYABLE => {
117            let request_id = request_id.unwrap_or(B256::ZERO);
118            let l1_base_fee = l1_base_fee.unwrap_or(U256::ZERO);
119            parse_submit_retryable_message(l2_msg, poster, request_id, l1_base_fee)
120        }
121        L1_MESSAGE_TYPE_ETH_DEPOSIT => {
122            let request_id = request_id.unwrap_or(B256::ZERO);
123            parse_eth_deposit_message(l2_msg, poster, request_id)
124        }
125        L1_MESSAGE_TYPE_BATCH_POSTING_REPORT => {
126            let request_id = request_id.unwrap_or(B256::ZERO);
127            parse_batch_posting_report(l2_msg, poster, request_id)
128        }
129        L1_MESSAGE_TYPE_BATCH_FOR_GAS_ESTIMATION => Err(io::Error::new(
130            io::ErrorKind::InvalidData,
131            "L1 message type BatchForGasEstimation is unimplemented",
132        )),
133        L1_MESSAGE_TYPE_INITIALIZE | L1_MESSAGE_TYPE_ROLLUP_EVENT => Ok(vec![]),
134        _ => Ok(vec![]),
135    }
136}
137
138#[allow(clippy::only_used_in_recursion)]
139fn parse_l2_message(
140    data: &[u8],
141    poster: Address,
142    request_id: Option<B256>,
143    depth: u32,
144    chain_id: u64,
145) -> Result<Vec<ParsedTransaction>, io::Error> {
146    const MAX_DEPTH: u32 = 16;
147    if depth > MAX_DEPTH || data.is_empty() {
148        return Ok(vec![]);
149    }
150
151    let kind = data[0];
152    let payload = &data[1..];
153
154    match kind {
155        L2_MESSAGE_KIND_SIGNED_COMPRESSED_TX => Err(io::Error::new(
156            io::ErrorKind::InvalidData,
157            "L2 message kind SignedCompressedTx is unimplemented",
158        )),
159        L2_MESSAGE_KIND_SIGNED_TX => {
160            // Decode and validate the tx type: reject Arbitrum internal types and blob txs.
161            // Chain ID is NOT checked here — legacy txs with v=27/28 (no EIP-155
162            // chain ID) are valid (e.g. deterministic deploy txs).
163            match ArbTransactionSigned::decode_2718(&mut &payload[..]) {
164                Ok(tx) => {
165                    let ty = tx.ty();
166                    if ty >= 0x64 || ty == 3 {
167                        return Err(io::Error::new(
168                            io::ErrorKind::InvalidData,
169                            format!("unsupported tx type: {ty}"),
170                        ));
171                    }
172                    Ok(vec![ParsedTransaction::Signed(payload.to_vec())])
173                }
174                Err(_) => Err(io::Error::new(
175                    io::ErrorKind::InvalidData,
176                    "failed to decode signed transaction",
177                )),
178            }
179        }
180        L2_MESSAGE_KIND_UNSIGNED_USER_TX => {
181            let tx = parse_unsigned_tx(payload, poster, request_id, kind)?;
182            Ok(vec![tx])
183        }
184        L2_MESSAGE_KIND_CONTRACT_TX => {
185            let tx = parse_unsigned_tx(payload, poster, request_id, kind)?;
186            Ok(vec![tx])
187        }
188        L2_MESSAGE_KIND_BATCH => {
189            let mut reader = Cursor::new(payload);
190            let mut txs = Vec::new();
191            let mut index: u64 = 0;
192            while let Ok(segment) = bytestring_from_reader(&mut reader) {
193                if segment.len() > MAX_L2_MESSAGE_SIZE {
194                    break;
195                }
196                // Derive sub-request ID if parent has one
197                let sub_request_id = request_id.map(|parent_id| {
198                    let mut preimage = [0u8; 64];
199                    preimage[..32].copy_from_slice(parent_id.as_slice());
200                    preimage[32..].copy_from_slice(&U256::from(index).to_be_bytes::<32>());
201                    B256::from(keccak256(preimage))
202                });
203                index += 1;
204                let mut sub_txs =
205                    parse_l2_message(&segment, poster, sub_request_id, depth + 1, chain_id)?;
206                txs.append(&mut sub_txs);
207            }
208            Ok(txs)
209        }
210        L2_MESSAGE_KIND_HEARTBEAT => Ok(vec![]),
211        L2_MESSAGE_KIND_NON_MUTATING_CALL => Ok(vec![]),
212        _ => Ok(vec![]),
213    }
214}
215
216/// Parse an unsigned tx or contract tx from the binary format.
217///
218/// Field format (all 32-byte big-endian):
219///   gasLimit: Hash (32 bytes) → u64
220///   maxFeePerGas: Hash (32 bytes) → U256
221///   nonce: Hash (32 bytes) → u64 (only for UnsignedUserTx kind)
222///   to: AddressFrom256 (32 bytes) → Address
223///   value: Hash (32 bytes) → U256
224///   calldata: remaining bytes (ReadAll)
225fn parse_unsigned_tx(
226    data: &[u8],
227    poster: Address,
228    request_id: Option<B256>,
229    kind: u8,
230) -> Result<ParsedTransaction, io::Error> {
231    let mut reader = Cursor::new(data);
232
233    let gas_limit = uint256_from_reader(&mut reader)?;
234    let gas_limit: u64 = gas_limit.try_into().map_err(|_| {
235        io::Error::new(
236            io::ErrorKind::InvalidData,
237            "unsigned user tx gas limit >= 2^64",
238        )
239    })?;
240
241    let max_fee_per_gas = uint256_from_reader(&mut reader)?;
242
243    let nonce = if kind == L2_MESSAGE_KIND_UNSIGNED_USER_TX {
244        let nonce_u256 = uint256_from_reader(&mut reader)?;
245        let n: u64 = nonce_u256.try_into().map_err(|_| {
246            io::Error::new(io::ErrorKind::InvalidData, "unsigned user tx nonce >= 2^64")
247        })?;
248        n
249    } else {
250        0
251    };
252
253    let to = address_from_256_from_reader(&mut reader)?;
254    let destination = if to == Address::ZERO { None } else { Some(to) };
255
256    let value = uint256_from_reader(&mut reader)?;
257
258    let mut calldata = Vec::new();
259    reader.read_to_end(&mut calldata)?;
260
261    match kind {
262        L2_MESSAGE_KIND_UNSIGNED_USER_TX => Ok(ParsedTransaction::UnsignedUserTx {
263            from: poster,
264            to: destination,
265            value,
266            gas: gas_limit,
267            gas_fee_cap: max_fee_per_gas,
268            nonce,
269            data: calldata,
270        }),
271        L2_MESSAGE_KIND_CONTRACT_TX => {
272            let req_id = request_id.ok_or_else(|| {
273                io::Error::new(
274                    io::ErrorKind::InvalidData,
275                    "cannot issue contract tx without L1 request id",
276                )
277            })?;
278            Ok(ParsedTransaction::ContractTx {
279                from: poster,
280                to: destination,
281                value,
282                gas: gas_limit,
283                gas_fee_cap: max_fee_per_gas,
284                data: calldata,
285                request_id: req_id,
286            })
287        }
288        _ => Err(io::Error::new(
289            io::ErrorKind::InvalidData,
290            "invalid L2 tx type in parseUnsignedTx",
291        )),
292    }
293}
294
295fn parse_l2_funded_by_l1(
296    data: &[u8],
297    poster: Address,
298    request_id: B256,
299) -> Result<Vec<ParsedTransaction>, io::Error> {
300    if data.is_empty() {
301        return Err(io::Error::new(
302            io::ErrorKind::InvalidData,
303            "L2FundedByL1 message has no data",
304        ));
305    }
306
307    let kind = data[0];
308
309    // Derive sub-request IDs: keccak256(requestId ++ U256(0)) and keccak256(requestId ++ U256(1))
310    let mut deposit_preimage = [0u8; 64];
311    deposit_preimage[..32].copy_from_slice(request_id.as_slice());
312    // U256(0) is already zeroed
313    let deposit_request_id = B256::from(keccak256(deposit_preimage));
314
315    let mut unsigned_preimage = [0u8; 64];
316    unsigned_preimage[..32].copy_from_slice(request_id.as_slice());
317    unsigned_preimage[63] = 1; // U256(1) in big-endian
318    let unsigned_request_id = B256::from(keccak256(unsigned_preimage));
319
320    let tx = parse_unsigned_tx(&data[1..], poster, Some(unsigned_request_id), kind)?;
321
322    // Extract value from the parsed tx for the deposit.
323    let tx_value = match &tx {
324        ParsedTransaction::UnsignedUserTx { value, .. } => *value,
325        ParsedTransaction::ContractTx { value, .. } => *value,
326        _ => U256::ZERO,
327    };
328
329    // Nitro's ArbitrumDepositTx for L2FundedByL1 has From unset (= zero)
330    // and To = header.Poster. Matches Go struct initialization default.
331    let deposit = ParsedTransaction::EthDeposit {
332        from: Address::ZERO,
333        to: poster,
334        value: tx_value,
335        request_id: deposit_request_id,
336    };
337
338    Ok(vec![deposit, tx])
339}
340
341fn parse_eth_deposit_message(
342    data: &[u8],
343    poster: Address,
344    request_id: B256,
345) -> Result<Vec<ParsedTransaction>, io::Error> {
346    let mut reader = Cursor::new(data);
347    let to = address_from_reader(&mut reader)?;
348    let value = uint256_from_reader(&mut reader)?;
349    Ok(vec![ParsedTransaction::EthDeposit {
350        from: poster,
351        to,
352        value,
353        request_id,
354    }])
355}
356
357fn parse_submit_retryable_message(
358    data: &[u8],
359    poster: Address,
360    request_id: B256,
361    l1_base_fee: U256,
362) -> Result<Vec<ParsedTransaction>, io::Error> {
363    let mut reader = Cursor::new(data);
364
365    // Field order matches parseSubmitRetryableMessage exactly.
366    let retry_to = address_from_256_from_reader(&mut reader)?;
367    let callvalue = uint256_from_reader(&mut reader)?;
368    let deposit = uint256_from_reader(&mut reader)?;
369    let max_submission_fee = uint256_from_reader(&mut reader)?;
370    let fee_refund_addr = address_from_256_from_reader(&mut reader)?;
371    let beneficiary = address_from_256_from_reader(&mut reader)?;
372    let gas_limit_u256 = uint256_from_reader(&mut reader)?;
373    let gas_limit = gas_limit_u256
374        .try_into()
375        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "gas limit too large"))?;
376    let gas_feature_cap = uint256_from_reader(&mut reader)?;
377
378    // Data length is encoded as a 32-byte hash, then raw bytes follow.
379    let data_length_hash = hash_from_reader(&mut reader)?;
380    let data_length = U256::from_be_bytes(data_length_hash.0)
381        .try_into()
382        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "data length too large"))?;
383    let mut calldata = vec![0u8; data_length];
384    if data_length > 0 {
385        io::Read::read_exact(&mut reader, &mut calldata)?;
386    }
387
388    let to = if retry_to == Address::ZERO {
389        None
390    } else {
391        Some(retry_to)
392    };
393
394    Ok(vec![ParsedTransaction::SubmitRetryable {
395        request_id,
396        l1_base_fee,
397        deposit,
398        callvalue,
399        gas_feature_cap,
400        gas_limit,
401        max_submission_fee,
402        from: poster,
403        to,
404        fee_refund_addr,
405        beneficiary,
406        data: calldata,
407    }])
408}
409
410fn parse_batch_posting_report(
411    data: &[u8],
412    _poster: Address,
413    _request_id: B256,
414) -> Result<Vec<ParsedTransaction>, io::Error> {
415    let mut reader = Cursor::new(data);
416
417    // All fields use 32-byte Hash format except batchPosterAddr (20 bytes)
418    // and extraGas (8-byte uint64, optional).
419    let batch_timestamp_u256 = uint256_from_reader(&mut reader)?;
420    let batch_timestamp: u64 = batch_timestamp_u256
421        .try_into()
422        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "batch timestamp too large"))?;
423
424    let batch_poster = address_from_reader(&mut reader)?;
425
426    let data_hash = hash_from_reader(&mut reader)?;
427
428    let batch_number_u256 = uint256_from_reader(&mut reader)?;
429    let batch_number: u64 = batch_number_u256
430        .try_into()
431        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "batch number too large"))?;
432
433    let l1_base_fee_estimate = uint256_from_reader(&mut reader)?;
434
435    // extraGas is optional — defaults to 0 on EOF.
436    let extra_gas = match uint64_from_reader(&mut reader) {
437        Ok(v) => v,
438        Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => 0,
439        Err(e) => return Err(e),
440    };
441
442    Ok(vec![ParsedTransaction::BatchPostingReport {
443        batch_timestamp,
444        batch_poster,
445        data_hash,
446        batch_number,
447        l1_base_fee_estimate,
448        extra_gas,
449    }])
450}
451
452// =====================================================================
453// Conversion to ArbTransactionSigned
454// =====================================================================
455
456/// Convert a `ParsedTransaction` into an `ArbTransactionSigned`.
457///
458/// The `chain_id` is needed for constructing Arbitrum-specific tx envelopes.
459/// Returns `None` for batch posting reports and internal start-block txs that
460/// are constructed separately by the internal tx module.
461pub fn parsed_tx_to_signed(
462    parsed: &ParsedTransaction,
463    chain_id: u64,
464) -> Option<ArbTransactionSigned> {
465    use arb_primitives::signed_tx::ArbTypedTransaction;
466
467    let chain_id_u256 = U256::from(chain_id);
468
469    let tx = match parsed {
470        ParsedTransaction::Signed(rlp_bytes) => {
471            // Standard signed Ethereum tx — decode via Decodable2718.
472            use alloy_eips::Decodable2718;
473            return ArbTransactionSigned::decode_2718(&mut rlp_bytes.as_slice()).ok();
474        }
475        ParsedTransaction::UnsignedUserTx {
476            from,
477            to,
478            value,
479            gas,
480            gas_fee_cap,
481            nonce,
482            data,
483        } => ArbTypedTransaction::Unsigned(ArbUnsignedTx {
484            chain_id: chain_id_u256,
485            from: *from,
486            nonce: *nonce,
487            gas_fee_cap: *gas_fee_cap,
488            gas: *gas,
489            to: *to,
490            value: *value,
491            data: Bytes::copy_from_slice(data),
492        }),
493        ParsedTransaction::ContractTx {
494            from,
495            to,
496            value,
497            gas,
498            gas_fee_cap,
499            data,
500            request_id,
501        } => ArbTypedTransaction::Contract(ArbContractTx {
502            chain_id: chain_id_u256,
503            request_id: *request_id,
504            from: *from,
505            gas_fee_cap: *gas_fee_cap,
506            gas: *gas,
507            to: *to,
508            value: *value,
509            data: Bytes::copy_from_slice(data),
510        }),
511        ParsedTransaction::EthDeposit {
512            from,
513            to,
514            value,
515            request_id,
516        } => ArbTypedTransaction::Deposit(ArbDepositTx {
517            chain_id: chain_id_u256,
518            l1_request_id: *request_id,
519            from: *from,
520            to: *to,
521            value: *value,
522        }),
523        ParsedTransaction::SubmitRetryable {
524            request_id,
525            l1_base_fee,
526            deposit,
527            callvalue,
528            gas_feature_cap,
529            gas_limit,
530            max_submission_fee,
531            from,
532            to,
533            fee_refund_addr,
534            beneficiary,
535            data,
536        } => ArbTypedTransaction::SubmitRetryable(ArbSubmitRetryableTx {
537            chain_id: chain_id_u256,
538            request_id: *request_id,
539            from: *from,
540            l1_base_fee: *l1_base_fee,
541            deposit_value: *deposit,
542            gas_fee_cap: *gas_feature_cap,
543            gas: *gas_limit,
544            retry_to: *to,
545            retry_value: *callvalue,
546            beneficiary: *beneficiary,
547            max_submission_fee: *max_submission_fee,
548            fee_refund_addr: *fee_refund_addr,
549            retry_data: Bytes::copy_from_slice(data),
550        }),
551        ParsedTransaction::BatchPostingReport { .. } => {
552            // Batch posting reports become internal txs with ABI-encoded data.
553            // These are constructed by the block producer, not this function.
554            return None;
555        }
556        ParsedTransaction::InternalStartBlock { .. } => {
557            // Start-block txs are constructed by the block producer.
558            return None;
559        }
560    };
561
562    let sig = alloy_primitives::Signature::new(U256::ZERO, U256::ZERO, false);
563    Some(ArbTransactionSigned::new_unhashed(tx, sig))
564}