From 55aaf07333f1c20f9303e3154e6fe7a0797cccdc Mon Sep 17 00:00:00 2001 From: Matthias Seitz Date: Thu, 3 Sep 2026 22:50:13 +0200 Subject: [PATCH] perf(sequencer): scan batch boundaries from receipts only The monitor rescans for BatchFinalized boundaries on every canonical-state notification, that is once per zone block, and the scan read the full body of every block in the range through block_by_number. Reth serves that from the in-memory canonical state as a deep clone of the body, or decodes the whole body from the database, while the scan used it only for a transaction hash and a transaction/receipt count check. The scan now reads receipts alone and keeps the transaction index that FinalizedBatchLog already records, so the finalizeWithdrawalBatch transaction is resolved by index once the boundary block is read. That block is read exactly once per batch and shared between batch reconstruction and the commitment snapshot, which each re-read it before, and the count check moved to that single read. Counting provider calls against the mock provider for a 64-block scan plus one batch: block_by_number 66 -> 1, receipts_by_block 66 -> 65. The two per-block monitor log lines that report the scanned range are now debug! rather than info!. --- crates/sequencer/src/monitor.rs | 14 +- crates/sequencer/src/settlement.rs | 227 +++++++++++++++++++++-------- 2 files changed, 178 insertions(+), 63 deletions(-) diff --git a/crates/sequencer/src/monitor.rs b/crates/sequencer/src/monitor.rs index 8b993db0e..4524f7b77 100644 --- a/crates/sequencer/src/monitor.rs +++ b/crates/sequencer/src/monitor.rs @@ -42,8 +42,8 @@ use crate::{ resolve_portal_zone_anchor, settlement::{ BatchAnchorConfig, BatchData, BatchSubmitError, BatchSubmitter, FinalizedBatchLog, - WithdrawalPage, ZoneBlockSnapshot, fetch_finalized_batch, fetch_finalized_batch_boundaries, - read_zone_block_snapshot, + WithdrawalPage, ZoneBlockSnapshot, block_with_receipts, fetch_finalized_batch_boundaries, + read_zone_block_snapshot, resolve_finalized_batch, zone_block_snapshot, }, withdrawals::SharedWithdrawalStore, }; @@ -407,13 +407,13 @@ impl ZoneMonitor

{ shutdown: &sync::CancellationToken, ) -> std::result::Result { let block_count = to - from + 1; - info!(from, to, block_count, "Processing zone block range"); + debug!(from, to, block_count, "Processing zone block range"); let boundaries = fetch_finalized_batch_boundaries(&self.provider, self.config.outbox_address, from, to) .await?; if boundaries.is_empty() { - info!(from, to, "No finalized batch boundaries ready to submit"); + debug!(from, to, "No finalized batch boundaries ready to submit"); return Ok(false); } @@ -462,9 +462,11 @@ impl ZoneMonitor

{ shutdown: &sync::CancellationToken, ) -> std::result::Result<(), BatchSubmitError> { let to = boundary.block_number; + let (block, receipts) = block_with_receipts(&self.provider, to)?; let finalized_batch = - fetch_finalized_batch(&self.provider, self.config.outbox_address, &boundary).await?; - let end_state = read_zone_block_snapshot(&self.provider, self.config.inbox_address, to)?; + resolve_finalized_batch(self.config.outbox_address, &boundary, &block, &receipts)?; + let end_state = + zone_block_snapshot(&self.provider, self.config.inbox_address, to, &receipts)?; if !finalized_batch.withdrawals.is_empty() { info!( diff --git a/crates/sequencer/src/settlement.rs b/crates/sequencer/src/settlement.rs index 7d04d3238..975c506ab 100644 --- a/crates/sequencer/src/settlement.rs +++ b/crates/sequencer/src/settlement.rs @@ -1064,8 +1064,7 @@ impl BatchSubmitter { let Some(&zone_block) = zone_block_by_slot.get(&portal_slot) else { continue; }; - let withdrawals = - fetch_slot_withdrawals(zone_provider, outbox_address, zone_block).await?; + let withdrawals = fetch_slot_withdrawals(zone_provider, outbox_address, zone_block)?; slot_withdrawals.insert(portal_slot, withdrawals); } @@ -1204,9 +1203,10 @@ pub(crate) struct FinalizedBatch { #[derive(Debug, Clone)] pub(crate) struct FinalizedBatchLog { pub(crate) block_number: u64, + /// Index of the transaction that emitted the event, used to resolve the + /// `finalizeWithdrawalBatch` transaction once the boundary block is read. tx_index: u64, log_index: u64, - tx_hash: B256, withdrawal_queue_hash: B256, withdrawal_batch_index: u64, } @@ -1493,16 +1493,18 @@ pub(crate) fn find_processed_offset( None } -fn block_with_receipts( +/// Read a canonical zone block together with its receipts. +/// +/// Only callers that need the transaction bodies should use this; scanning for events needs +/// [`block_receipts`] alone. +pub(crate) fn block_with_receipts( provider: &P, number: u64, ) -> Result<(Block, Vec)> { let block = provider .block_by_number(number)? .ok_or_else(|| eyre::eyre!("canonical zone block {number} not found"))?; - let receipts = provider - .receipts_by_block(BlockHashOrNumber::Number(number))? - .ok_or_else(|| eyre::eyre!("receipts for canonical zone block {number} not found"))?; + let receipts = block_receipts(provider, number)?; if block.body.transactions.len() != receipts.len() { return Err(eyre::eyre!( "zone block {number} has {} transactions but {} receipts", @@ -1513,6 +1515,15 @@ fn block_with_receipts( Ok((block, receipts)) } +fn block_receipts( + provider: &P, + number: u64, +) -> Result> { + provider + .receipts_by_block(BlockHashOrNumber::Number(number))? + .ok_or_else(|| eyre::eyre!("receipts for canonical zone block {number} not found")) +} + /// Read the settlement commitments emitted by the deterministic system transaction in a zone /// block. pub(crate) fn read_zone_block_snapshot( @@ -1520,7 +1531,17 @@ pub(crate) fn read_zone_block_snapshot( inbox_address: Address, number: u64, ) -> Result { - let (_, receipts) = block_with_receipts(provider, number)?; + let receipts = block_receipts(provider, number)?; + zone_block_snapshot(provider, inbox_address, number, &receipts) +} + +/// [`read_zone_block_snapshot`] for callers that already hold the block's receipts. +pub(crate) fn zone_block_snapshot( + provider: &P, + inbox_address: Address, + number: u64, + receipts: &[TempoReceipt], +) -> Result { let mut tempo_block_number = None; let mut processed_deposit_hash = None; let mut processed_deposit_number = None; @@ -1584,20 +1605,20 @@ pub(crate) async fn fetch_finalized_batch_boundaries( Ok(boundaries) } -/// Fetch one finalized L2 withdrawal batch. +/// Reconstruct one finalized L2 withdrawal batch from its boundary block. /// /// The submitted hash and index come from the supplied `BatchFinalized` event. /// Withdrawal structs are reconstructed from `WithdrawalRequested` logs in the /// same block: every non-empty withdrawal batch is finalized in the block that /// contains its requests. -pub(crate) async fn fetch_finalized_batch( - zone_provider: &P, +pub(crate) fn resolve_finalized_batch( outbox_address: Address, target: &FinalizedBatchLog, + block: &Block, + receipts: &[TempoReceipt], ) -> Result { - let (block, receipts) = block_with_receipts(zone_provider, target.block_number)?; let mut requests = Vec::new(); - for (tx, receipt) in block.body.transactions.iter().zip(&receipts) { + for (tx, receipt) in block.body.transactions.iter().zip(receipts) { for log in receipt.logs() { if log.address != outbox_address || log.topics().first() != Some(&IZoneOutbox::WithdrawalRequested::SIGNATURE_HASH) @@ -1619,12 +1640,11 @@ pub(crate) async fn fetch_finalized_batch( let finalize_tx = block .body .transactions - .iter() - .find(|tx| *tx.tx_hash() == target.tx_hash) + .get(target.tx_index as usize) .ok_or_else(|| { eyre::eyre!( - "missing finalizeWithdrawalBatch tx {} for zone block {}", - target.tx_hash, + "missing finalizeWithdrawalBatch tx at index {} for zone block {}", + target.tx_index, target.block_number ) })?; @@ -1633,7 +1653,7 @@ pub(crate) async fn fetch_finalized_batch( .map_err(|err| { eyre::eyre!( "failed to decode finalizeWithdrawalBatch calldata for {}: {err}", - target.tx_hash + finalize_tx.tx_hash() ) })? .encryptedSenders; @@ -1678,22 +1698,23 @@ pub(crate) async fn fetch_finalized_batch( /// outbox finalizes pending withdrawals in the same zone block as their /// requests, so recovery needs to inspect only the block referenced by the /// slot's `BatchSubmitted.nextBlockHash`. -pub(crate) async fn fetch_slot_withdrawals( +pub(crate) fn fetch_slot_withdrawals( zone_provider: &impl ZoneSequencerProvider, outbox_address: Address, block_number: u64, ) -> Result> { - let boundaries = - fetch_finalized_batch_boundaries(zone_provider, outbox_address, block_number, block_number) - .await?; + let (block, receipts) = block_with_receipts(zone_provider, block_number)?; + let mut boundaries = Vec::new(); + collect_finalized_batch_logs(outbox_address, block_number, &receipts, &mut boundaries)?; + if boundaries.len() > 1 { + return Err(eyre::eyre!( + "zone block {block_number} contains more than one BatchFinalized event" + )); + } let target = boundaries.into_iter().next().ok_or_else(|| { eyre::eyre!("zone block {block_number} does not contain a BatchFinalized boundary") })?; - Ok( - fetch_finalized_batch(zone_provider, outbox_address, &target) - .await? - .withdrawals, - ) + Ok(resolve_finalized_batch(outbox_address, &target, &block, &receipts)?.withdrawals) } fn fetch_finalized_batch_logs( @@ -1704,38 +1725,47 @@ fn fetch_finalized_batch_logs( ) -> Result> { let mut finalized_batches = Vec::new(); for block_number in from..=to { - let (block, receipts) = block_with_receipts(provider, block_number)?; - for (tx_index, (tx, receipt)) in block - .body - .transactions - .iter() - .zip(receipts.iter()) - .enumerate() - { - for (log_index, log) in receipt.logs().iter().enumerate() { - if log.address != outbox_address - || log.topics().first() != Some(&IZoneOutbox::BatchFinalized::SIGNATURE_HASH) - { - continue; - } - let event = IZoneOutbox::BatchFinalized::decode_log(log).map_err(|err| { - eyre::eyre!("invalid BatchFinalized log in zone block {block_number}: {err}") - })?; - finalized_batches.push(FinalizedBatchLog { - block_number, - tx_index: tx_index as u64, - log_index: log_index as u64, - tx_hash: *tx.tx_hash(), - withdrawal_queue_hash: event.withdrawalQueueHash, - withdrawal_batch_index: event.withdrawalBatchIndex, - }); - } - } + let receipts = block_receipts(provider, block_number)?; + collect_finalized_batch_logs( + outbox_address, + block_number, + &receipts, + &mut finalized_batches, + )?; } finalized_batches.sort_by_key(|batch| (batch.block_number, batch.tx_index, batch.log_index)); Ok(finalized_batches) } +/// Append every `BatchFinalized` event emitted by `outbox_address` in one block's receipts. +fn collect_finalized_batch_logs( + outbox_address: Address, + block_number: u64, + receipts: &[TempoReceipt], + finalized_batches: &mut Vec, +) -> Result<()> { + for (tx_index, receipt) in receipts.iter().enumerate() { + for (log_index, log) in receipt.logs().iter().enumerate() { + if log.address != outbox_address + || log.topics().first() != Some(&IZoneOutbox::BatchFinalized::SIGNATURE_HASH) + { + continue; + } + let event = IZoneOutbox::BatchFinalized::decode_log(log).map_err(|err| { + eyre::eyre!("invalid BatchFinalized log in zone block {block_number}: {err}") + })?; + finalized_batches.push(FinalizedBatchLog { + block_number, + tx_index: tx_index as u64, + log_index: log_index as u64, + withdrawal_queue_hash: event.withdrawalQueueHash, + withdrawal_batch_index: event.withdrawalBatchIndex, + }); + } + } + Ok(()) +} + fn backward_log_query_start(hi: u64, floor: u64) -> u64 { hi.saturating_sub(LOG_QUERY_BLOCK_CHUNK - 1).max(floor) } @@ -1744,8 +1774,8 @@ fn backward_log_query_start(hi: u64, floor: u64) -> u64 { mod tests { use super::*; use crate::abi; - use alloy_consensus::Header as ConsensusHeader; - use alloy_primitives::{B256, address}; + use alloy_consensus::{Header as ConsensusHeader, Signed, TxLegacy}; + use alloy_primitives::{B256, Log, Signature, address}; use alloy_provider::ProviderBuilder; use alloy_rpc_types_eth::Header as RpcHeader; use alloy_sol_types::SolValue; @@ -1753,7 +1783,7 @@ mod tests { use proptest::prelude::*; use reth_provider::test_utils::MockEthProvider; use tempo_alloy::rpc::TempoHeaderResponse; - use tempo_primitives::{Block, TempoHeader, TempoPrimitives}; + use tempo_primitives::{Block, TempoHeader, TempoPrimitives, TempoTxEnvelope, TempoTxType}; fn mock_l1(asserter: Asserter) -> DynProvider { ProviderBuilder::new_with_network::() @@ -2677,4 +2707,87 @@ mod tests { let result = resolve_pending_slots(5, 6, &events, &slot_withdrawals, corrupted_hash); assert!(result.is_err()); } + + fn legacy_tx(input: Bytes) -> TempoTxEnvelope { + TempoTxEnvelope::Legacy(Signed::new_unhashed( + TxLegacy { + input, + ..Default::default() + }, + Signature::test_signature(), + )) + } + + fn receipt_with_logs(logs: Vec) -> TempoReceipt { + TempoReceipt { + tx_type: TempoTxType::Legacy, + success: true, + cumulative_gas_used: 0, + logs, + } + } + + /// The boundary scan runs once per canonical zone block, so it must get by with receipts: + /// only the boundary block body is read, and its `finalizeWithdrawalBatch` transaction is + /// resolved by index. Blocks before the boundary have no body registered here. + #[tokio::test] + async fn scans_boundaries_from_receipts_and_resolves_the_batch_by_tx_index() { + let outbox_address = Address::repeat_byte(0x22); + let boundary_number = 8u64; + let zone = MockEthProvider::::new(); + for number in 1..boundary_number { + zone.add_receipts(number, vec![receipt_with_logs(Vec::new())]); + } + + let finalize_call = abi::IZoneOutbox::finalizeWithdrawalBatchCall { + count: U256::ZERO, + blockNumber: boundary_number, + encryptedSenders: Vec::new(), + }; + let mut header = TempoHeader::default(); + header.inner.number = boundary_number; + zone.add_block( + B256::repeat_byte(0x08), + Block { + header, + body: alloy_consensus::BlockBody { + transactions: vec![ + legacy_tx(Bytes::new()), + legacy_tx(finalize_call.abi_encode().into()), + ], + ..Default::default() + }, + }, + ); + zone.add_receipts( + boundary_number, + vec![ + receipt_with_logs(Vec::new()), + receipt_with_logs(vec![Log { + address: outbox_address, + data: IZoneOutbox::BatchFinalized { + withdrawalQueueHash: B256::ZERO, + withdrawalBatchIndex: 3, + } + .encode_log_data(), + }]), + ], + ); + + let boundaries = + fetch_finalized_batch_boundaries(&zone, outbox_address, 1, boundary_number) + .await + .unwrap(); + + assert_eq!(boundaries.len(), 1); + assert_eq!(boundaries[0].block_number, boundary_number); + assert_eq!(boundaries[0].tx_index, 1); + + let (block, receipts) = block_with_receipts(&zone, boundary_number).unwrap(); + let batch = + resolve_finalized_batch(outbox_address, &boundaries[0], &block, &receipts).unwrap(); + + assert_eq!(batch.finalized_index, 3); + assert!(batch.withdrawals.is_empty()); + } }