From bc0b6b1169ff92191f7dea5c3dacd736429eddcd Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:25:29 +0000 Subject: [PATCH 1/7] fix: cache ancestry headers across batches --- crates/sequencer/src/settlement.rs | 192 ++++++++++++++++++++++++----- 1 file changed, 163 insertions(+), 29 deletions(-) diff --git a/crates/sequencer/src/settlement.rs b/crates/sequencer/src/settlement.rs index 1c42cf93c..60560557e 100644 --- a/crates/sequencer/src/settlement.rs +++ b/crates/sequencer/src/settlement.rs @@ -34,6 +34,7 @@ use alloy_rlp::Encodable; use alloy_sol_types::{SolCall, SolEvent}; use eyre::Result; use futures::{StreamExt, TryStreamExt}; +use parking_lot::RwLock; use tempo_alloy::{TempoNetwork, rpc::TempoCallBuilderExt}; use tracing::{info, instrument, warn}; @@ -170,6 +171,18 @@ pub struct BatchSubmitter { l1_fetch_concurrency: usize, /// EIP-2935 history and safety-margin limits used for anchor decisions. anchor_config: BatchAnchorConfig, + /// Validated, RLP-encoded L1 headers retained across overlapping ancestry + /// requests. Settlement batches are submitted in order, so later requests + /// can reuse almost the entire preceding range. + ancestry_header_cache: RwLock>, +} + +/// One validated L1 header retained for ancestry proof construction. +#[derive(Debug, Clone)] +struct CachedAncestryHeader { + parent_hash: B256, + hash: B256, + encoded: Bytes, } impl BatchSubmitter { @@ -204,6 +217,7 @@ impl BatchSubmitter { genesis_tempo_block_number, l1_fetch_concurrency: 16, anchor_config, + ancestry_header_cache: RwLock::new(BTreeMap::new()), } } @@ -403,7 +417,7 @@ impl BatchSubmitter { } /// Fetch and RLP-encode L1 block headers from `from + 1` to `to` (inclusive), - /// validating the parent-hash chain. + /// validating the parent-hash chain and reusing cached overlapping headers. /// /// Returns headers in ascending block-number order. The first header's /// `parent_hash` is validated against the hash of block `from`, ensuring the @@ -415,21 +429,15 @@ impl BatchSubmitter { return Ok(Vec::new()); } - let concurrency = self.l1_fetch_concurrency; - let range_start = from + 1; - let count = (to - from) as usize; - - // Fetch the base block's header to seed the parent-hash chain validation. - let base_header = self - .l1_provider - .get_header_by_number(from.into()) - .await? - .ok_or_else(|| eyre::eyre!("L1 header not found for base block {from}"))?; - let mut base_buf = Vec::with_capacity(600); - base_header.inner.inner.encode(&mut base_buf); - let base_hash = alloy_primitives::keccak256(&base_buf); - - let mut fetched = stream::iter(range_start..=to) + let missing = { + let cache = self.ancestry_header_cache.read(); + (from..=to) + .filter(|block_number| !cache.contains_key(block_number)) + .collect::>() + }; + let cache_hits = (to - from + 1) as usize - missing.len(); + + let mut fetched = stream::iter(missing.iter().copied()) .map(|block_number| { let provider = &self.l1_provider; async move { @@ -442,30 +450,95 @@ impl BatchSubmitter { Ok::<_, eyre::Report>((block_number, header.inner.inner)) } }) - .buffered(concurrency); + .buffered(self.l1_fetch_concurrency); - let mut headers = Vec::with_capacity(count); - let mut prev_hash: Option = Some(base_hash); + let mut new_headers = Vec::with_capacity(missing.len()); while let Some((block_number, header)) = fetched.try_next().await? { - if let Some(expected_parent) = prev_hash - && header.inner.parent_hash != expected_parent + let mut buf = Vec::with_capacity(600); + header.encode(&mut buf); + let header_hash = alloy_primitives::keccak256(&buf); + new_headers.push(( + block_number, + CachedAncestryHeader { + parent_hash: header.inner.parent_hash, + hash: header_hash, + encoded: Bytes::from(buf), + }, + )); + } + + let mut resolved = { + let cache = self.ancestry_header_cache.read(); + cache + .range(from..=to) + .map(|(block_number, header)| (*block_number, header.clone())) + .collect::>() + }; + for (block_number, header) in new_headers { + if let Some(existing) = resolved.get(&block_number) + && existing.hash != header.hash { return Err(eyre::eyre!( - "parent-hash chain broken at block {block_number}: \ - expected parent_hash={expected_parent}, got={}", - header.inner.parent_hash + "conflicting L1 header at cached block {block_number}: \ + cached={}, fetched={}", + existing.hash, + header.hash )); } + resolved.insert(block_number, header); + } - let mut buf = Vec::with_capacity(600); - header.encode(&mut buf); - let header_hash = alloy_primitives::keccak256(&buf); - prev_hash = Some(header_hash); + let base_hash = resolved + .get(&from) + .ok_or_else(|| eyre::eyre!("L1 header not found for base block {from}"))? + .hash; + let mut prev_hash = base_hash; + let mut headers = Vec::with_capacity((to - from) as usize); + + for block_number in (from + 1)..=to { + let header = resolved + .get(&block_number) + .ok_or_else(|| eyre::eyre!("L1 header not found for block {block_number}"))?; + if header.parent_hash != prev_hash { + return Err(eyre::eyre!( + "parent-hash chain broken at block {block_number}: \ + expected parent_hash={prev_hash}, got={}", + header.parent_hash + )); + } + prev_hash = header.hash; + headers.push(header.encoded.clone()); + } - headers.push(Bytes::from(buf)); + let mut cache = self.ancestry_header_cache.write(); + for (block_number, header) in resolved { + if let Some(existing) = cache.get(&block_number) + && existing.hash != header.hash + { + return Err(eyre::eyre!( + "conflicting L1 header at cached block {block_number}: \ + cached={}, fetched={}", + existing.hash, + header.hash + )); + } + cache.insert(block_number, header); } + // Ordered settlement never needs headers older than the current base. + // Retain the base itself because it commonly becomes part of the next + // overlapping range and anchors validation without another RPC call. + cache.retain(|block_number, _| *block_number >= from); + + info!( + from, + to, + cache_hits, + fetched = missing.len(), + "resolved ancestry headers" + ); + Ok(headers) } @@ -1079,7 +1152,37 @@ pub(crate) struct ZoneBlockSnapshot { mod tests { use super::*; use crate::abi; + use alloy_consensus::Header as ConsensusHeader; use alloy_primitives::{B256, address}; + use alloy_provider::ProviderBuilder; + use alloy_rpc_types_eth::Header as RpcHeader; + use alloy_transport::mock::Asserter; + use tempo_alloy::rpc::TempoHeaderResponse; + use tempo_primitives::TempoHeader; + + fn mock_l1_header(number: u64, parent_hash: B256) -> (TempoHeaderResponse, B256) { + let header = TempoHeader { + inner: ConsensusHeader { + number, + parent_hash, + ..Default::default() + }, + ..Default::default() + }; + let hash = alloy_primitives::keccak256(alloy_rlp::encode(&header)); + ( + TempoHeaderResponse { + inner: RpcHeader { + hash, + inner: header, + total_difficulty: None, + size: None, + }, + timestamp_millis: 0, + }, + hash, + ) + } fn test_withdrawal(to: Address, amount: u128) -> abi::Withdrawal { abi::Withdrawal { @@ -1108,6 +1211,37 @@ mod tests { assert!(BatchAnchorConfig::new(10, 11).is_err()); } + #[tokio::test] + async fn ancestry_header_cache_fetches_only_new_suffix() { + let asserter = Asserter::new(); + let provider = ProviderBuilder::new_with_network::() + .connect_mocked_client(asserter.clone()) + .erased(); + let submitter = BatchSubmitter::new(Address::ZERO, provider, 0); + + let mut parent_hash = B256::ZERO; + let mut headers = Vec::new(); + for number in 10..=15 { + let (header, hash) = mock_l1_header(number, parent_hash); + headers.push(header); + parent_hash = hash; + } + + // The initial range fetches its base plus all ancestry headers. + for header in &headers[..5] { + asserter.push_success(header); + } + let first = submitter.fetch_ancestry_headers(10, 14).await.unwrap(); + assert_eq!(first.len(), 4); + + // The overlapping range reuses blocks 11..=14 and fetches only block 15. + // If the implementation repeats any cached RPC call, the mock has no + // additional response queued and the test fails. + asserter.push_success(&headers[5]); + let second = submitter.fetch_ancestry_headers(11, 15).await.unwrap(); + assert_eq!(second.len(), 4); + } + #[test] fn find_offset_no_withdrawals_processed() { let w0 = test_withdrawal(address!("0x0000000000000000000000000000000000000001"), 100); From a3d61287c417d00bdfe984db5a79f04682b7122e Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:35:34 +0000 Subject: [PATCH 2/7] fix: bound ancestry header cache --- crates/sequencer/src/settlement.rs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/sequencer/src/settlement.rs b/crates/sequencer/src/settlement.rs index 60560557e..066b6d2ae 100644 --- a/crates/sequencer/src/settlement.rs +++ b/crates/sequencer/src/settlement.rs @@ -47,6 +47,12 @@ const DEFAULT_EIP2935_HISTORY_WINDOW: u64 = 8192 - 1; /// the block falls out of the window between our check and on-chain execution. const DEFAULT_EIP2935_SAFETY_MARGIN: u64 = 360; +/// Maximum number of encoded L1 headers retained between ancestry submissions. +/// +/// At roughly 600 bytes per header, this caps payload storage near 150 MiB plus +/// map overhead while covering more than the current Zone E recovery gap. +const DEFAULT_ANCESTRY_HEADER_CACHE_CAPACITY: usize = 262_144; + /// EIP-2935 anchor limits used by the batch submitter. /// /// Production uses the real 8191-block EIP-2935 history window with a safety @@ -175,6 +181,8 @@ pub struct BatchSubmitter { /// requests. Settlement batches are submitted in order, so later requests /// can reuse almost the entire preceding range. ancestry_header_cache: RwLock>, + /// Hard entry limit for [`Self::ancestry_header_cache`]. + ancestry_header_cache_capacity: usize, } /// One validated L1 header retained for ancestry proof construction. @@ -218,6 +226,7 @@ impl BatchSubmitter { l1_fetch_concurrency: 16, anchor_config, ancestry_header_cache: RwLock::new(BTreeMap::new()), + ancestry_header_cache_capacity: DEFAULT_ANCESTRY_HEADER_CACHE_CAPACITY, } } @@ -526,10 +535,12 @@ impl BatchSubmitter { cache.insert(block_number, header); } - // Ordered settlement never needs headers older than the current base. - // Retain the base itself because it commonly becomes part of the next - // overlapping range and anchors validation without another RPC call. + // Ordered settlement advances the lower bound monotonically, so the + // lowest block numbers are also the least recently useful entries. cache.retain(|block_number, _| *block_number >= from); + while cache.len() > self.ancestry_header_cache_capacity { + cache.pop_first(); + } info!( from, @@ -1217,7 +1228,8 @@ mod tests { let provider = ProviderBuilder::new_with_network::() .connect_mocked_client(asserter.clone()) .erased(); - let submitter = BatchSubmitter::new(Address::ZERO, provider, 0); + let mut submitter = BatchSubmitter::new(Address::ZERO, provider, 0); + submitter.ancestry_header_cache_capacity = 4; let mut parent_hash = B256::ZERO; let mut headers = Vec::new(); @@ -1233,6 +1245,7 @@ mod tests { } let first = submitter.fetch_ancestry_headers(10, 14).await.unwrap(); assert_eq!(first.len(), 4); + assert_eq!(submitter.ancestry_header_cache.read().len(), 4); // The overlapping range reuses blocks 11..=14 and fetches only block 15. // If the implementation repeats any cached RPC call, the mock has no From 4245446837a68d4c4a27d20f2acf78732af4b1bf Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:45:16 +0000 Subject: [PATCH 3/7] refactor: use lru for ancestry cache --- Cargo.lock | 1 + Cargo.toml | 1 + crates/sequencer/Cargo.toml | 1 + crates/sequencer/src/settlement.rs | 51 +++++++++++++----------------- 4 files changed, 25 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 84ba40655..9906a1499 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13438,6 +13438,7 @@ dependencies = [ "eyre", "futures", "k256", + "lru 0.16.4", "metrics", "parking_lot", "reth-metrics", diff --git a/Cargo.toml b/Cargo.toml index d7ecad336..c25cd983e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -203,6 +203,7 @@ eyre = "0.6.12" futures = "0.3.31" indicatif = "0.18" itertools = "0.14.0" +lru = "0.16.4" metrics = "0.24.3" k256 = { version = "0.13.4", features = ["arithmetic", "ecdh"] } parking_lot = "0.12.4" diff --git a/crates/sequencer/Cargo.toml b/crates/sequencer/Cargo.toml index 0c423375b..c4c59f2f2 100644 --- a/crates/sequencer/Cargo.toml +++ b/crates/sequencer/Cargo.toml @@ -35,6 +35,7 @@ alloy-transport.workspace = true eyre.workspace = true futures.workspace = true k256.workspace = true +lru.workspace = true metrics.workspace = true parking_lot.workspace = true tokio.workspace = true diff --git a/crates/sequencer/src/settlement.rs b/crates/sequencer/src/settlement.rs index 066b6d2ae..13522367d 100644 --- a/crates/sequencer/src/settlement.rs +++ b/crates/sequencer/src/settlement.rs @@ -23,7 +23,7 @@ //! configured direct window by falling back to ancestry mode — a recent anchor //! block plus a locally validated parent-hash header chain. -use std::collections::BTreeMap; +use std::{collections::BTreeMap, num::NonZeroUsize}; use crate::abi::{self, BlockTransition, DepositQueueTransition, ZoneOutbox, ZonePortal}; use alloy_consensus::Transaction; @@ -34,6 +34,7 @@ use alloy_rlp::Encodable; use alloy_sol_types::{SolCall, SolEvent}; use eyre::Result; use futures::{StreamExt, TryStreamExt}; +use lru::LruCache; use parking_lot::RwLock; use tempo_alloy::{TempoNetwork, rpc::TempoCallBuilderExt}; use tracing::{info, instrument, warn}; @@ -180,9 +181,7 @@ pub struct BatchSubmitter { /// Validated, RLP-encoded L1 headers retained across overlapping ancestry /// requests. Settlement batches are submitted in order, so later requests /// can reuse almost the entire preceding range. - ancestry_header_cache: RwLock>, - /// Hard entry limit for [`Self::ancestry_header_cache`]. - ancestry_header_cache_capacity: usize, + ancestry_header_cache: RwLock>, } /// One validated L1 header retained for ancestry proof construction. @@ -225,8 +224,9 @@ impl BatchSubmitter { genesis_tempo_block_number, l1_fetch_concurrency: 16, anchor_config, - ancestry_header_cache: RwLock::new(BTreeMap::new()), - ancestry_header_cache_capacity: DEFAULT_ANCESTRY_HEADER_CACHE_CAPACITY, + ancestry_header_cache: RwLock::new(LruCache::new( + NonZeroUsize::new(DEFAULT_ANCESTRY_HEADER_CACHE_CAPACITY).unwrap(), + )), } } @@ -438,11 +438,18 @@ impl BatchSubmitter { return Ok(Vec::new()); } - let missing = { - let cache = self.ancestry_header_cache.read(); - (from..=to) - .filter(|block_number| !cache.contains_key(block_number)) - .collect::>() + let (mut resolved, missing) = { + let mut cache = self.ancestry_header_cache.write(); + let mut resolved = BTreeMap::new(); + let mut missing = Vec::new(); + for block_number in from..=to { + if let Some(header) = cache.get(&block_number) { + resolved.insert(block_number, header.clone()); + } else { + missing.push(block_number); + } + } + (resolved, missing) }; let cache_hits = (to - from + 1) as usize - missing.len(); @@ -477,13 +484,6 @@ impl BatchSubmitter { )); } - let mut resolved = { - let cache = self.ancestry_header_cache.read(); - cache - .range(from..=to) - .map(|(block_number, header)| (*block_number, header.clone())) - .collect::>() - }; for (block_number, header) in new_headers { if let Some(existing) = resolved.get(&block_number) && existing.hash != header.hash @@ -522,7 +522,7 @@ impl BatchSubmitter { let mut cache = self.ancestry_header_cache.write(); for (block_number, header) in resolved { - if let Some(existing) = cache.get(&block_number) + if let Some(existing) = cache.peek(&block_number) && existing.hash != header.hash { return Err(eyre::eyre!( @@ -532,14 +532,7 @@ impl BatchSubmitter { header.hash )); } - cache.insert(block_number, header); - } - - // Ordered settlement advances the lower bound monotonically, so the - // lowest block numbers are also the least recently useful entries. - cache.retain(|block_number, _| *block_number >= from); - while cache.len() > self.ancestry_header_cache_capacity { - cache.pop_first(); + cache.put(block_number, header); } info!( @@ -1228,8 +1221,8 @@ mod tests { let provider = ProviderBuilder::new_with_network::() .connect_mocked_client(asserter.clone()) .erased(); - let mut submitter = BatchSubmitter::new(Address::ZERO, provider, 0); - submitter.ancestry_header_cache_capacity = 4; + let submitter = BatchSubmitter::new(Address::ZERO, provider, 0); + *submitter.ancestry_header_cache.write() = LruCache::new(NonZeroUsize::new(4).unwrap()); let mut parent_hash = B256::ZERO; let mut headers = Vec::new(); From 81d6ba970f40e83146c879cf18643cb5e8bd0bce Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:00:15 +0000 Subject: [PATCH 4/7] fix: reuse ancestry anchor across batches --- crates/sequencer/src/settlement.rs | 48 +++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/sequencer/src/settlement.rs b/crates/sequencer/src/settlement.rs index 13522367d..177d2ca39 100644 --- a/crates/sequencer/src/settlement.rs +++ b/crates/sequencer/src/settlement.rs @@ -114,6 +114,23 @@ impl Default for BatchAnchorConfig { } } +/// Reuse an ancestry anchor while it is ahead of the batch and remains inside +/// the effective EIP-2935 window; otherwise select a fresh safe anchor. +fn select_ancestry_anchor( + cached_anchor: Option, + tempo_block_number: u64, + current_l1_block: u64, + anchor_config: BatchAnchorConfig, +) -> u64 { + cached_anchor + .filter(|anchor| { + *anchor > tempo_block_number + && *anchor <= current_l1_block + && current_l1_block - *anchor < anchor_config.effective_window() + }) + .unwrap_or_else(|| current_l1_block.saturating_sub(anchor_config.safety_margin())) +} + /// Maximum number of pending withdrawal queue slots in the portal ring buffer. pub(crate) const WITHDRAWAL_QUEUE_CAPACITY: u64 = 100; @@ -182,6 +199,9 @@ pub struct BatchSubmitter { /// requests. Settlement batches are submitted in order, so later requests /// can reuse almost the entire preceding range. ancestry_header_cache: RwLock>, + /// Recent L1 anchor reused across ancestry batches until it approaches the + /// edge of the EIP-2935 history window. + ancestry_anchor: RwLock>, } /// One validated L1 header retained for ancestry proof construction. @@ -227,6 +247,7 @@ impl BatchSubmitter { ancestry_header_cache: RwLock::new(LruCache::new( NonZeroUsize::new(DEFAULT_ANCESTRY_HEADER_CACHE_CAPACITY).unwrap(), )), + ancestry_anchor: RwLock::new(None), } } @@ -404,7 +425,17 @@ impl BatchSubmitter { return Ok(AnchorMode::Direct); } - let anchor_block = current_l1_block.saturating_sub(self.anchor_config.safety_margin()); + let anchor_block = { + let mut cached_anchor = self.ancestry_anchor.write(); + let anchor = select_ancestry_anchor( + *cached_anchor, + tempo_block_number, + current_l1_block, + self.anchor_config, + ); + *cached_anchor = Some(anchor); + anchor + }; let ancestry_headers = self .fetch_ancestry_headers(tempo_block_number, anchor_block) .await?; @@ -1215,6 +1246,21 @@ mod tests { assert!(BatchAnchorConfig::new(10, 11).is_err()); } + #[test] + fn ancestry_anchor_is_reused_until_it_nears_expiry() { + let config = BatchAnchorConfig::new(10, 2).unwrap(); + + let anchor = select_ancestry_anchor(None, 50, 100, config); + assert_eq!(anchor, 98); + assert_eq!(select_ancestry_anchor(Some(anchor), 60, 105, config), 98); + + // At the effective-window boundary, rotate to a fresh safe anchor. + assert_eq!(select_ancestry_anchor(Some(anchor), 60, 106, config), 104); + + // The anchor must also remain strictly ahead of the batch's Tempo block. + assert_eq!(select_ancestry_anchor(Some(anchor), 98, 106, config), 104); + } + #[tokio::test] async fn ancestry_header_cache_fetches_only_new_suffix() { let asserter = Asserter::new(); From 857ac2071ee84851cc2a25a9e205b585e687fc53 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:01:52 +0000 Subject: [PATCH 5/7] revert: defer ancestry anchor reuse --- crates/sequencer/src/settlement.rs | 48 +----------------------------- 1 file changed, 1 insertion(+), 47 deletions(-) diff --git a/crates/sequencer/src/settlement.rs b/crates/sequencer/src/settlement.rs index 177d2ca39..13522367d 100644 --- a/crates/sequencer/src/settlement.rs +++ b/crates/sequencer/src/settlement.rs @@ -114,23 +114,6 @@ impl Default for BatchAnchorConfig { } } -/// Reuse an ancestry anchor while it is ahead of the batch and remains inside -/// the effective EIP-2935 window; otherwise select a fresh safe anchor. -fn select_ancestry_anchor( - cached_anchor: Option, - tempo_block_number: u64, - current_l1_block: u64, - anchor_config: BatchAnchorConfig, -) -> u64 { - cached_anchor - .filter(|anchor| { - *anchor > tempo_block_number - && *anchor <= current_l1_block - && current_l1_block - *anchor < anchor_config.effective_window() - }) - .unwrap_or_else(|| current_l1_block.saturating_sub(anchor_config.safety_margin())) -} - /// Maximum number of pending withdrawal queue slots in the portal ring buffer. pub(crate) const WITHDRAWAL_QUEUE_CAPACITY: u64 = 100; @@ -199,9 +182,6 @@ pub struct BatchSubmitter { /// requests. Settlement batches are submitted in order, so later requests /// can reuse almost the entire preceding range. ancestry_header_cache: RwLock>, - /// Recent L1 anchor reused across ancestry batches until it approaches the - /// edge of the EIP-2935 history window. - ancestry_anchor: RwLock>, } /// One validated L1 header retained for ancestry proof construction. @@ -247,7 +227,6 @@ impl BatchSubmitter { ancestry_header_cache: RwLock::new(LruCache::new( NonZeroUsize::new(DEFAULT_ANCESTRY_HEADER_CACHE_CAPACITY).unwrap(), )), - ancestry_anchor: RwLock::new(None), } } @@ -425,17 +404,7 @@ impl BatchSubmitter { return Ok(AnchorMode::Direct); } - let anchor_block = { - let mut cached_anchor = self.ancestry_anchor.write(); - let anchor = select_ancestry_anchor( - *cached_anchor, - tempo_block_number, - current_l1_block, - self.anchor_config, - ); - *cached_anchor = Some(anchor); - anchor - }; + let anchor_block = current_l1_block.saturating_sub(self.anchor_config.safety_margin()); let ancestry_headers = self .fetch_ancestry_headers(tempo_block_number, anchor_block) .await?; @@ -1246,21 +1215,6 @@ mod tests { assert!(BatchAnchorConfig::new(10, 11).is_err()); } - #[test] - fn ancestry_anchor_is_reused_until_it_nears_expiry() { - let config = BatchAnchorConfig::new(10, 2).unwrap(); - - let anchor = select_ancestry_anchor(None, 50, 100, config); - assert_eq!(anchor, 98); - assert_eq!(select_ancestry_anchor(Some(anchor), 60, 105, config), 98); - - // At the effective-window boundary, rotate to a fresh safe anchor. - assert_eq!(select_ancestry_anchor(Some(anchor), 60, 106, config), 104); - - // The anchor must also remain strictly ahead of the batch's Tempo block. - assert_eq!(select_ancestry_anchor(Some(anchor), 98, 106, config), 104); - } - #[tokio::test] async fn ancestry_header_cache_fetches_only_new_suffix() { let asserter = Asserter::new(); From f3f86e5f33446f93889bb968f8b2c4e3d7aa4d6c Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:03:35 +0000 Subject: [PATCH 6/7] refactor: use schnellru for ancestry cache --- Cargo.lock | 1 + Cargo.toml | 2 +- crates/sequencer/Cargo.toml | 2 +- crates/sequencer/src/settlement.rs | 24 ++++++++++++++---------- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9906a1499..4d4c81193 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13703,6 +13703,7 @@ dependencies = [ "metrics", "parking_lot", "reth-metrics", + "schnellru", "serde_json", "tempo-alloy", "tempo-primitives", diff --git a/Cargo.toml b/Cargo.toml index c25cd983e..eff5c52ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -203,7 +203,6 @@ eyre = "0.6.12" futures = "0.3.31" indicatif = "0.18" itertools = "0.14.0" -lru = "0.16.4" metrics = "0.24.3" k256 = { version = "0.13.4", features = ["arithmetic", "ecdh"] } parking_lot = "0.12.4" @@ -217,6 +216,7 @@ serde = { version = "1.0.219", default-features = false, features = [ "alloc", ] } serde_json = "1.0.142" +schnellru = "0.2.4" thiserror = "2" tokio = { version = "1.45.1", features = ["full"] } tokio-util = "0.7.18" diff --git a/crates/sequencer/Cargo.toml b/crates/sequencer/Cargo.toml index c4c59f2f2..27ca66b22 100644 --- a/crates/sequencer/Cargo.toml +++ b/crates/sequencer/Cargo.toml @@ -35,9 +35,9 @@ alloy-transport.workspace = true eyre.workspace = true futures.workspace = true k256.workspace = true -lru.workspace = true metrics.workspace = true parking_lot.workspace = true +schnellru.workspace = true tokio.workspace = true tracing.workspace = true diff --git a/crates/sequencer/src/settlement.rs b/crates/sequencer/src/settlement.rs index 13522367d..80b8a52dc 100644 --- a/crates/sequencer/src/settlement.rs +++ b/crates/sequencer/src/settlement.rs @@ -23,7 +23,7 @@ //! configured direct window by falling back to ancestry mode — a recent anchor //! block plus a locally validated parent-hash header chain. -use std::{collections::BTreeMap, num::NonZeroUsize}; +use std::collections::BTreeMap; use crate::abi::{self, BlockTransition, DepositQueueTransition, ZoneOutbox, ZonePortal}; use alloy_consensus::Transaction; @@ -34,8 +34,8 @@ use alloy_rlp::Encodable; use alloy_sol_types::{SolCall, SolEvent}; use eyre::Result; use futures::{StreamExt, TryStreamExt}; -use lru::LruCache; use parking_lot::RwLock; +use schnellru::{ByLength, LruMap}; use tempo_alloy::{TempoNetwork, rpc::TempoCallBuilderExt}; use tracing::{info, instrument, warn}; @@ -52,7 +52,7 @@ const DEFAULT_EIP2935_SAFETY_MARGIN: u64 = 360; /// /// At roughly 600 bytes per header, this caps payload storage near 150 MiB plus /// map overhead while covering more than the current Zone E recovery gap. -const DEFAULT_ANCESTRY_HEADER_CACHE_CAPACITY: usize = 262_144; +const DEFAULT_ANCESTRY_HEADER_CACHE_CAPACITY: u32 = 262_144; /// EIP-2935 anchor limits used by the batch submitter. /// @@ -181,7 +181,7 @@ pub struct BatchSubmitter { /// Validated, RLP-encoded L1 headers retained across overlapping ancestry /// requests. Settlement batches are submitted in order, so later requests /// can reuse almost the entire preceding range. - ancestry_header_cache: RwLock>, + ancestry_header_cache: RwLock>, } /// One validated L1 header retained for ancestry proof construction. @@ -224,9 +224,9 @@ impl BatchSubmitter { genesis_tempo_block_number, l1_fetch_concurrency: 16, anchor_config, - ancestry_header_cache: RwLock::new(LruCache::new( - NonZeroUsize::new(DEFAULT_ANCESTRY_HEADER_CACHE_CAPACITY).unwrap(), - )), + ancestry_header_cache: RwLock::new(LruMap::new(ByLength::new( + DEFAULT_ANCESTRY_HEADER_CACHE_CAPACITY, + ))), } } @@ -522,7 +522,7 @@ impl BatchSubmitter { let mut cache = self.ancestry_header_cache.write(); for (block_number, header) in resolved { - if let Some(existing) = cache.peek(&block_number) + if let Some(existing) = cache.get(&block_number) && existing.hash != header.hash { return Err(eyre::eyre!( @@ -532,7 +532,11 @@ impl BatchSubmitter { header.hash )); } - cache.put(block_number, header); + if !cache.insert(block_number, header) { + return Err(eyre::eyre!( + "failed to cache L1 header for block {block_number}" + )); + } } info!( @@ -1222,7 +1226,7 @@ mod tests { .connect_mocked_client(asserter.clone()) .erased(); let submitter = BatchSubmitter::new(Address::ZERO, provider, 0); - *submitter.ancestry_header_cache.write() = LruCache::new(NonZeroUsize::new(4).unwrap()); + *submitter.ancestry_header_cache.write() = LruMap::new(ByLength::new(4)); let mut parent_hash = B256::ZERO; let mut headers = Vec::new(); From d55a8c777df7e2ceee59f860a23cbb3402494a4b Mon Sep 17 00:00:00 2001 From: Roman Krasiuk Date: Fri, 17 Jul 2026 13:44:34 +0200 Subject: [PATCH 7/7] fix lockfile --- Cargo.lock | 1 - 1 file changed, 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 4d4c81193..4ea6f7cbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13438,7 +13438,6 @@ dependencies = [ "eyre", "futures", "k256", - "lru 0.16.4", "metrics", "parking_lot", "reth-metrics",