arbos/
internal_tx.rs

1use alloy_primitives::{Address, B256, U256};
2
3use arb_chainspec::arbos_version;
4
5use crate::{
6    arbos_state::ArbosState,
7    arbos_types::{legacy_cost_for_stats, BatchDataStats},
8    burn::Burner,
9};
10
11/// Standard Ethereum base transaction gas.
12const TX_GAS: u64 = 21_000;
13
14// ---------------------------------------------------------------------------
15// Method selectors (keccak256 of ABI signatures)
16// ---------------------------------------------------------------------------
17
18/// startBlock(uint256,uint64,uint64,uint64)
19pub const INTERNAL_TX_START_BLOCK_METHOD_ID: [u8; 4] = [0x6b, 0xf6, 0xa4, 0x2d];
20
21/// batchPostingReport(uint256,address,uint64,uint64,uint256)
22pub const INTERNAL_TX_BATCH_POSTING_REPORT_METHOD_ID: [u8; 4] = [0xb6, 0x69, 0x37, 0x71];
23
24/// batchPostingReportV2(uint256,address,uint64,uint64,uint64,uint64,uint256)
25pub const INTERNAL_TX_BATCH_POSTING_REPORT_V2_METHOD_ID: [u8; 4] = [0x99, 0x98, 0x26, 0x9e];
26
27// ---------------------------------------------------------------------------
28// Well-known system addresses
29// ---------------------------------------------------------------------------
30
31pub const ARB_RETRYABLE_TX_ADDRESS: Address = {
32    let mut bytes = [0u8; 20];
33    bytes[18] = 0x00;
34    bytes[19] = 0x6e;
35    Address::new(bytes)
36};
37
38pub const ARB_SYS_ADDRESS: Address = {
39    let mut bytes = [0u8; 20];
40    bytes[19] = 0x64;
41    Address::new(bytes)
42};
43
44/// Additional tokens in the calldata for floor gas accounting.
45///
46/// Raw batch has a 40-byte header (5 uint64s) that doesn't come from calldata.
47/// The addSequencerL2BatchFromOrigin call has a selector + 5 additional fields.
48/// Token count: 4*4 (selector) + 4*24 (uint64 padding) + 4*12+12 (address) = 172
49pub const FLOOR_GAS_ADDITIONAL_TOKENS: u64 = 172;
50
51// ---------------------------------------------------------------------------
52// L1 block info
53// ---------------------------------------------------------------------------
54
55/// L1 block info passed to internal transactions.
56#[derive(Debug, Clone)]
57pub struct L1Info {
58    pub poster: Address,
59    pub l1_block_number: u64,
60    pub l1_timestamp: u64,
61}
62
63impl L1Info {
64    pub fn new(poster: Address, l1_block_number: u64, l1_timestamp: u64) -> Self {
65        Self {
66            poster,
67            l1_block_number,
68            l1_timestamp,
69        }
70    }
71}
72
73// ---------------------------------------------------------------------------
74// Event IDs
75// ---------------------------------------------------------------------------
76
77pub const L2_TO_L1_TRANSACTION_EVENT_ID: B256 = {
78    let bytes: [u8; 32] = [
79        0x5b, 0xaa, 0xa8, 0x7d, 0xb3, 0x86, 0x36, 0x5b, 0x5c, 0x16, 0x1b, 0xe3, 0x77, 0xbc, 0x3d,
80        0x8e, 0x31, 0x7e, 0x8d, 0x98, 0xd7, 0x1a, 0x3c, 0xa7, 0xed, 0x7d, 0x55, 0x53, 0x40, 0xc8,
81        0xf7, 0x67,
82    ];
83    B256::new(bytes)
84};
85
86pub const L2_TO_L1_TX_EVENT_ID: B256 = {
87    let bytes: [u8; 32] = [
88        0x3e, 0x7a, 0xaf, 0xa7, 0x7d, 0xbf, 0x18, 0x6b, 0x7f, 0xd4, 0x88, 0x00, 0x6b, 0xef, 0xf8,
89        0x93, 0x74, 0x4c, 0xaa, 0x3c, 0x4f, 0x6f, 0x29, 0x9e, 0x8a, 0x70, 0x9f, 0xa2, 0x08, 0x73,
90        0x74, 0xfc,
91    ];
92    B256::new(bytes)
93};
94
95pub const REDEEM_SCHEDULED_EVENT_ID: B256 = {
96    let bytes: [u8; 32] = [
97        0x5c, 0xcd, 0x00, 0x95, 0x02, 0x50, 0x9c, 0xf2, 0x87, 0x62, 0xc6, 0x78, 0x58, 0x99, 0x4d,
98        0x85, 0xb1, 0x63, 0xbb, 0x6e, 0x45, 0x1f, 0x5e, 0x9d, 0xf7, 0xc5, 0xe1, 0x8c, 0x9c, 0x2e,
99        0x12, 0x3e,
100    ];
101    B256::new(bytes)
102};
103
104// ---------------------------------------------------------------------------
105// Decoded internal tx data
106// ---------------------------------------------------------------------------
107
108/// Decoded startBlock(uint256 l1BaseFee, uint64 l1BlockNumber, uint64 l2BlockNumber, uint64
109/// timePassed)
110#[derive(Debug, Clone)]
111pub struct StartBlockData {
112    pub l1_base_fee: U256,
113    pub l1_block_number: u64,
114    pub l2_block_number: u64,
115    pub time_passed: u64,
116}
117
118/// Decoded batchPostingReport(uint256, address, uint64, uint64, uint256)
119#[derive(Debug, Clone)]
120pub struct BatchPostingReportData {
121    pub batch_timestamp: u64,
122    pub batch_poster: Address,
123    pub batch_data_gas: u64,
124    pub l1_base_fee: U256,
125}
126
127/// Decoded batchPostingReportV2(uint256, address, uint64, uint64, uint64, uint64, uint256)
128#[derive(Debug, Clone)]
129pub struct BatchPostingReportV2Data {
130    pub batch_timestamp: u64,
131    pub batch_poster: Address,
132    pub batch_calldata_length: u64,
133    pub batch_calldata_non_zeros: u64,
134    pub batch_extra_gas: u64,
135    pub l1_base_fee: U256,
136}
137
138// ---------------------------------------------------------------------------
139// ABI encoding
140// ---------------------------------------------------------------------------
141
142/// Creates the ABI-encoded data for a startBlock internal transaction.
143pub fn encode_start_block(
144    l1_base_fee: U256,
145    l1_block_number: u64,
146    l2_block_number: u64,
147    time_passed: u64,
148) -> Vec<u8> {
149    let mut data = Vec::with_capacity(4 + 32 * 4);
150    data.extend_from_slice(&INTERNAL_TX_START_BLOCK_METHOD_ID);
151    data.extend_from_slice(&l1_base_fee.to_be_bytes::<32>());
152    data.extend_from_slice(&B256::left_padding_from(&l1_block_number.to_be_bytes()).0);
153    data.extend_from_slice(&B256::left_padding_from(&l2_block_number.to_be_bytes()).0);
154    data.extend_from_slice(&B256::left_padding_from(&time_passed.to_be_bytes()).0);
155    data
156}
157
158/// Creates the ABI-encoded data for a batchPostingReport internal transaction (v1).
159///
160/// ABI: batchPostingReport(uint256 timestamp, address poster, bytes32 dataHash,
161///                          uint256 batchNum, uint256 l1BaseFee)
162pub fn encode_batch_posting_report(
163    batch_timestamp: u64,
164    batch_poster: Address,
165    batch_number: u64,
166    batch_data_gas: u64,
167    l1_base_fee: U256,
168) -> Vec<u8> {
169    let mut data = Vec::with_capacity(4 + 32 * 5);
170    data.extend_from_slice(&INTERNAL_TX_BATCH_POSTING_REPORT_METHOD_ID);
171    data.extend_from_slice(&B256::left_padding_from(&batch_timestamp.to_be_bytes()).0);
172    data.extend_from_slice(&B256::left_padding_from(batch_poster.as_slice()).0);
173    data.extend_from_slice(&B256::left_padding_from(&batch_number.to_be_bytes()).0);
174    data.extend_from_slice(&B256::left_padding_from(&batch_data_gas.to_be_bytes()).0);
175    data.extend_from_slice(&l1_base_fee.to_be_bytes::<32>());
176    data
177}
178
179/// Creates the ABI-encoded data for a batchPostingReportV2 internal transaction.
180pub fn encode_batch_posting_report_v2(
181    batch_timestamp: u64,
182    batch_poster: Address,
183    batch_number: u64,
184    batch_calldata_length: u64,
185    batch_calldata_non_zeros: u64,
186    batch_extra_gas: u64,
187    l1_base_fee: U256,
188) -> Vec<u8> {
189    let mut data = Vec::with_capacity(4 + 32 * 7);
190    data.extend_from_slice(&INTERNAL_TX_BATCH_POSTING_REPORT_V2_METHOD_ID);
191    data.extend_from_slice(&B256::left_padding_from(&batch_timestamp.to_be_bytes()).0);
192    data.extend_from_slice(&B256::left_padding_from(batch_poster.as_slice()).0);
193    data.extend_from_slice(&B256::left_padding_from(&batch_number.to_be_bytes()).0);
194    data.extend_from_slice(&B256::left_padding_from(&batch_calldata_length.to_be_bytes()).0);
195    data.extend_from_slice(&B256::left_padding_from(&batch_calldata_non_zeros.to_be_bytes()).0);
196    data.extend_from_slice(&B256::left_padding_from(&batch_extra_gas.to_be_bytes()).0);
197    data.extend_from_slice(&l1_base_fee.to_be_bytes::<32>());
198    data
199}
200
201// ---------------------------------------------------------------------------
202// ABI decoding
203// ---------------------------------------------------------------------------
204
205/// Decode startBlock data from raw internal tx bytes.
206pub fn decode_start_block_data(data: &[u8]) -> Result<StartBlockData, String> {
207    if data.len() < 4 + 32 * 4 {
208        return Err(format!(
209            "start block data too short: expected >= 132, got {}",
210            data.len()
211        ));
212    }
213    let args = &data[4..];
214    let l1_block_number = u256_to_u64(&args[32..64], "l1_block_number")?;
215    let l2_block_number = u256_to_u64(&args[64..96], "l2_block_number")?;
216    let time_passed = u256_to_u64(&args[96..128], "time_passed")?;
217    Ok(StartBlockData {
218        l1_base_fee: U256::from_be_slice(&args[0..32]),
219        l1_block_number,
220        l2_block_number,
221        time_passed,
222    })
223}
224
225fn u256_to_u64(slice: &[u8], field: &str) -> Result<u64, String> {
226    U256::from_be_slice(slice)
227        .try_into()
228        .map_err(|_| format!("{field} does not fit in u64"))
229}
230
231fn decode_batch_posting_report(data: &[u8]) -> Result<BatchPostingReportData, String> {
232    // 5 ABI words: uint256, address, uint64, uint64, uint256
233    if data.len() < 4 + 32 * 5 {
234        return Err(format!(
235            "batch posting report data too short: expected >= 164, got {}",
236            data.len()
237        ));
238    }
239    let args = &data[4..];
240    Ok(BatchPostingReportData {
241        batch_timestamp: u256_to_u64(&args[0..32], "batch_timestamp")?,
242        batch_poster: Address::from_slice(&args[44..64]),
243        batch_data_gas: u256_to_u64(&args[96..128], "batch_data_gas")?,
244        l1_base_fee: U256::from_be_slice(&args[128..160]),
245    })
246}
247
248fn decode_batch_posting_report_v2(data: &[u8]) -> Result<BatchPostingReportV2Data, String> {
249    // 7 ABI words: uint256, address, uint64, uint64, uint64, uint64, uint256
250    if data.len() < 4 + 32 * 7 {
251        return Err(format!(
252            "batch posting report v2 data too short: expected >= 228, got {}",
253            data.len()
254        ));
255    }
256    let args = &data[4..];
257    Ok(BatchPostingReportV2Data {
258        batch_timestamp: u256_to_u64(&args[0..32], "batch_timestamp")?,
259        batch_poster: Address::from_slice(&args[44..64]),
260        batch_calldata_length: u256_to_u64(&args[96..128], "batch_calldata_length")?,
261        batch_calldata_non_zeros: u256_to_u64(&args[128..160], "batch_calldata_non_zeros")?,
262        batch_extra_gas: u256_to_u64(&args[160..192], "batch_extra_gas")?,
263        l1_base_fee: U256::from_be_slice(&args[192..224]),
264    })
265}
266
267// ---------------------------------------------------------------------------
268// Dispatch
269// ---------------------------------------------------------------------------
270
271/// Context needed by the internal transaction dispatch from the block executor.
272pub struct InternalTxContext {
273    pub block_number: u64,
274    pub current_time: u64,
275    pub prev_hash: B256,
276}
277
278/// Apply an internal transaction update to ArbOS state.
279///
280/// Dispatches on the 4-byte method selector to handle:
281/// - StartBlock: records L1 block hashes, reaps expired retryables, updates L2 pricing, and checks
282///   for ArbOS upgrades.
283/// - BatchPostingReport (v1 and v2): updates L1 pricing based on batch poster spending.
284pub fn apply_internal_tx_update<D: revm::Database, B: Burner, F, G>(
285    data: &[u8],
286    state: &mut ArbosState<D, B>,
287    ctx: &InternalTxContext,
288    mut transfer_fn: F,
289    mut balance_of: G,
290) -> Result<(), String>
291where
292    F: FnMut(Address, Address, U256) -> Result<(), ()>,
293    G: FnMut(Address) -> U256,
294{
295    if data.len() < 4 {
296        return Err(format!(
297            "internal tx data too short ({} bytes, need at least 4)",
298            data.len()
299        ));
300    }
301
302    let selector: [u8; 4] = data[0..4].try_into().unwrap();
303
304    match selector {
305        INTERNAL_TX_START_BLOCK_METHOD_ID => {
306            let inputs = decode_start_block_data(data)?;
307            apply_start_block(inputs, state, ctx, &mut transfer_fn, &mut balance_of)
308        }
309        INTERNAL_TX_BATCH_POSTING_REPORT_METHOD_ID => {
310            let inputs = decode_batch_posting_report(data)?;
311            apply_batch_posting_report(inputs, state, ctx, &mut transfer_fn)
312        }
313        INTERNAL_TX_BATCH_POSTING_REPORT_V2_METHOD_ID => {
314            let inputs = decode_batch_posting_report_v2(data)?;
315            apply_batch_posting_report_v2(inputs, state, ctx, &mut transfer_fn)
316        }
317        _ => Err(format!(
318            "unknown internal tx selector: {:02x}{:02x}{:02x}{:02x}",
319            selector[0], selector[1], selector[2], selector[3]
320        )),
321    }
322}
323
324fn apply_start_block<D: revm::Database, B: Burner, F, G>(
325    inputs: StartBlockData,
326    state: &mut ArbosState<D, B>,
327    ctx: &InternalTxContext,
328    transfer_fn: &mut F,
329    balance_of: &mut G,
330) -> Result<(), String>
331where
332    F: FnMut(Address, Address, U256) -> Result<(), ()>,
333    G: FnMut(Address) -> U256,
334{
335    let arbos_version = state.arbos_version();
336
337    let mut l1_block_number = inputs.l1_block_number;
338    let mut time_passed = inputs.time_passed;
339
340    // Before ArbOS v3, incorrectly used the L2 block number as time_passed.
341    if arbos_version < arbos_version::ARBOS_VERSION_3 {
342        time_passed = inputs.l2_block_number;
343    }
344
345    // Before ArbOS v8, incorrectly used L1 block number one too high.
346    if arbos_version < arbos_version::ARBOS_VERSION_8 {
347        l1_block_number = l1_block_number.saturating_add(1);
348    }
349
350    // Record L1 block hashes if L1 block number advanced.
351    let old_l1_block_number = state
352        .blockhashes
353        .l1_block_number()
354        .map_err(|_| "failed to read l1 block number")?;
355
356    if l1_block_number > old_l1_block_number {
357        state
358            .blockhashes
359            .record_new_l1_block(l1_block_number - 1, ctx.prev_hash, arbos_version)
360            .map_err(|_| "failed to record L1 block")?;
361    }
362
363    // Try to reap 2 expired retryables.
364    let _ = state.retryable_state.try_to_reap_one_retryable(
365        ctx.current_time,
366        &mut *transfer_fn,
367        &mut *balance_of,
368    );
369    let _ = state.retryable_state.try_to_reap_one_retryable(
370        ctx.current_time,
371        &mut *transfer_fn,
372        &mut *balance_of,
373    );
374
375    // Update L2 pricing model.
376    let _ = state
377        .l2_pricing_state
378        .update_pricing_model(time_passed, arbos_version);
379
380    // Check for scheduled ArbOS upgrade.
381    state
382        .upgrade_arbos_version_if_necessary(ctx.current_time)
383        .map_err(|_| "ArbOS upgrade failed (node may be out of date)")?;
384
385    Ok(())
386}
387
388fn apply_batch_posting_report<D: revm::Database, B: Burner, F>(
389    inputs: BatchPostingReportData,
390    state: &mut ArbosState<D, B>,
391    ctx: &InternalTxContext,
392    transfer_fn: &mut F,
393) -> Result<(), String>
394where
395    F: FnMut(Address, Address, U256) -> Result<(), ()>,
396{
397    let per_batch_gas = state.l1_pricing_state.per_batch_gas_cost().unwrap_or(0);
398
399    // gasSpent = SaturatingAdd(perBatchGas, SaturatingCast[int64](batchDataGas))
400    // Then SaturatingUCast[uint64](gasSpent) — clamps negative result to 0.
401    let batch_data_gas_i64 = i64::try_from(inputs.batch_data_gas).unwrap_or(i64::MAX);
402    let gas_spent_signed = per_batch_gas.saturating_add(batch_data_gas_i64);
403    let gas_spent = gas_spent_signed.max(0) as u64;
404    let wei_spent = inputs.l1_base_fee.saturating_mul(U256::from(gas_spent));
405
406    if let Err(e) = state.l1_pricing_state.update_for_batch_poster_spending(
407        inputs.batch_timestamp,
408        ctx.current_time,
409        inputs.batch_poster,
410        wei_spent,
411        inputs.l1_base_fee,
412        &mut *transfer_fn,
413    ) {
414        tracing::warn!(error = ?e, "L1 pricing update failed for batch posting report");
415    }
416
417    Ok(())
418}
419
420fn apply_batch_posting_report_v2<D: revm::Database, B: Burner, F>(
421    inputs: BatchPostingReportV2Data,
422    state: &mut ArbosState<D, B>,
423    ctx: &InternalTxContext,
424    transfer_fn: &mut F,
425) -> Result<(), String>
426where
427    F: FnMut(Address, Address, U256) -> Result<(), ()>,
428{
429    let arbos_version = state.arbos_version();
430
431    // Compute gas from calldata stats (legacy cost model).
432    let mut gas_spent = legacy_cost_for_stats(&BatchDataStats {
433        length: inputs.batch_calldata_length,
434        non_zeros: inputs.batch_calldata_non_zeros,
435    });
436
437    gas_spent = gas_spent.saturating_add(inputs.batch_extra_gas);
438
439    // Add per-batch gas overhead.
440    let per_batch_gas = state.l1_pricing_state.per_batch_gas_cost().unwrap_or(0);
441
442    gas_spent = gas_spent.saturating_add(per_batch_gas.max(0) as u64);
443
444    // Floor gas computation (ArbOS v50+).
445    if arbos_version >= arbos_version::ARBOS_VERSION_50 {
446        let gas_floor_per_token = state
447            .l1_pricing_state
448            .parent_gas_floor_per_token()
449            .unwrap_or(0);
450
451        let total_tokens = inputs
452            .batch_calldata_length
453            .saturating_add(inputs.batch_calldata_non_zeros.saturating_mul(3))
454            .saturating_add(FLOOR_GAS_ADDITIONAL_TOKENS);
455
456        let floor_gas_spent = gas_floor_per_token
457            .saturating_mul(total_tokens)
458            .saturating_add(TX_GAS);
459
460        if floor_gas_spent > gas_spent {
461            gas_spent = floor_gas_spent;
462        }
463    }
464
465    let wei_spent = inputs.l1_base_fee.saturating_mul(U256::from(gas_spent));
466
467    if let Err(e) = state.l1_pricing_state.update_for_batch_poster_spending(
468        inputs.batch_timestamp,
469        ctx.current_time,
470        inputs.batch_poster,
471        wei_spent,
472        inputs.l1_base_fee,
473        &mut *transfer_fn,
474    ) {
475        tracing::warn!(error = ?e, "L1 pricing update failed for batch posting report v2");
476    }
477
478    Ok(())
479}