diff --git a/Cargo.lock b/Cargo.lock index 84ba40655..4e49a9c7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13701,7 +13701,9 @@ dependencies = [ "k256", "metrics", "parking_lot", + "proptest", "reth-metrics", + "schnellru", "serde_json", "tempo-alloy", "tempo-primitives", diff --git a/Cargo.toml b/Cargo.toml index d7ecad336..cf1f1b77f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -207,6 +207,7 @@ metrics = "0.24.3" k256 = { version = "0.13.4", features = ["arithmetic", "ecdh"] } parking_lot = "0.12.4" p256 = { version = "0.13.2", default-features = false, features = ["ecdsa"] } +proptest = "1.11.0" rand = "0.8.5" sha2 = "0.10.9" rayon = "1.10" @@ -216,6 +217,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 0c423375b..a9f4b8d0d 100644 --- a/crates/sequencer/Cargo.toml +++ b/crates/sequencer/Cargo.toml @@ -37,10 +37,12 @@ futures.workspace = true k256.workspace = true metrics.workspace = true parking_lot.workspace = true +schnellru.workspace = true tokio.workspace = true tracing.workspace = true [dev-dependencies] +proptest.workspace = true serde_json.workspace = true tempo-primitives.workspace = true tokio-tungstenite.workspace = true diff --git a/crates/sequencer/src/settlement.rs b/crates/sequencer/src/settlement.rs index 1c42cf93c..cceeed0b8 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, fmt}; use crate::abi::{self, BlockTransition, DepositQueueTransition, ZoneOutbox, ZonePortal}; use alloy_consensus::Transaction; @@ -34,6 +34,8 @@ use alloy_rlp::Encodable; use alloy_sol_types::{SolCall, SolEvent}; use eyre::Result; use futures::{StreamExt, TryStreamExt}; +use parking_lot::RwLock; +use schnellru::{ByLength, LruMap}; use tempo_alloy::{TempoNetwork, rpc::TempoCallBuilderExt}; use tracing::{info, instrument, warn}; @@ -46,6 +48,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: u32 = 262_144; + /// EIP-2935 anchor limits used by the batch submitter. /// /// Production uses the real 8191-block EIP-2935 history window with a safety @@ -170,6 +178,97 @@ 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, +} + +/// A complete, ordered, parent-linked ancestry range. +/// +/// `headers` excludes the base block at `from`; `fetched_headers` contains only +/// entries that the caller should commit to the cache after resolution succeeds. +#[derive(Debug)] +struct ResolvedAncestry { + headers: Vec, + fetched_headers: Vec<(u64, CachedAncestryHeader)>, +} + +/// Merge cached and fetched headers into one validated ancestry range. +fn resolve_ancestry_headers( + from: u64, + to: u64, + cached: Vec<(u64, CachedAncestryHeader)>, + fetched: Vec<(u64, CachedAncestryHeader)>, +) -> Result { + debug_assert!(from < to, "caller skips empty ancestry ranges"); + + let range_len = (to - from + 1) as usize; + let fetched_count = fetched.len(); + let mut merged = vec![None; range_len]; + + let mut insert = |block_number, header, was_fetched| -> Result<()> { + if !(from..=to).contains(&block_number) { + return Err(eyre::eyre!( + "received out-of-range L1 header for block {block_number}; expected {from}..={to}" + )); + } + let index = (block_number - from) as usize; + if merged[index].replace((header, was_fetched)).is_some() { + return Err(eyre::eyre!( + "received duplicate L1 header for block {block_number}" + )); + } + Ok(()) + }; + for (block_number, header) in cached { + insert(block_number, header, false)?; + } + for (block_number, header) in fetched { + insert(block_number, header, true)?; + } + + let mut merged = merged.into_iter(); + let (base, base_was_fetched) = merged + .next() + .flatten() + .ok_or_else(|| eyre::eyre!("L1 header not found for base block {from}"))?; + let mut parent_hash = base.hash; + let mut headers = Vec::with_capacity(range_len - 1); + let mut fetched_headers = Vec::with_capacity(fetched_count); + if base_was_fetched { + fetched_headers.push((from, base)); + } + + for (block_number, entry) in ((from + 1)..=to).zip(merged) { + let (header, was_fetched) = + entry.ok_or_else(|| eyre::eyre!("L1 header not found for block {block_number}"))?; + if header.parent_hash != parent_hash { + return Err(eyre::eyre!( + "parent-hash chain broken at block {block_number}: \ + expected parent_hash={parent_hash}, got={}", + header.parent_hash + )); + } + parent_hash = header.hash; + headers.push(header.encoded.clone()); + if was_fetched { + fetched_headers.push((block_number, header)); + } + } + + Ok(ResolvedAncestry { + headers, + fetched_headers, + }) } impl BatchSubmitter { @@ -204,6 +303,9 @@ impl BatchSubmitter { genesis_tempo_block_number, l1_fetch_concurrency: 16, anchor_config, + ancestry_header_cache: RwLock::new(LruMap::new(ByLength::new( + DEFAULT_ANCESTRY_HEADER_CACHE_CAPACITY, + ))), } } @@ -254,35 +356,19 @@ impl BatchSubmitter { nextDepositNumber: batch.next_deposit_number, }; - let anchor_mode = self.resolve_anchor_mode(batch.tempo_block_number).await?; + let (anchor_mode, current_l1_block) = + self.resolve_anchor_mode(batch.tempo_block_number).await?; let recent_tempo_block_number = anchor_mode.recent_block_number(); - let (current_l1_block, portal_block_hash) = tokio::join!( - self.l1_provider.get_block_number(), - self.read_portal_block_hash(), - ); - let current_l1_block = current_l1_block?; - let portal_block_hash = portal_block_hash?; info!( - ?anchor_mode, + anchor_mode = %anchor_mode, recent_tempo_block_number, current_l1_block, - portal_block_hash = %portal_block_hash, batch_prev_block_hash = %batch.prev_block_hash, nonce_key = ?SUBMIT_BATCH_NONCE_KEY, - "Preparing submitBatch to ZonePortal on L1" + "Submitting batch to ZonePortal on L1" ); - if portal_block_hash != batch.prev_block_hash { - warn!( - portal_block_hash = %portal_block_hash, - batch_prev_block_hash = %batch.prev_block_hash, - "Portal block hash does not match batch prev hash before submitBatch" - ); - } - - info!(?anchor_mode, "Submitting batch to ZonePortal on L1"); - let pending = self .portal .submitBatch( @@ -365,7 +451,7 @@ impl BatchSubmitter { /// - **Ancestry** (gap ≥ configured effective window): a recent L1 block /// behind the configured safety margin is used as anchor. Ancestry headers /// are collected and validated for future prover integration. - async fn resolve_anchor_mode(&self, tempo_block_number: u64) -> Result { + async fn resolve_anchor_mode(&self, tempo_block_number: u64) -> Result<(AnchorMode, u64)> { let current_l1_block = self.l1_provider.get_block_number().await?; if tempo_block_number >= current_l1_block { @@ -378,7 +464,14 @@ impl BatchSubmitter { let gap = current_l1_block.saturating_sub(tempo_block_number); if gap < self.anchor_config.effective_window() { - return Ok(AnchorMode::Direct); + // The cache is only useful during ancestry recovery. Replace it + // instead of clearing it so the hash table's allocation is freed. + let has_cached_headers = !self.ancestry_header_cache.read().is_empty(); + if has_cached_headers { + *self.ancestry_header_cache.write() = + LruMap::new(ByLength::new(DEFAULT_ANCESTRY_HEADER_CACHE_CAPACITY)); + } + return Ok((AnchorMode::Direct, current_l1_block)); } let anchor_block = current_l1_block.saturating_sub(self.anchor_config.safety_margin()); @@ -396,14 +489,17 @@ impl BatchSubmitter { "tempo_block_number outside EIP-2935 effective window, using ancestry mode" ); - Ok(AnchorMode::Ancestry { - anchor_block, - ancestry_headers, - }) + Ok(( + AnchorMode::Ancestry { + anchor_block, + ancestry_headers, + }, + current_l1_block, + )) } /// 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 +511,25 @@ 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) + // Snapshot the cache without changing its LRU order. Network requests + // and validation happen after the read lock is released. + let (cached, missing) = { + let cache = self.ancestry_header_cache.read(); + let mut cached = Vec::new(); + let mut missing = Vec::new(); + for block_number in from..=to { + if let Some(header) = cache.peek(&block_number) { + cached.push((block_number, header.clone())); + } else { + missing.push(block_number); + } + } + (cached, missing) + }; + let cache_hits = cached.len(); + + // Fetch and encode only the cache misses. + let fetched = stream::iter(missing.iter().copied()) .map(|block_number| { let provider = &self.l1_provider; async move { @@ -439,33 +539,59 @@ impl BatchSubmitter { .ok_or_else(|| { eyre::eyre!("L1 header not found for block {block_number}") })?; - Ok::<_, eyre::Report>((block_number, header.inner.inner)) + let header = header.inner.inner; + let mut encoded = Vec::with_capacity(600); + header.encode(&mut encoded); + let cached_header = CachedAncestryHeader { + parent_hash: header.inner.parent_hash, + hash: alloy_primitives::keccak256(&encoded), + encoded: Bytes::from(encoded), + }; + Ok::<_, eyre::Report>((block_number, cached_header)) } }) - .buffered(concurrency); - - let mut headers = Vec::with_capacity(count); - let mut prev_hash: Option = Some(base_hash); + .buffer_unordered(self.l1_fetch_concurrency) + .try_collect::>() + .await?; - while let Some((block_number, header)) = fetched.try_next().await? { - if let Some(expected_parent) = prev_hash - && header.inner.parent_hash != expected_parent - { + // Pure resolution owns merging, ordering, completeness, duplicate, and + // parent-hash validation. Do not mutate the cache unless it succeeds. + let ResolvedAncestry { + headers, + fetched_headers, + } = resolve_ancestry_headers(from, to, cached, fetched)?; + let fetched_count = fetched_headers.len(); + + // Commit only entries fetched from the snapshot's misses. Another task + // may have filled one while the network requests were in flight. + let mut cache = self.ancestry_header_cache.write(); + for (block_number, header) in fetched_headers { + if let Some(existing) = cache.peek(&block_number) { + if existing.hash != header.hash { + return Err(eyre::eyre!( + "conflicting L1 header at cached block {block_number}: \ + cached={}, fetched={}", + existing.hash, + header.hash + )); + } + continue; + } + if !cache.insert(block_number, header) { return Err(eyre::eyre!( - "parent-hash chain broken at block {block_number}: \ - expected parent_hash={expected_parent}, got={}", - header.inner.parent_hash + "failed to cache L1 header for block {block_number}" )); } - - let mut buf = Vec::with_capacity(600); - header.encode(&mut buf); - let header_hash = alloy_primitives::keccak256(&buf); - prev_hash = Some(header_hash); - - headers.push(Bytes::from(buf)); } + info!( + from, + to, + cache_hits, + fetched = fetched_count, + "resolved ancestry headers" + ); + Ok(headers) } @@ -1033,7 +1159,6 @@ fn backward_log_query_start(hi: u64, floor: u64) -> u64 { /// `submit_batch` can use ancestry mode when the batch-final block's /// `tempoBlockNumber` has fallen outside the configured direct-submission /// window. -#[derive(Debug)] #[allow(dead_code)] // Ancestry::ancestry_headers is collected but not yet consumed — available for prover integration enum AnchorMode { /// `tempoBlockNumber` is within the effective EIP-2935 window — the portal @@ -1063,6 +1188,15 @@ impl AnchorMode { } } +impl fmt::Display for AnchorMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Direct => f.write_str("direct"), + Self::Ancestry { .. } => f.write_str("ancestry"), + } + } +} + /// Zone L2 state read at a specific block, used to populate [`BatchData`]. pub(crate) struct ZoneBlockSnapshot { /// Latest Tempo L1 block number as seen by the zone. @@ -1079,7 +1213,202 @@ 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 proptest::prelude::*; + 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 synthetic_ancestry(from: u64, payloads: &[Vec]) -> Vec<(u64, CachedAncestryHeader)> { + let mut parent_hash = B256::ZERO; + payloads + .iter() + .enumerate() + .map(|(index, payload)| { + let block_number = from + u64::try_from(index).unwrap(); + let mut encoded = Vec::with_capacity(size_of::() + payload.len()); + encoded.extend_from_slice(&block_number.to_be_bytes()); + encoded.extend_from_slice(payload); + let encoded = Bytes::from(encoded); + let hash = alloy_primitives::keccak256(&encoded); + let header = CachedAncestryHeader { + parent_hash, + hash, + encoded, + }; + parent_hash = hash; + (block_number, header) + }) + .collect() + } + + fn ancestry_case() -> impl Strategy>, Vec)> { + (0_u64..10_000, 2_usize..33).prop_flat_map(|(from, len)| { + ( + Just(from), + proptest::collection::vec(proptest::collection::vec(any::(), 0..64), len), + proptest::collection::vec(any::(), len), + ) + }) + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + #[test] + fn ancestry_resolution_is_independent_of_fetched_order( + (from, payloads, order_keys) in ancestry_case(), + ) { + let chain = synthetic_ancestry(from, &payloads); + let to = chain.last().unwrap().0; + let expected = resolve_ancestry_headers(from, to, Vec::new(), chain.clone()) + .unwrap() + .headers; + + let mut permuted = chain + .into_iter() + .zip(order_keys) + .collect::>(); + permuted.sort_by_key(|(_, key)| *key); + let permuted = permuted + .into_iter() + .map(|(header, _)| header) + .collect(); + + let actual = resolve_ancestry_headers(from, to, Vec::new(), permuted) + .unwrap() + .headers; + prop_assert_eq!(actual, expected); + } + + #[test] + fn ancestry_resolution_is_independent_of_cache_partition( + (from, payloads, order_keys) in ancestry_case(), + cache_mask in any::(), + ) { + let chain = synthetic_ancestry(from, &payloads); + let to = chain.last().unwrap().0; + let cold = resolve_ancestry_headers(from, to, Vec::new(), chain.clone()) + .unwrap() + .headers; + let (cached, fetched): (Vec<_>, Vec<_>) = chain + .into_iter() + .enumerate() + .partition(|(index, _)| cache_mask & (1_u128 << index) != 0); + let cached = cached.into_iter().map(|(_, header)| header).collect(); + let mut fetched = fetched + .into_iter() + .map(|(index, header)| (order_keys[index], header)) + .collect::>(); + fetched.sort_by_key(|(order_key, _)| *order_key); + let fetched = fetched + .into_iter() + .map(|(_, header)| header) + .collect::>(); + let mut expected_fetched = fetched + .iter() + .map(|(block_number, header)| (*block_number, header.hash)) + .collect::>(); + expected_fetched.sort_by_key(|(block_number, _)| *block_number); + + let partitioned = resolve_ancestry_headers(from, to, cached, fetched) + .unwrap(); + let actual_fetched = partitioned + .fetched_headers + .iter() + .map(|(block_number, header)| (*block_number, header.hash)) + .collect::>(); + prop_assert_eq!(partitioned.headers, cold); + prop_assert_eq!(actual_fetched, expected_fetched); + } + + #[test] + fn ancestry_resolution_rejects_parent_hash_corruption( + (from, payloads, _) in ancestry_case(), + corrupt_index in any::(), + ) { + let mut chain = synthetic_ancestry(from, &payloads); + let to = chain.last().unwrap().0; + let corrupt_index = 1 + corrupt_index % (chain.len() - 1); + chain[corrupt_index].1.parent_hash[0] ^= 1; + + prop_assert!(resolve_ancestry_headers(from, to, Vec::new(), chain).is_err()); + } + + #[test] + fn ancestry_resolution_rejects_malformed_header_sets( + (from, payloads, _) in ancestry_case(), + malformed_index in any::(), + ) { + let chain = synthetic_ancestry(from, &payloads); + let to = chain.last().unwrap().0; + let malformed_index = malformed_index % chain.len(); + + let mut missing = chain.clone(); + missing.remove(malformed_index); + prop_assert!( + resolve_ancestry_headers(from, to, Vec::new(), missing).is_err(), + "missing header was accepted" + ); + + let mut duplicate = chain.clone(); + duplicate.push(chain[malformed_index].clone()); + prop_assert!( + resolve_ancestry_headers(from, to, Vec::new(), duplicate).is_err(), + "duplicate header was accepted" + ); + + let mut out_of_range = chain.clone(); + out_of_range.push((to + 1, chain[malformed_index].1.clone())); + prop_assert!( + resolve_ancestry_headers(from, to, Vec::new(), out_of_range).is_err(), + "out-of-range header was accepted" + ); + } + + #[test] + fn ancestry_resolution_returns_exact_range_without_base( + (from, payloads, _) in ancestry_case(), + ) { + let chain = synthetic_ancestry(from, &payloads); + let to = chain.last().unwrap().0; + let expected = chain[1..] + .iter() + .map(|(_, header)| header.encoded.clone()) + .collect::>(); + + let resolved = resolve_ancestry_headers(from, to, Vec::new(), chain).unwrap(); + prop_assert_eq!(resolved.headers.len(), usize::try_from(to - from).unwrap()); + prop_assert_eq!(resolved.headers, expected); + } + } fn test_withdrawal(to: Address, amount: u128) -> abi::Withdrawal { abi::Withdrawal { @@ -1108,6 +1437,136 @@ 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); + *submitter.ancestry_header_cache.write() = LruMap::new(ByLength::new(4)); + + 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(); + let expected_first = headers[1..5] + .iter() + .map(|header| Bytes::from(alloy_rlp::encode(&header.inner.inner))) + .collect::>(); + assert_eq!(first, expected_first); + 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 + // additional response queued and the test fails. + asserter.push_success(&headers[5]); + let second = submitter.fetch_ancestry_headers(11, 15).await.unwrap(); + let expected_second = headers[2..6] + .iter() + .map(|header| Bytes::from(alloy_rlp::encode(&header.inner.inner))) + .collect::>(); + assert_eq!(second, expected_second); + + let cache = submitter.ancestry_header_cache.read(); + assert!(cache.peek(&11).is_none()); + for block_number in 12..=15 { + assert!(cache.peek(&block_number).is_some()); + } + } + + #[tokio::test] + async fn ancestry_header_cache_hits_do_not_rewrite_entries() { + let asserter = Asserter::new(); + let provider = ProviderBuilder::new_with_network::() + .connect_mocked_client(asserter.clone()) + .erased(); + let submitter = BatchSubmitter::new(Address::ZERO, provider, 0); + *submitter.ancestry_header_cache.write() = LruMap::new(ByLength::new(4)); + + let mut parent_hash = B256::ZERO; + for number in 10..=13 { + let (header, hash) = mock_l1_header(number, parent_hash); + asserter.push_success(&header); + parent_hash = hash; + } + + submitter.fetch_ancestry_headers(10, 13).await.unwrap(); + assert_eq!( + submitter + .ancestry_header_cache + .read() + .peek_oldest() + .map(|(block_number, _)| *block_number), + Some(10) + ); + + // Resolving a fully cached range must not promote or replace every hit. + submitter.fetch_ancestry_headers(10, 12).await.unwrap(); + assert_eq!( + submitter + .ancestry_header_cache + .read() + .peek_oldest() + .map(|(block_number, _)| *block_number), + Some(10) + ); + assert!(asserter.read_q().is_empty()); + } + + #[tokio::test] + async fn anchor_resolution_returns_observed_l1_tip() { + let asserter = Asserter::new(); + let provider = ProviderBuilder::new_with_network::() + .connect_mocked_client(asserter.clone()) + .erased(); + let submitter = BatchSubmitter::new(Address::ZERO, provider, 0); + + asserter.push_success(&100_u64); + let (mode, current_l1_block) = submitter.resolve_anchor_mode(99).await.unwrap(); + + assert!(matches!(mode, AnchorMode::Direct)); + assert_eq!(current_l1_block, 100); + assert!(asserter.read_q().is_empty()); + } + + #[tokio::test] + async fn direct_anchor_resolution_drops_ancestry_cache() { + 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 cached_header = CachedAncestryHeader { + parent_hash: B256::ZERO, + hash: B256::repeat_byte(0x11), + encoded: Bytes::from_static(&[0x01]), + }; + assert!( + submitter + .ancestry_header_cache + .write() + .insert(98, cached_header) + ); + + asserter.push_success(&100_u64); + let (mode, _) = submitter.resolve_anchor_mode(99).await.unwrap(); + + assert!(matches!(mode, AnchorMode::Direct)); + assert!(submitter.ancestry_header_cache.read().is_empty()); + assert!(asserter.read_q().is_empty()); + } + #[test] fn find_offset_no_withdrawals_processed() { let w0 = test_withdrawal(address!("0x0000000000000000000000000000000000000001"), 100);