arbos/
block_processor.rs

1use alloy_primitives::{Address, B256, U256};
2use arb_chainspec::arbos_version as arb_ver;
3
4use crate::{header::ArbHeaderInfo, internal_tx::L1Info, l2_pricing::GETH_BLOCK_GAS_LIMIT};
5
6/// Standard Ethereum transaction gas.
7const TX_GAS: u64 = 21_000;
8
9/// A sequencer-implementation rejection raised by [`SequencingHooks`] filter
10/// methods.
11///
12/// Sequencer hooks are inherently policy-driven (allowlists, mempool budgets,
13/// custom validation): the rejection reason is opaque to the state machine and
14/// is preserved verbatim for diagnostics.
15#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
16#[error("sequencer rejected transaction: {reason}")]
17pub struct FilterReject {
18    /// Free-form reason supplied by the rejecting hook.
19    pub reason: String,
20}
21
22impl FilterReject {
23    /// Construct a new rejection from any [`Into<String>`] reason.
24    pub fn new(reason: impl Into<String>) -> Self {
25        Self {
26            reason: reason.into(),
27        }
28    }
29}
30
31/// Errors raised while recording a tx outcome or finalising a block.
32#[derive(thiserror::Error, Debug)]
33pub enum BlockProcessorError {
34    /// The internal start-block transaction reported an EVM-level error.
35    /// Internal txs must never fail; this aborts block production.
36    #[error("internal start-block tx failed: {reason}")]
37    InternalTxFailed {
38        /// Verbatim EVM error message from the failed internal tx.
39        reason: String,
40    },
41
42    /// The post-block balance delta does not match the deposits/withdrawals
43    /// tracked during block production.
44    #[error("unexpected balance delta {actual} (expected {expected})")]
45    BalanceDelta {
46        /// Actual delta observed in state.
47        actual: i128,
48        /// Delta tracked from deposits/withdrawals.
49        expected: i128,
50    },
51}
52
53// =====================================================================
54// Conditional options
55// =====================================================================
56
57/// Conditional options that may be attached to a transaction.
58#[derive(Debug, Clone, Default)]
59pub struct ConditionalOptions {
60    pub known_accounts: Vec<(Address, Option<B256>)>,
61    pub block_number_min: Option<u64>,
62    pub block_number_max: Option<u64>,
63    pub timestamp_min: Option<u64>,
64    pub timestamp_max: Option<u64>,
65}
66
67// =====================================================================
68// Sequencing hooks
69// =====================================================================
70
71/// Hooks for the sequencer to control block production.
72pub trait SequencingHooks {
73    /// Returns the next transaction to include, or None if the block is complete.
74    fn next_tx_to_sequence(&mut self) -> Option<Vec<u8>>;
75
76    /// Filters a transaction before execution.
77    fn pre_tx_filter(&self, tx: &[u8]) -> Result<(), FilterReject>;
78
79    /// Filters a transaction after execution.
80    fn post_tx_filter(&self, tx: &[u8], result: &[u8]) -> Result<(), FilterReject>;
81
82    /// Determines whether to discard invalid txs early.
83    fn discard_invalid_txs_early(&self) -> bool;
84
85    /// Block-level filter applied after all transactions are processed.
86    fn block_filter(
87        &self,
88        _header: &NewHeaderResult,
89        _txs: &[Vec<u8>],
90        _receipts: &[Vec<u8>],
91    ) -> Result<(), FilterReject> {
92        Ok(())
93    }
94
95    /// Inserts the error for the last tx.
96    fn insert_last_tx_error(&mut self, _err: String) {}
97}
98
99/// Default no-op implementation for sequencing hooks.
100pub struct NoopSequencingHooks;
101
102impl SequencingHooks for NoopSequencingHooks {
103    fn next_tx_to_sequence(&mut self) -> Option<Vec<u8>> {
104        None
105    }
106
107    fn pre_tx_filter(&self, _tx: &[u8]) -> Result<(), FilterReject> {
108        Ok(())
109    }
110
111    fn post_tx_filter(&self, _tx: &[u8], _result: &[u8]) -> Result<(), FilterReject> {
112        Ok(())
113    }
114
115    fn discard_invalid_txs_early(&self) -> bool {
116        false
117    }
118}
119
120// =====================================================================
121// Block production types
122// =====================================================================
123
124/// The result of block production.
125#[derive(Debug, Clone)]
126pub struct BlockProductionResult {
127    pub l1_info: L1Info,
128    pub num_txs: usize,
129    pub gas_used: u64,
130}
131
132/// Parameters for creating a new block header.
133#[derive(Debug, Clone)]
134pub struct NewHeaderParams {
135    pub parent_hash: B256,
136    pub parent_number: u64,
137    pub parent_timestamp: u64,
138    pub parent_extra_data: Vec<u8>,
139    pub parent_mix_hash: B256,
140    pub coinbase: Address,
141    pub timestamp: u64,
142    pub base_fee: U256,
143}
144
145/// Computed header fields from `create_new_header`.
146#[derive(Debug, Clone)]
147pub struct NewHeaderResult {
148    pub parent_hash: B256,
149    pub coinbase: Address,
150    pub number: u64,
151    pub gas_limit: u64,
152    pub timestamp: u64,
153    pub extra_data: Vec<u8>,
154    pub mix_hash: B256,
155    pub base_fee: U256,
156    pub difficulty: U256,
157}
158
159// =====================================================================
160// Header creation and finalization
161// =====================================================================
162
163/// Create new header fields for an Arbitrum block.
164///
165/// In reth, the actual header construction is done by the block builder;
166/// this computes the Arbitrum-specific fields.
167pub fn create_new_header(
168    l1_info: Option<&L1Info>,
169    prev_hash: B256,
170    prev_number: u64,
171    prev_timestamp: u64,
172    prev_extra: &[u8],
173    prev_mix_hash: B256,
174    base_fee: U256,
175) -> NewHeaderResult {
176    let mut timestamp = 0u64;
177    let mut coinbase = Address::ZERO;
178
179    if let Some(info) = l1_info {
180        timestamp = info.l1_timestamp;
181        coinbase = info.poster;
182    }
183
184    if timestamp < prev_timestamp {
185        timestamp = prev_timestamp;
186    }
187
188    let mut extra_data = vec![0u8; 32];
189    let copy_len = prev_extra.len().min(32);
190    extra_data[..copy_len].copy_from_slice(&prev_extra[..copy_len]);
191
192    NewHeaderResult {
193        parent_hash: prev_hash,
194        coinbase,
195        number: prev_number + 1,
196        gas_limit: GETH_BLOCK_GAS_LIMIT,
197        timestamp,
198        extra_data,
199        mix_hash: prev_mix_hash,
200        base_fee,
201        difficulty: U256::from(1),
202    }
203}
204
205/// Compute the Arbitrum header info to finalize a block.
206///
207/// This corresponds to `FinalizeBlock` which sets header fields from ArbOS state.
208/// We derive the info and let the block assembler apply it.
209pub fn finalize_block_header_info(
210    send_root: B256,
211    send_count: u64,
212    l1_block_number: u64,
213    arbos_version: u64,
214    collect_tips: bool,
215) -> ArbHeaderInfo {
216    ArbHeaderInfo {
217        send_root,
218        send_count,
219        l1_block_number,
220        arbos_format_version: arbos_version,
221        collect_tips,
222    }
223}
224
225// =====================================================================
226// Block production engine
227// =====================================================================
228
229/// The outcome of attempting to apply a single transaction.
230#[derive(Debug)]
231pub enum TxOutcome {
232    /// Transaction was executed successfully.
233    Success(TxResult),
234    /// Transaction was invalid and should be skipped.
235    Invalid(String),
236}
237
238/// A successfully executed transaction's metadata.
239#[derive(Debug, Clone)]
240pub struct TxResult {
241    /// Gas used by this transaction (from header gas tracking).
242    pub gas_used: u64,
243    /// L1 poster data gas for this transaction.
244    pub data_gas: u64,
245    /// Whether the EVM execution itself succeeded (receipt status).
246    pub evm_success: bool,
247    /// Scheduled retryable redeems produced by this tx.
248    pub scheduled_txs: Vec<Vec<u8>>,
249    /// Whether the EVM reported an error (internal txs must not fail).
250    pub evm_error: Option<String>,
251}
252
253/// Per-tx decision made by the block production loop.
254#[derive(Debug)]
255pub enum TxAction {
256    /// Execute the internal start-block transaction.
257    ExecuteStartBlock,
258    /// Execute a retryable redeem.
259    ExecuteRedeem(Vec<u8>),
260    /// Execute a user/sequencer transaction.
261    ExecuteUserTx(Vec<u8>),
262    /// Block is complete.
263    Done,
264}
265
266/// Tracks block-level state during production.
267///
268/// The block executor creates a `BlockProductionState` at the start of the
269/// block, then calls `next_tx_action` in a loop to get transactions, and
270/// `record_tx_outcome` after executing each one. After the loop, call
271/// `finalize` for post-block checks.
272#[derive(Debug)]
273pub struct BlockProductionState {
274    /// Block gas remaining for rate-limiting.
275    pub block_gas_left: u64,
276    /// Pending retryable redeems scheduled by prior transactions.
277    redeems: Vec<Vec<u8>>,
278    /// Whether the internal start-block tx has been produced yet.
279    start_block_produced: bool,
280    /// Count of user transactions processed.
281    user_txs_processed: usize,
282    /// Expected balance delta from L1 deposits/withdrawals.
283    pub expected_balance_delta: i128,
284    /// The ArbOS version (may be updated after internal tx).
285    arbos_version: u64,
286    /// Block timestamp.
287    pub timestamp: u64,
288    /// Block base fee.
289    pub base_fee: U256,
290}
291
292impl BlockProductionState {
293    /// Create a new block production state.
294    pub fn new(
295        per_block_gas_limit: u64,
296        arbos_version: u64,
297        timestamp: u64,
298        base_fee: U256,
299    ) -> Self {
300        Self {
301            block_gas_left: per_block_gas_limit,
302            redeems: Vec::new(),
303            start_block_produced: false,
304            user_txs_processed: 0,
305            expected_balance_delta: 0,
306            arbos_version,
307            timestamp,
308            base_fee,
309        }
310    }
311
312    /// Get the next transaction action. The block executor calls this in a loop.
313    pub fn next_tx_action<H: SequencingHooks>(&mut self, hooks: &mut H) -> TxAction {
314        if !self.start_block_produced {
315            self.start_block_produced = true;
316            return TxAction::ExecuteStartBlock;
317        }
318
319        // Process queued redeems first (FIFO).
320        if !self.redeems.is_empty() {
321            let redeem = self.redeems.remove(0);
322            return TxAction::ExecuteRedeem(redeem);
323        }
324
325        // Ask the sequencer for the next transaction.
326        match hooks.next_tx_to_sequence() {
327            Some(tx_bytes) => {
328                // If the block has no gas left, skip user txs.
329                if self.block_gas_left < TX_GAS {
330                    hooks.insert_last_tx_error("block gas limit reached".to_string());
331                    return TxAction::Done;
332                }
333                TxAction::ExecuteUserTx(tx_bytes)
334            }
335            None => TxAction::Done,
336        }
337    }
338
339    /// Check whether a user tx can fit in the remaining block gas.
340    ///
341    /// In ArbOS < 50, user txs whose compute gas exceeds block_gas_left
342    /// are rejected (after the first tx). In ArbOS >= 50, per-tx gas limiting
343    /// is handled in the gas charging hook instead.
344    pub fn should_reject_for_block_gas(&self, compute_gas: u64, is_user_tx: bool) -> bool {
345        self.arbos_version < arb_ver::ARBOS_VERSION_50
346            && compute_gas > self.block_gas_left
347            && is_user_tx
348            && self.user_txs_processed > 0
349    }
350
351    /// Compute the poster data gas cost in L2 terms for block-level tracking.
352    pub fn compute_data_gas(poster_cost: U256, base_fee: U256, tx_gas: u64) -> u64 {
353        if base_fee.is_zero() {
354            return 0;
355        }
356
357        let poster_cost_in_l2_gas = poster_cost / base_fee;
358        let data_gas: u64 = poster_cost_in_l2_gas.try_into().unwrap_or(u64::MAX);
359
360        // Cap to tx gas limit.
361        data_gas.min(tx_gas)
362    }
363
364    /// Record the result of executing a transaction.
365    ///
366    /// Returns [`BlockProcessorError::InternalTxFailed`] if the internal
367    /// start-block tx reported an EVM error.
368    pub fn record_tx_outcome(
369        &mut self,
370        action: &TxAction,
371        outcome: TxOutcome,
372    ) -> Result<(), BlockProcessorError> {
373        match outcome {
374            TxOutcome::Invalid(err) => {
375                // Invalid txs still consume a TX_GAS worth of block gas.
376                match action {
377                    TxAction::ExecuteUserTx(_) => {
378                        self.block_gas_left = self.block_gas_left.saturating_sub(TX_GAS);
379                        self.user_txs_processed += 1;
380                    }
381                    _ => {
382                        self.block_gas_left = self.block_gas_left.saturating_sub(TX_GAS);
383                    }
384                }
385                tracing::debug!(err, "tx invalid, skipped");
386                Ok(())
387            }
388            TxOutcome::Success(result) => {
389                // Internal start-block tx must not fail.
390                if matches!(action, TxAction::ExecuteStartBlock)
391                    && let Some(ref err) = result.evm_error
392                {
393                    return Err(BlockProcessorError::InternalTxFailed {
394                        reason: err.clone(),
395                    });
396                }
397
398                let tx_gas_used = result.gas_used;
399                let data_gas = result.data_gas;
400
401                // Subtract gas burned for scheduled redeems (ArbOS >= 4).
402                if self.arbos_version >= arb_ver::ARBOS_VERSION_3 {
403                    for scheduled in &result.scheduled_txs {
404                        // Each scheduled retryable has gas reserved.
405                        // The gas is embedded in the retryable tx encoding;
406                        // the executor should subtract it from tx_gas_used.
407                        let _ = scheduled; // gas deduction handled by executor
408                    }
409                }
410
411                // Queue any scheduled redeems.
412                self.redeems.extend(result.scheduled_txs);
413
414                // Compute used compute gas for block rate limiting.
415                let compute_used = if tx_gas_used >= data_gas {
416                    let c = tx_gas_used - data_gas;
417                    if c < TX_GAS { TX_GAS } else { c }
418                } else {
419                    tracing::error!(tx_gas_used, data_gas, "tx used less gas than expected");
420                    TX_GAS
421                };
422
423                self.block_gas_left = self.block_gas_left.saturating_sub(compute_used);
424
425                if matches!(action, TxAction::ExecuteUserTx(_)) {
426                    self.user_txs_processed += 1;
427                }
428
429                Ok(())
430            }
431        }
432    }
433
434    /// Track deposit balance delta for post-block verification.
435    pub fn track_deposit(&mut self, value: U256) {
436        let value_i128: i128 = value.try_into().unwrap_or(i128::MAX);
437        self.expected_balance_delta = self.expected_balance_delta.saturating_add(value_i128);
438    }
439
440    /// Track withdrawal balance delta from L2->L1 tx events.
441    pub fn track_withdrawal(&mut self, value: U256) {
442        let value_i128: i128 = value.try_into().unwrap_or(i128::MAX);
443        self.expected_balance_delta = self.expected_balance_delta.saturating_sub(value_i128);
444    }
445
446    /// Update ArbOS version (called after internal tx execution may upgrade).
447    pub fn set_arbos_version(&mut self, version: u64) {
448        self.arbos_version = version;
449    }
450
451    /// Verify the post-block balance delta matches expected deposits/withdrawals.
452    pub fn verify_balance_delta(
453        &self,
454        actual_balance_delta: i128,
455        debug_mode: bool,
456    ) -> Result<(), BlockProcessorError> {
457        if actual_balance_delta == self.expected_balance_delta {
458            return Ok(());
459        }
460
461        if actual_balance_delta > self.expected_balance_delta || debug_mode {
462            return Err(BlockProcessorError::BalanceDelta {
463                actual: actual_balance_delta,
464                expected: self.expected_balance_delta,
465            });
466        }
467
468        // Funds were burnt (not minted), only log an error.
469        tracing::error!(
470            actual = actual_balance_delta,
471            expected = self.expected_balance_delta,
472            "unexpected balance delta (funds burnt)"
473        );
474        Ok(())
475    }
476
477    /// Total user transactions processed.
478    pub fn user_txs_processed(&self) -> usize {
479        self.user_txs_processed
480    }
481
482    /// Current ArbOS version.
483    pub fn arbos_version(&self) -> u64 {
484        self.arbos_version
485    }
486}