Skip to main content

evm_fork_cache/reactive/
mod.rs

1//! Protocol-neutral reactive runtime for cache state effects.
2//!
3//! The reactive runtime generalizes the log-only [`events`](crate::events)
4//! pipeline into a handler pipeline that can ingest logs, block notifications,
5//! and pending transaction signals. Handlers remain pure synchronous functions:
6//! they read through [`StateView`], return structured
7//! [`ReactiveEffect`] values, and let the runtime validate and commit cache
8//! mutations through [`StateUpdate`].
9//!
10//! This module intentionally contains no protocol, AMM, strategy, signing, or
11//! transaction-submission concepts. Downstream crates can layer those domains on
12//! top by implementing [`ReactiveHandler`] and [`ReactiveHook`].
13
14use std::{
15    any::Any,
16    borrow::Cow,
17    collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
18    fmt,
19    future::Future,
20    hash::Hash,
21    marker::PhantomData,
22    num::NonZeroU64,
23    path::PathBuf,
24    pin::Pin,
25    sync::{
26        Arc,
27        atomic::{AtomicU64, Ordering},
28    },
29    time::{Duration, Instant},
30};
31
32use alloy_consensus::{BlockHeader as _, Transaction as _};
33use alloy_eips::{BlockId, BlockNumberOrTag};
34use alloy_network::{
35    Ethereum, Network,
36    primitives::{
37        BlockResponse as _, HeaderResponse as HeaderResponseTrait,
38        TransactionResponse as TransactionResponseTrait,
39    },
40};
41use alloy_primitives::{Address, B256, Bytes, FixedBytes, Keccak256, U256};
42use alloy_provider::{Provider, RootProvider};
43use alloy_rpc_client::BatchRequest;
44use alloy_rpc_types_eth::{Filter, FilterSet, Log};
45pub use alloy_transport_balancer::EndpointId;
46use bincode::Options;
47use futures::{StreamExt, stream};
48use futures::{
49    future::{Either, poll_fn, select},
50    stream::{BoxStream, FuturesUnordered},
51};
52#[cfg(feature = "reactive-ws")]
53use tokio::sync::broadcast;
54
55use crate::{
56    cache::{
57        AccountProof, BlockStateDiff, DurableCheckpointBlock, DurableCheckpointError,
58        DurableCheckpointIdentity, DurableCheckpointMetadata, DurableCheckpointStore, EvmCache,
59        EvmCacheStateSnapshot, LoadedDurableCheckpoint,
60    },
61    errors::{BlockContextError, StorageFetchResult},
62    events::{EventDecoder, StateView},
63    freshness::FreshnessRegistry,
64    state_update::{AccountPatch, PurgeScope, StateDiff, StateUpdate},
65};
66
67#[cfg(feature = "raw-flashblocks-json")]
68mod raw_json_flashblocks;
69#[cfg(feature = "raw-flashblocks-json")]
70pub use raw_json_flashblocks::{
71    BufferedRawJsonFlashblocksAdapter, FlashblockInvalidation, FlashblockInvalidationReason,
72    FlashblockSnapshot, FlashblockUpdate, FlashblockUpdateAcknowledgement,
73    FlashblockUpdateChannelError, FlashblockUpdateSender, RawJsonFlashblocksAdapter,
74    RawJsonFlashblocksError, RawJsonFlashblocksLimits, TimedFlashblockUpdate,
75};
76
77/// Input accepted by the reactive runtime.
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub enum ReactiveInput<N: Network = Ethereum> {
80    /// A canonical or removed EVM log, using Alloy's RPC log type.
81    Log(Log),
82    /// A block header response for header-oriented handlers.
83    BlockHeader(N::HeaderResponse),
84    /// A full block response for block handlers that need transaction bodies.
85    FullBlock(N::BlockResponse),
86    /// A pending transaction hash.
87    PendingTxHash(B256),
88    /// A full pending transaction body.
89    PendingTx(N::TransactionResponse),
90}
91
92/// Context supplied with each [`ReactiveInput`].
93#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
94pub struct ReactiveContext {
95    /// Chain id, when known.
96    pub chain_id: Option<u64>,
97    /// Where the input came from.
98    pub source: InputSource,
99    /// Lifecycle status of the input.
100    pub chain_status: ChainStatus,
101    /// Block metadata associated with the input, when known.
102    pub block: Option<BlockRef>,
103    /// Transaction index for log or transaction inputs.
104    pub transaction_index: Option<u64>,
105    /// Log index for log inputs.
106    pub log_index: Option<u64>,
107}
108
109/// Minimal block identity carried through reports.
110#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
111pub struct BlockRef {
112    /// Block number.
113    pub number: u64,
114    /// Block hash.
115    pub hash: B256,
116    /// Parent hash, when known.
117    pub parent_hash: Option<B256>,
118    /// Block timestamp, when known.
119    pub timestamp: Option<u64>,
120}
121
122/// Stable provider identity attached to provider-originated input.
123///
124/// `generation` changes whenever a caller replaces or reconnects the concrete
125/// provider session behind the same configured endpoint. Follow-up reads can
126/// use this value to prefer the exact source that announced speculative state
127/// without putting URLs or credentials into event payloads.
128#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
129pub struct ProviderRef {
130    /// Operator-defined endpoint identity.
131    pub endpoint: EndpointId,
132    /// Concrete connection/session generation.
133    pub generation: u64,
134}
135
136impl ProviderRef {
137    /// Construct provider provenance for one connection generation.
138    pub fn new(endpoint: impl Into<EndpointId>, generation: u64) -> Self {
139        Self {
140            endpoint: endpoint.into(),
141            generation,
142        }
143    }
144}
145
146/// Process-local monotonic time at which a Flashblock source item first
147/// entered the typed subscriber boundary.
148///
149/// This metadata never participates in Flashblock identity, ordering,
150/// canonical state, or execution authority.
151#[derive(Clone, Copy, Debug, PartialEq, Eq)]
152pub struct FlashblockIngressTiming {
153    source_ingress: Instant,
154}
155
156impl FlashblockIngressTiming {
157    /// Bind a source item to its earliest process-local typed arrival.
158    pub const fn new(source_ingress: Instant) -> Self {
159        Self { source_ingress }
160    }
161
162    /// Earliest process-local typed arrival for the source item.
163    pub const fn source_ingress(self) -> Instant {
164        self.source_ingress
165    }
166
167    /// Retain the earliest contributing source arrival.
168    pub fn earliest(self, other: Self) -> Self {
169        Self::new(self.source_ingress.min(other.source_ingress))
170    }
171}
172
173/// Identity of one cumulative pre-confirmed Flashblock snapshot.
174#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
175pub struct FlashblockRef {
176    /// Provider session that supplied this snapshot.
177    pub provider: ProviderRef,
178    /// Sequencer payload id shared by every Flashblock in the full block.
179    ///
180    /// Some provider wire shapes omit this indexed-payload identifier.
181    pub payload_id: Option<FixedBytes<8>>,
182    /// Zero-based Flashblock index, when exposed by the endpoint.
183    pub index: Option<u64>,
184    /// Pending block number represented by this cumulative snapshot.
185    pub block_number: u64,
186    /// Provider-generation-scoped commitment to this exact cumulative view.
187    ///
188    /// This is deliberately not a canonical or provider-reported block hash.
189    /// It remains non-zero even when a pending endpoint uses the zero hash
190    /// placeholder permitted by the Flashblocks specification.
191    pub content_hash: B256,
192    /// Non-placeholder partial block hash reported by the provider, when any.
193    pub partial_block_hash: Option<B256>,
194    /// Canonical parent of the pending block, when exposed.
195    pub parent_hash: Option<B256>,
196    /// State root after this cumulative snapshot, when exposed.
197    pub state_root: Option<B256>,
198    /// Transaction-trie root committed by a cumulative block-shaped preview.
199    pub transactions_root: Option<B256>,
200    /// Ordered cumulative transaction membership for this preview.
201    pub transaction_hashes: Vec<B256>,
202    /// Pending block timestamp, when exposed.
203    pub timestamp: Option<u64>,
204    /// Pending EIP-1559 base fee, when exposed.
205    pub base_fee_per_gas: Option<u64>,
206    /// Pending block beneficiary / fee recipient, when exposed.
207    pub beneficiary: Option<Address>,
208    /// Pending block randomness value, when exposed.
209    pub prevrandao: Option<B256>,
210    /// Pending block gas limit, when exposed.
211    pub gas_limit: Option<u64>,
212}
213
214impl FlashblockRef {
215    /// Convert the pre-confirmed identity into the block metadata used by
216    /// ordinary log routing. The hash is the provider-generation-scoped
217    /// [`content_hash`](Self::content_hash), never a canonical block hash, and
218    /// must not advance canonical coverage.
219    pub const fn block_ref(&self) -> BlockRef {
220        BlockRef {
221            number: self.block_number,
222            hash: self.content_hash,
223            parent_hash: self.parent_hash,
224            timestamp: self.timestamp,
225        }
226    }
227
228    /// Whether the cumulative preview contains `transaction_hash`.
229    pub fn contains_transaction(&self, transaction_hash: &B256) -> bool {
230        self.transaction_hashes.contains(transaction_hash)
231    }
232
233    fn transaction_index(&self, transaction_hash: &B256) -> Option<u64> {
234        self.transaction_hashes
235            .iter()
236            .position(|candidate| candidate == transaction_hash)
237            .and_then(|index| u64::try_from(index).ok())
238    }
239
240    fn same_payload(&self, other: &Self) -> bool {
241        self.provider == other.provider
242            && match (self.payload_id, other.payload_id) {
243                (Some(left), Some(right)) => left == right,
244                _ => {
245                    self.block_number == other.block_number && self.parent_hash == other.parent_hash
246                }
247            }
248    }
249
250    /// Whether two cumulative previews bind the same pending-block base
251    /// fields, excluding payload index, cumulative transactions, and derived
252    /// content commitments.
253    ///
254    /// This provider-free predicate lets applications retain lineage metadata
255    /// only across snapshots that cannot have crossed a pending-block
256    /// replacement boundary. It grants no canonical or execution authority.
257    #[cfg(feature = "raw-flashblocks-json")]
258    pub fn same_base_identity(&self, other: &Self) -> bool {
259        self.block_number == other.block_number
260            && self.parent_hash == other.parent_hash
261            && self.timestamp == other.timestamp
262            && self.base_fee_per_gas == other.base_fee_per_gas
263            && self.beneficiary == other.beneficiary
264            && self.prevrandao == other.prevrandao
265            && self.gas_limit == other.gas_limit
266    }
267
268    fn is_cumulative_successor_of(&self, previous: &Self) -> bool {
269        self.same_payload(previous)
270            && self.transaction_hashes.len() >= previous.transaction_hashes.len()
271            && self
272                .transaction_hashes
273                .starts_with(&previous.transaction_hashes)
274            && match (previous.index, self.index) {
275                (Some(previous), Some(current)) => current >= previous,
276                _ => true,
277            }
278    }
279}
280
281/// Whether the subscriber may use Flashblocks for speculative delivery.
282#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
283pub enum PreconfirmationMode {
284    /// Use only canonical subscription/polling behavior.
285    #[default]
286    Disabled,
287    /// Prefer Flashblocks, but retain canonical operation when the selected
288    /// chain/provider cannot establish the pre-confirmation stream.
289    Preferred,
290    /// Fail setup/reconnect closed unless Flashblocks can be established.
291    Required,
292}
293
294/// Indexed OP Stack `newFlashblocks` subscription payload.
295#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
296pub struct BaseFlashblockPayload {
297    /// Block-builder payload id shared by every incremental snapshot.
298    pub payload_id: FixedBytes<8>,
299    /// Zero-based incremental snapshot index.
300    pub index: u64,
301    /// Header fields present on index zero.
302    pub base: Option<BaseFlashblockBase>,
303    /// Cumulative state commitments for this snapshot.
304    pub diff: BaseFlashblockDiff,
305    /// Supplemental block identity retained across current Base versions.
306    #[serde(default)]
307    pub metadata: Option<BaseFlashblockMetadata>,
308}
309
310/// Stable index-zero header subset from Base's Flashblocks wire format.
311#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
312pub struct BaseFlashblockBase {
313    /// Canonical parent block hash.
314    pub parent_hash: B256,
315    /// Pending block number.
316    #[serde(deserialize_with = "deserialize_rpc_u64")]
317    pub block_number: u64,
318    /// Pending block timestamp.
319    #[serde(deserialize_with = "deserialize_rpc_u64")]
320    pub timestamp: u64,
321    /// Pending block gas limit.
322    #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
323    pub gas_limit: Option<u64>,
324    /// Pending EIP-1559 base fee.
325    #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
326    pub base_fee_per_gas: Option<u64>,
327    /// Pending block beneficiary / fee recipient.
328    #[serde(default, alias = "fee_recipient", alias = "feeRecipient")]
329    pub beneficiary: Option<Address>,
330    /// Pending block randomness value.
331    #[serde(
332        default,
333        alias = "prev_randao",
334        alias = "prevRandao",
335        alias = "mixHash"
336    )]
337    pub prevrandao: Option<B256>,
338}
339
340/// Stable commitment subset from Base's Flashblocks wire format.
341#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
342pub struct BaseFlashblockDiff {
343    /// State root after this cumulative snapshot.
344    pub state_root: B256,
345    /// Partial block hash after this cumulative snapshot.
346    pub block_hash: B256,
347    /// Transactions added by this indexed Flashblock diff.
348    #[serde(default)]
349    pub transactions: Vec<serde_json::Value>,
350    /// Transaction root when exposed by the provider.
351    #[serde(default)]
352    pub transactions_root: Option<B256>,
353}
354
355/// Stable metadata subset used when index-greater-than-zero payloads omit the
356/// Base header object.
357#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
358pub struct BaseFlashblockMetadata {
359    /// Pending block number (currently encoded as a JSON integer).
360    #[serde(deserialize_with = "deserialize_rpc_u64")]
361    pub block_number: u64,
362}
363
364/// Cumulative block-shaped `newFlashblocks` wire shape used by some OP Stack
365/// providers.
366#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
367#[serde(rename_all = "camelCase")]
368struct BaseFlashblockBlockPayload {
369    hash: B256,
370    #[serde(deserialize_with = "deserialize_rpc_u64")]
371    number: u64,
372    parent_hash: B256,
373    state_root: B256,
374    #[serde(default)]
375    transactions_root: Option<B256>,
376    #[serde(default)]
377    transactions: Vec<serde_json::Value>,
378    #[serde(deserialize_with = "deserialize_rpc_u64")]
379    timestamp: u64,
380    #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
381    base_fee_per_gas: Option<u64>,
382    #[serde(default, alias = "beneficiary", alias = "feeRecipient")]
383    miner: Option<Address>,
384    #[serde(default, alias = "prevRandao")]
385    mix_hash: Option<B256>,
386    #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
387    gas_limit: Option<u64>,
388}
389
390/// OP Stack providers expose either an indexed diff envelope or a cumulative
391/// block-shaped envelope for `newFlashblocks`. Accept both so provider rollout
392/// differences do not force callers onto separate subscriber paths.
393#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
394#[serde(untagged)]
395enum BaseFlashblockWirePayload {
396    Indexed(BaseFlashblockPayload),
397    Block(BaseFlashblockBlockPayload),
398}
399
400fn deserialize_rpc_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
401where
402    D: serde::Deserializer<'de>,
403{
404    #[derive(serde::Deserialize)]
405    #[serde(untagged)]
406    enum RpcU64 {
407        Number(u64),
408        String(String),
409    }
410
411    match <RpcU64 as serde::Deserialize>::deserialize(deserializer)? {
412        RpcU64::Number(number) => Ok(number),
413        RpcU64::String(value) => {
414            let value = value.strip_prefix("0x").unwrap_or(&value);
415            u64::from_str_radix(value, 16).map_err(serde::de::Error::custom)
416        }
417    }
418}
419
420fn deserialize_optional_rpc_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
421where
422    D: serde::Deserializer<'de>,
423{
424    #[derive(serde::Deserialize)]
425    #[serde(untagged)]
426    enum RpcU64 {
427        Number(u64),
428        String(String),
429    }
430
431    let Some(value) = <Option<RpcU64> as serde::Deserialize>::deserialize(deserializer)? else {
432        return Ok(None);
433    };
434    match value {
435        RpcU64::Number(number) => Ok(Some(number)),
436        RpcU64::String(value) => {
437            let value = value.strip_prefix("0x").unwrap_or(&value);
438            u64::from_str_radix(value, 16)
439                .map(Some)
440                .map_err(serde::de::Error::custom)
441        }
442    }
443}
444
445fn non_placeholder_hash(hash: B256) -> Option<B256> {
446    (!hash.is_zero()).then_some(hash)
447}
448
449fn flashblock_transaction_hashes(
450    transactions: &[serde_json::Value],
451) -> Result<Vec<B256>, SubscriberError> {
452    let hashes: Vec<B256> = transactions
453        .iter()
454        .map(|transaction| {
455            let value = match transaction {
456                serde_json::Value::String(value) => value.as_str(),
457                serde_json::Value::Object(object) => object
458                    .get("hash")
459                    .or_else(|| object.get("transactionHash"))
460                    .and_then(serde_json::Value::as_str)
461                    .ok_or_else(|| {
462                        SubscriberError::Provider(
463                            "Flashblock transaction object is missing its hash".into(),
464                        )
465                    })?,
466                _ => {
467                    return Err(SubscriberError::Provider(
468                        "Flashblock transaction must be a hash, raw transaction, or object".into(),
469                    ));
470                }
471            };
472            if value.len() == 66 {
473                return value.parse::<B256>().map_err(|error| {
474                    SubscriberError::Provider(format!(
475                        "Flashblock transaction hash is invalid: {error}"
476                    ))
477                });
478            }
479            let encoded = value.strip_prefix("0x").unwrap_or(value);
480            let raw = alloy_primitives::hex::decode(encoded).map_err(|error| {
481                SubscriberError::Provider(format!(
482                    "Flashblock raw transaction is invalid hex: {error}"
483                ))
484            })?;
485            Ok(alloy_primitives::keccak256(raw))
486        })
487        .collect::<Result<_, _>>()?;
488    let mut unique = HashSet::with_capacity(hashes.len());
489    if hashes.iter().any(|hash| !unique.insert(*hash)) {
490        return Err(SubscriberError::Provider(
491            "Flashblock cumulative transaction membership contains a duplicate hash".into(),
492        ));
493    }
494    Ok(hashes)
495}
496
497struct FlashblockContentCommitment<'a> {
498    provider: &'a ProviderRef,
499    payload_id: Option<FixedBytes<8>>,
500    index: Option<u64>,
501    block_number: u64,
502    partial_block_hash: Option<B256>,
503    parent_hash: Option<B256>,
504    state_root: Option<B256>,
505    transactions_root: Option<B256>,
506    transaction_hashes: &'a [B256],
507    timestamp: Option<u64>,
508    base_fee_per_gas: Option<u64>,
509    beneficiary: Option<Address>,
510    prevrandao: Option<B256>,
511    gas_limit: Option<u64>,
512}
513
514fn flashblock_content_hash(content: FlashblockContentCommitment<'_>) -> B256 {
515    let mut commitment = Keccak256::new();
516    commitment.update(b"evm-fork-cache/flashblock-content/v1");
517    let endpoint = content.provider.endpoint.as_str().as_bytes();
518    commitment.update((endpoint.len() as u64).to_be_bytes());
519    commitment.update(endpoint);
520    commitment.update(content.provider.generation.to_be_bytes());
521    commitment.update(content.block_number.to_be_bytes());
522    commit_optional_bytes(
523        &mut commitment,
524        content.payload_id.as_ref().map(FixedBytes::as_slice),
525    );
526    commit_optional_u64(&mut commitment, content.index);
527    commit_optional_bytes(
528        &mut commitment,
529        content
530            .partial_block_hash
531            .as_ref()
532            .map(FixedBytes::as_slice),
533    );
534    commit_optional_bytes(
535        &mut commitment,
536        content.parent_hash.as_ref().map(FixedBytes::as_slice),
537    );
538    commit_optional_bytes(
539        &mut commitment,
540        content.state_root.as_ref().map(FixedBytes::as_slice),
541    );
542    commit_optional_bytes(
543        &mut commitment,
544        content.transactions_root.as_ref().map(FixedBytes::as_slice),
545    );
546    commitment.update((content.transaction_hashes.len() as u64).to_be_bytes());
547    for transaction_hash in content.transaction_hashes {
548        commitment.update(transaction_hash);
549    }
550    commit_optional_u64(&mut commitment, content.timestamp);
551    commit_optional_u64(&mut commitment, content.base_fee_per_gas);
552    commit_optional_bytes(
553        &mut commitment,
554        content
555            .beneficiary
556            .as_ref()
557            .map(|address| address.as_slice()),
558    );
559    commit_optional_bytes(
560        &mut commitment,
561        content.prevrandao.as_ref().map(FixedBytes::as_slice),
562    );
563    commit_optional_u64(&mut commitment, content.gas_limit);
564    let hash = commitment.finalize();
565    if hash.is_zero() {
566        B256::with_last_byte(1)
567    } else {
568        hash
569    }
570}
571
572#[cfg(feature = "raw-flashblocks-json")]
573fn validate_standard_flashblock_snapshot(
574    snapshot: &FlashblockSnapshot,
575) -> Result<(), SubscriberError> {
576    let flashblock = &snapshot.flashblock;
577    if flashblock.payload_id.is_none() || flashblock.index.is_none() {
578        return Err(SubscriberError::Provider(
579            "external Flashblock snapshot is missing its indexed payload identity".into(),
580        ));
581    }
582    let expected_content_hash = flashblock_content_hash(FlashblockContentCommitment {
583        provider: &flashblock.provider,
584        payload_id: flashblock.payload_id,
585        index: flashblock.index,
586        block_number: flashblock.block_number,
587        partial_block_hash: flashblock.partial_block_hash,
588        parent_hash: flashblock.parent_hash,
589        state_root: flashblock.state_root,
590        transactions_root: flashblock.transactions_root,
591        transaction_hashes: &flashblock.transaction_hashes,
592        timestamp: flashblock.timestamp,
593        base_fee_per_gas: flashblock.base_fee_per_gas,
594        beneficiary: flashblock.beneficiary,
595        prevrandao: flashblock.prevrandao,
596        gas_limit: flashblock.gas_limit,
597    });
598    if flashblock.content_hash != expected_content_hash {
599        return Err(SubscriberError::Provider(
600            "external Flashblock content commitment is invalid".into(),
601        ));
602    }
603
604    let mut transactions = HashSet::with_capacity(flashblock.transaction_hashes.len());
605    if flashblock
606        .transaction_hashes
607        .iter()
608        .any(|hash| !transactions.insert(*hash))
609    {
610        return Err(SubscriberError::Provider(
611            "external Flashblock cumulative transaction membership contains a duplicate hash"
612                .into(),
613        ));
614    }
615
616    let mut log_ids = HashSet::with_capacity(snapshot.logs.len());
617    for log in &snapshot.logs {
618        if log.removed || log.block_number != Some(flashblock.block_number) {
619            return Err(SubscriberError::Provider(
620                "external pre-confirmed log disagrees with its Flashblock block identity".into(),
621            ));
622        }
623        if log.block_hash != Some(flashblock.content_hash) {
624            return Err(SubscriberError::Provider(
625                "external pre-confirmed log is not bound to its Flashblock content commitment"
626                    .into(),
627            ));
628        }
629        let transaction_hash = log.transaction_hash.ok_or_else(|| {
630            SubscriberError::Provider(
631                "external pre-confirmed log is missing its transaction hash".into(),
632            )
633        })?;
634        let expected_transaction_index = flashblock
635            .transaction_index(&transaction_hash)
636            .ok_or_else(|| {
637                SubscriberError::Provider(
638                    "external pre-confirmed log transaction is absent from the cumulative Flashblock"
639                        .into(),
640                )
641            })?;
642        if log.transaction_index != Some(expected_transaction_index) {
643            return Err(SubscriberError::Provider(
644                "external pre-confirmed log transaction index disagrees with cumulative membership"
645                    .into(),
646            ));
647        }
648        let log_index = log.log_index.ok_or_else(|| {
649            SubscriberError::Provider("external pre-confirmed log is missing its log index".into())
650        })?;
651        if !log_ids.insert((transaction_hash, log_index)) {
652            return Err(SubscriberError::Provider(
653                "external Flashblock snapshot contains a duplicate log identity".into(),
654            ));
655        }
656    }
657    Ok(())
658}
659
660fn commit_optional_bytes(commitment: &mut Keccak256, value: Option<&[u8]>) {
661    match value {
662        Some(value) => {
663            commitment.update([1]);
664            commitment.update((value.len() as u64).to_be_bytes());
665            commitment.update(value);
666        }
667        None => commitment.update([0]),
668    }
669}
670
671fn commit_optional_u64(commitment: &mut Keccak256, value: Option<u64>) {
672    match value {
673        Some(value) => {
674            commitment.update([1]);
675            commitment.update(value.to_be_bytes());
676        }
677        None => commitment.update([0]),
678    }
679}
680
681/// Exact chain/block identity of an RPC cache snapshot adopted as the starting
682/// point for reactive event continuity.
683#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
684pub struct ReactiveCanonicalBaseline {
685    /// Chain whose state the cache snapshot contains.
686    pub chain_id: u64,
687    /// Canonical block through which the snapshot already embodies state.
688    pub block: BlockRef,
689}
690
691impl ReactiveCanonicalBaseline {
692    /// Construct an exact cache snapshot baseline.
693    pub const fn new(chain_id: u64, block: BlockRef) -> Self {
694        Self { chain_id, block }
695    }
696}
697
698/// Ordered chain-lifecycle control delivered by an event subscriber.
699///
700/// Controls live inside [`ReactiveInputBatch`] so they share the same delivery
701/// token, durable checkpoint, and ordering guarantees as ordinary event data.
702/// Reorg controls are applied in declaration order before replacement records;
703/// progress, barrier, safe, and finalized controls are committed in declaration
704/// order after the records. A reorg declared after a post-record control is
705/// rejected because its ordering would otherwise be ambiguous.
706#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
707#[non_exhaustive]
708pub enum ChainControl {
709    /// Replace the old canonical branch after `common_ancestor` with `new_tip`.
710    Reorg {
711        /// Last block common to the old and new canonical branches.
712        common_ancestor: BlockRef,
713        /// Tip of the branch that ceased to be canonical.
714        old_tip: BlockRef,
715        /// Tip of the newly canonical branch known by the source.
716        new_tip: BlockRef,
717    },
718    /// Update the source's safe head.
719    Safe(BlockRef),
720    /// Update the source's finalized head.
721    Finalized(BlockRef),
722    /// Advance authoritative canonical coverage without fabricating a full header.
723    ///
724    /// Indexers that only know compact block identity should emit this control.
725    /// It never runs block handlers. The runtime exact-hash pins provider reads
726    /// and installs known `NUMBER`/timestamp values, but clears unproven
727    /// header-only environment fields such as base fee and beneficiary.
728    CanonicalProgress(BlockRef),
729    /// Attest that no notification loss has gone unhealed on any log source at
730    /// or below this block.
731    ///
732    /// This is a *negative* guarantee, and deliberately so. A source cannot
733    /// prove from its own log stream that every matching log through block `N`
734    /// arrived — a filter that matched nothing for a hundred blocks is
735    /// indistinguishable from one whose notifications were dropped. What a
736    /// source can prove is that it detected no loss it did not repair, which is
737    /// exactly the fact a consumer cannot establish for itself.
738    ///
739    /// A consumer combines this with its own ordering evidence to decide when a
740    /// block's log set is closed. That evidence must come from the log stream
741    /// itself — a delivered log for a strictly later block — or from a positive
742    /// proof of absence such as the block's `logsBloom` excluding every
743    /// interest. A header for a later block is *not* such evidence: `newHeads`
744    /// is an independent subscription, so it establishes nothing about whether
745    /// an earlier block's logs have been delivered. Neither is a timer. Sources that cannot make this promise simply
746    /// never emit it, and advertise the absence through
747    /// [`SubscriberCapability::LogCoverageAttestation`]; that distinction is why
748    /// silence here must never be read as an attestation.
749    ///
750    /// Unlike [`Self::CanonicalProgress`] this makes no claim about chain
751    /// progress and never advances the cache's pinned block.
752    LogCoverage(BlockRef),
753    /// Ordered cutover or synchronization fence.
754    Barrier {
755        /// Subscriber-defined opaque barrier identity.
756        id: Vec<u8>,
757        /// Highest canonical event block included before the fence, if known.
758        block: Option<BlockRef>,
759    },
760}
761
762/// Provider-neutral snapshot consumed by [`validate_canonical_sequence`].
763///
764/// Composite subscribers can persist this small chain-state view beside their
765/// own delivery checkpoint and validate a complete delivery envelope before it
766/// reaches a [`ReactiveRuntime`]. The retained history may be sparse (blocks
767/// without matching events need not be present), but it must contain at most
768/// one compatible identity per height. Its oldest entry is also the durable
769/// rollback horizon: an unretained explicit ancestor is accepted only when that
770/// oldest entry is at or below the ancestor. This type carries no cache data,
771/// event payloads, handler state, or transport-specific cursor.
772///
773/// The serde representation is a convenience for caller-owned persistence; it
774/// is not a versioned wire or checkpoint format. Durable protocols should wrap
775/// it in their own versioned envelope and define migrations before upgrading
776/// this pre-1.0 crate. External callers also own retention: successful
777/// validation appends canonical identities but does not silently discard the
778/// rollback proof window. Bound it with [`Self::retain_recent_history`] after
779/// committing the matching source cursor/ACK.
780#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
781pub struct CanonicalSequenceState {
782    retained_canonical_history: Vec<BlockRef>,
783    coverage_head: Option<BlockRef>,
784    safe_head: Option<BlockRef>,
785    finalized_head: Option<BlockRef>,
786    log_coverage_head: Option<BlockRef>,
787}
788
789impl CanonicalSequenceState {
790    /// Construct a validation snapshot from retained canonical metadata.
791    ///
792    /// Construction does not validate ordering, adjacency, coverage, or
793    /// finality invariants. Call [`Self::validate`] before installing decoded or
794    /// externally assembled state.
795    pub fn new(
796        retained_canonical_history: Vec<BlockRef>,
797        coverage_head: Option<BlockRef>,
798        safe_head: Option<BlockRef>,
799        finalized_head: Option<BlockRef>,
800    ) -> Self {
801        Self {
802            retained_canonical_history,
803            coverage_head,
804            safe_head,
805            finalized_head,
806            log_coverage_head: None,
807        }
808    }
809
810    /// Seed the attested log-coverage watermark.
811    ///
812    /// Kept separate from [`Self::new`] so restoring durable state that predates
813    /// log-coverage attestation stays a compile-time no-op: an absent watermark
814    /// means "no source has attested", never "attested at genesis".
815    #[must_use]
816    pub fn with_log_coverage_head(mut self, log_coverage_head: Option<BlockRef>) -> Self {
817        self.log_coverage_head = log_coverage_head;
818        self
819    }
820
821    /// Sparse retained canonical history in ascending processing order.
822    pub fn retained_canonical_history(&self) -> &[BlockRef] {
823        &self.retained_canonical_history
824    }
825
826    /// Highest canonical identity covered by this state, when known.
827    pub const fn coverage_head(&self) -> Option<&BlockRef> {
828        self.coverage_head.as_ref()
829    }
830
831    /// Latest safe head accepted by the validator, when known.
832    pub const fn safe_head(&self) -> Option<&BlockRef> {
833        self.safe_head.as_ref()
834    }
835
836    /// Latest finalized head accepted by the validator, when known.
837    pub const fn finalized_head(&self) -> Option<&BlockRef> {
838        self.finalized_head.as_ref()
839    }
840
841    /// Highest block at or below which no log-notification loss went unhealed.
842    ///
843    /// `None` means no source has attested, which is not an attestation of
844    /// anything: treat it as unknown, never as complete. See
845    /// [`ChainControl::LogCoverage`].
846    pub const fn log_coverage_head(&self) -> Option<&BlockRef> {
847        self.log_coverage_head.as_ref()
848    }
849
850    /// Retain at most the newest `max_entries` canonical history identities.
851    ///
852    /// Coverage and safe/finalized heads are unchanged. The oldest retained
853    /// identity defines how far strict validation can prove a complete
854    /// rollback, so choose a bound at least as large as the deployment's
855    /// supported reorg depth and trim only after atomically committing the
856    /// corresponding validated state and source cursor. `0` intentionally
857    /// produces a coverage-only snapshot.
858    pub fn retain_recent_history(&mut self, max_entries: usize) {
859        let remove = self
860            .retained_canonical_history
861            .len()
862            .saturating_sub(max_entries);
863        self.retained_canonical_history.drain(..remove);
864    }
865
866    /// Validate a decoded/checkpointed snapshot before installing it.
867    ///
868    /// This rejects out-of-order or conflicting retained identities,
869    /// broken adjacent parent links, retained history without coverage,
870    /// incompatible coverage/finality aliases, hash reuse across heights,
871    /// known parent hashes at non-adjacent heights, finality beyond coverage,
872    /// and a finalized head beyond or conflicting with the safe head.
873    ///
874    /// # Errors
875    ///
876    /// Returns [`ReactiveError`] when any retained identity, parent link,
877    /// coverage alias, or safe/finalized relationship violates the canonical
878    /// snapshot invariants described above.
879    pub fn validate(&self) -> Result<(), ReactiveError> {
880        validate_canonical_sequence_snapshot(self)
881    }
882}
883
884/// Cache-free canonical transition proven by [`validate_canonical_sequence`].
885#[derive(Clone, Debug, PartialEq, Eq)]
886#[non_exhaustive]
887pub enum CanonicalSequenceMutation {
888    /// Rewind the listed retained identities and continue from `common_ancestor`.
889    Rewind {
890        /// Surviving canonical anchor, when one is retained or authenticated.
891        /// `None` is a transient same-envelope state: callers must stage the
892        /// complete validation atomically and may checkpoint only the returned
893        /// `next_state`, after a later canonical mutation installs the proven
894        /// replacement.
895        common_ancestor: Option<BlockRef>,
896        /// Exact retained identities removed by the transition.
897        dropped: Vec<BlockRef>,
898    },
899    /// Accept or enrich one canonical identity.
900    Canonical(BlockRef),
901    /// Accept a safe-head update with metadata resolved against prior state.
902    Safe(BlockRef),
903    /// Accept a finalized-head update with metadata resolved against prior state.
904    Finalized(BlockRef),
905    /// Advance the attested log-coverage watermark.
906    LogCoverage(BlockRef),
907}
908
909/// Successful result of provider-neutral canonical envelope validation.
910#[derive(Clone, Debug, PartialEq, Eq)]
911pub struct CanonicalSequenceValidation {
912    pre_record_state: CanonicalSequenceState,
913    next_state: CanonicalSequenceState,
914    mutations: Vec<CanonicalSequenceMutation>,
915    normalized_chain_controls: Vec<ChainControl>,
916}
917
918impl CanonicalSequenceValidation {
919    /// State after pre-record explicit reorg controls and before event records.
920    pub const fn pre_record_state(&self) -> &CanonicalSequenceState {
921        &self.pre_record_state
922    }
923
924    /// Fully validated state after records and post-record controls.
925    pub const fn next_state(&self) -> &CanonicalSequenceState {
926        &self.next_state
927    }
928
929    /// Ordered cache-free canonical mutations proven by this envelope.
930    pub fn mutations(&self) -> &[CanonicalSequenceMutation] {
931        &self.mutations
932    }
933
934    /// Controls safe to forward after composite overlap normalization.
935    ///
936    /// Ordinary validation retains the original controls. See
937    /// [`normalize_and_validate_canonical_sequence`] for the mode that removes
938    /// compatible stale progress and converts a stale blockful barrier into the
939    /// same barrier identity without a block assertion. Equal-height controls
940    /// that add previously absent parent/timestamp metadata remain present;
941    /// older compatible enrichment is intentionally not applied because the
942    /// corresponding regressive control is not forwarded to the runtime.
943    pub fn normalized_chain_controls(&self) -> &[ChainControl] {
944        &self.normalized_chain_controls
945    }
946}
947
948/// Lifecycle status for an input.
949#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
950#[non_exhaustive]
951pub enum ChainStatus {
952    /// The input is mempool-only and must not mutate canonical cache state.
953    Pending,
954    /// The input is ordered into an ephemeral sequencer-built Flashblock.
955    ///
956    /// Handlers may update the runtime's speculative overlay for this status,
957    /// but the update never advances canonical coverage or durable journals.
958    Preconfirmed {
959        /// Shared exact cumulative pre-confirmation snapshot observed by the
960        /// source. Sharing keeps ordinary canonical records compact and makes
961        /// multi-log Flashblock delivery cheap to clone.
962        flashblock: Arc<FlashblockRef>,
963    },
964    /// The input is included in a block with a confirmation count.
965    Included {
966        /// Included block.
967        block: BlockRef,
968        /// Confirmation count.
969        confirmations: u64,
970    },
971    /// The input is in the chain's safe head.
972    Safe {
973        /// Safe block.
974        block: BlockRef,
975    },
976    /// The input is in the finalized head.
977    Finalized {
978        /// Finalized block.
979        block: BlockRef,
980    },
981    /// The input was dropped by a reorg.
982    Reorged {
983        /// Block the input was dropped from.
984        dropped_from: BlockRef,
985    },
986}
987
988/// Source of an input batch.
989#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
990#[non_exhaustive]
991pub enum InputSource {
992    /// Caller-supplied batch.
993    Batch,
994    /// Live subscription stream.
995    Subscription,
996    /// Polling subscriber.
997    Poll,
998    /// Historical backfill.
999    Backfill,
1000    /// Sequencer pre-confirmation / Flashblocks surface.
1001    Flashblocks,
1002    /// Test or synthetic input.
1003    Synthetic,
1004}
1005
1006/// Stable identity used for input deduplication and reports.
1007#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1008pub enum InputRef {
1009    /// Stable log identity.
1010    Log {
1011        /// Chain id, when known.
1012        chain_id: Option<u64>,
1013        /// Block hash containing the log.
1014        block_hash: B256,
1015        /// Transaction hash that emitted the log.
1016        transaction_hash: B256,
1017        /// Log index within the block.
1018        log_index: u64,
1019    },
1020    /// Stable pending transaction identity.
1021    PendingTx {
1022        /// Chain id, when known.
1023        chain_id: Option<u64>,
1024        /// Transaction hash.
1025        hash: B256,
1026    },
1027    /// Stable block identity.
1028    Block {
1029        /// Chain id, when known.
1030        chain_id: Option<u64>,
1031        /// Block hash.
1032        hash: B256,
1033        /// Block number.
1034        number: u64,
1035    },
1036}
1037
1038/// Representation and lifecycle class retained alongside an [`InputRef`].
1039///
1040/// `InputRef` identifies the underlying chain object. This discriminator keeps
1041/// distinct handler inputs from collapsing merely because they commit to the
1042/// same object: a header and full block, a pending hash and hydrated body, and
1043/// canonical versus reorg-signalling log delivery are independently routable.
1044#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1045#[non_exhaustive]
1046pub enum ReactiveInputKind {
1047    /// Canonical log data.
1048    CanonicalLog,
1049    /// Removed or otherwise reorg-signalling log data.
1050    ReorgSignalLog,
1051    /// Header-only block representation.
1052    BlockHeader,
1053    /// Full block representation.
1054    FullBlock,
1055    /// Hash-only pending transaction representation.
1056    PendingTxHash,
1057    /// Hydrated pending transaction representation.
1058    PendingTx,
1059}
1060
1061/// Validated, representation-aware identity for one reactive input.
1062///
1063/// Composite subscribers can use this as a dedupe key without conflating
1064/// independently routable representations. When a key repeats, use
1065/// [`ReactiveInputRecord::same_deduplicable_payload`] to distinguish a true
1066/// provider overlap from a conflicting payload.
1067#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1068pub struct ReactiveInputIdentity {
1069    input_ref: InputRef,
1070    kind: ReactiveInputKind,
1071}
1072
1073impl ReactiveInputIdentity {
1074    /// Validate and construct an identity from explicit wire/codec parts.
1075    ///
1076    /// `InputRef` identifies the underlying object, while `kind` identifies its
1077    /// representation/lifecycle. Only log kinds may pair with [`InputRef::Log`],
1078    /// block representations with [`InputRef::Block`], and pending-transaction
1079    /// representations with [`InputRef::PendingTx`]. This constructor lets
1080    /// external codecs rebuild the otherwise-private invariant without serde or
1081    /// layout-dependent decoding.
1082    ///
1083    /// # Errors
1084    ///
1085    /// Returns [`ReactiveInputIdentityError`] when `input_ref` does not belong
1086    /// to the supplied representation `kind`.
1087    pub fn try_from_parts(
1088        input_ref: InputRef,
1089        kind: ReactiveInputKind,
1090    ) -> Result<Self, ReactiveInputIdentityError> {
1091        let compatible = matches!(
1092            (input_ref, kind),
1093            (
1094                InputRef::Log { .. },
1095                ReactiveInputKind::CanonicalLog | ReactiveInputKind::ReorgSignalLog
1096            ) | (
1097                InputRef::Block { .. },
1098                ReactiveInputKind::BlockHeader | ReactiveInputKind::FullBlock
1099            ) | (
1100                InputRef::PendingTx { .. },
1101                ReactiveInputKind::PendingTxHash | ReactiveInputKind::PendingTx
1102            )
1103        );
1104        if !compatible {
1105            return Err(ReactiveInputIdentityError { input_ref, kind });
1106        }
1107        Ok(Self { input_ref, kind })
1108    }
1109
1110    /// Underlying stable chain-object reference.
1111    pub const fn input_ref(&self) -> InputRef {
1112        self.input_ref
1113    }
1114
1115    /// Exact handler-input representation and lifecycle class.
1116    pub const fn kind(&self) -> ReactiveInputKind {
1117        self.kind
1118    }
1119}
1120
1121/// An explicit [`InputRef`] and [`ReactiveInputKind`] describe incompatible
1122/// object/representation classes.
1123#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
1124#[error("reactive input kind {kind:?} is incompatible with input reference {input_ref:?}")]
1125pub struct ReactiveInputIdentityError {
1126    input_ref: InputRef,
1127    kind: ReactiveInputKind,
1128}
1129
1130impl ReactiveInputIdentityError {
1131    /// Rejected stable object reference.
1132    pub const fn input_ref(&self) -> InputRef {
1133        self.input_ref
1134    }
1135
1136    /// Rejected representation/lifecycle kind.
1137    pub const fn kind(&self) -> ReactiveInputKind {
1138        self.kind
1139    }
1140}
1141
1142/// Reliability of state effects emitted by a handler.
1143#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1144pub enum StateEffectQuality {
1145    /// Effects are exact from the input alone.
1146    ExactFromInput,
1147    /// Effects were applied, but follow-up resync is pending.
1148    AppliedWithPendingResync,
1149    /// Effects came from authoritative resync.
1150    ResyncedAuthoritatively,
1151    /// State requires repair before it should be trusted.
1152    RequiresRepair,
1153    /// No canonical state effect was emitted.
1154    NoStateEffect,
1155}
1156
1157/// Identifier for a reactive handler.
1158#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize)]
1159pub struct HandlerId(String);
1160
1161impl HandlerId {
1162    /// Create a non-empty handler id.
1163    ///
1164    /// # Panics
1165    ///
1166    /// Panics when `id` is empty. Use [`try_new`](Self::try_new) for untrusted
1167    /// configuration or wire input.
1168    pub fn new(id: impl Into<String>) -> Self {
1169        Self::try_new(id).expect("handler id must not be empty")
1170    }
1171
1172    /// Validate and create a handler id from untrusted input.
1173    ///
1174    /// # Errors
1175    ///
1176    /// Returns [`HandlerIdError`] when `id` is empty. The empty identity is
1177    /// reserved for canonical/global protocol scope.
1178    pub fn try_new(id: impl Into<String>) -> Result<Self, HandlerIdError> {
1179        let id = id.into();
1180        if id.is_empty() {
1181            return Err(HandlerIdError);
1182        }
1183        Ok(Self(id))
1184    }
1185
1186    /// Return the id as a string slice.
1187    pub fn as_str(&self) -> &str {
1188        &self.0
1189    }
1190}
1191
1192impl<'de> serde::Deserialize<'de> for HandlerId {
1193    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1194    where
1195        D: serde::Deserializer<'de>,
1196    {
1197        let id = <String as serde::Deserialize>::deserialize(deserializer)?;
1198        Self::try_new(id).map_err(serde::de::Error::custom)
1199    }
1200}
1201
1202/// An empty handler identity cannot be represented portably across subscriber
1203/// protocols because the empty owner is reserved for canonical/global scope.
1204#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
1205#[error("handler id must not be empty")]
1206pub struct HandlerIdError;
1207
1208impl fmt::Display for HandlerId {
1209    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1210        self.0.fmt(f)
1211    }
1212}
1213
1214/// Lightweight report label.
1215#[derive(Clone, Debug, PartialEq, Eq, Hash)]
1216pub struct ReportTag {
1217    /// Label key.
1218    pub key: String,
1219    /// Label value.
1220    pub value: String,
1221}
1222
1223impl ReportTag {
1224    /// Create a report tag.
1225    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
1226        Self {
1227            key: key.into(),
1228            value: value.into(),
1229        }
1230    }
1231}
1232
1233/// Domain-neutral hook signal emitted by a handler.
1234#[derive(Clone)]
1235pub struct HookSignal {
1236    /// Signal namespace owned by the caller.
1237    pub namespace: Cow<'static, str>,
1238    /// Signal kind within the namespace.
1239    pub kind: Cow<'static, str>,
1240    /// Additional labels for routing or observability.
1241    pub labels: Vec<ReportTag>,
1242    /// Optional in-process typed payload.
1243    pub payload: Option<Arc<dyn Any + Send + Sync>>,
1244}
1245
1246impl fmt::Debug for HookSignal {
1247    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1248        f.debug_struct("HookSignal")
1249            .field("namespace", &self.namespace)
1250            .field("kind", &self.kind)
1251            .field("labels", &self.labels)
1252            .field("payload", &self.payload.as_ref().map(|_| "<payload>"))
1253            .finish()
1254    }
1255}
1256
1257/// Effect emitted by a [`ReactiveHandler`].
1258#[derive(Clone, Debug)]
1259pub enum ReactiveEffect {
1260    /// Canonical cache mutation applied through [`EvmCache::apply_updates`].
1261    StateUpdate(StateUpdate),
1262    /// Request for authoritative state repair.
1263    Resync(ResyncRequest),
1264    /// Rich invalidation request lowered to [`StateUpdate::Purge`].
1265    Invalidate(InvalidationRequest),
1266    /// Hook signal dispatched after committed mutation phases.
1267    Hook(HookSignal),
1268    /// Speculative signal for mempool or downstream work.
1269    Speculative(SpeculativeRequest),
1270}
1271
1272/// Handler output for a single input.
1273#[derive(Clone, Debug)]
1274pub struct HandlerOutcome {
1275    /// Effects emitted by the handler.
1276    pub effects: Vec<ReactiveEffect>,
1277    /// Reliability of emitted state effects.
1278    pub quality: StateEffectQuality,
1279    /// Labels copied into reports.
1280    pub tags: Vec<ReportTag>,
1281}
1282
1283impl HandlerOutcome {
1284    /// Construct an empty outcome with the supplied quality.
1285    pub fn empty(quality: StateEffectQuality) -> Self {
1286        Self {
1287            effects: Vec::new(),
1288            quality,
1289            tags: Vec::new(),
1290        }
1291    }
1292}
1293
1294/// One input and its execution context.
1295#[derive(Clone, Debug)]
1296pub struct ReactiveInputRecord<N: Network = Ethereum> {
1297    /// Input value.
1298    pub input: ReactiveInput<N>,
1299    /// Input context.
1300    pub context: ReactiveContext,
1301    /// Provider session that originated this input, when it came from a
1302    /// concrete provider rather than a synthetic or aggregate source.
1303    pub provider: Option<ProviderRef>,
1304}
1305
1306impl<N: Network> ReactiveInputRecord<N> {
1307    /// Create an input record.
1308    pub fn new(input: ReactiveInput<N>, context: ReactiveContext) -> Self {
1309        Self {
1310            input,
1311            context,
1312            provider: None,
1313        }
1314    }
1315
1316    /// Attach provider provenance used to route follow-up reads.
1317    #[must_use]
1318    pub fn with_provider(mut self, provider: ProviderRef) -> Self {
1319        self.provider = Some(provider);
1320        self
1321    }
1322
1323    /// Compute the stable input reference used for deduplication.
1324    pub fn input_ref(&self) -> InputRef {
1325        input_ref(&self.input, &self.context)
1326    }
1327
1328    /// Validate payload/context coherence and return a representation-aware
1329    /// identity suitable for subscriber and runtime deduplication.
1330    ///
1331    /// Validation is fail-closed for canonical logs: their block, transaction,
1332    /// and log positions must be complete and agree with the context. Block and
1333    /// pending-transaction representations receive the corresponding lifecycle,
1334    /// inclusion-wrapper, and payload/context checks. This does not recompute a
1335    /// claimed header hash, transaction root, or transaction signature; exact
1336    /// subscriber payload commitments remain the transport-integrity boundary
1337    /// for those cryptographic claims.
1338    ///
1339    /// # Errors
1340    ///
1341    /// Returns [`ReactiveError::InvalidInputRecord`] when the payload,
1342    /// lifecycle, inclusion metadata, or context is incomplete or internally
1343    /// inconsistent.
1344    pub fn validated_identity(&self) -> Result<ReactiveInputIdentity, ReactiveError> {
1345        validate_input_record(self)?;
1346        let kind = match &self.input {
1347            ReactiveInput::Log(log)
1348                if log.removed
1349                    || matches!(self.context.chain_status, ChainStatus::Reorged { .. }) =>
1350            {
1351                ReactiveInputKind::ReorgSignalLog
1352            }
1353            ReactiveInput::Log(_) => ReactiveInputKind::CanonicalLog,
1354            ReactiveInput::BlockHeader(_) => ReactiveInputKind::BlockHeader,
1355            ReactiveInput::FullBlock(_) => ReactiveInputKind::FullBlock,
1356            ReactiveInput::PendingTxHash(_) => ReactiveInputKind::PendingTxHash,
1357            ReactiveInput::PendingTx(_) => ReactiveInputKind::PendingTx,
1358        };
1359        ReactiveInputIdentity::try_from_parts(self.input_ref(), kind).map_err(|error| {
1360            ReactiveError::InvalidInputRecord {
1361                message: error.to_string(),
1362            }
1363        })
1364    }
1365
1366    /// Whether two same-identity records carry the same deduplicable payload.
1367    ///
1368    /// This deliberately ignores [`ReactiveContext`]: the same provider object
1369    /// can legitimately arrive from backfill and subscription transports with
1370    /// different provenance or confirmation metadata. Callers must first
1371    /// compare [`validated_identity`](Self::validated_identity) and reconcile
1372    /// lifecycle/context authority separately. Logs are compared structurally;
1373    /// block and transaction hashes are cryptographic commitments for the
1374    /// remaining same-representation payloads. Full block responses and
1375    /// hydrated pending transaction bodies deliberately return `false`: the
1376    /// core does not currently prove a supplied body against the header's
1377    /// transaction root or compare every response field, so a composite source
1378    /// must preserve both rather than suppress one based only on its hash.
1379    pub fn same_deduplicable_payload(&self, other: &Self) -> bool {
1380        match (&self.input, &other.input) {
1381            (ReactiveInput::Log(left), ReactiveInput::Log(right)) => {
1382                left.inner == right.inner
1383                    && left.block_hash == right.block_hash
1384                    && left.block_number == right.block_number
1385                    && optional_metadata_compatible(
1386                        left.block_timestamp.as_ref(),
1387                        right.block_timestamp.as_ref(),
1388                    )
1389                    && left.transaction_hash == right.transaction_hash
1390                    && left.transaction_index == right.transaction_index
1391                    && left.log_index == right.log_index
1392                    && left.removed == right.removed
1393            }
1394            (ReactiveInput::BlockHeader(left), ReactiveInput::BlockHeader(right)) => {
1395                left.hash() == right.hash()
1396            }
1397            (ReactiveInput::FullBlock(_), ReactiveInput::FullBlock(_)) => false,
1398            (ReactiveInput::PendingTxHash(left), ReactiveInput::PendingTxHash(right)) => {
1399                left == right
1400            }
1401            (ReactiveInput::PendingTx(_), ReactiveInput::PendingTx(_)) => false,
1402            _ => false,
1403        }
1404    }
1405
1406    /// Whether this representation has a complete payload-equivalence contract
1407    /// and may participate in duplicate suppression.
1408    ///
1409    /// Full block and hydrated pending transaction bodies are intentionally
1410    /// excluded until their complete body/response integrity is validated.
1411    pub fn is_payload_deduplicable(&self) -> bool {
1412        matches!(
1413            &self.input,
1414            ReactiveInput::Log(_) | ReactiveInput::BlockHeader(_) | ReactiveInput::PendingTxHash(_)
1415        )
1416    }
1417
1418    /// Merge `other` when it is the same safely deduplicable provider object.
1419    ///
1420    /// Returns `Ok(false)` for a different identity or a representation whose
1421    /// complete payload cannot be proven equivalent. A same-identity payload or
1422    /// semantic conflict returns an error. Successful merges are deterministic:
1423    /// optional block/timestamp metadata is enriched, canonical lifecycle moves
1424    /// toward `Finalized` then `Safe` then the highest-confirmation `Included`,
1425    /// and provenance uses a stable source priority. The result is therefore
1426    /// independent of historical/live arrival order.
1427    ///
1428    /// # Errors
1429    ///
1430    /// Returns [`ReactiveError`] when either record is invalid, or when equal
1431    /// identities carry conflicting payload or semantic context.
1432    pub fn merge_compatible_duplicate(&mut self, other: &Self) -> Result<bool, ReactiveError> {
1433        let identity = self.validated_identity()?;
1434        let other_identity = other.validated_identity()?;
1435        if identity != other_identity
1436            || !self.is_payload_deduplicable()
1437            || !other.is_payload_deduplicable()
1438        {
1439            return Ok(false);
1440        }
1441        if !self.same_deduplicable_payload(other) || !self.dedupe_context_is_compatible(other) {
1442            return Err(ReactiveError::InvalidInputRecord {
1443                message: format!(
1444                    "conflicting payload or semantic context for identity {identity:?}"
1445                ),
1446            });
1447        }
1448        let mut merged = self.clone();
1449        merge_deduplicable_record(&mut merged, other);
1450        merged.validated_identity()?;
1451        *self = merged;
1452        Ok(true)
1453    }
1454
1455    /// Whether semantic context agrees for deduplication across transports.
1456    ///
1457    /// Provenance source and confirmation count may legitimately differ at a
1458    /// historical/live overlap and are ignored. Chain id, lifecycle class, and
1459    /// transaction/log positions must agree. Block number/hash are exact;
1460    /// optional parent/timestamp metadata may be enriched by one source but two
1461    /// present conflicting values are rejected.
1462    pub fn dedupe_context_is_compatible(&self, other: &Self) -> bool {
1463        let left = &self.context;
1464        let right = &other.context;
1465        left.chain_id == right.chain_id
1466            && optional_block_refs_are_compatible(left.block.as_ref(), right.block.as_ref())
1467            && left.transaction_index == right.transaction_index
1468            && left.log_index == right.log_index
1469            && chain_statuses_are_dedupe_compatible(&left.chain_status, &right.chain_status)
1470    }
1471}
1472
1473fn chain_statuses_are_dedupe_compatible(left: &ChainStatus, right: &ChainStatus) -> bool {
1474    match (left, right) {
1475        (ChainStatus::Pending, ChainStatus::Pending)
1476        | (ChainStatus::Reorged { .. }, ChainStatus::Reorged { .. }) => true,
1477        (
1478            ChainStatus::Preconfirmed { flashblock: left },
1479            ChainStatus::Preconfirmed { flashblock: right },
1480        ) => left == right,
1481        (
1482            ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. },
1483            ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. },
1484        ) => true,
1485        _ => false,
1486    }
1487}
1488
1489fn optional_metadata_compatible<T: PartialEq>(left: Option<&T>, right: Option<&T>) -> bool {
1490    left.zip(right).is_none_or(|(left, right)| left == right)
1491}
1492
1493fn optional_block_refs_are_compatible(left: Option<&BlockRef>, right: Option<&BlockRef>) -> bool {
1494    match (left, right) {
1495        (None, None) => true,
1496        (Some(left), Some(right)) => {
1497            left.number == right.number
1498                && left.hash == right.hash
1499                && optional_metadata_compatible(
1500                    left.parent_hash.as_ref(),
1501                    right.parent_hash.as_ref(),
1502                )
1503                && optional_metadata_compatible(left.timestamp.as_ref(), right.timestamp.as_ref())
1504        }
1505        _ => false,
1506    }
1507}
1508
1509fn merge_deduplicable_record<N: Network>(
1510    retained: &mut ReactiveInputRecord<N>,
1511    incoming: &ReactiveInputRecord<N>,
1512) {
1513    if let (ReactiveInput::Log(retained), ReactiveInput::Log(incoming)) =
1514        (&mut retained.input, &incoming.input)
1515        && retained.block_timestamp.is_none()
1516    {
1517        retained.block_timestamp = incoming.block_timestamp;
1518    }
1519    if let (Some(retained), Some(incoming)) =
1520        (&mut retained.context.block, incoming.context.block.as_ref())
1521    {
1522        enrich_block_ref(retained, incoming);
1523    }
1524    retained.context.chain_status = merged_chain_status(
1525        &retained.context.chain_status,
1526        &incoming.context.chain_status,
1527    );
1528    if input_source_rank(incoming.context.source) > input_source_rank(retained.context.source) {
1529        retained.context.source = incoming.context.source;
1530    }
1531    if retained.provider.is_none() {
1532        retained.provider = incoming.provider.clone();
1533    }
1534}
1535
1536fn enrich_block_ref(retained: &mut BlockRef, incoming: &BlockRef) {
1537    if retained.parent_hash.is_none() {
1538        retained.parent_hash = incoming.parent_hash;
1539    }
1540    if retained.timestamp.is_none() {
1541        retained.timestamp = incoming.timestamp;
1542    }
1543}
1544
1545fn merged_chain_status(retained: &ChainStatus, incoming: &ChainStatus) -> ChainStatus {
1546    let merged_block = |left: &BlockRef, right: &BlockRef| {
1547        let mut block = *left;
1548        enrich_block_ref(&mut block, right);
1549        block
1550    };
1551    match (retained, incoming) {
1552        (ChainStatus::Pending, ChainStatus::Pending) => ChainStatus::Pending,
1553        (
1554            ChainStatus::Preconfirmed { flashblock: left },
1555            ChainStatus::Preconfirmed { flashblock: right },
1556        ) => {
1557            debug_assert_eq!(left, right, "compatible pre-confirmed records agree");
1558            ChainStatus::Preconfirmed {
1559                flashblock: left.clone(),
1560            }
1561        }
1562        (
1563            ChainStatus::Reorged { dropped_from: left },
1564            ChainStatus::Reorged {
1565                dropped_from: right,
1566            },
1567        ) => ChainStatus::Reorged {
1568            dropped_from: merged_block(left, right),
1569        },
1570        (left, right) => {
1571            let (left_block, left_rank, left_confirmations) = canonical_status_parts(left)
1572                .expect("compatible duplicate has a canonical lifecycle");
1573            let (right_block, right_rank, right_confirmations) = canonical_status_parts(right)
1574                .expect("compatible duplicate has a canonical lifecycle");
1575            let block = merged_block(left_block, right_block);
1576            let rank = left_rank.max(right_rank);
1577            match rank {
1578                3 => ChainStatus::Finalized { block },
1579                2 => ChainStatus::Safe { block },
1580                _ => ChainStatus::Included {
1581                    block,
1582                    confirmations: left_confirmations.max(right_confirmations),
1583                },
1584            }
1585        }
1586    }
1587}
1588
1589fn canonical_status_parts(status: &ChainStatus) -> Option<(&BlockRef, u8, u64)> {
1590    match status {
1591        ChainStatus::Included {
1592            block,
1593            confirmations,
1594        } => Some((block, 1, *confirmations)),
1595        ChainStatus::Safe { block } => Some((block, 2, 0)),
1596        ChainStatus::Finalized { block } => Some((block, 3, 0)),
1597        ChainStatus::Pending | ChainStatus::Preconfirmed { .. } | ChainStatus::Reorged { .. } => {
1598            None
1599        }
1600    }
1601}
1602
1603fn input_source_rank(source: InputSource) -> u8 {
1604    match source {
1605        InputSource::Backfill => 0,
1606        InputSource::Poll => 1,
1607        InputSource::Subscription => 2,
1608        InputSource::Flashblocks => 3,
1609        InputSource::Batch => 4,
1610        InputSource::Synthetic => 5,
1611    }
1612}
1613
1614/// Opaque subscriber-owned token attached to a delivered input batch.
1615///
1616/// Subscribers that provide durable, at-least-once delivery can use this token
1617/// to identify the batch that becomes committable after runtime ingestion
1618/// succeeds. The runtime never interprets the bytes. A token must be immutable,
1619/// stable across replay, and must never identify two different batch payloads.
1620/// Subscriber implementations must preserve delivery order while one token is
1621/// awaiting acknowledgement; [`ReactiveEngine`] retries it before polling a
1622/// later batch.
1623#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1624pub struct SubscriberDeliveryToken(Vec<u8>);
1625
1626impl SubscriberDeliveryToken {
1627    /// Create an opaque delivery token from subscriber-owned bytes.
1628    pub fn new(bytes: Vec<u8>) -> Self {
1629        Self(bytes)
1630    }
1631
1632    /// Borrow the opaque token bytes.
1633    pub fn as_bytes(&self) -> &[u8] {
1634        &self.0
1635    }
1636
1637    /// Consume the token into its opaque bytes.
1638    pub fn into_bytes(self) -> Vec<u8> {
1639        self.0
1640    }
1641}
1642
1643/// Opaque source checkpoint associated with a delivered batch.
1644///
1645/// Unlike [`SubscriberDeliveryToken`], which identifies the delivery to
1646/// acknowledge, this value describes provider-specific resume state. The core
1647/// crate persists and returns the bytes without interpreting their format.
1648#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1649pub struct SubscriberCheckpoint(Vec<u8>);
1650
1651impl SubscriberCheckpoint {
1652    /// Create an opaque source checkpoint from subscriber-owned bytes.
1653    pub fn new(bytes: Vec<u8>) -> Self {
1654        Self(bytes)
1655    }
1656
1657    /// Borrow the opaque checkpoint bytes.
1658    pub fn as_bytes(&self) -> &[u8] {
1659        &self.0
1660    }
1661
1662    /// Consume the checkpoint into its opaque bytes.
1663    pub fn into_bytes(self) -> Vec<u8> {
1664        self.0
1665    }
1666}
1667
1668/// Subscriber-supplied commitment to the exact canonical wire payload of one
1669/// delivered batch.
1670///
1671/// The core includes this value in its durable replay witness. It is required
1672/// for tokened block-header, full-block, and hydrated-transaction payloads whose
1673/// network-generic Rust response types cannot be serialized completely by the
1674/// core. The source must recompute the commitment from a stable canonical
1675/// encoding on every replay; reusing a commitment for changed bytes violates the
1676/// [`EventSubscriber`] contract.
1677#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1678pub struct SubscriberPayloadCommitment(B256);
1679
1680impl SubscriberPayloadCommitment {
1681    /// Wrap a cryptographic commitment produced by the subscriber.
1682    pub const fn new(commitment: B256) -> Self {
1683        Self(commitment)
1684    }
1685
1686    /// Return the committed digest.
1687    pub const fn digest(&self) -> B256 {
1688        self.0
1689    }
1690}
1691
1692/// Durable subscriber position restored together with cache/runtime state.
1693///
1694/// The core never interprets provider checkpoint bytes. Composite and remote
1695/// subscribers use this synchronous hand-off to seed their source cursors,
1696/// replay fences, and canonical overlap journals before polling resumes.
1697#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1698#[non_exhaustive]
1699pub struct SubscriberResumePosition {
1700    /// Chain whose canonical position and provider cursor are being restored.
1701    pub chain_id: u64,
1702    /// Authoritative canonical coverage embodied by the restored cache.
1703    pub coverage_head: BlockRef,
1704    /// Ordered canonical identities still retained for in-window reconciliation.
1705    pub canonical_history: Vec<BlockRef>,
1706    /// Last delivery token whose effects are already represented by the cache.
1707    /// It may still be pending at the source when the process stopped after its
1708    /// durable save but before the source acknowledgement committed.
1709    pub delivery_token: Option<SubscriberDeliveryToken>,
1710    /// Provider-specific durable cursor committed with that delivery.
1711    pub subscriber_checkpoint: Option<SubscriberCheckpoint>,
1712}
1713
1714impl SubscriberResumePosition {
1715    /// Construct a complete restored subscriber position.
1716    pub fn new(
1717        chain_id: u64,
1718        coverage_head: BlockRef,
1719        canonical_history: Vec<BlockRef>,
1720        delivery_token: Option<SubscriberDeliveryToken>,
1721        subscriber_checkpoint: Option<SubscriberCheckpoint>,
1722    ) -> Self {
1723        Self {
1724            chain_id,
1725            coverage_head,
1726            canonical_history,
1727            delivery_token,
1728            subscriber_checkpoint,
1729        }
1730    }
1731}
1732
1733/// Runtime routing audience for one delivered subscriber batch.
1734///
1735/// Historical catch-up for a newly registered handler must not be routed
1736/// through older handlers whose filters happen to overlap. Subscribers retain
1737/// that provenance by targeting the batch at the exact logical owners that
1738/// requested it. Ordinary canonical delivery remains broadcast to every
1739/// matching handler.
1740#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1741#[non_exhaustive]
1742pub enum DeliveryAudience {
1743    /// Route each record through every matching registered handler.
1744    #[default]
1745    All,
1746    /// Route each record only through the named matching handlers.
1747    Owners(Vec<HandlerId>),
1748    /// Route through every matching handler except the named owners.
1749    ///
1750    /// Composite subscribers use this to deliver the residual audience after an
1751    /// overlapping source already committed the same input for selected owners.
1752    AllExcept(Vec<HandlerId>),
1753}
1754
1755/// How one delivered record participates in the runtime's canonical state machine.
1756///
1757/// Routing and chain authority are deliberately independent: [`DeliveryAudience`]
1758/// selects handlers, while this value decides whether a record may advance or
1759/// rewind global chain state. Historical replay for a newly added owner must use
1760/// [`OwnerCatchup`](Self::OwnerCatchup), even though its original on-chain status
1761/// is canonical.
1762#[derive(
1763    Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
1764)]
1765#[non_exhaustive]
1766pub enum DeliveryScope {
1767    /// Authoritative live canonical delivery.
1768    #[default]
1769    Canonical,
1770    /// Authoritative historical/recovery delivery that advances canonical progress.
1771    CanonicalProgress,
1772    /// Historical replay routed to selected owners without changing global chain state.
1773    OwnerCatchup,
1774    /// Ephemeral pre-confirmation delivery applied only to the speculative
1775    /// cache overlay.
1776    Preconfirmed,
1777}
1778
1779impl DeliveryScope {
1780    const fn advances_canonical_state(self) -> bool {
1781        matches!(self, Self::Canonical | Self::CanonicalProgress)
1782    }
1783}
1784
1785/// One input together with its routing and canonical-processing provenance.
1786#[derive(Clone, Debug)]
1787pub struct ReactiveInputDelivery<N: Network = Ethereum> {
1788    record: ReactiveInputRecord<N>,
1789    audience: DeliveryAudience,
1790    scope: DeliveryScope,
1791}
1792
1793impl<N: Network> ReactiveInputDelivery<N> {
1794    /// Construct one lossless delivered record.
1795    pub fn new(
1796        record: ReactiveInputRecord<N>,
1797        audience: DeliveryAudience,
1798        scope: DeliveryScope,
1799    ) -> Self {
1800        Self {
1801            record,
1802            audience,
1803            scope,
1804        }
1805    }
1806
1807    /// Borrow the runtime input record.
1808    pub const fn record(&self) -> &ReactiveInputRecord<N> {
1809        &self.record
1810    }
1811
1812    /// Borrow the exact routing audience.
1813    pub const fn audience(&self) -> &DeliveryAudience {
1814        &self.audience
1815    }
1816
1817    /// Return the record's canonical-processing scope.
1818    pub const fn scope(&self) -> DeliveryScope {
1819        self.scope
1820    }
1821
1822    /// Consume this value into its complete parts.
1823    pub fn into_parts(self) -> (ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope) {
1824        (self.record, self.audience, self.scope)
1825    }
1826}
1827
1828/// Complete contents of a consumed [`ReactiveInputBatch`].
1829///
1830/// Use this instead of [`ReactiveInputBatch::into_records`], which intentionally
1831/// discards subscriber commit and chain-lifecycle metadata.
1832#[derive(Clone, Debug)]
1833#[non_exhaustive]
1834pub struct ReactiveInputBatchParts<N: Network = Ethereum> {
1835    /// Authoritative chain identity for controls and records in this batch.
1836    pub chain_id: Option<u64>,
1837    /// Records with per-record routing and chain provenance.
1838    pub deliveries: Vec<ReactiveInputDelivery<N>>,
1839    /// Subscriber delivery token committed after ingestion.
1840    pub delivery_token: Option<SubscriberDeliveryToken>,
1841    /// Provider-specific resume cursor associated with the delivery.
1842    pub subscriber_checkpoint: Option<SubscriberCheckpoint>,
1843    /// Exact opaque wire-payload commitment supplied by the subscriber.
1844    pub payload_commitment: Option<SubscriberPayloadCommitment>,
1845    /// Ordered chain controls sharing the delivery's commit boundary.
1846    pub chain_controls: Vec<ChainControl>,
1847    /// Original typed source ingress for a preconfirmed-only batch.
1848    pub preconfirmation_timing: Option<FlashblockIngressTiming>,
1849}
1850
1851/// Batch of reactive input records.
1852#[derive(Clone, Debug)]
1853pub struct ReactiveInputBatch<N: Network = Ethereum> {
1854    records: Vec<ReactiveInputRecord<N>>,
1855    chain_id: Option<u64>,
1856    delivery_token: Option<SubscriberDeliveryToken>,
1857    subscriber_checkpoint: Option<SubscriberCheckpoint>,
1858    payload_commitment: Option<SubscriberPayloadCommitment>,
1859    audience: DeliveryAudience,
1860    record_audiences: Option<Vec<DeliveryAudience>>,
1861    delivery_scope: DeliveryScope,
1862    record_delivery_scopes: Option<Vec<DeliveryScope>>,
1863    chain_controls: Vec<ChainControl>,
1864    preconfirmation_timing: Option<FlashblockIngressTiming>,
1865}
1866
1867type RuntimeInputDelivery<N> = (ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope);
1868
1869impl<N: Network> ReactiveInputBatch<N> {
1870    /// Create a batch from records.
1871    pub fn new(records: Vec<ReactiveInputRecord<N>>) -> Self {
1872        let chain_id = common_record_chain_id(&records);
1873        Self {
1874            records,
1875            chain_id,
1876            delivery_token: None,
1877            subscriber_checkpoint: None,
1878            payload_commitment: None,
1879            audience: DeliveryAudience::All,
1880            record_audiences: None,
1881            delivery_scope: DeliveryScope::Canonical,
1882            record_delivery_scopes: None,
1883            chain_controls: Vec::new(),
1884            preconfirmation_timing: None,
1885        }
1886    }
1887
1888    /// Bind the complete batch, including control-only progress/finality, to a
1889    /// chain. Runtime ingestion rejects a different cache chain.
1890    pub fn with_chain_id(mut self, chain_id: u64) -> Self {
1891        self.chain_id = Some(chain_id);
1892        self
1893    }
1894
1895    /// Authoritative batch chain identity, when supplied or unambiguously
1896    /// derived from its records.
1897    pub const fn chain_id(&self) -> Option<u64> {
1898        self.chain_id
1899    }
1900
1901    /// Attach the subscriber-owned token committed after successful ingestion.
1902    pub fn with_delivery_token(mut self, token: SubscriberDeliveryToken) -> Self {
1903        self.delivery_token = Some(token);
1904        self
1905    }
1906
1907    /// Borrow the subscriber-owned delivery token, when present.
1908    pub fn delivery_token(&self) -> Option<&SubscriberDeliveryToken> {
1909        self.delivery_token.as_ref()
1910    }
1911
1912    /// Attach provider-specific resume state included by this delivery.
1913    pub fn with_subscriber_checkpoint(mut self, checkpoint: SubscriberCheckpoint) -> Self {
1914        self.subscriber_checkpoint = Some(checkpoint);
1915        self
1916    }
1917
1918    /// Borrow provider-specific resume state, when present.
1919    pub fn subscriber_checkpoint(&self) -> Option<&SubscriberCheckpoint> {
1920        self.subscriber_checkpoint.as_ref()
1921    }
1922
1923    /// Attach a commitment to the exact canonical wire payload represented by
1924    /// this batch.
1925    pub fn with_payload_commitment(mut self, commitment: SubscriberPayloadCommitment) -> Self {
1926        self.payload_commitment = Some(commitment);
1927        self
1928    }
1929
1930    /// Borrow the subscriber-supplied exact payload commitment, when present.
1931    pub const fn payload_commitment(&self) -> Option<&SubscriberPayloadCommitment> {
1932        self.payload_commitment.as_ref()
1933    }
1934
1935    /// Restrict runtime routing to exact logical interest owners.
1936    pub fn with_audience(mut self, audience: DeliveryAudience) -> Self {
1937        self.audience = audience;
1938        self.record_audiences = None;
1939        self
1940    }
1941
1942    /// Delivery audience captured by the subscriber.
1943    pub const fn audience(&self) -> &DeliveryAudience {
1944        &self.audience
1945    }
1946
1947    /// Create a batch whose records retain independent delivery audiences.
1948    pub fn from_scoped_records(
1949        records: impl IntoIterator<Item = (ReactiveInputRecord<N>, DeliveryAudience)>,
1950    ) -> Self {
1951        let (records, record_audiences): (Vec<_>, Vec<_>) = records.into_iter().unzip();
1952        let chain_id = common_record_chain_id(&records);
1953        Self {
1954            records,
1955            chain_id,
1956            delivery_token: None,
1957            subscriber_checkpoint: None,
1958            payload_commitment: None,
1959            audience: DeliveryAudience::All,
1960            record_audiences: Some(record_audiences),
1961            delivery_scope: DeliveryScope::Canonical,
1962            record_delivery_scopes: None,
1963            chain_controls: Vec::new(),
1964            preconfirmation_timing: None,
1965        }
1966    }
1967
1968    /// Create a batch with independent routing and canonical provenance per record.
1969    pub fn from_deliveries(deliveries: impl IntoIterator<Item = ReactiveInputDelivery<N>>) -> Self {
1970        Self::from_scoped_records_with_delivery_scope(
1971            deliveries
1972                .into_iter()
1973                .map(ReactiveInputDelivery::into_parts),
1974        )
1975    }
1976
1977    /// Audience for the record at `index`.
1978    pub fn record_audience(&self, index: usize) -> Option<&DeliveryAudience> {
1979        if index >= self.records.len() {
1980            return None;
1981        }
1982        Some(
1983            self.record_audiences
1984                .as_ref()
1985                .and_then(|audiences| audiences.get(index))
1986                .unwrap_or(&self.audience),
1987        )
1988    }
1989
1990    /// Set how every record in this batch participates in canonical state.
1991    pub fn with_delivery_scope(mut self, scope: DeliveryScope) -> Self {
1992        self.delivery_scope = scope;
1993        self.record_delivery_scopes = None;
1994        self
1995    }
1996
1997    /// Canonical-processing scope for the record at `index`.
1998    pub fn record_delivery_scope(&self, index: usize) -> Option<DeliveryScope> {
1999        if index >= self.records.len() {
2000            return None;
2001        }
2002        Some(
2003            self.record_delivery_scopes
2004                .as_ref()
2005                .and_then(|scopes| scopes.get(index))
2006                .copied()
2007                .unwrap_or(self.delivery_scope),
2008        )
2009    }
2010
2011    fn from_scoped_records_with_delivery_scope(
2012        records: impl IntoIterator<Item = (ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>,
2013    ) -> Self {
2014        let mut input_records = Vec::new();
2015        let mut audiences = Vec::new();
2016        let mut scopes = Vec::new();
2017        for (record, audience, scope) in records {
2018            input_records.push(record);
2019            audiences.push(audience);
2020            scopes.push(scope);
2021        }
2022        let chain_id = common_record_chain_id(&input_records);
2023        Self {
2024            records: input_records,
2025            chain_id,
2026            delivery_token: None,
2027            subscriber_checkpoint: None,
2028            payload_commitment: None,
2029            audience: DeliveryAudience::All,
2030            record_audiences: Some(audiences),
2031            delivery_scope: DeliveryScope::Canonical,
2032            record_delivery_scopes: Some(scopes),
2033            chain_controls: Vec::new(),
2034            preconfirmation_timing: None,
2035        }
2036    }
2037
2038    /// Attach original typed source ingress to a preconfirmed-only batch.
2039    pub fn with_preconfirmation_timing(mut self, timing: FlashblockIngressTiming) -> Self {
2040        self.preconfirmation_timing = Some(timing);
2041        self
2042    }
2043
2044    /// Original typed source ingress for a preconfirmed-only batch.
2045    pub const fn preconfirmation_timing(&self) -> Option<FlashblockIngressTiming> {
2046        self.preconfirmation_timing
2047    }
2048
2049    /// Attach ordered chain-lifecycle controls to this delivery.
2050    ///
2051    /// A control-only batch must also call [`with_chain_id`](Self::with_chain_id).
2052    /// When records are present, their unanimous chain id is derived by the
2053    /// constructor; a missing or cache-mismatched authoritative batch identity
2054    /// is rejected before any control mutates runtime state.
2055    pub fn with_chain_controls(mut self, controls: impl IntoIterator<Item = ChainControl>) -> Self {
2056        self.chain_controls = controls.into_iter().collect();
2057        self
2058    }
2059
2060    /// Ordered chain-lifecycle controls in this delivery.
2061    pub fn chain_controls(&self) -> &[ChainControl] {
2062        &self.chain_controls
2063    }
2064
2065    /// Borrow the records in this batch.
2066    pub fn records(&self) -> &[ReactiveInputRecord<N>] {
2067        &self.records
2068    }
2069
2070    /// Consume the batch into only its input records.
2071    ///
2072    /// This is intentionally lossy: it discards the authoritative batch chain
2073    /// identity, routing audiences, delivery scopes, ordered chain controls,
2074    /// acknowledgement tokens, and provider checkpoints. Adapters should use
2075    /// [`into_parts`](Self::into_parts) instead.
2076    pub fn into_records(self) -> Vec<ReactiveInputRecord<N>> {
2077        self.records
2078    }
2079
2080    /// Consume the batch without losing subscriber or chain-lifecycle metadata.
2081    pub fn into_parts(self) -> ReactiveInputBatchParts<N> {
2082        let chain_id = self.chain_id;
2083        let delivery_token = self.delivery_token;
2084        let subscriber_checkpoint = self.subscriber_checkpoint;
2085        let payload_commitment = self.payload_commitment;
2086        let chain_controls = self.chain_controls;
2087        let preconfirmation_timing = self.preconfirmation_timing;
2088        let audiences = self
2089            .record_audiences
2090            .unwrap_or_else(|| vec![self.audience; self.records.len()]);
2091        let scopes = self
2092            .record_delivery_scopes
2093            .unwrap_or_else(|| vec![self.delivery_scope; self.records.len()]);
2094        let deliveries = self
2095            .records
2096            .into_iter()
2097            .zip(audiences)
2098            .zip(scopes)
2099            .map(|((record, audience), scope)| ReactiveInputDelivery::new(record, audience, scope))
2100            .collect();
2101        ReactiveInputBatchParts {
2102            chain_id,
2103            deliveries,
2104            delivery_token,
2105            subscriber_checkpoint,
2106            payload_commitment,
2107            chain_controls,
2108            preconfirmation_timing,
2109        }
2110    }
2111
2112    fn into_runtime_parts(self) -> (Vec<RuntimeInputDelivery<N>>, Vec<ChainControl>, Option<u64>) {
2113        let audiences = self
2114            .record_audiences
2115            .unwrap_or_else(|| vec![self.audience; self.records.len()]);
2116        let scopes = self
2117            .record_delivery_scopes
2118            .unwrap_or_else(|| vec![self.delivery_scope; self.records.len()]);
2119        let records = self
2120            .records
2121            .into_iter()
2122            .zip(audiences)
2123            .zip(scopes)
2124            .map(|((record, audience), scope)| (record, audience, scope))
2125            .collect();
2126        (records, self.chain_controls, self.chain_id)
2127    }
2128
2129    fn take_delivery_token(&mut self) -> Option<SubscriberDeliveryToken> {
2130        self.delivery_token.take()
2131    }
2132
2133    fn take_subscriber_checkpoint(&mut self) -> Option<SubscriberCheckpoint> {
2134        self.subscriber_checkpoint.take()
2135    }
2136}
2137
2138fn common_record_chain_id<N: Network>(records: &[ReactiveInputRecord<N>]) -> Option<u64> {
2139    let chain_id = records.first()?.context.chain_id?;
2140    records
2141        .iter()
2142        .all(|record| record.context.chain_id == Some(chain_id))
2143        .then_some(chain_id)
2144}
2145
2146/// Pure synchronous handler for reactive inputs.
2147pub trait ReactiveHandler<N: Network = Ethereum>: Send + Sync {
2148    /// Stable handler id.
2149    fn id(&self) -> HandlerId;
2150
2151    /// Interests used by subscribers and the local router.
2152    fn interests(&self) -> Vec<ReactiveInterest<N>>;
2153
2154    /// Exhaustive exact keys for log inputs this handler can accept.
2155    ///
2156    /// Returning `None` keeps the handler on the compatibility fallback path.
2157    /// Returning an index promises that every matching log has at least one of
2158    /// its keys; the registry still re-checks the handler's original
2159    /// [`LogInterest`]s and local matchers before dispatch.
2160    fn log_route_index(&self) -> Option<LogRouteIndex> {
2161        None
2162    }
2163
2164    /// Handle one input against a read-only cache view.
2165    fn handle(
2166        &self,
2167        ctx: &ReactiveContext,
2168        input: &ReactiveInput<N>,
2169        state: &dyn StateView,
2170    ) -> Result<HandlerOutcome, HandlerError>;
2171}
2172
2173/// Hook invoked after reports are built and cache mutation phases have ended.
2174///
2175/// Hooks are synchronous in-process observers, not a durable transactional
2176/// outbox. The runtime never dispatches reports for a batch it rejects or rolls
2177/// back during checkpoint staging, and it dispatches a successfully staged
2178/// batch at most once per live engine. A process crash can still occur between
2179/// hook dispatch and durable checkpoint or transport acknowledgement. External
2180/// side effects therefore need their own idempotency key (normally an
2181/// [`InputRef`] or [`SubscriberDeliveryToken`]) and durable delivery mechanism.
2182pub trait ReactiveHook<N: Network = Ethereum>: Send + Sync {
2183    /// Observe a runtime report.
2184    fn on_report(&self, report: Arc<ReactiveReport<N>>);
2185}
2186
2187/// Reactive subscription interest.
2188#[allow(clippy::large_enum_variant)]
2189#[derive(Clone)]
2190pub enum ReactiveInterest<N: Network = Ethereum> {
2191    /// Log interest.
2192    Logs(LogInterest),
2193    /// Block interest.
2194    Blocks(BlockInterest),
2195    /// Pending transaction interest.
2196    PendingTransactions(PendingTxInterest<N>),
2197}
2198
2199impl<N: Network> fmt::Debug for ReactiveInterest<N> {
2200    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2201        match self {
2202            Self::Logs(interest) => f.debug_tuple("Logs").field(interest).finish(),
2203            Self::Blocks(interest) => f.debug_tuple("Blocks").field(interest).finish(),
2204            Self::PendingTransactions(interest) => f
2205                .debug_tuple("PendingTransactions")
2206                .field(interest)
2207                .finish(),
2208        }
2209    }
2210}
2211
2212/// Interest in logs.
2213#[derive(Clone)]
2214pub struct LogInterest {
2215    /// Provider-side filter.
2216    pub provider_filter: Filter,
2217    /// Optional local matcher for predicates providers cannot express.
2218    pub local_matcher: Option<Arc<dyn LogMatcher>>,
2219    /// Optional route-key extraction strategy.
2220    pub route_key: Option<RouteKeySpec>,
2221}
2222
2223impl LogInterest {
2224    /// Return true if the log matches both the provider filter and local matcher.
2225    pub fn matches(&self, log: &Log) -> bool {
2226        self.provider_filter.rpc_matches(log)
2227            && self
2228                .local_matcher
2229                .as_ref()
2230                .is_none_or(|matcher| matcher.matches(log))
2231    }
2232
2233    /// Extract the route key for a matching log, if configured.
2234    pub fn route_key(&self, log: &Log) -> Option<RouteKey> {
2235        self.route_key.as_ref().and_then(|spec| spec.extract(log))
2236    }
2237}
2238
2239impl fmt::Debug for LogInterest {
2240    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2241        f.debug_struct("LogInterest")
2242            .field("provider_filter", &self.provider_filter)
2243            .field(
2244                "local_matcher",
2245                &self.local_matcher.as_ref().map(|_| "<matcher>"),
2246            )
2247            .field("route_key", &self.route_key)
2248            .finish()
2249    }
2250}
2251
2252/// Local log predicate.
2253pub trait LogMatcher: Send + Sync {
2254    /// Return true when the log should be routed to the handler.
2255    fn matches(&self, log: &Log) -> bool;
2256}
2257
2258/// Route-key extraction strategy for logs.
2259#[derive(Clone)]
2260pub enum RouteKeySpec {
2261    /// Route by emitting address.
2262    EmitterAddress,
2263    /// Route by indexed topic.
2264    Topic {
2265        /// Topic index.
2266        index: usize,
2267    },
2268    /// Route by a byte slice in log data.
2269    DataSlice {
2270        /// Byte offset in the data payload.
2271        offset: usize,
2272        /// Number of bytes to copy.
2273        len: usize,
2274    },
2275    /// Custom extractor.
2276    Custom(Arc<dyn RouteKeyExtractor>),
2277}
2278
2279impl RouteKeySpec {
2280    /// Extract a route key from a log.
2281    pub fn extract(&self, log: &Log) -> Option<RouteKey> {
2282        match self {
2283            Self::EmitterAddress => Some(RouteKey::Address(log.address())),
2284            Self::Topic { index } => log.topics().get(*index).copied().map(RouteKey::Bytes32),
2285            Self::DataSlice { offset, len } => {
2286                let data = log.inner.data.data.as_ref();
2287                let end = offset.checked_add(*len)?;
2288                data.get(*offset..end)
2289                    .map(|bytes| RouteKey::Bytes(bytes.to_vec()))
2290            }
2291            Self::Custom(extractor) => extractor.extract(log),
2292        }
2293    }
2294}
2295
2296impl fmt::Debug for RouteKeySpec {
2297    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2298        match self {
2299            Self::EmitterAddress => f.write_str("EmitterAddress"),
2300            Self::Topic { index } => f.debug_struct("Topic").field("index", index).finish(),
2301            Self::DataSlice { offset, len } => f
2302                .debug_struct("DataSlice")
2303                .field("offset", offset)
2304                .field("len", len)
2305                .finish(),
2306            Self::Custom(_) => f.write_str("Custom(<extractor>)"),
2307        }
2308    }
2309}
2310
2311/// Extracts custom route keys from logs.
2312pub trait RouteKeyExtractor: Send + Sync {
2313    /// Extract a route key.
2314    fn extract(&self, log: &Log) -> Option<RouteKey>;
2315}
2316
2317/// Extracted route key.
2318#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2319pub enum RouteKey {
2320    /// Address key.
2321    Address(Address),
2322    /// 32-byte key.
2323    Bytes32(B256),
2324    /// Arbitrary bytes key.
2325    Bytes(Vec<u8>),
2326}
2327
2328/// Exact protocol-neutral key used to select candidate log handlers.
2329#[non_exhaustive]
2330#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2331pub enum LogRouteKey {
2332    /// Emitting contract address.
2333    Emitter(Address),
2334    /// Exact indexed topic.
2335    Topic {
2336        /// Topic position in the log.
2337        index: usize,
2338        /// Expected topic value.
2339        value: B256,
2340    },
2341    /// Exact byte slice in the log data.
2342    DataSlice {
2343        /// Byte offset in the data payload.
2344        offset: usize,
2345        /// Expected bytes.
2346        value: Vec<u8>,
2347    },
2348}
2349
2350/// Non-empty exhaustive OR-set of exact log route keys.
2351#[derive(Clone, Debug, PartialEq, Eq)]
2352pub struct LogRouteIndex {
2353    keys: Vec<LogRouteKey>,
2354}
2355
2356impl LogRouteIndex {
2357    /// Construct an index from one required key and optional additional keys.
2358    pub fn new(primary: LogRouteKey, additional: impl IntoIterator<Item = LogRouteKey>) -> Self {
2359        let mut keys = vec![primary];
2360        for key in additional {
2361            if !keys.contains(&key) {
2362                keys.push(key);
2363            }
2364        }
2365        Self { keys }
2366    }
2367
2368    /// Construct a single-key index.
2369    pub fn single(key: LogRouteKey) -> Self {
2370        Self { keys: vec![key] }
2371    }
2372
2373    /// Exact keys in declaration order.
2374    pub fn keys(&self) -> &[LogRouteKey] {
2375        &self.keys
2376    }
2377}
2378
2379/// Exact log route selected by [`ReactiveRegistry::route_log`].
2380#[derive(Clone, Debug, PartialEq, Eq)]
2381pub struct ReactiveLogRoute {
2382    /// Handler whose log interest matched.
2383    pub handler_id: HandlerId,
2384    /// Optional route key extracted from the matching log interest.
2385    pub route_key: Option<RouteKey>,
2386}
2387
2388/// Interest in block inputs.
2389#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2390pub struct BlockInterest {
2391    /// Block input mode.
2392    pub mode: BlockInterestMode,
2393}
2394
2395impl Default for BlockInterest {
2396    fn default() -> Self {
2397        Self {
2398            mode: BlockInterestMode::Header,
2399        }
2400    }
2401}
2402
2403/// Block subscription mode.
2404#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
2405pub enum BlockInterestMode {
2406    /// Header-only block input.
2407    Header,
2408    /// Full block input.
2409    FullBlock,
2410}
2411
2412/// Interest in pending transaction inputs.
2413#[derive(Clone)]
2414pub struct PendingTxInterest<N: Network = Ethereum> {
2415    /// Whether the handler requires full transaction bodies.
2416    pub full_transactions: bool,
2417    /// Sender matcher.
2418    pub from: AddressMatcher,
2419    /// Recipient matcher.
2420    pub to: AddressMatcher,
2421    /// Calldata selector matcher.
2422    pub selectors: SelectorMatcher,
2423    /// Optional local transaction matcher.
2424    pub local_matcher: Option<Arc<dyn PendingTxMatcher<N>>>,
2425}
2426
2427impl<N: Network> Default for PendingTxInterest<N> {
2428    fn default() -> Self {
2429        Self {
2430            full_transactions: false,
2431            from: AddressMatcher::Any,
2432            to: AddressMatcher::Any,
2433            selectors: SelectorMatcher::Any,
2434            local_matcher: None,
2435        }
2436    }
2437}
2438
2439impl<N: Network> PendingTxInterest<N> {
2440    fn matches_hash_only(&self) -> bool {
2441        !self.full_transactions
2442            && self.from.is_any()
2443            && self.to.is_any()
2444            && self.selectors.is_any()
2445            && self.local_matcher.is_none()
2446    }
2447
2448    fn matches_tx(&self, tx: &N::TransactionResponse) -> bool {
2449        self.from.matches(tx.from())
2450            && self.to.matches_option(tx.to())
2451            && self.selectors.matches(tx.input())
2452            && self
2453                .local_matcher
2454                .as_ref()
2455                .is_none_or(|matcher| matcher.matches(tx))
2456    }
2457}
2458
2459impl<N: Network> fmt::Debug for PendingTxInterest<N> {
2460    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2461        f.debug_struct("PendingTxInterest")
2462            .field("full_transactions", &self.full_transactions)
2463            .field("from", &self.from)
2464            .field("to", &self.to)
2465            .field("selectors", &self.selectors)
2466            .field(
2467                "local_matcher",
2468                &self.local_matcher.as_ref().map(|_| "<matcher>"),
2469            )
2470            .finish()
2471    }
2472}
2473
2474/// Address matching helper for pending transaction interests.
2475#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2476pub enum AddressMatcher {
2477    /// Match every address.
2478    Any,
2479    /// Match one address.
2480    Exact(Address),
2481    /// Match any address in the list.
2482    AnyOf(Vec<Address>),
2483}
2484
2485impl AddressMatcher {
2486    /// Return true when the matcher is unconstrained.
2487    pub fn is_any(&self) -> bool {
2488        matches!(self, Self::Any)
2489    }
2490
2491    /// Match a present address.
2492    pub fn matches(&self, address: Address) -> bool {
2493        match self {
2494            Self::Any => true,
2495            Self::Exact(expected) => *expected == address,
2496            Self::AnyOf(addresses) => addresses.contains(&address),
2497        }
2498    }
2499
2500    /// Match an optional address.
2501    pub fn matches_option(&self, address: Option<Address>) -> bool {
2502        match (self, address) {
2503            (Self::Any, _) => true,
2504            (_, Some(address)) => self.matches(address),
2505            _ => false,
2506        }
2507    }
2508}
2509
2510/// Calldata selector matching helper.
2511#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2512pub enum SelectorMatcher {
2513    /// Match every selector.
2514    Any,
2515    /// Match any selector in the list.
2516    AnyOf(Vec<[u8; 4]>),
2517}
2518
2519impl SelectorMatcher {
2520    /// Return true when the matcher is unconstrained.
2521    pub fn is_any(&self) -> bool {
2522        matches!(self, Self::Any)
2523    }
2524
2525    /// Match calldata bytes.
2526    pub fn matches(&self, input: &Bytes) -> bool {
2527        match self {
2528            Self::Any => true,
2529            Self::AnyOf(selectors) => input
2530                .get(..4)
2531                .and_then(|bytes| bytes.try_into().ok())
2532                .is_some_and(|selector| selectors.contains(&selector)),
2533        }
2534    }
2535}
2536
2537/// Local predicate over a full pending transaction.
2538pub trait PendingTxMatcher<N: Network = Ethereum>: Send + Sync {
2539    /// Return true when the transaction should be routed to the handler.
2540    fn matches(&self, tx: &N::TransactionResponse) -> bool;
2541}
2542
2543/// How a tracked account is kept live by the per-block root gate (Phase-8 step 4).
2544///
2545/// The `storageHash` root gate behaves *oppositely* for two contract shapes, so
2546/// liveness strategy is per-contract:
2547///
2548/// - A sparse-interest contract (a few balance slots, e.g. WETH) has its root
2549///   churn on nearly every block, so the root is a noisy gate — [`Slots`] opts
2550///   out. Its enumerated slots stay fresh via decoders + cadence reconcile.
2551/// - A whole-economic-state contract (e.g. a Uniswap-V2 pool) has
2552///   `root_moved ≈ my_state_changed`, so [`WholeAccount`] opts in: probe the root
2553///   each canonical block; a move a decoder did not cover is a coverage gap.
2554///
2555/// A false-positive resync is never *incorrect* — it costs one batched read — so
2556/// the policy is a **pure cost knob**, not a correctness lever.
2557///
2558/// [`Slots`]: TrackingPolicy::Slots
2559/// [`WholeAccount`]: TrackingPolicy::WholeAccount
2560#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
2561#[non_exhaustive]
2562pub enum TrackingPolicy {
2563    /// Sparse interest (e.g. WETH: a few balance slots). The root churns on
2564    /// nearly every block, so it is a noisy gate — this policy is **never**
2565    /// root-gated (spec Decision 3). Keep the enumerated slots fresh via decoders
2566    /// and cadence reconcile.
2567    Slots {
2568        /// The enumerated storage slots of interest.
2569        slots: Vec<U256>,
2570    },
2571    /// Whole economic state (e.g. a V2 pool). `root_moved ≈ my_state_changed`, so
2572    /// the root is a tight, cheap gate: probe each canonical block; on a move no
2573    /// decoder covered, emit a [`ReactiveReport::CoverageGap`] and schedule a
2574    /// [`ResyncReason::RootMoved`] repair.
2575    WholeAccount,
2576    /// Balance / nonce / code-hash only — resolved from the same `get_proof`
2577    /// response's account fields; no storage interest. Native balance/nonce
2578    /// changes do **not** move the storage root, so this policy compares the
2579    /// account fields directly across blocks rather than root-gating.
2580    Scalars,
2581}
2582
2583/// How often the reactive root gate probes tracked accounts
2584/// ([`TrackingPolicy::WholeAccount`] / [`TrackingPolicy::Scalars`]; the
2585/// `Scalars` account-fields comparison rides the same firing).
2586///
2587/// `eth_getProof` is the slowest read this crate issues, so per-block probing
2588/// is never the default. Skipping blocks is safe by construction: the gate
2589/// diffs `root_now` against its **persisted baseline**, never
2590/// block-over-block, so a move in any skipped block is still visible at the
2591/// next firing — cadence trades detection lag (at most `n − 1` blocks) for
2592/// cost, never eventual detection. The decoder-touched set accumulates across
2593/// skipped blocks and drains per firing, so a covered write in a skipped
2594/// block never false-positives as a [`ReactiveReport::CoverageGap`].
2595#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2596pub enum RootGateCadence {
2597    /// Probe at most once every `n` canonical blocks (the first canonical
2598    /// block ever seen always fires, so baseline adoption does not wait a
2599    /// full window). `EveryNBlocks(1)` is per-block probing.
2600    EveryNBlocks(NonZeroU64),
2601    /// Root gate off: coverage gaps surface only via decoders + freshness.
2602    Disabled,
2603}
2604
2605impl RootGateCadence {
2606    /// Probe at most once every `n` canonical blocks, clamping `0` to `1`.
2607    ///
2608    /// # Cost
2609    ///
2610    /// Each firing issues one `eth_getProof` per tracked account — the most
2611    /// expensive read this crate makes — so the request rate is
2612    /// `tracked accounts / n` per canonical block. A few hundred tracked
2613    /// accounts on a fast chain is a substantial standing budget. The gate is
2614    /// inert until [`ReactiveRuntime::track_account`] is called, so a runtime
2615    /// that never tracks accounts never pays it.
2616    #[must_use]
2617    pub fn every_n_blocks(n: u64) -> Self {
2618        Self::EveryNBlocks(NonZeroU64::new(n.max(1)).expect("clamped to at least 1"))
2619    }
2620}
2621
2622impl Default for RootGateCadence {
2623    /// Every 16 canonical blocks — ~3.2 min worst-case detection lag on
2624    /// mainnet for a 16× probe-cost cut. Fast-block chains should *raise*
2625    /// `n`, not lower it.
2626    fn default() -> Self {
2627        Self::every_n_blocks(16)
2628    }
2629}
2630
2631/// Per-account baseline held by the root gate: the last observed on-chain root
2632/// and account fields, plus the block they were observed at.
2633///
2634/// The gate diffs the on-chain root **across time** (never local-vs-chain, per
2635/// spec §6): it persists the *observed* root as a baseline and compares
2636/// `root_now` to it. This is a currency gate, not a completeness gate.
2637#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
2638struct TrackedRoot {
2639    last_root: B256,
2640    last_block: u64,
2641    balance: U256,
2642    nonce: u64,
2643    code_hash: B256,
2644}
2645
2646/// Request for authoritative state repair.
2647#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2648pub struct ResyncRequest {
2649    /// Resync id.
2650    pub id: ResyncId,
2651    /// Reason for the request.
2652    pub reason: ResyncReason,
2653    /// Block selection for the read.
2654    pub block: ResyncBlock,
2655    /// Targets to resync.
2656    pub targets: Vec<ResyncTarget>,
2657    /// Scheduling priority.
2658    pub priority: ResyncPriority,
2659}
2660
2661/// Resync id.
2662#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2663pub struct ResyncId(String);
2664
2665impl ResyncId {
2666    /// Create a resync id.
2667    pub fn new(id: impl Into<String>) -> Self {
2668        Self(id.into())
2669    }
2670}
2671
2672/// Reason for a resync request.
2673#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2674#[non_exhaustive]
2675pub enum ResyncReason {
2676    /// Handler requested repair.
2677    HandlerRequested,
2678    /// State effect could not be applied completely.
2679    SkippedStateEffect,
2680    /// A missed block range was detected; caller-scheduled repair.
2681    ///
2682    /// The runtime does not fabricate a targetless [`ResyncRequest`] for a missed
2683    /// range (there are no known targets to resync). This reason is provided so a
2684    /// caller building its own repair in response to a
2685    /// [`ReactiveReport::MissedBlockRange`] can attribute it.
2686    MissedBlockRange,
2687    /// A tracked account's storage root moved with no covering decoder.
2688    ///
2689    /// Emitted by the per-block root gate (Phase-8 step 4). A
2690    /// [`WholeAccount`](TrackingPolicy::WholeAccount)-tracked account's
2691    /// `storageHash` moved between the adopted baseline and the current canonical
2692    /// block, yet no decoder wrote that account during the block — a coverage gap.
2693    /// The gate schedules a resync with this reason to re-read the account
2694    /// authoritatively and self-heal the blind spot. Also used for the
2695    /// [`Scalars`](TrackingPolicy::Scalars) account-field freshness path.
2696    RootMoved,
2697    /// Caller-defined reason.
2698    Custom(String),
2699}
2700
2701/// Block target for a resync.
2702#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2703pub enum ResyncBlock {
2704    /// Latest block.
2705    Latest,
2706    /// Current provider pre-confirmation state.
2707    Pending,
2708    /// Safe head.
2709    Safe,
2710    /// Finalized head.
2711    Finalized,
2712    /// Block number.
2713    Number(u64),
2714    /// Block hash and number.
2715    Hash {
2716        /// Block number.
2717        number: u64,
2718        /// Block hash.
2719        hash: B256,
2720        /// Require the hash to still be canonical.
2721        require_canonical: bool,
2722    },
2723}
2724
2725/// State target for a resync.
2726#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2727pub enum ResyncTarget {
2728    /// One storage slot.
2729    StorageSlot {
2730        /// Contract address.
2731        address: Address,
2732        /// Storage slot.
2733        slot: U256,
2734    },
2735    /// Multiple storage slots on one contract.
2736    StorageSlots {
2737        /// Contract address.
2738        address: Address,
2739        /// Storage slots.
2740        slots: Vec<U256>,
2741    },
2742    /// Account fields.
2743    Account {
2744        /// Account address.
2745        address: Address,
2746        /// Fields to resync.
2747        fields: AccountFieldMask,
2748    },
2749}
2750
2751/// Account fields requested by a resync.
2752#[derive(
2753    Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
2754)]
2755pub struct AccountFieldMask {
2756    /// Balance field.
2757    pub balance: bool,
2758    /// Nonce field.
2759    pub nonce: bool,
2760    /// Code field.
2761    pub code: bool,
2762}
2763
2764/// Resync priority.
2765#[derive(
2766    Clone,
2767    Copy,
2768    Debug,
2769    Default,
2770    PartialEq,
2771    Eq,
2772    Hash,
2773    PartialOrd,
2774    Ord,
2775    serde::Serialize,
2776    serde::Deserialize,
2777)]
2778pub enum ResyncPriority {
2779    /// Low priority.
2780    Low,
2781    /// Normal priority.
2782    #[default]
2783    Normal,
2784    /// High priority.
2785    High,
2786}
2787
2788/// Rich invalidation request lowered to [`StateUpdate::Purge`].
2789#[derive(Clone, Debug, PartialEq, Eq)]
2790pub struct InvalidationRequest {
2791    /// Purge scope.
2792    pub scope: PurgeScope,
2793    /// Address to purge.
2794    pub address: Address,
2795    /// Reason for reporting.
2796    pub reason: InvalidationReason,
2797}
2798
2799/// Invalidation reason.
2800#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2801pub enum InvalidationReason {
2802    /// Handler requested invalidation.
2803    HandlerRequested,
2804    /// Reorg invalidation.
2805    Reorg,
2806    /// Caller-defined reason.
2807    Custom(String),
2808}
2809
2810/// Speculative signal emitted by handlers.
2811#[derive(Clone, Debug, PartialEq, Eq)]
2812pub struct SpeculativeRequest {
2813    /// Speculative request id.
2814    pub id: SpeculativeId,
2815    /// Input that triggered the request.
2816    pub input_ref: InputRef,
2817    /// Labels for downstream routing.
2818    pub labels: Vec<ReportTag>,
2819}
2820
2821/// Speculative request id.
2822#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2823pub struct SpeculativeId(String);
2824
2825impl SpeculativeId {
2826    /// Create a speculative id.
2827    pub fn new(id: impl Into<String>) -> Self {
2828        Self(id.into())
2829    }
2830}
2831
2832/// Configuration for [`ReactiveRuntime`].
2833#[derive(Clone, Debug, PartialEq, Eq)]
2834pub struct ReactiveConfig {
2835    /// Hook backpressure policy. **Reserved — currently has no effect.** Hook
2836    /// dispatch is synchronous today (every report is delivered to every hook in
2837    /// order), so this field is a no-op placeholder for a future async dispatcher.
2838    /// Setting it to anything other than the default does not change behavior.
2839    pub hook_backpressure: HookBackpressure,
2840    /// Reorg journal depth: the number of recent canonical blocks whose effects
2841    /// are journaled for rollback. This is **load-bearing** for reorg recovery:
2842    /// only blocks still resident in the journal can be recovered. A reorg deeper
2843    /// than `journal_depth` recovers the blocks still in the journal and leaves
2844    /// the aged-out blocks' effects in place — they are **neither rolled back nor
2845    /// purged**, so the freshness/validation loop is the only backstop for that
2846    /// span. `0` disables journaling entirely: no reorg is rolled back or purged.
2847    ///
2848    /// Set `journal_depth` to exceed the deepest reorg you intend to recover
2849    /// precisely. When a reorg references a block that is no longer in the journal,
2850    /// the runtime emits a `tracing::warn!` so the under-recovery is observable
2851    /// rather than silent. Checkpointed engine ingestion is stricter: explicit
2852    /// reorgs, implicit parent replacements, and removed/reorged records whose
2853    /// rollback proof falls outside the retained effect journal are rejected
2854    /// before mutation, durable save, or acknowledgement. Align this depth with
2855    /// the complete reorg horizon promised by the subscriber.
2856    pub journal_depth: usize,
2857}
2858
2859impl Default for ReactiveConfig {
2860    fn default() -> Self {
2861        Self {
2862            hook_backpressure: HookBackpressure::Block,
2863            journal_depth: 64,
2864        }
2865    }
2866}
2867
2868/// Hook backpressure policy.
2869#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
2870pub enum HookBackpressure {
2871    /// Block the producer until hooks are accepted.
2872    Block,
2873    /// Drop the newest report under pressure.
2874    DropNewest,
2875    /// Drop the oldest report under pressure.
2876    DropOldest,
2877    /// Return an error under pressure.
2878    Error,
2879}
2880
2881/// Queryable coarse health of the reactive cache.
2882///
2883/// The runtime starts [`Healthy`](CacheHealth::Healthy) and transitions to a
2884/// degraded or unhealthy state when it detects that its recovery guarantees no
2885/// longer hold (for example a reorg that runs deeper than the journal, so some
2886/// dropped effects are neither rolled back nor purged). Later waves report
2887/// missed-range and coverage-gap conditions into the same state machine.
2888#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2889#[non_exhaustive]
2890pub enum CacheHealth {
2891    /// All recovery guarantees hold; the cache is fully self-consistent.
2892    #[default]
2893    Healthy,
2894    /// A recoverable inconsistency was detected (for example under-recovered
2895    /// reorg effects); `since_block` records the block that triggered the
2896    /// transition.
2897    Degraded {
2898        /// Block number at which the degradation was first observed.
2899        since_block: u64,
2900    },
2901    /// A more serious inconsistency was detected; `since_block` records the
2902    /// block that triggered the transition.
2903    Unhealthy {
2904        /// Block number at which the unhealthy condition was first observed.
2905        since_block: u64,
2906    },
2907}
2908
2909/// Point-in-time copy of the reactive runtime's observability counters.
2910///
2911/// Returned by [`ReactiveRuntime::metrics`]. Each field is a monotonically
2912/// increasing count over the lifetime of the runtime. Counters wired by later
2913/// waves (missed-range detection, storage-hash coverage gaps, stale-verdict
2914/// tracking) remain zero until those waves land.
2915#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2916#[non_exhaustive]
2917pub struct CacheMetricsSnapshot {
2918    /// Reorgs that ran deeper than the journal, so aged-out effects could not be
2919    /// rolled back or purged.
2920    pub deep_reorgs: u64,
2921    /// Reorgs for which a [`ReorgReport`] recovery ran (including deep reorgs).
2922    pub reorgs_recovered: u64,
2923    /// Storage resync targets considered by the resync execution pass.
2924    pub resync_requests: u64,
2925    /// Storage resync targets that could not be fetched or applied.
2926    pub resync_failures: u64,
2927    /// Ranges of blocks the runtime detected it did not observe (reserved).
2928    pub missed_ranges: u64,
2929    /// Storage-hash coverage gaps detected (reserved).
2930    pub coverage_gaps: u64,
2931    /// Pending-source inputs that attempted a canonical cache effect.
2932    pub pending_contamination: u64,
2933    /// Verdicts served past their freshness horizon (reserved).
2934    pub stale_verdicts: u64,
2935}
2936
2937/// Internal atomic-backed counters mirrored by [`CacheMetricsSnapshot`].
2938///
2939/// Fields are [`AtomicU64`] so counters can be incremented behind a shared
2940/// reference; [`ReactiveRuntime::metrics`] loads each with [`Ordering::Relaxed`]
2941/// into a plain [`CacheMetricsSnapshot`].
2942#[derive(Debug, Default)]
2943struct CacheMetrics {
2944    deep_reorgs: AtomicU64,
2945    reorgs_recovered: AtomicU64,
2946    resync_requests: AtomicU64,
2947    resync_failures: AtomicU64,
2948    missed_ranges: AtomicU64,
2949    coverage_gaps: AtomicU64,
2950    pending_contamination: AtomicU64,
2951    stale_verdicts: AtomicU64,
2952}
2953
2954impl CacheMetrics {
2955    fn snapshot(&self) -> CacheMetricsSnapshot {
2956        CacheMetricsSnapshot {
2957            deep_reorgs: self.deep_reorgs.load(Ordering::Relaxed),
2958            reorgs_recovered: self.reorgs_recovered.load(Ordering::Relaxed),
2959            resync_requests: self.resync_requests.load(Ordering::Relaxed),
2960            resync_failures: self.resync_failures.load(Ordering::Relaxed),
2961            missed_ranges: self.missed_ranges.load(Ordering::Relaxed),
2962            coverage_gaps: self.coverage_gaps.load(Ordering::Relaxed),
2963            pending_contamination: self.pending_contamination.load(Ordering::Relaxed),
2964            stale_verdicts: self.stale_verdicts.load(Ordering::Relaxed),
2965        }
2966    }
2967
2968    fn restore(&self, snapshot: CacheMetricsSnapshot) {
2969        self.deep_reorgs
2970            .store(snapshot.deep_reorgs, Ordering::Relaxed);
2971        self.reorgs_recovered
2972            .store(snapshot.reorgs_recovered, Ordering::Relaxed);
2973        self.resync_requests
2974            .store(snapshot.resync_requests, Ordering::Relaxed);
2975        self.resync_failures
2976            .store(snapshot.resync_failures, Ordering::Relaxed);
2977        self.missed_ranges
2978            .store(snapshot.missed_ranges, Ordering::Relaxed);
2979        self.coverage_gaps
2980            .store(snapshot.coverage_gaps, Ordering::Relaxed);
2981        self.pending_contamination
2982            .store(snapshot.pending_contamination, Ordering::Relaxed);
2983        self.stale_verdicts
2984            .store(snapshot.stale_verdicts, Ordering::Relaxed);
2985    }
2986}
2987
2988/// Runtime report.
2989#[derive(Clone, Debug)]
2990#[non_exhaustive]
2991pub enum ReactiveReport<N: Network = Ethereum> {
2992    /// Input was accepted after deduplication.
2993    Input(InputReport<N>),
2994    /// Handlers produced outcomes.
2995    Decoded(DecodedReport<N>),
2996    /// Direct state effects were applied.
2997    Applied(AppliedReport<N>),
2998    /// Resync request was scheduled or completed.
2999    Resynced(ResyncReport),
3000    /// Block-level processing completed.
3001    BlockCommitted(BlockReport<N>),
3002    /// Reorg processing report.
3003    Reorg(ReorgReport<N>),
3004    /// Ordered source control accepted by the runtime.
3005    ChainControl(ChainControlReport),
3006    /// A forward gap in the canonical block sequence was detected: blocks between
3007    /// the last-seen head and an arriving block were never observed.
3008    MissedBlockRange(MissedRangeReport<N>),
3009    /// Cache health transitioned between states.
3010    Health(HealthReport<N>),
3011    /// A tracked account's storage root moved with no covering decoder — a
3012    /// coverage gap the per-block root gate detected (Phase-8 step 4).
3013    CoverageGap(CoverageGapReport<N>),
3014    /// Runtime or handler error.
3015    Error(ReactiveErrorReport<N>),
3016}
3017
3018/// Report emitted after an ordered source control is accepted.
3019#[derive(Clone, Debug, PartialEq, Eq)]
3020pub struct ChainControlReport {
3021    /// Control in its original delivery order.
3022    pub control: ChainControl,
3023}
3024
3025/// Input acceptance report.
3026#[derive(Clone, Debug)]
3027pub struct InputReport<N: Network = Ethereum> {
3028    /// Input reference.
3029    pub input_ref: InputRef,
3030    /// Input context.
3031    pub context: ReactiveContext,
3032    /// Provider session that originated the input, when known.
3033    pub provider: Option<ProviderRef>,
3034    /// Network marker.
3035    pub _network: PhantomData<N>,
3036}
3037
3038/// Decoding report.
3039#[derive(Clone, Debug)]
3040pub struct DecodedReport<N: Network = Ethereum> {
3041    /// Input reference.
3042    pub input_ref: InputRef,
3043    /// Handler ids that matched the input.
3044    pub handler_ids: Vec<HandlerId>,
3045    /// Network marker.
3046    pub _network: PhantomData<N>,
3047}
3048
3049/// Applied state report.
3050#[derive(Clone, Debug)]
3051pub struct AppliedReport<N: Network = Ethereum> {
3052    /// Input reference.
3053    pub input_ref: InputRef,
3054    /// Handler that produced the applied effects.
3055    pub handler_id: HandlerId,
3056    /// State effect quality.
3057    pub quality: StateEffectQuality,
3058    /// Labels emitted by the handler.
3059    pub tags: Vec<ReportTag>,
3060    /// Merged state diff from applied updates and invalidations.
3061    pub diff: StateDiff,
3062    /// State updates applied through the cache.
3063    pub state_updates: Vec<StateUpdate>,
3064    /// Invalidation requests lowered to purge updates.
3065    pub invalidations: Vec<InvalidationRequest>,
3066    /// Resync requests surfaced for a scheduler.
3067    pub resyncs: Vec<ResyncRequest>,
3068    /// Speculative requests surfaced for downstream users.
3069    pub speculative: Vec<SpeculativeRequest>,
3070    /// Hook signals emitted by the handler.
3071    pub hook_signals: Vec<HookSignal>,
3072    /// Network marker.
3073    pub _network: PhantomData<N>,
3074}
3075
3076/// Report of the storage resync requests executed during an ingest cycle: the
3077/// requests considered, the authoritative updates built from successful fetches
3078/// (and their applied diff), and any targets that could not be resynced.
3079#[derive(Clone, Debug, Default, PartialEq, Eq)]
3080pub struct ResyncReport {
3081    /// Requests considered by the resync execution pass.
3082    pub requested: Vec<ResyncRequest>,
3083    /// Authoritative state updates built from successful resync fetches.
3084    pub state_updates: Vec<StateUpdate>,
3085    /// Diff returned by applying [`state_updates`](Self::state_updates).
3086    pub diff: StateDiff,
3087    /// Targets that could not be resynced.
3088    pub failed: Vec<ResyncFailure>,
3089}
3090
3091/// One resync target that could not be fetched or applied.
3092#[derive(Clone, Debug, PartialEq, Eq)]
3093pub struct ResyncFailure {
3094    /// Request that produced the failed target.
3095    pub request_id: ResyncId,
3096    /// Block selection used for the failed target.
3097    pub block: ResyncBlock,
3098    /// Target that could not be resynced.
3099    pub target: ResyncTarget,
3100    /// Stable failure classification for retry policy and metrics.
3101    pub kind: ResyncFailureKind,
3102    /// Human-readable failure reason.
3103    pub message: String,
3104}
3105
3106/// Stable classification for a failed resync target.
3107#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
3108#[non_exhaustive]
3109pub enum ResyncFailureKind {
3110    /// A storage target could not be fetched because no storage batch fetcher is configured.
3111    MissingStorageFetcher,
3112    /// The storage batch fetcher returned an error for the requested slot.
3113    StorageFetchFailed,
3114    /// The storage batch fetcher did not return a result for the requested slot.
3115    StorageFetchOmitted,
3116    /// An account target could not be fetched because no account proof fetcher is configured.
3117    MissingAccountFetcher,
3118    /// The account proof fetcher returned an error for the requested address.
3119    AccountFetchFailed,
3120    /// The account proof fetcher did not return a result for the requested address.
3121    AccountFetchOmitted,
3122}
3123
3124/// Block processing report.
3125#[derive(Clone, Debug)]
3126pub struct BlockReport<N: Network = Ethereum> {
3127    /// Block reference, when known.
3128    pub block: Option<BlockRef>,
3129    /// Input references committed for the block.
3130    pub inputs: Vec<InputRef>,
3131    /// Network marker.
3132    pub _network: PhantomData<N>,
3133}
3134
3135/// Report of a detected reorg and the recovery it performed: the dropped
3136/// block(s) and inputs, the exact rollback updates applied for reversible dropped
3137/// effects, the conservative purge updates for irreversible ones, the canceled
3138/// hash-pinned resyncs, and why recovery ran.
3139///
3140/// Recovery only covers blocks still resident in the journal. If a reorg runs
3141/// deeper than [`ReactiveConfig::journal_depth`], the aged-out blocks do not
3142/// appear here and their effects are neither rolled back nor purged (the runtime
3143/// logs a `tracing::warn!` in that case); the freshness/validation loop is the
3144/// backstop for that span. Checkpointed engine ingestion rejects explicit,
3145/// implicit-parent, and removed-log recovery outside the retained journal
3146/// instead of producing and durably acknowledging a partial report.
3147/// Non-checkpointed ingestion still emits this report when no journal entry was
3148/// recoverable; in that case `dropped` identifies the signal/head when known,
3149/// while `dropped_blocks` and rollback effects are empty.
3150#[derive(Clone, Debug)]
3151pub struct ReorgReport<N: Network = Ethereum> {
3152    /// First dropped block, when known.
3153    pub dropped: Option<BlockRef>,
3154    /// Blocks dropped from the journal, in ascending journal order.
3155    pub dropped_blocks: Vec<BlockRef>,
3156    /// Input references that belonged to dropped blocks.
3157    pub dropped_inputs: Vec<InputRef>,
3158    /// Exact rollback updates applied for reversible dropped effects.
3159    pub rollback_updates: Vec<StateUpdate>,
3160    /// Diff returned by applying [`rollback_updates`](Self::rollback_updates).
3161    pub rollback_diff: StateDiff,
3162    /// Conservative purge updates applied for irreversible dropped effects.
3163    pub purge_updates: Vec<StateUpdate>,
3164    /// Diff returned by applying [`purge_updates`](Self::purge_updates).
3165    pub purge_diff: StateDiff,
3166    /// Hash-pinned pending resync requests canceled because their block was dropped.
3167    pub canceled_resyncs: Vec<ResyncRequest>,
3168    /// Reorg trigger.
3169    pub reason: ReorgReason,
3170    /// Network marker.
3171    pub _network: PhantomData<N>,
3172}
3173
3174/// Reason reorg recovery ran.
3175#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
3176pub enum ReorgReason {
3177    /// A provider emitted an Alloy removed log.
3178    RemovedLog,
3179    /// The input context explicitly marked an input as reorged.
3180    ReorgedInput,
3181    /// A canonical block did not connect to the journaled head.
3182    ParentMismatch,
3183    /// A subscriber delivered an explicit canonical branch transition.
3184    Explicit,
3185}
3186
3187/// Report of a forward gap in the canonical block sequence: an arriving block
3188/// whose number is more than one past the last-seen head, so the blocks in
3189/// between were never observed (for example during a subscription disconnect).
3190///
3191/// The arriving block is still accepted and applied — the chain extends — so this
3192/// report only makes the skipped span observable; it does not drop the block. The
3193/// span `from..=to` is inclusive of both endpoints.
3194#[derive(Clone, Debug)]
3195pub struct MissedRangeReport<N: Network = Ethereum> {
3196    /// First skipped block (`last-seen block number + 1`).
3197    pub from: u64,
3198    /// Last skipped block (`arriving block number - 1`).
3199    pub to: u64,
3200    /// The arriving block's number.
3201    pub block: u64,
3202    /// Network marker.
3203    pub _network: PhantomData<N>,
3204}
3205
3206/// Report of a [`CacheHealth`] transition, emitted into the ingest cycle that
3207/// caused it and delivered to hooks through the normal dispatch path.
3208#[derive(Clone, Debug)]
3209pub struct HealthReport<N: Network = Ethereum> {
3210    /// Health state before the transition.
3211    pub from: CacheHealth,
3212    /// Health state after the transition.
3213    pub to: CacheHealth,
3214    /// Block number associated with the transition, when known.
3215    pub block: Option<u64>,
3216    /// Network marker.
3217    pub _network: PhantomData<N>,
3218}
3219
3220/// Report that a tracked account's storage root moved on a canonical block that
3221/// no decoder covered — a coverage gap surfaced by the per-block root gate
3222/// (Phase-8 step 4).
3223///
3224/// An account's `storageHash` is a collision-resistant commitment over all of its
3225/// storage, so a moved root proves *something* under the account changed. When
3226/// that account is [`WholeAccount`](TrackingPolicy::WholeAccount)-tracked and the
3227/// batch's touched-address set does not include it, the change arrived through a
3228/// path no decoder observed. The runtime emits this report (delivered through the
3229/// normal dispatch path so [`ReactiveHook::on_report`] observers see it),
3230/// increments [`CacheMetricsSnapshot::coverage_gaps`], and schedules a
3231/// [`ResyncReason::RootMoved`] repair to re-read the account authoritatively.
3232#[derive(Clone, Debug)]
3233pub struct CoverageGapReport<N: Network = Ethereum> {
3234    /// The tracked account whose root moved with no covering decoder.
3235    pub address: Address,
3236    /// The canonical block number at which the gap was observed.
3237    pub block: u64,
3238    /// Network marker.
3239    pub _network: PhantomData<N>,
3240}
3241
3242/// Report of a non-fatal error surfaced during an ingest cycle, with the
3243/// associated input (when known) and a human-readable message.
3244#[derive(Clone, Debug)]
3245pub struct ReactiveErrorReport<N: Network = Ethereum> {
3246    /// Input associated with the error, when known.
3247    pub input_ref: Option<InputRef>,
3248    /// Error message.
3249    pub message: String,
3250    /// Network marker.
3251    pub _network: PhantomData<N>,
3252}
3253
3254/// Batch report returned by [`ReactiveRuntime::ingest_batch`] and
3255/// [`ReactiveRuntime::ingest_batch_with_resync`].
3256#[derive(Clone, Debug)]
3257pub struct ReactiveBatchReport<N: Network = Ethereum> {
3258    /// Applied reports in commit order.
3259    pub applied: Vec<AppliedReport<N>>,
3260    /// Resync requests surfaced during the batch.
3261    pub resyncs: Vec<ResyncRequest>,
3262    /// Speculative requests surfaced during the batch.
3263    pub speculative: Vec<SpeculativeRequest>,
3264    /// Hook reports dispatched after mutation phases.
3265    pub reports: Vec<Arc<ReactiveReport<N>>>,
3266}
3267
3268impl<N: Network> Default for ReactiveBatchReport<N> {
3269    fn default() -> Self {
3270        Self {
3271            applied: Vec::new(),
3272            resyncs: Vec::new(),
3273            speculative: Vec::new(),
3274            reports: Vec::new(),
3275        }
3276    }
3277}
3278
3279/// Error returned by a handler.
3280#[derive(Clone, Debug, PartialEq, Eq)]
3281pub struct HandlerError {
3282    message: String,
3283}
3284
3285impl HandlerError {
3286    /// Create a handler error from a message.
3287    pub fn new(message: impl Into<String>) -> Self {
3288        Self {
3289            message: message.into(),
3290        }
3291    }
3292}
3293
3294impl fmt::Display for HandlerError {
3295    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3296        self.message.fmt(f)
3297    }
3298}
3299
3300impl std::error::Error for HandlerError {}
3301
3302impl From<String> for HandlerError {
3303    fn from(message: String) -> Self {
3304        Self::new(message)
3305    }
3306}
3307
3308impl From<&str> for HandlerError {
3309    fn from(message: &str) -> Self {
3310        Self::new(message)
3311    }
3312}
3313
3314/// Runtime error.
3315#[derive(Debug, thiserror::Error)]
3316#[non_exhaustive]
3317pub enum ReactiveError {
3318    /// Handler returned an error.
3319    #[error("handler `{handler_id}` failed: {source}")]
3320    HandlerFailed {
3321        /// Handler id.
3322        handler_id: HandlerId,
3323        /// Handler error.
3324        source: HandlerError,
3325    },
3326    /// Multiple handlers emitted incompatible absolute writes for one input.
3327    #[error(
3328        "conflicting effects for input {input_ref:?} on target {target:?}: `{first}` vs `{second}`"
3329    )]
3330    ConflictingEffects {
3331        /// Input reference.
3332        input_ref: Box<InputRef>,
3333        /// Conflicting target.
3334        target: Box<EffectTarget>,
3335        /// First handler id.
3336        first: HandlerId,
3337        /// Second handler id.
3338        second: HandlerId,
3339    },
3340    /// Pending inputs attempted to mutate canonical cache state.
3341    #[error(
3342        "pending input {input_ref:?} emitted invalid canonical effect `{effect_kind}` from `{handler_id}`"
3343    )]
3344    InvalidPendingEffect {
3345        /// Input reference.
3346        input_ref: Box<InputRef>,
3347        /// Handler id.
3348        handler_id: HandlerId,
3349        /// Effect kind.
3350        effect_kind: &'static str,
3351    },
3352    /// A subscriber supplied payload metadata that is incomplete or
3353    /// contradicts the accompanying context.
3354    #[error("invalid reactive input record: {message}")]
3355    InvalidInputRecord {
3356        /// Human-readable invariant violation.
3357        message: String,
3358    },
3359    /// A source delivered a contradictory chain-lifecycle transition.
3360    #[error("invalid chain control: {message}")]
3361    InvalidChainControl {
3362        /// Human-readable invariant violation.
3363        message: String,
3364    },
3365    /// Owner-scoped catch-up would mutate a historical block for which the
3366    /// runtime has no rollback journal entry.
3367    #[error(
3368        "owner catch-up block {number} {hash} is outside the retained canonical rollback journal"
3369    )]
3370    OwnerCatchupOutsideJournal {
3371        /// Catch-up block number.
3372        number: u64,
3373        /// Catch-up block hash.
3374        hash: B256,
3375    },
3376    /// Registration error.
3377    #[error(transparent)]
3378    Register(#[from] RegisterError),
3379}
3380
3381/// Handler registration error.
3382#[derive(Debug, thiserror::Error)]
3383#[non_exhaustive]
3384pub enum RegisterError {
3385    /// Duplicate handler id.
3386    #[error("handler id `{0}` is already registered")]
3387    DuplicateHandler(HandlerId),
3388}
3389
3390/// Error returned when [`ReactiveEngine`] cannot register a handler on both the
3391/// runtime and subscriber sides.
3392#[derive(Debug, thiserror::Error)]
3393#[non_exhaustive]
3394pub enum ReactiveEngineRegisterError {
3395    /// Runtime registry rejected the handler.
3396    #[error(transparent)]
3397    Register(#[from] RegisterError),
3398    /// Subscriber rejected the handler's interests.
3399    #[error(transparent)]
3400    Subscriber(#[from] SubscriberError),
3401    /// Owner-only history was not constrained to one hash-certified block that
3402    /// remains in the runtime rollback journal.
3403    #[error(
3404        "owner backfill {start_block}..={end_block:?} must target exactly one hash-certified block in the retained rollback journal"
3405    )]
3406    BackfillOutsideJournal {
3407        /// First requested block.
3408        start_block: u64,
3409        /// Inclusive requested upper bound, if bounded.
3410        end_block: Option<u64>,
3411        /// Hash-certified anchor supplied by the caller, if any.
3412        retained_anchor: Option<BlockRef>,
3413    },
3414}
3415
3416/// Error adopting an RPC snapshot as a runtime's canonical continuity
3417/// baseline.
3418#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
3419#[non_exhaustive]
3420pub enum ReactiveBaselineError {
3421    /// Runtime or engine delivery state already contains lifecycle work.
3422    #[error("cannot adopt a canonical baseline after reactive processing has started")]
3423    ActiveRuntime,
3424    /// An exact repeat is allowed, but the requested baseline conflicts with
3425    /// the previously adopted block.
3426    #[error(
3427        "canonical baseline conflicts with existing block {existing_number} {existing_hash} (requested {requested_number} {requested_hash})"
3428    )]
3429    ConflictingBaseline {
3430        /// Existing baseline number.
3431        existing_number: u64,
3432        /// Existing baseline hash.
3433        existing_hash: B256,
3434        /// Requested baseline number.
3435        requested_number: u64,
3436        /// Requested baseline hash.
3437        requested_hash: B256,
3438    },
3439    /// Typed baseline and cache identify different chains.
3440    #[error("baseline chain id {baseline_chain_id} does not match cache chain id {cache_chain_id}")]
3441    CacheChainMismatch {
3442        /// Chain declared by the baseline.
3443        baseline_chain_id: u64,
3444        /// Chain configured on the cache.
3445        cache_chain_id: u64,
3446    },
3447    /// The cache is not hash-pinned to the exact adopted canonical block.
3448    #[error("cache block selector is not canonically hash-pinned to baseline {number} {hash}")]
3449    CacheBlockMismatch {
3450        /// Expected baseline number.
3451        number: u64,
3452        /// Expected baseline hash.
3453        hash: B256,
3454    },
3455}
3456
3457/// Error returned by [`ReactiveEngine`] helpers that combine subscriber polling
3458/// and runtime ingestion.
3459#[derive(Debug, thiserror::Error)]
3460#[non_exhaustive]
3461pub enum ReactiveEngineError {
3462    /// Subscriber polling failed.
3463    #[error(transparent)]
3464    Subscriber(#[from] SubscriberError),
3465    /// Runtime ingestion failed.
3466    #[error(transparent)]
3467    Runtime(ReactiveError),
3468    /// Canonical cold-start baseline adoption failed.
3469    #[error(transparent)]
3470    Baseline(#[from] ReactiveBaselineError),
3471    /// Runtime ingestion succeeded, but its durable delivery acknowledgement
3472    /// did not commit. The subscriber may replay the batch.
3473    #[error("runtime ingestion succeeded but subscriber acknowledgement failed: {0}")]
3474    Acknowledgement(#[source] SubscriberError),
3475    /// Runtime ingestion succeeded, but the resulting cache state could not be
3476    /// durably checkpointed. The engine retains the commit in memory and must
3477    /// retry it before polling another batch.
3478    #[error("runtime ingestion succeeded but durable checkpoint commit failed: {0}")]
3479    Checkpoint(#[source] DurableCheckpointError),
3480    /// A checkpointed ingest had no canonical block to bind the state to.
3481    #[error("cannot durably checkpoint reactive state before observing a canonical block")]
3482    MissingCheckpointBlock,
3483    /// Speculative pre-confirmation state is intentionally excluded from
3484    /// canonical durable checkpoints.
3485    #[error("pre-confirmed Flashblock batches cannot be durably checkpointed")]
3486    PreconfirmationNotCheckpointable,
3487    /// Runtime rollback/finality state could not be encoded for the checkpoint.
3488    #[error("failed to encode durable reactive runtime state: {0}")]
3489    RuntimeCheckpoint(String),
3490    /// A crash-safe checkpoint commit is pending, so the engine cannot switch
3491    /// to ordinary acknowledgement ordering without first completing it.
3492    #[error("cannot use ordinary ingestion while a durable checkpoint commit is pending")]
3493    PendingCheckpointCommit,
3494    /// An ordinary delivery acknowledgement is pending, so the engine cannot
3495    /// switch to checkpointed ingestion and retroactively make it durable.
3496    #[error("cannot use checkpointed ingestion while an ordinary acknowledgement is pending")]
3497    PendingAcknowledgementCommit,
3498    /// A caller attempted to use a raw ingestion helper with subscriber-owned
3499    /// commit metadata. Only the combined polling helpers can preserve the
3500    /// required ingest-before-checkpoint-before-acknowledgement ordering.
3501    #[error(
3502        "raw engine ingestion cannot consume delivery tokens or subscriber checkpoints; use a combined next_ingest helper"
3503    )]
3504    UncommittedDeliveryMetadata,
3505    /// Subscriber and cache are bound to different chains.
3506    #[error(
3507        "subscriber chain id {subscriber_chain_id} does not match cache chain id {cache_chain_id}"
3508    )]
3509    SubscriberChainMismatch {
3510        /// Chain reported by the subscriber.
3511        subscriber_chain_id: u64,
3512        /// Chain configured on the cache.
3513        cache_chain_id: u64,
3514    },
3515    /// Crash-safe checkpoint APIs require durable replay/resume semantics.
3516    #[error("subscriber does not advertise durable replay support")]
3517    SubscriberNotDurable,
3518    /// A restored delivery token predates or otherwise lacks the core witness
3519    /// needed to prove that a replay carries the same delivery.
3520    #[error(
3521        "committed delivery token has no delivery witness; replay cannot be acknowledged safely"
3522    )]
3523    MissingReplayWitness,
3524    /// A source reused a committed token for different records, routing,
3525    /// controls, chain identity, or provider resume state.
3526    #[error("replayed delivery token does not match its committed delivery witness")]
3527    ReplayDeliveryMismatch,
3528    /// The stable delivery witness could not be encoded.
3529    #[error("failed to encode durable delivery witness: {0}")]
3530    DeliveryWitness(String),
3531    /// A tokened network-generic header/body cannot be witnessed completely
3532    /// without a source-supplied canonical wire commitment.
3533    #[error(
3534        "tokened block-header, full-block, or hydrated-transaction delivery requires an exact payload commitment"
3535    )]
3536    MissingPayloadCommitment,
3537    /// Cache state changed after a batch was staged for a checkpoint. Retrying
3538    /// would bind those unrelated mutations to the older delivery metadata.
3539    #[error(
3540        "cache changed while durable checkpoint commit was pending (staged generation {staged_generation}, current generation {current_generation})"
3541    )]
3542    PendingCheckpointCacheChanged {
3543        /// Generation immediately after the staged batch was ingested.
3544        staged_generation: u64,
3545        /// Generation observed when checkpoint commit was retried.
3546        current_generation: u64,
3547    },
3548    /// Checkpointed ingestion cannot durably acknowledge a reorg when the
3549    /// runtime no longer retains every potentially affected journal entry.
3550    #[error(
3551        "reorg after block {common_ancestor} exceeds the retained rollback journal (oldest retained block {oldest_journaled:?}, configured depth {journal_depth})"
3552    )]
3553    CheckpointReorgOutsideJournal {
3554        /// Last block shared by the old and replacement branches.
3555        common_ancestor: u64,
3556        /// Oldest retained effect-bearing journal block, if any.
3557        oldest_journaled: Option<u64>,
3558        /// Configured maximum journal entries.
3559        journal_depth: usize,
3560    },
3561    /// Owner-scoped catch-up would mutate a historical block for which the
3562    /// runtime has no rollback journal entry.
3563    #[error(
3564        "owner catch-up block {number} {hash} is outside the retained canonical rollback journal"
3565    )]
3566    OwnerCatchupOutsideJournal {
3567        /// Catch-up block number.
3568        number: u64,
3569        /// Catch-up block hash.
3570        hash: B256,
3571    },
3572}
3573
3574impl From<ReactiveError> for ReactiveEngineError {
3575    fn from(error: ReactiveError) -> Self {
3576        match error {
3577            ReactiveError::OwnerCatchupOutsideJournal { number, hash } => {
3578                Self::OwnerCatchupOutsideJournal { number, hash }
3579            }
3580            error => Self::Runtime(error),
3581        }
3582    }
3583}
3584
3585/// Error restoring a durable checkpoint anchor into an active runtime.
3586#[derive(Debug, thiserror::Error)]
3587#[non_exhaustive]
3588pub enum ReactiveCheckpointRestoreError {
3589    /// A runtime with canonical journal state cannot be silently rewound.
3590    #[error("cannot restore a durable checkpoint into a runtime with canonical journal state")]
3591    ActiveRuntime,
3592    /// Stored runtime recovery bytes were malformed or unsupported.
3593    #[error("invalid durable reactive runtime state: {0}")]
3594    InvalidRuntimeCheckpoint(String),
3595    /// Checkpoint identity or cache restoration failed before activation.
3596    #[error(transparent)]
3597    Checkpoint(#[from] DurableCheckpointError),
3598    /// Subscriber rejected the restored durable cursor or canonical position.
3599    #[error("subscriber rejected durable resume position: {0}")]
3600    Subscriber(#[source] SubscriberError),
3601    /// Subscriber and checkpoint identities name different chains.
3602    #[error(
3603        "subscriber chain id {subscriber_chain_id} does not match checkpoint chain id {checkpoint_chain_id}"
3604    )]
3605    SubscriberChainMismatch {
3606        /// Chain reported by the subscriber.
3607        subscriber_chain_id: u64,
3608        /// Chain committed by the checkpoint identity.
3609        checkpoint_chain_id: u64,
3610    },
3611    /// Restoring event continuity requires a durable replay-capable subscriber.
3612    #[error("subscriber does not advertise durable replay support")]
3613    SubscriberNotDurable,
3614}
3615
3616/// Result of one crash-safe subscriber ingest cycle.
3617#[derive(Clone, Debug)]
3618#[non_exhaustive]
3619pub enum CheckpointedIngest<N: Network = Ethereum> {
3620    /// A new batch was ingested, durably checkpointed, and acknowledged.
3621    Applied(ReactiveBatchReport<N>),
3622    /// The checkpoint already contained this replayed delivery token, so the
3623    /// batch was acknowledged without applying its effects twice.
3624    ReplayAcknowledged,
3625}
3626
3627/// Absolute write target used for conflict reports.
3628#[derive(Clone, Debug, PartialEq, Eq, Hash)]
3629pub enum EffectTarget {
3630    /// Storage slot target.
3631    StorageSlot {
3632        /// Contract address.
3633        address: Address,
3634        /// Storage slot.
3635        slot: U256,
3636    },
3637    /// Account balance target.
3638    AccountBalance {
3639        /// Account address.
3640        address: Address,
3641    },
3642    /// Account nonce target.
3643    AccountNonce {
3644        /// Account address.
3645        address: Address,
3646    },
3647    /// Account code target.
3648    AccountCode {
3649        /// Account address.
3650        address: Address,
3651    },
3652    /// Masked storage slot target.
3653    MaskedStorageSlot {
3654        /// Contract address.
3655        address: Address,
3656        /// Storage slot.
3657        slot: U256,
3658        /// Bit mask.
3659        mask: U256,
3660    },
3661}
3662
3663#[derive(Clone, Debug, PartialEq, Eq)]
3664enum AbsoluteValue {
3665    U256(U256),
3666    U64(u64),
3667    Bytes(Bytes),
3668}
3669
3670/// Reactive runtime.
3671pub struct ReactiveRuntime<N: Network = Ethereum> {
3672    registry: ReactiveRegistry<N>,
3673    hooks: Vec<Arc<dyn ReactiveHook<N>>>,
3674    config: ReactiveConfig,
3675    journal: VecDeque<BlockJournal<N>>,
3676    coverage_head: Option<BlockRef>,
3677    /// Highest block a source has attested carries no unhealed log-notification
3678    /// loss. Never inferred: absent until a source says so.
3679    log_coverage_head: Option<BlockRef>,
3680    pending_resyncs: Vec<ResyncRequest>,
3681    health: CacheHealth,
3682    safe_head: Option<BlockRef>,
3683    finalized_head: Option<BlockRef>,
3684    metrics: CacheMetrics,
3685    /// Opt-in freshness registry the runtime stamps for canonical event writes.
3686    ///
3687    /// `None` by default (behavior unchanged); populated by
3688    /// [`enable_freshness_stamping`](Self::enable_freshness_stamping). When
3689    /// present, applying a canonical handler storage-slot effect stamps the
3690    /// touched `(address, slot)` as [`Validity::ValidThrough`](crate::freshness::Validity::ValidThrough)`(N)`
3691    /// so event-maintained slots stop being needlessly re-verified while aging to
3692    /// volatile once the clock passes `N`.
3693    freshness: Option<FreshnessRegistry>,
3694    /// Per-account tracking registry consulted by the per-block root gate
3695    /// (Phase-8 step 4). Empty by default; populated by
3696    /// [`track_account`](Self::track_account). When empty the gate is a no-op.
3697    tracking: HashMap<Address, TrackingPolicy>,
3698    /// Per-account root/field baselines the gate diffs against across blocks.
3699    /// Adopted on first probe and re-adopted on every observed move.
3700    tracked_roots: HashMap<Address, TrackedRoot>,
3701    /// How often the root gate fires (§6.2); see [`RootGateCadence`].
3702    root_gate_cadence: RootGateCadence,
3703    /// Canonical block of the last root-gate firing. `None` until the first
3704    /// firing (which happens at the first canonical block ever seen, so
3705    /// baseline adoption never waits a full cadence window).
3706    last_gate_block: Option<u64>,
3707    /// Union of decoder-touched addresses since the last root-gate firing,
3708    /// drained when it fires. Under cadence the gap rule "root moved ∧ addr ∉
3709    /// touched" must judge against every covered write in the window, or a
3710    /// decoder-covered write in a skipped block would false-positive as a
3711    /// [`ReactiveReport::CoverageGap`].
3712    touched_since_gate: HashSet<Address>,
3713    /// Disposable pre-confirmation branch layered over the canonical cache.
3714    /// This is deliberately omitted from durable runtime checkpoints.
3715    preconfirmed_branch: Option<PreconfirmedBranch>,
3716}
3717
3718#[derive(Clone)]
3719struct PreconfirmedBranch {
3720    flashblock: FlashblockRef,
3721    canonical_cache: EvmCacheStateSnapshot,
3722}
3723
3724#[derive(Clone, Debug)]
3725struct BlockJournal<N: Network = Ethereum> {
3726    block: BlockRef,
3727    inputs: Vec<InputRef>,
3728    applied: Vec<AppliedReport<N>>,
3729    handler_ids: Vec<HandlerId>,
3730    resynced: Vec<ResyncReport>,
3731    rollback_diffs: Vec<StateDiff>,
3732}
3733
3734// 4: adds `log_coverage_head`, the attested log-completeness watermark.
3735const DURABLE_RUNTIME_CHECKPOINT_VERSION: u32 = 4;
3736
3737#[derive(serde::Serialize, serde::Deserialize)]
3738struct DurableRuntimeCheckpoint {
3739    version: u32,
3740    safe_head: Option<BlockRef>,
3741    finalized_head: Option<BlockRef>,
3742    health: CacheHealth,
3743    pending_resyncs: Vec<ResyncRequest>,
3744    coverage_head: Option<BlockRef>,
3745    log_coverage_head: Option<BlockRef>,
3746    journal: Vec<DurableBlockJournal>,
3747    freshness: Option<FreshnessRegistry>,
3748    tracking: HashMap<Address, TrackingPolicy>,
3749    tracked_roots: HashMap<Address, TrackedRoot>,
3750    root_gate_cadence: RootGateCadence,
3751    last_gate_block: Option<u64>,
3752    touched_since_gate: HashSet<Address>,
3753    metrics: CacheMetricsSnapshot,
3754}
3755
3756#[derive(serde::Serialize, serde::Deserialize)]
3757struct DurableBlockJournal {
3758    block: BlockRef,
3759    handler_ids: Vec<HandlerId>,
3760    rollback_diffs: Vec<StateDiff>,
3761}
3762
3763struct DurableRuntimeRestorePlan {
3764    checkpoint: Option<DurableRuntimeCheckpoint>,
3765    fallback_history: Vec<BlockRef>,
3766}
3767
3768impl DurableRuntimeRestorePlan {
3769    fn canonical_history(&self) -> Vec<BlockRef> {
3770        self.checkpoint.as_ref().map_or_else(
3771            || self.fallback_history.clone(),
3772            |checkpoint| checkpoint.journal.iter().map(|entry| entry.block).collect(),
3773        )
3774    }
3775}
3776
3777#[derive(Clone)]
3778struct ReactiveRuntimeState<N: Network> {
3779    journal: VecDeque<BlockJournal<N>>,
3780    coverage_head: Option<BlockRef>,
3781    log_coverage_head: Option<BlockRef>,
3782    pending_resyncs: Vec<ResyncRequest>,
3783    health: CacheHealth,
3784    safe_head: Option<BlockRef>,
3785    finalized_head: Option<BlockRef>,
3786    freshness: Option<FreshnessRegistry>,
3787    tracking: HashMap<Address, TrackingPolicy>,
3788    tracked_roots: HashMap<Address, TrackedRoot>,
3789    root_gate_cadence: RootGateCadence,
3790    last_gate_block: Option<u64>,
3791    touched_since_gate: HashSet<Address>,
3792    metrics: CacheMetricsSnapshot,
3793}
3794
3795#[derive(Clone)]
3796struct ChainControlState {
3797    journal_invalidated_from: Option<u64>,
3798    resolved_canonical_blocks: HashMap<(u64, B256), BlockRef>,
3799}
3800
3801/// Canonical branch fragments already rolled back by the current atomic batch.
3802///
3803/// Providers commonly emit one removed notification per log after one signal
3804/// has already drained the complete dropped block (and every retained
3805/// descendant). Explicit reorg controls can be followed by the same redundant
3806/// lifecycle records. Exact identities decide whether removal recovery is
3807/// redundant; numeric spans are retained only as same-batch proof for a
3808/// parentless replacement after those exact journal entries were drained.
3809#[derive(Default)]
3810struct BatchDroppedCanonical {
3811    identities: HashSet<(u64, B256)>,
3812    implicit_spans: Vec<(u64, u64)>,
3813}
3814
3815impl BatchDroppedCanonical {
3816    fn covers_implicit_number(&self, number: u64) -> bool {
3817        self.implicit_spans
3818            .iter()
3819            .any(|(from, through)| number >= *from && number <= *through)
3820    }
3821
3822    fn contains(&self, block: &BlockRef) -> bool {
3823        self.identities.contains(&(block.number, block.hash))
3824    }
3825
3826    fn record_identity(&mut self, block: &BlockRef) {
3827        self.identities.insert((block.number, block.hash));
3828    }
3829
3830    fn record_explicit(&mut self, _common_ancestor: &BlockRef, old_tip: &BlockRef) {
3831        self.identities.insert((old_tip.number, old_tip.hash));
3832    }
3833
3834    fn record_drained(&mut self, blocks: &[BlockRef]) {
3835        let Some(from) = blocks.iter().map(|block| block.number).min() else {
3836            return;
3837        };
3838        let through = blocks
3839            .iter()
3840            .map(|block| block.number)
3841            .max()
3842            .expect("a non-empty drained set has a maximum");
3843        self.implicit_spans.push((from, through));
3844        self.identities
3845            .extend(blocks.iter().map(|block| (block.number, block.hash)));
3846    }
3847}
3848
3849/// Registry and router for provider-neutral reactive handlers.
3850///
3851/// The registry stores pure [`ReactiveHandler`]s in registration order, exposes
3852/// consolidated provider-side log filters for subscription setup, and routes
3853/// provider logs back to the exact matching log interests. Consolidated filters
3854/// may be safe supersets; [`Self::route_log`] always re-checks the original
3855/// [`LogInterest`] and its local matcher before returning a route.
3856pub struct ReactiveRegistry<N: Network = Ethereum> {
3857    handlers: BTreeMap<u128, RegisteredHandler<N>>,
3858    handler_positions: HashMap<HandlerId, u128>,
3859    next_handler_position: u128,
3860    indexed_log_handlers: HashMap<LogRouteKey, BTreeSet<u128>>,
3861    fallback_log_handlers: BTreeSet<u128>,
3862    data_slice_shapes: HashMap<(usize, usize), usize>,
3863}
3864
3865struct RegisteredHandler<N: Network = Ethereum> {
3866    id: HandlerId,
3867    handler: Arc<dyn ReactiveHandler<N>>,
3868    interests: Vec<ReactiveInterest<N>>,
3869    has_log_interests: bool,
3870    log_route_index: Option<LogRouteIndex>,
3871}
3872
3873impl<N: Network> Default for ReactiveRegistry<N> {
3874    fn default() -> Self {
3875        Self::new()
3876    }
3877}
3878
3879impl<N: Network> ReactiveRegistry<N> {
3880    /// Create an empty registry.
3881    pub fn new() -> Self {
3882        Self {
3883            handlers: BTreeMap::new(),
3884            handler_positions: HashMap::new(),
3885            next_handler_position: 0,
3886            indexed_log_handlers: HashMap::new(),
3887            fallback_log_handlers: BTreeSet::new(),
3888            data_slice_shapes: HashMap::new(),
3889        }
3890    }
3891
3892    /// Register a handler, preserving registration order.
3893    ///
3894    /// Duplicate handler ids are rejected with
3895    /// [`RegisterError::DuplicateHandler`].
3896    ///
3897    /// # Errors
3898    ///
3899    /// Returns [`RegisterError::DuplicateHandler`] when the id is already
3900    /// registered.
3901    pub fn register_handler(
3902        &mut self,
3903        handler: Arc<dyn ReactiveHandler<N>>,
3904    ) -> Result<(), RegisterError> {
3905        let id = handler.id();
3906        if self.handler_positions.contains_key(&id) {
3907            return Err(RegisterError::DuplicateHandler(id));
3908        }
3909        let interests = handler.interests();
3910        self.insert_handler_prepared(id, handler, interests);
3911        Ok(())
3912    }
3913
3914    fn insert_handler_prepared(
3915        &mut self,
3916        id: HandlerId,
3917        handler: Arc<dyn ReactiveHandler<N>>,
3918        interests: Vec<ReactiveInterest<N>>,
3919    ) {
3920        debug_assert!(!self.handler_positions.contains_key(&id));
3921        let has_log_interests = interests
3922            .iter()
3923            .any(|interest| matches!(interest, ReactiveInterest::Logs(_)));
3924        let log_route_index = handler.log_route_index();
3925        if self.next_handler_position == u128::MAX {
3926            self.compact_handler_positions();
3927        }
3928        let position = self.next_handler_position;
3929        self.next_handler_position += 1;
3930        self.handler_positions.insert(id.clone(), position);
3931        if let Some(index) = &log_route_index {
3932            for key in index.keys() {
3933                if let LogRouteKey::DataSlice { offset, value } = key {
3934                    *self
3935                        .data_slice_shapes
3936                        .entry((*offset, value.len()))
3937                        .or_default() += 1;
3938                }
3939                self.indexed_log_handlers
3940                    .entry(key.clone())
3941                    .or_default()
3942                    .insert(position);
3943            }
3944        } else if has_log_interests {
3945            self.fallback_log_handlers.insert(position);
3946        }
3947        self.handlers.insert(
3948            position,
3949            RegisteredHandler {
3950                id,
3951                handler,
3952                interests,
3953                has_log_interests,
3954                log_route_index,
3955            },
3956        );
3957    }
3958
3959    /// Remove one handler by id, leaving all other handlers and interests intact.
3960    ///
3961    /// Returns the removed handler when the id was registered. Cache eviction is
3962    /// intentionally outside this API: unregistering stops future routing and
3963    /// decode for the handler only.
3964    pub fn unregister_handler(&mut self, id: &HandlerId) -> Option<Arc<dyn ReactiveHandler<N>>> {
3965        let position = self.handler_positions.remove(id)?;
3966        let registered = self.handlers.remove(&position)?;
3967        if let Some(index) = &registered.log_route_index {
3968            for key in index.keys() {
3969                let remove_bucket = self
3970                    .indexed_log_handlers
3971                    .get_mut(key)
3972                    .is_some_and(|owners| {
3973                        owners.remove(&position);
3974                        owners.is_empty()
3975                    });
3976                if remove_bucket {
3977                    self.indexed_log_handlers.remove(key);
3978                }
3979                if let LogRouteKey::DataSlice { offset, value } = key {
3980                    let shape = (*offset, value.len());
3981                    let remove_shape =
3982                        self.data_slice_shapes.get_mut(&shape).is_some_and(|count| {
3983                            *count -= 1;
3984                            *count == 0
3985                        });
3986                    if remove_shape {
3987                        self.data_slice_shapes.remove(&shape);
3988                    }
3989                }
3990            }
3991        } else {
3992            self.fallback_log_handlers.remove(&position);
3993        }
3994        Some(registered.handler)
3995    }
3996
3997    /// Return true when `id` is currently registered.
3998    pub fn contains_handler(&self, id: &HandlerId) -> bool {
3999        self.handler_positions.contains_key(id)
4000    }
4001
4002    /// Ids of all registered handlers, in registration (= routing) order.
4003    pub fn handler_ids(&self) -> Vec<HandlerId> {
4004        self.handlers
4005            .values()
4006            .map(|handler| handler.id.clone())
4007            .collect()
4008    }
4009
4010    /// Borrow the interests owned by one handler.
4011    pub fn handler_interests(&self, id: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
4012        self.handler_positions
4013            .get(id)
4014            .and_then(|position| self.handlers.get(position))
4015            .map(|registered| registered.interests.as_slice())
4016    }
4017
4018    /// Return all registered interests in handler registration order.
4019    pub fn interests(&self) -> Vec<ReactiveInterest<N>> {
4020        self.handlers
4021            .values()
4022            .flat_map(|handler| handler.interests.clone())
4023            .collect()
4024    }
4025
4026    /// Return consolidated provider-side log filters.
4027    ///
4028    /// Filters are emitted in deterministic first-registration order by
4029    /// compatible block option. Within each returned filter, address and topic
4030    /// sets are unioned independently, which can intentionally overfetch. Use
4031    /// [`Self::route_log`] to enforce the exact original [`LogInterest`]s.
4032    pub fn log_subscription_filters(&self) -> Vec<Filter> {
4033        let mut filters = Vec::new();
4034        for interest in self.log_interests() {
4035            merge_log_subscription_filter(&mut filters, &interest.provider_filter);
4036        }
4037        filters
4038    }
4039
4040    /// Route a log to exact matching handler interests.
4041    ///
4042    /// Routes are returned in handler registration order. Each handler appears
4043    /// at most once for a log, using the first matching log interest declared by
4044    /// that handler.
4045    pub fn route_log(&self, log: &Log) -> Vec<ReactiveLogRoute> {
4046        self.log_handler_candidates(log)
4047            .into_iter()
4048            .filter_map(|handler| handler.route_log(log))
4049            .collect()
4050    }
4051
4052    fn log_handler_candidates(&self, log: &Log) -> Vec<&RegisteredHandler<N>> {
4053        let mut indexed_positions = Vec::new();
4054        if let Some(indexed) = self
4055            .indexed_log_handlers
4056            .get(&LogRouteKey::Emitter(log.address()))
4057        {
4058            indexed_positions.extend(indexed.iter().copied());
4059        }
4060        for (index, value) in log.topics().iter().copied().enumerate() {
4061            if let Some(indexed) = self
4062                .indexed_log_handlers
4063                .get(&LogRouteKey::Topic { index, value })
4064            {
4065                indexed_positions.extend(indexed.iter().copied());
4066            }
4067        }
4068        let data = log.inner.data.data.as_ref();
4069        for &(offset, len) in self.data_slice_shapes.keys() {
4070            let Some(end) = offset.checked_add(len) else {
4071                continue;
4072            };
4073            let Some(value) = data.get(offset..end) else {
4074                continue;
4075            };
4076            if let Some(indexed) = self.indexed_log_handlers.get(&LogRouteKey::DataSlice {
4077                offset,
4078                value: value.to_vec(),
4079            }) {
4080                indexed_positions.extend(indexed.iter().copied());
4081            }
4082        }
4083        if indexed_positions.is_empty() {
4084            if self.fallback_log_handlers.is_empty() {
4085                return Vec::new();
4086            }
4087            if !self.indexed_log_handlers.is_empty() {
4088                return self
4089                    .fallback_log_handlers
4090                    .iter()
4091                    .filter_map(|position| self.handlers.get(position))
4092                    .collect();
4093            }
4094            return self
4095                .handlers
4096                .values()
4097                .filter(|handler| handler.has_log_interests && handler.log_route_index.is_none())
4098                .collect();
4099        }
4100
4101        indexed_positions.extend(self.fallback_log_handlers.iter().copied());
4102        indexed_positions.sort_unstable();
4103        indexed_positions.dedup();
4104        indexed_positions
4105            .into_iter()
4106            .filter_map(|position| self.handlers.get(&position))
4107            .collect()
4108    }
4109
4110    fn handlers(&self) -> impl Iterator<Item = &RegisteredHandler<N>> {
4111        self.handlers.values()
4112    }
4113
4114    fn log_interests(&self) -> impl Iterator<Item = &LogInterest> {
4115        self.handlers.values().flat_map(|handler| {
4116            handler
4117                .interests
4118                .iter()
4119                .filter_map(|interest| match interest {
4120                    ReactiveInterest::Logs(interest) => Some(interest),
4121                    ReactiveInterest::Blocks(_) | ReactiveInterest::PendingTransactions(_) => None,
4122                })
4123        })
4124    }
4125
4126    fn compact_handler_positions(&mut self) {
4127        let handlers = std::mem::take(&mut self.handlers);
4128        self.handler_positions.clear();
4129        self.indexed_log_handlers.clear();
4130        self.fallback_log_handlers.clear();
4131        self.data_slice_shapes.clear();
4132
4133        for (position, (_, handler)) in handlers.into_iter().enumerate() {
4134            let position = position as u128;
4135            self.handler_positions.insert(handler.id.clone(), position);
4136            if let Some(index) = &handler.log_route_index {
4137                for key in index.keys() {
4138                    if let LogRouteKey::DataSlice { offset, value } = key {
4139                        *self
4140                            .data_slice_shapes
4141                            .entry((*offset, value.len()))
4142                            .or_default() += 1;
4143                    }
4144                    self.indexed_log_handlers
4145                        .entry(key.clone())
4146                        .or_default()
4147                        .insert(position);
4148                }
4149            } else if handler.has_log_interests {
4150                self.fallback_log_handlers.insert(position);
4151            }
4152            self.handlers.insert(position, handler);
4153        }
4154        self.next_handler_position = self.handlers.len() as u128;
4155    }
4156}
4157
4158impl<N: Network> ReactiveRuntime<N> {
4159    /// Create an empty runtime.
4160    pub fn new(config: ReactiveConfig) -> Self {
4161        Self {
4162            registry: ReactiveRegistry::new(),
4163            hooks: Vec::new(),
4164            config,
4165            journal: VecDeque::new(),
4166            coverage_head: None,
4167            log_coverage_head: None,
4168            pending_resyncs: Vec::new(),
4169            health: CacheHealth::Healthy,
4170            safe_head: None,
4171            finalized_head: None,
4172            metrics: CacheMetrics::default(),
4173            freshness: None,
4174            tracking: HashMap::new(),
4175            tracked_roots: HashMap::new(),
4176            root_gate_cadence: RootGateCadence::default(),
4177            last_gate_block: None,
4178            touched_since_gate: HashSet::new(),
4179            preconfirmed_branch: None,
4180        }
4181    }
4182
4183    fn checkpoint_state(&self) -> ReactiveRuntimeState<N> {
4184        ReactiveRuntimeState {
4185            journal: self.journal.clone(),
4186            coverage_head: self.coverage_head,
4187            log_coverage_head: self.log_coverage_head,
4188            pending_resyncs: self.pending_resyncs.clone(),
4189            health: self.health,
4190            safe_head: self.safe_head,
4191            finalized_head: self.finalized_head,
4192            freshness: self.freshness.clone(),
4193            tracking: self.tracking.clone(),
4194            tracked_roots: self.tracked_roots.clone(),
4195            root_gate_cadence: self.root_gate_cadence,
4196            last_gate_block: self.last_gate_block,
4197            touched_since_gate: self.touched_since_gate.clone(),
4198            metrics: self.metrics.snapshot(),
4199        }
4200    }
4201
4202    fn is_pristine_for_checkpoint_restore(&self) -> bool {
4203        self.preconfirmed_branch.is_none()
4204            && self.journal.is_empty()
4205            && self.coverage_head.is_none()
4206            && self.pending_resyncs.is_empty()
4207            && self.health == CacheHealth::Healthy
4208            && self.safe_head.is_none()
4209            && self.finalized_head.is_none()
4210            && self.tracked_roots.is_empty()
4211            && self.last_gate_block.is_none()
4212            && self.touched_since_gate.is_empty()
4213            && self.metrics.snapshot() == CacheMetricsSnapshot::default()
4214    }
4215
4216    fn adopted_baseline_only(&self) -> Option<BlockRef> {
4217        let baseline = self.coverage_head?;
4218        let journal_is_baseline_only = if self.config.journal_depth == 0 {
4219            self.journal.is_empty()
4220        } else {
4221            self.journal.len() == 1
4222                && self.journal.front().is_some_and(|entry| {
4223                    entry.block == baseline
4224                        && entry.inputs.is_empty()
4225                        && entry.applied.is_empty()
4226                        && entry.handler_ids.is_empty()
4227                        && entry.resynced.is_empty()
4228                        && entry.rollback_diffs.is_empty()
4229                })
4230        };
4231        (self.preconfirmed_branch.is_none()
4232            && journal_is_baseline_only
4233            && self.pending_resyncs.is_empty()
4234            && self.health == CacheHealth::Healthy
4235            && self.safe_head.is_none()
4236            && self.finalized_head.is_none()
4237            && self.tracked_roots.is_empty()
4238            && self.last_gate_block.is_none()
4239            && self.touched_since_gate.is_empty()
4240            && self.metrics.snapshot() == CacheMetricsSnapshot::default())
4241        .then_some(baseline)
4242    }
4243
4244    fn restore_state(&mut self, state: ReactiveRuntimeState<N>) {
4245        self.journal = state.journal;
4246        self.coverage_head = state.coverage_head;
4247        self.log_coverage_head = state.log_coverage_head;
4248        self.pending_resyncs = state.pending_resyncs;
4249        self.health = state.health;
4250        self.safe_head = state.safe_head;
4251        self.finalized_head = state.finalized_head;
4252        self.freshness = state.freshness;
4253        self.tracking = state.tracking;
4254        self.tracked_roots = state.tracked_roots;
4255        self.root_gate_cadence = state.root_gate_cadence;
4256        self.last_gate_block = state.last_gate_block;
4257        self.touched_since_gate = state.touched_since_gate;
4258        self.metrics.restore(state.metrics);
4259    }
4260
4261    fn restore_transaction_state(&mut self, state: ReactiveRuntimeState<N>) {
4262        // Metrics describe lifetime observations, including rejected attempts,
4263        // and are documented as monotonic. Roll back canonical/runtime state
4264        // without erasing the failure signal that caused the transaction to
4265        // abort.
4266        let metrics = self.metrics.snapshot();
4267        self.restore_state(state);
4268        self.metrics.restore(metrics);
4269    }
4270
4271    fn durable_checkpoint_bytes(&self) -> Result<Vec<u8>, ReactiveEngineError> {
4272        let checkpoint = DurableRuntimeCheckpoint {
4273            version: DURABLE_RUNTIME_CHECKPOINT_VERSION,
4274            safe_head: self.safe_head,
4275            finalized_head: self.finalized_head,
4276            health: self.health,
4277            pending_resyncs: self.pending_resyncs.clone(),
4278            coverage_head: self.coverage_head,
4279            log_coverage_head: self.log_coverage_head,
4280            journal: self
4281                .journal
4282                .iter()
4283                .map(|entry| DurableBlockJournal {
4284                    block: entry.block,
4285                    handler_ids: entry.handler_ids.clone(),
4286                    rollback_diffs: entry.rollback_diffs.clone(),
4287                })
4288                .collect(),
4289            freshness: self.freshness.clone(),
4290            tracking: self.tracking.clone(),
4291            tracked_roots: self.tracked_roots.clone(),
4292            root_gate_cadence: self.root_gate_cadence,
4293            last_gate_block: self.last_gate_block,
4294            touched_since_gate: self.touched_since_gate.clone(),
4295            metrics: self.metrics.snapshot(),
4296        };
4297        bincode::serialize(&checkpoint)
4298            .map_err(|error| ReactiveEngineError::RuntimeCheckpoint(error.to_string()))
4299    }
4300
4301    fn plan_durable_checkpoint_restore(
4302        &self,
4303        bytes: &[u8],
4304        expected_coverage: &BlockRef,
4305    ) -> Result<DurableRuntimeRestorePlan, ReactiveCheckpointRestoreError> {
4306        let mut cursor = std::io::Cursor::new(bytes);
4307        let mut checkpoint: DurableRuntimeCheckpoint = bincode::DefaultOptions::new()
4308            .with_fixint_encoding()
4309            .with_limit(bytes.len() as u64)
4310            .deserialize_from(&mut cursor)
4311            .map_err(|error| {
4312                ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(error.to_string())
4313            })?;
4314        if cursor.position() != bytes.len() as u64 {
4315            return Err(ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(
4316                "runtime checkpoint has trailing bytes".to_owned(),
4317            ));
4318        }
4319        if checkpoint.version != DURABLE_RUNTIME_CHECKPOINT_VERSION {
4320            return Err(ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(
4321                format!(
4322                    "unsupported runtime checkpoint version {}",
4323                    checkpoint.version
4324                ),
4325            ));
4326        }
4327        self.validate_durable_runtime_checkpoint(&checkpoint, expected_coverage)?;
4328
4329        let retained = self.config.journal_depth.min(checkpoint.journal.len());
4330        let discard = checkpoint.journal.len() - retained;
4331        checkpoint.journal.drain(..discard);
4332        Ok(DurableRuntimeRestorePlan {
4333            checkpoint: Some(checkpoint),
4334            fallback_history: Vec::new(),
4335        })
4336    }
4337
4338    fn apply_durable_checkpoint_restore(&mut self, plan: DurableRuntimeRestorePlan) {
4339        let Some(checkpoint) = plan.checkpoint else {
4340            self.journal = plan
4341                .fallback_history
4342                .into_iter()
4343                .map(|block| BlockJournal {
4344                    block,
4345                    inputs: Vec::new(),
4346                    applied: Vec::new(),
4347                    handler_ids: Vec::new(),
4348                    resynced: Vec::new(),
4349                    rollback_diffs: Vec::new(),
4350                })
4351                .collect();
4352            return;
4353        };
4354        self.safe_head = checkpoint.safe_head;
4355        self.finalized_head = checkpoint.finalized_head;
4356        self.health = checkpoint.health;
4357        self.pending_resyncs = checkpoint.pending_resyncs;
4358        self.coverage_head = checkpoint.coverage_head;
4359        self.log_coverage_head = checkpoint.log_coverage_head;
4360        self.journal = checkpoint
4361            .journal
4362            .into_iter()
4363            .map(|entry| BlockJournal {
4364                block: entry.block,
4365                inputs: Vec::new(),
4366                applied: Vec::new(),
4367                handler_ids: entry.handler_ids,
4368                resynced: Vec::new(),
4369                rollback_diffs: entry.rollback_diffs,
4370            })
4371            .collect();
4372        self.freshness = checkpoint.freshness;
4373        self.tracking = checkpoint.tracking;
4374        self.tracked_roots = checkpoint.tracked_roots;
4375        self.root_gate_cadence = checkpoint.root_gate_cadence;
4376        self.last_gate_block = checkpoint.last_gate_block;
4377        self.touched_since_gate = checkpoint.touched_since_gate;
4378        self.metrics.restore(checkpoint.metrics);
4379    }
4380
4381    fn validate_durable_runtime_checkpoint(
4382        &self,
4383        checkpoint: &DurableRuntimeCheckpoint,
4384        expected_coverage: &BlockRef,
4385    ) -> Result<(), ReactiveCheckpointRestoreError> {
4386        let invalid =
4387            |message: String| ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(message);
4388        let Some(coverage) = checkpoint.coverage_head.as_ref() else {
4389            return Err(invalid(
4390                "runtime checkpoint is missing its canonical coverage head".into(),
4391            ));
4392        };
4393        if !optional_block_refs_are_compatible(Some(coverage), Some(expected_coverage)) {
4394            return Err(invalid(format!(
4395                "runtime coverage {}:{:?} conflicts with checkpoint metadata {}:{:?}",
4396                coverage.number, coverage.hash, expected_coverage.number, expected_coverage.hash
4397            )));
4398        }
4399        for (label, head) in [
4400            ("safe", checkpoint.safe_head.as_ref()),
4401            ("finalized", checkpoint.finalized_head.as_ref()),
4402        ] {
4403            let Some(head) = head else { continue };
4404            if head.number > coverage.number
4405                || (head.number == coverage.number && head.hash != coverage.hash)
4406            {
4407                return Err(invalid(format!(
4408                    "{label} head {}:{:?} lies beyond or conflicts with canonical coverage {}:{:?}",
4409                    head.number, head.hash, coverage.number, coverage.hash
4410                )));
4411            }
4412            if head.number.checked_add(1) == Some(coverage.number)
4413                && coverage
4414                    .parent_hash
4415                    .is_some_and(|parent| parent != head.hash)
4416            {
4417                return Err(invalid(format!(
4418                    "canonical coverage does not descend from adjacent {label} head"
4419                )));
4420            }
4421        }
4422        if let (Some(finalized), Some(safe)) = (
4423            checkpoint.finalized_head.as_ref(),
4424            checkpoint.safe_head.as_ref(),
4425        ) {
4426            if finalized.number > safe.number
4427                || (finalized.number == safe.number && finalized.hash != safe.hash)
4428            {
4429                return Err(invalid(
4430                    "finalized head is above or conflicts with the safe head".into(),
4431                ));
4432            }
4433            if finalized.number.checked_add(1) == Some(safe.number)
4434                && safe.parent_hash != Some(finalized.hash)
4435            {
4436                return Err(invalid(
4437                    "adjacent safe head does not descend from finalized head".into(),
4438                ));
4439            }
4440        }
4441
4442        let mut previous: Option<&DurableBlockJournal> = None;
4443        for entry in &checkpoint.journal {
4444            if entry.block.number > coverage.number
4445                || (entry.block.number == coverage.number && entry.block.hash != coverage.hash)
4446            {
4447                return Err(invalid(format!(
4448                    "journal block {}:{:?} lies beyond or conflicts with canonical coverage",
4449                    entry.block.number, entry.block.hash
4450                )));
4451            }
4452            if let Some(previous) = previous {
4453                if entry.block.number <= previous.block.number {
4454                    return Err(invalid(
4455                        "runtime journal block numbers are not strictly increasing".into(),
4456                    ));
4457                }
4458                if previous.block.number.checked_add(1) == Some(entry.block.number)
4459                    && entry.block.parent_hash.is_some()
4460                    && entry.block.parent_hash != Some(previous.block.hash)
4461                {
4462                    return Err(invalid(
4463                        "adjacent runtime journal blocks are not parent-linked".into(),
4464                    ));
4465                }
4466            }
4467            for (label, head) in [
4468                ("safe", checkpoint.safe_head.as_ref()),
4469                ("finalized", checkpoint.finalized_head.as_ref()),
4470            ] {
4471                if let Some(head) = head
4472                    && head.number == entry.block.number
4473                    && !optional_block_refs_are_compatible(Some(head), Some(&entry.block))
4474                {
4475                    return Err(invalid(format!(
4476                        "{label} head conflicts with the retained journal at block {}",
4477                        head.number
4478                    )));
4479                }
4480            }
4481            let mut handler_ids = HashSet::new();
4482            if entry
4483                .handler_ids
4484                .iter()
4485                .any(|handler_id| !handler_ids.insert(handler_id))
4486            {
4487                return Err(invalid(
4488                    "runtime journal contains duplicate handler generation ids".into(),
4489                ));
4490            }
4491            previous = Some(entry);
4492        }
4493        if let Some(tail) = checkpoint.journal.last()
4494            && tail.block.number == coverage.number
4495            && !optional_block_refs_are_compatible(Some(&tail.block), Some(coverage))
4496        {
4497            return Err(invalid(format!(
4498                "runtime journal tail conflicts with canonical coverage at block {}",
4499                coverage.number
4500            )));
4501        }
4502        if let Some(tail) = checkpoint.journal.last()
4503            && tail.block.number.checked_add(1) == Some(coverage.number)
4504            && coverage
4505                .parent_hash
4506                .is_some_and(|parent_hash| parent_hash != tail.block.hash)
4507        {
4508            return Err(invalid(format!(
4509                "canonical coverage does not descend from adjacent runtime journal tail at block {}",
4510                tail.block.number
4511            )));
4512        }
4513
4514        if let Some(last_gate_block) = checkpoint.last_gate_block {
4515            if last_gate_block > coverage.number {
4516                return Err(invalid(
4517                    "root-gate cursor lies beyond canonical coverage".into(),
4518                ));
4519            }
4520        } else if !checkpoint.tracked_roots.is_empty() {
4521            return Err(invalid(
4522                "root-gate baselines exist without a completed gate cursor".into(),
4523            ));
4524        }
4525        for (address, baseline) in &checkpoint.tracked_roots {
4526            let Some(policy) = checkpoint.tracking.get(address) else {
4527                return Err(invalid(
4528                    "root-gate baseline has no corresponding tracking policy".into(),
4529                ));
4530            };
4531            if matches!(policy, TrackingPolicy::Slots { .. }) {
4532                return Err(invalid(
4533                    "slot-only tracking cannot carry an account root baseline".into(),
4534                ));
4535            }
4536            if baseline.last_block > coverage.number
4537                || checkpoint
4538                    .last_gate_block
4539                    .is_some_and(|last_gate| baseline.last_block > last_gate)
4540            {
4541                return Err(invalid(
4542                    "root-gate baseline lies beyond the committed gate window".into(),
4543                ));
4544            }
4545        }
4546        Ok(())
4547    }
4548
4549    /// Track `address` under `policy` for the per-block root gate (Phase-8 step 4).
4550    ///
4551    /// Tracking is strictly opt-in: a runtime with no tracked accounts runs the
4552    /// gate as a no-op. Registering an account clears any baseline it held (a
4553    /// policy change re-adopts on the next probe rather than diffing against a
4554    /// baseline captured under the old policy). Each [`RootGateCadence`]
4555    /// firing, the gate
4556    /// probes tracked [`WholeAccount`](TrackingPolicy::WholeAccount) and
4557    /// [`Scalars`](TrackingPolicy::Scalars) accounts' roots/fields via the
4558    /// account-proof seam and, on a move no decoder covered, emits a
4559    /// [`ReactiveReport::CoverageGap`] and schedules a
4560    /// [`ResyncReason::RootMoved`] repair. [`Slots`](TrackingPolicy::Slots)
4561    /// accounts are never root-gated (spec Decision 3).
4562    /// # Cost
4563    ///
4564    /// Tracking an account with a root-gated policy enrols it in the root gate,
4565    /// which issues one `eth_getProof` per tracked account every
4566    /// [`RootGateCadence`] window. That is the most expensive read this crate
4567    /// makes, and it is standing traffic for as long as the account is tracked;
4568    /// [`TrackingPolicy::Slots`] opts out of the gate entirely.
4569    pub fn track_account(&mut self, address: Address, policy: TrackingPolicy) {
4570        self.tracking.insert(address, policy);
4571        self.tracked_roots.remove(&address);
4572    }
4573
4574    /// Stop tracking `address`, dropping its policy and any adopted baseline.
4575    ///
4576    /// Returns `true` if the account was tracked.
4577    pub fn untrack_account(&mut self, address: Address) -> bool {
4578        self.tracked_roots.remove(&address);
4579        self.tracking.remove(&address).is_some()
4580    }
4581
4582    /// Set how often the root gate probes tracked accounts (default:
4583    /// [`RootGateCadence::default`] — every 16 canonical blocks; see the
4584    /// [`RootGateCadence`] docs for why skipping blocks loses no detection).
4585    ///
4586    /// Reconfiguring resets the gate's window bookkeeping (the touched-address
4587    /// accumulator and the last-fired block), so a stale window never leaks
4588    /// into the new cadence: the next canonical block fires the gate.
4589    pub fn set_root_gate_cadence(&mut self, cadence: RootGateCadence) {
4590        self.root_gate_cadence = cadence;
4591        self.last_gate_block = None;
4592        self.touched_since_gate.clear();
4593    }
4594
4595    /// Highest block a source has attested carries no unhealed log-notification
4596    /// loss, when any source has attested.
4597    ///
4598    /// `None` means unknown, not complete. A consumer deciding whether it may
4599    /// treat a buffered log set as authoritative must require a watermark at or
4600    /// above the block in question — never infer completeness from silence. See
4601    /// [`ChainControl::LogCoverage`].
4602    pub const fn log_coverage_head(&self) -> Option<&BlockRef> {
4603        self.log_coverage_head.as_ref()
4604    }
4605
4606    /// The configured [`RootGateCadence`].
4607    pub fn root_gate_cadence(&self) -> RootGateCadence {
4608        self.root_gate_cadence
4609    }
4610
4611    /// Enable freshness stamping of canonical event-derived writes (opt-in).
4612    ///
4613    /// Installs a [`FreshnessRegistry`] the runtime owns; while it is present,
4614    /// applying a canonical handler storage-slot effect for a block `N` stamps the
4615    /// touched `(address, slot)` as
4616    /// [`Validity::ValidThrough`](crate::freshness::Validity::ValidThrough)`(N)`.
4617    /// The slot is therefore not volatile *at* `N` (event-maintained, no need to
4618    /// re-verify) but ages to volatile once the clock passes `N`.
4619    ///
4620    /// Idempotent: if a registry is already installed it is left untouched, so an
4621    /// existing registry (and any stamps it holds) is never clobbered.
4622    pub fn enable_freshness_stamping(&mut self) {
4623        if self.freshness.is_none() {
4624            self.freshness = Some(FreshnessRegistry::new());
4625        }
4626    }
4627
4628    /// Borrow the runtime's freshness registry, if stamping was enabled.
4629    ///
4630    /// Returns `None` unless
4631    /// [`enable_freshness_stamping`](Self::enable_freshness_stamping) was called.
4632    pub fn freshness(&self) -> Option<&FreshnessRegistry> {
4633        self.freshness.as_ref()
4634    }
4635
4636    /// Mutably borrow the runtime's freshness registry, if stamping was enabled.
4637    ///
4638    /// Returns `None` unless
4639    /// [`enable_freshness_stamping`](Self::enable_freshness_stamping) was called.
4640    pub fn freshness_mut(&mut self) -> Option<&mut FreshnessRegistry> {
4641        self.freshness.as_mut()
4642    }
4643
4644    /// Return the current queryable [`CacheHealth`] of the runtime.
4645    pub fn health(&self) -> CacheHealth {
4646        self.health
4647    }
4648
4649    /// Return a point-in-time snapshot of the runtime's observability counters.
4650    pub fn metrics(&self) -> CacheMetricsSnapshot {
4651        self.metrics.snapshot()
4652    }
4653
4654    /// Complete the caller-driven self-heal by returning health to
4655    /// [`CacheHealth::Healthy`].
4656    ///
4657    /// A trust-loss event (a reorg deeper than the journal, or a detected missed
4658    /// block range) escalates health toward [`CacheHealth::Unhealthy`] as a
4659    /// "stop until rebuilt" signal that the caller must act on. Once the caller
4660    /// has resynced or rebuilt the affected state, it invokes this to clear the
4661    /// signal. It does not emit a [`ReactiveReport::Health`] report, since it is
4662    /// called outside an ingest cycle.
4663    pub fn reset_health(&mut self) {
4664        self.health = CacheHealth::Healthy;
4665    }
4666
4667    /// Escalate health one rung up the trust-loss ladder for a trust-loss event
4668    /// observed at `block`, returning a [`ReactiveReport::Health`] report when the
4669    /// state actually changes.
4670    ///
4671    /// The ladder is:
4672    /// - [`Healthy`](CacheHealth::Healthy) -> [`Degraded`](CacheHealth::Degraded)
4673    /// - [`Degraded`](CacheHealth::Degraded) -> [`Unhealthy`](CacheHealth::Unhealthy)
4674    /// - [`Unhealthy`](CacheHealth::Unhealthy) -> no change (`None`)
4675    ///
4676    /// A first event degrades; a second escalates to the terminal
4677    /// [`Unhealthy`](CacheHealth::Unhealthy) stop signal. This is shared by both
4678    /// trust-loss paths (deep reorg beyond the journal and missed-range
4679    /// detection) so mixed event types climb the same ladder.
4680    fn escalate_trust(&mut self, block: u64) -> Option<Arc<ReactiveReport<N>>> {
4681        let to = match self.health {
4682            CacheHealth::Healthy => CacheHealth::Degraded { since_block: block },
4683            CacheHealth::Degraded { .. } => CacheHealth::Unhealthy { since_block: block },
4684            CacheHealth::Unhealthy { .. } => return None,
4685        };
4686        self.transition_health(to, Some(block))
4687    }
4688
4689    /// Transition health to `to`, returning a [`ReactiveReport::Health`] report
4690    /// when the state actually changes.
4691    ///
4692    /// The returned report must be threaded into the ingest cycle's dispatched
4693    /// reports so it reaches hooks and appears in
4694    /// [`ReactiveBatchReport::reports`]. Returns `None` when `to` equals the
4695    /// current state (no transition, no report).
4696    fn transition_health(
4697        &mut self,
4698        to: CacheHealth,
4699        block: Option<u64>,
4700    ) -> Option<Arc<ReactiveReport<N>>> {
4701        if to == self.health {
4702            return None;
4703        }
4704        let from = self.health;
4705        self.health = to;
4706        Some(Arc::new(ReactiveReport::Health(HealthReport {
4707            from,
4708            to,
4709            block,
4710            _network: PhantomData,
4711        })))
4712    }
4713
4714    /// Register a handler.
4715    ///
4716    /// # Errors
4717    ///
4718    /// Returns [`RegisterError::DuplicateHandler`] when the id is already
4719    /// registered.
4720    pub fn register_handler(
4721        &mut self,
4722        handler: Arc<dyn ReactiveHandler<N>>,
4723    ) -> Result<(), RegisterError> {
4724        self.registry.register_handler(handler)
4725    }
4726
4727    /// Remove one handler from the runtime registry without resetting runtime state.
4728    ///
4729    /// This delegates to [`ReactiveRegistry::unregister_handler`] only. It does
4730    /// not clear the reorg journal, health, metrics, hooks, pending resyncs,
4731    /// tracking policy, freshness registry, or root-gate baselines, and it does
4732    /// not purge [`EvmCache`] state. Callers that want cache eviction must issue
4733    /// explicit `StateUpdate::purge` updates or use cache purge APIs separately.
4734    pub fn unregister_handler(&mut self, id: &HandlerId) -> Option<Arc<dyn ReactiveHandler<N>>> {
4735        self.registry.unregister_handler(id)
4736    }
4737
4738    /// Return true when the runtime has a registered handler with `id`.
4739    pub fn contains_handler(&self, id: &HandlerId) -> bool {
4740        self.registry.contains_handler(id)
4741    }
4742
4743    /// Ids of all registered handlers, in registration (= routing) order.
4744    pub fn handler_ids(&self) -> Vec<HandlerId> {
4745        self.registry.handler_ids()
4746    }
4747
4748    /// Borrow the interests owned by one registered handler.
4749    pub fn handler_interests(&self, id: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
4750        self.registry.handler_interests(id)
4751    }
4752
4753    /// The most recently journaled canonical block, if any.
4754    ///
4755    /// This is the runtime's current chain position: the canonical block most
4756    /// recently recorded by ingestion. Reorged blocks are dropped from the
4757    /// journal during recovery, so a rolled-back head does not linger here.
4758    /// [`ReactiveEngine::register_handler`] uses it as the default backfill
4759    /// anchor for handlers registered mid-lifecycle. An ordered barrier may
4760    /// advance this coverage position across an empty event range. `None` until
4761    /// the first canonical input or barrier is accepted.
4762    pub fn last_canonical_block(&self) -> Option<BlockRef> {
4763        self.coverage_head
4764    }
4765
4766    /// Adopt an exact RPC snapshot block as this runtime's canonical starting
4767    /// position without applying effects or dispatching reports.
4768    ///
4769    /// Handlers, hooks, tracking policy, and freshness configuration may be
4770    /// installed before adoption, but no chain input, finality, resync,
4771    /// root-gate observation, or health transition may have occurred. An exact
4772    /// repeat is idempotent; a different repeat and any active runtime fail
4773    /// closed. Prefer [`ReactiveEngine::adopt_canonical_baseline`] when a cache
4774    /// and subscriber are available so chain identity and the cache's exact
4775    /// hash pin are validated too.
4776    ///
4777    /// # Errors
4778    ///
4779    /// Returns [`ReactiveBaselineError::ActiveRuntime`] after any runtime
4780    /// activity, or [`ReactiveBaselineError::ConflictingBaseline`] when a
4781    /// different baseline has already been adopted.
4782    pub fn adopt_canonical_baseline(
4783        &mut self,
4784        baseline: BlockRef,
4785    ) -> Result<(), ReactiveBaselineError> {
4786        self.validate_canonical_baseline_adoption(baseline)?;
4787        if self.adopted_baseline_only().is_some() {
4788            return Ok(());
4789        }
4790
4791        self.coverage_head = Some(baseline);
4792        if self.config.journal_depth > 0 {
4793            self.journal.push_back(BlockJournal {
4794                block: baseline,
4795                inputs: Vec::new(),
4796                applied: Vec::new(),
4797                handler_ids: Vec::new(),
4798                resynced: Vec::new(),
4799                rollback_diffs: Vec::new(),
4800            });
4801        }
4802        Ok(())
4803    }
4804
4805    fn validate_canonical_baseline_adoption(
4806        &self,
4807        baseline: BlockRef,
4808    ) -> Result<(), ReactiveBaselineError> {
4809        if let Some(existing) = self.adopted_baseline_only() {
4810            return if existing == baseline {
4811                Ok(())
4812            } else {
4813                Err(ReactiveBaselineError::ConflictingBaseline {
4814                    existing_number: existing.number,
4815                    existing_hash: existing.hash,
4816                    requested_number: baseline.number,
4817                    requested_hash: baseline.hash,
4818                })
4819            };
4820        }
4821        if !self.is_pristine_for_checkpoint_restore() {
4822            return Err(ReactiveBaselineError::ActiveRuntime);
4823        }
4824        Ok(())
4825    }
4826
4827    /// Most recent safe head explicitly reported by the event source.
4828    pub const fn safe_head(&self) -> Option<&BlockRef> {
4829        self.safe_head.as_ref()
4830    }
4831
4832    /// Most recent finalized head explicitly reported by the event source.
4833    pub const fn finalized_head(&self) -> Option<&BlockRef> {
4834        self.finalized_head.as_ref()
4835    }
4836
4837    /// Return whether the retained reorg journal still contains an applied
4838    /// record for `handler_id`.
4839    ///
4840    /// The record is retained even when the handler emitted only resync work,
4841    /// so an owner can keep an explicit cache-eviction fence active for exactly
4842    /// as long as a later rollback could restore effects from that handler
4843    /// generation. This query is bounded by [`ReactiveConfig::journal_depth`].
4844    pub fn has_journaled_handler_effects(&self, handler_id: &HandlerId) -> bool {
4845        self.journal
4846            .iter()
4847            .any(|entry| entry.handler_ids.contains(handler_id))
4848    }
4849
4850    /// Return the distinct handler generations represented in the retained
4851    /// reorg journal.
4852    ///
4853    /// This scans the bounded journal once, allowing a lifecycle owner to age a
4854    /// large set of cache-eviction fences without rescanning the journal for
4855    /// every handler.
4856    pub fn journaled_handler_ids(&self) -> HashSet<HandlerId> {
4857        self.journal
4858            .iter()
4859            .flat_map(|entry| entry.handler_ids.iter().cloned())
4860            .collect()
4861    }
4862
4863    /// Queued resync requests: surfaced by handlers but not yet executed by an
4864    /// [`ingest_batch_with_resync`](Self::ingest_batch_with_resync) pass.
4865    ///
4866    /// Callers driving resync execution themselves (plain
4867    /// [`ingest_batch`](Self::ingest_batch) loops) can read the ledger here;
4868    /// reorg recovery cancels entries whose pinned blocks were dropped, and
4869    /// [`cancel_pending_resync`](Self::cancel_pending_resync) drops exact
4870    /// generation-owned work, while
4871    /// [`cancel_pending_resyncs`](Self::cancel_pending_resyncs) drops entries
4872    /// for exclusively torn-down accounts.
4873    pub fn pending_resyncs(&self) -> &[ResyncRequest] {
4874        &self.pending_resyncs
4875    }
4876
4877    /// Cancel every queued request with the exact logical `id`.
4878    ///
4879    /// Unlike [`cancel_pending_resyncs`](Self::cancel_pending_resyncs), this
4880    /// removes whole requests and never touches other work merely because it
4881    /// targets the same account. It is therefore the safe primitive for
4882    /// generation-scoped owner teardown when the caller maintains an
4883    /// owner-to-[`ResyncId`] index. Requests already returned to the caller in
4884    /// an earlier batch report cannot be recalled.
4885    pub fn cancel_pending_resync(&mut self, id: &ResyncId) -> Vec<ResyncRequest> {
4886        self.cancel_pending_resyncs_by_id(std::slice::from_ref(id))
4887    }
4888
4889    /// Cancel queued requests whose logical ids occur in `ids` in one queue pass.
4890    ///
4891    /// Duplicate and unknown ids are harmless. Cancelled requests retain their
4892    /// pending-queue order, independent of caller id order. This is the batch
4893    /// teardown primitive for owners that can have many pending repairs; it
4894    /// avoids rescanning the complete pending queue once per owned id.
4895    pub fn cancel_pending_resyncs_by_id(&mut self, ids: &[ResyncId]) -> Vec<ResyncRequest> {
4896        if ids.is_empty() {
4897            return Vec::new();
4898        }
4899        let ids: HashSet<&ResyncId> = ids.iter().collect();
4900        let mut cancelled = Vec::new();
4901        self.pending_resyncs.retain(|request| {
4902            if ids.contains(&request.id) {
4903                cancelled.push(request.clone());
4904                false
4905            } else {
4906                true
4907            }
4908        });
4909        cancelled
4910    }
4911
4912    /// Cancel queued resync work that targets `address`, returning the
4913    /// cancelled portions.
4914    ///
4915    /// Every pending [`ResyncRequest`] target referencing `address` is removed;
4916    /// a request reduced to zero targets is dropped entirely, while
4917    /// mixed-target requests keep their other accounts queued. Each returned
4918    /// request mirrors the original id/reason/block/priority and carries only
4919    /// the targets that were cancelled.
4920    ///
4921    /// This is appropriate only when the caller owns the complete account. For
4922    /// a pool sharing a vault or emitter with other owners, cancel its exact
4923    /// request IDs through
4924    /// [`cancel_pending_resync`](Self::cancel_pending_resync) instead. It cannot
4925    /// recall requests already returned to the caller in earlier batch reports.
4926    pub fn cancel_pending_resyncs(&mut self, address: Address) -> Vec<ResyncRequest> {
4927        let mut cancelled = Vec::new();
4928        self.pending_resyncs.retain_mut(|request| {
4929            let (matching, remaining): (Vec<_>, Vec<_>) = request
4930                .targets
4931                .drain(..)
4932                .partition(|target| resync_target_address(target) == address);
4933            request.targets = remaining;
4934            if !matching.is_empty() {
4935                cancelled.push(ResyncRequest {
4936                    id: request.id.clone(),
4937                    reason: request.reason.clone(),
4938                    block: request.block.clone(),
4939                    targets: matching,
4940                    priority: request.priority,
4941                });
4942            }
4943            !request.targets.is_empty()
4944        });
4945        cancelled
4946    }
4947
4948    /// Register a hook.
4949    ///
4950    /// # Errors
4951    ///
4952    /// This implementation is currently infallible; the `Result` preserves the
4953    /// registration contract for future hook validation.
4954    pub fn register_hook(&mut self, hook: Arc<dyn ReactiveHook<N>>) -> Result<(), RegisterError> {
4955        self.hooks.push(hook);
4956        Ok(())
4957    }
4958
4959    /// Return all registered interests in handler registration order.
4960    pub fn interests(&self) -> Vec<ReactiveInterest<N>> {
4961        self.registry.interests()
4962    }
4963
4964    /// Ingest a batch, apply valid direct state effects, and dispatch reports.
4965    ///
4966    /// The commit is atomic on `Err`: cache state and canonical runtime state are
4967    /// restored before the error returns, and hooks see no reports. Monotonic
4968    /// observability counters still retain rejected-attempt signals.
4969    /// The current rollback guard snapshots complete mutable cache state once per
4970    /// batch, so callers should preserve transport batching rather than splitting
4971    /// one delivery into many one-record calls.
4972    ///
4973    /// # Errors
4974    ///
4975    /// Returns [`ReactiveError`] when records or controls are invalid, canonical
4976    /// continuity cannot be proven, a handler rejects input, or an effect cannot
4977    /// be applied. A pre-confirmed batch additionally requires an adopted
4978    /// canonical coverage head and must identify its exact child by number and
4979    /// parent hash. Cache and canonical runtime state are restored before
4980    /// return; a lineage failure revokes any active speculative branch.
4981    pub fn ingest_batch(
4982        &mut self,
4983        cache: &mut EvmCache,
4984        batch: ReactiveInputBatch<N>,
4985    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
4986        let preconfirmation = batch_preconfirmation(&batch)?;
4987        if let Some(flashblock) = preconfirmation.as_ref() {
4988            self.prepare_preconfirmed_branch(cache, flashblock)?;
4989        } else {
4990            self.discard_preconfirmed_branch(cache);
4991        }
4992        let cache_state = EvmCacheStateSnapshot::capture(cache);
4993        let runtime_state = self.checkpoint_state();
4994        let batch_report = match self.ingest_batch_direct(cache, batch) {
4995            Ok(report) => report,
4996            Err(error) => {
4997                cache_state.restore(cache);
4998                self.restore_transaction_state(runtime_state);
4999                return Err(error);
5000            }
5001        };
5002        if let Some(flashblock) = preconfirmation {
5003            self.restore_transaction_state(runtime_state);
5004            if let Some(branch) = self.preconfirmed_branch.as_mut() {
5005                branch.flashblock = flashblock;
5006            }
5007        }
5008        self.dispatch_reports(&batch_report.reports);
5009        let _ = &self.config;
5010        Ok(batch_report)
5011    }
5012
5013    /// Ingest a batch, then execute surfaced storage resync requests.
5014    ///
5015    /// This entrypoint preserves [`ingest_batch`](Self::ingest_batch) behavior for
5016    /// direct handler effects, then runs a synchronous resync phase over the
5017    /// collected [`ResyncRequest`]s. Storage targets are fetched through
5018    /// [`EvmCache::storage_batch_fetcher`] grouped by [`ResyncBlock`], successful
5019    /// values are applied as [`StateUpdate::slot`] updates through
5020    /// [`EvmCache::apply_updates`], and unsupported or failed targets are reported
5021    /// in [`ResyncReport::failed`]. It does not start subscribers, background
5022    /// workers, or network transport.
5023    ///
5024    /// # Errors
5025    ///
5026    /// Returns [`ReactiveError`] for the same validation, continuity, handler,
5027    /// or direct-effect failures as [`ingest_batch`](Self::ingest_batch). Failed
5028    /// resync targets are reported in the successful batch report instead.
5029    pub fn ingest_batch_with_resync(
5030        &mut self,
5031        cache: &mut EvmCache,
5032        batch: ReactiveInputBatch<N>,
5033    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
5034        let preconfirmation = batch_preconfirmation(&batch)?;
5035        if let Some(flashblock) = preconfirmation.as_ref() {
5036            self.prepare_preconfirmed_branch(cache, flashblock)?;
5037        } else {
5038            self.discard_preconfirmed_branch(cache);
5039        }
5040        let cache_state = EvmCacheStateSnapshot::capture(cache);
5041        let runtime_state = self.checkpoint_state();
5042        let batch_report = match self.ingest_batch_with_resync_direct(cache, batch) {
5043            Ok(report) => report,
5044            Err(error) => {
5045                cache_state.restore(cache);
5046                self.restore_transaction_state(runtime_state);
5047                return Err(error);
5048            }
5049        };
5050
5051        if let Some(flashblock) = preconfirmation {
5052            self.restore_transaction_state(runtime_state);
5053            if let Some(branch) = self.preconfirmed_branch.as_mut() {
5054                branch.flashblock = flashblock;
5055            }
5056        }
5057
5058        self.dispatch_reports(&batch_report.reports);
5059        let _ = &self.config;
5060        Ok(batch_report)
5061    }
5062
5063    /// Active speculative Flashblock snapshot, when the cache currently
5064    /// includes pre-confirmed effects.
5065    pub fn active_preconfirmation(&self) -> Option<&FlashblockRef> {
5066        self.preconfirmed_branch
5067            .as_ref()
5068            .map(|branch| &branch.flashblock)
5069    }
5070
5071    /// Restore the cache to its canonical state and discard any speculative
5072    /// Flashblock effects.
5073    pub fn discard_preconfirmation(&mut self, cache: &mut EvmCache) {
5074        self.discard_preconfirmed_branch(cache);
5075    }
5076
5077    fn discard_preconfirmed_branch(&mut self, cache: &mut EvmCache) {
5078        if let Some(branch) = self.preconfirmed_branch.take() {
5079            branch.canonical_cache.restore(cache);
5080        }
5081    }
5082
5083    fn prepare_preconfirmed_branch(
5084        &mut self,
5085        cache: &mut EvmCache,
5086        incoming: &FlashblockRef,
5087    ) -> Result<(), ReactiveError> {
5088        let Some(canonical) = self.coverage_head else {
5089            self.discard_preconfirmed_branch(cache);
5090            return Err(ReactiveError::InvalidInputRecord {
5091                message: "pre-confirmed state requires an exact canonical coverage baseline".into(),
5092            });
5093        };
5094        if canonical.number.checked_add(1) != Some(incoming.block_number) {
5095            self.discard_preconfirmed_branch(cache);
5096            return Err(ReactiveError::InvalidInputRecord {
5097                message: format!(
5098                    "pre-confirmed block {} is not the exact successor of canonical block {}",
5099                    incoming.block_number, canonical.number
5100                ),
5101            });
5102        }
5103        if incoming.parent_hash != Some(canonical.hash) {
5104            self.discard_preconfirmed_branch(cache);
5105            return Err(ReactiveError::InvalidInputRecord {
5106                message: "pre-confirmed block parent does not match the canonical coverage hash"
5107                    .into(),
5108            });
5109        }
5110        if let Some(active) = self.preconfirmed_branch.as_ref()
5111            && active.flashblock.same_payload(incoming)
5112        {
5113            if let (Some(active_index), Some(incoming_index)) =
5114                (active.flashblock.index, incoming.index)
5115                && incoming_index < active_index
5116            {
5117                return Err(ReactiveError::InvalidInputRecord {
5118                    message: format!(
5119                        "Flashblock index regressed from {active_index} to {incoming_index}"
5120                    ),
5121                });
5122            }
5123            if active.flashblock.index.is_some()
5124                && active.flashblock.index == incoming.index
5125                && active.flashblock.content_hash != incoming.content_hash
5126            {
5127                self.discard_preconfirmed_branch(cache);
5128                return Err(ReactiveError::InvalidInputRecord {
5129                    message: "same Flashblock payload/index carried conflicting cumulative content"
5130                        .into(),
5131                });
5132            }
5133            install_preconfirmed_cache_context(cache, incoming);
5134            return Ok(());
5135        }
5136
5137        self.discard_preconfirmed_branch(cache);
5138        self.preconfirmed_branch = Some(PreconfirmedBranch {
5139            flashblock: incoming.clone(),
5140            canonical_cache: EvmCacheStateSnapshot::capture(cache),
5141        });
5142        install_preconfirmed_cache_context(cache, incoming);
5143        Ok(())
5144    }
5145
5146    fn ingest_batch_with_resync_direct(
5147        &mut self,
5148        cache: &mut EvmCache,
5149        batch: ReactiveInputBatch<N>,
5150    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
5151        let mut batch_report = self.ingest_batch_direct(cache, batch)?;
5152        if !batch_report.resyncs.is_empty() {
5153            let resync_report = execute_resync_requests(cache, &batch_report.resyncs);
5154            // Count unique logical requests: several handlers may emit the same
5155            // ResyncId in one batch, and duplicates fan out per-origin in the
5156            // report but are one unit of resync work for the metric.
5157            let unique_requests = resync_report
5158                .requested
5159                .iter()
5160                .map(|request| &request.id)
5161                .collect::<HashSet<_>>()
5162                .len();
5163            self.metrics
5164                .resync_requests
5165                .fetch_add(unique_requests as u64, Ordering::Relaxed);
5166            self.metrics
5167                .resync_failures
5168                .fetch_add(resync_report.failed.len() as u64, Ordering::Relaxed);
5169            self.remove_pending_resyncs(batch_report.resyncs.iter().map(|request| &request.id));
5170            self.record_journal_resync(&resync_report);
5171            batch_report
5172                .reports
5173                .push(Arc::new(ReactiveReport::Resynced(resync_report)));
5174        }
5175        Ok(batch_report)
5176    }
5177
5178    fn ingest_batch_direct(
5179        &mut self,
5180        cache: &mut EvmCache,
5181        batch: ReactiveInputBatch<N>,
5182    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
5183        let (records, chain_controls, batch_chain_id) = batch.into_runtime_parts();
5184        if let Some(chain_id) = batch_chain_id
5185            && chain_id != cache.chain_id()
5186        {
5187            return Err(ReactiveError::InvalidInputRecord {
5188                message: format!(
5189                    "batch chain id {chain_id} does not match cache chain id {}",
5190                    cache.chain_id()
5191                ),
5192            });
5193        }
5194        if !chain_controls.is_empty() && batch_chain_id.is_none() {
5195            return Err(ReactiveError::InvalidChainControl {
5196                message: "chain-control batches require an authoritative batch chain id".into(),
5197            });
5198        }
5199        for (record, _, _) in &records {
5200            record.validated_identity()?;
5201            if let Some(chain_id) = record.context.chain_id
5202                && chain_id != cache.chain_id()
5203            {
5204                return Err(ReactiveError::InvalidInputRecord {
5205                    message: format!(
5206                        "input chain id {chain_id} does not match cache chain id {}",
5207                        cache.chain_id()
5208                    ),
5209                });
5210            }
5211        }
5212        let records = sort_scoped_records(dedupe_scoped_records(records)?);
5213
5214        let mut batch_report = ReactiveBatchReport::default();
5215        let mut reports_to_dispatch = Vec::new();
5216        let control_split = validate_control_phase_order(&chain_controls)?;
5217        let (pre_record_controls, post_record_controls) = chain_controls.split_at(control_split);
5218        let pre_record_state =
5219            self.validate_ingest_sequence(pre_record_controls, post_record_controls, &records)?;
5220        self.validate_owner_catchup_against_journal(&pre_record_state, &records)?;
5221        let mut batch_dropped = BatchDroppedCanonical::default();
5222        for control in pre_record_controls {
5223            if let ChainControl::Reorg {
5224                common_ancestor,
5225                old_tip,
5226                ..
5227            } = control
5228            {
5229                batch_dropped.record_explicit(common_ancestor, old_tip);
5230                let drained = self
5231                    .journal
5232                    .iter()
5233                    .filter(|entry| entry.block.number > common_ancestor.number)
5234                    .map(|entry| entry.block)
5235                    .collect::<Vec<_>>();
5236                batch_dropped.record_drained(&drained);
5237            }
5238        }
5239        let certified_progress_through = post_record_controls
5240            .iter()
5241            .filter_map(canonical_coverage_control_block)
5242            .map(|block| block.number)
5243            .max();
5244        for control in pre_record_controls.iter().cloned() {
5245            self.apply_chain_control(cache, control, &mut batch_report, &mut reports_to_dispatch);
5246        }
5247        // Phase-8 step 4: accumulate the addresses a decoder actually wrote this
5248        // batch (union of applied `StateDiff` addresses) and the batch's canonical
5249        // block number, so the per-block root gate can run once after the record
5250        // loop with the full touched set.
5251        let mut touched_addrs: HashSet<Address> = HashSet::new();
5252        let mut canonical_batch_block: Option<u64> = None;
5253
5254        for (record, audience, delivery_scope) in records {
5255            let raw_canonical_block = canonical_record_block(&record).copied();
5256            let canonical_block = raw_canonical_block.map(|block| {
5257                pre_record_state
5258                    .resolved_canonical_blocks
5259                    .get(&(block.number, block.hash))
5260                    .copied()
5261                    .unwrap_or(block)
5262            });
5263            let input_ref = record.input_ref();
5264            reports_to_dispatch.push(Arc::new(ReactiveReport::Input(InputReport {
5265                input_ref,
5266                context: record.context.clone(),
5267                provider: record.provider.clone(),
5268                _network: PhantomData,
5269            })));
5270
5271            let recovered_reorg = if delivery_scope.advances_canonical_state() {
5272                if let Some(block) = canonical_block.as_ref() {
5273                    let gap_is_certified = delivery_scope == DeliveryScope::CanonicalProgress
5274                        && certified_progress_through
5275                            .is_some_and(|through| block.number <= through);
5276                    let parentless_replacement_is_proven = raw_canonical_block.is_some_and(|raw| {
5277                        raw.parent_hash.is_none()
5278                            && batch_dropped.covers_implicit_number(raw.number)
5279                    });
5280                    self.recover_for_canonical_input(
5281                        cache,
5282                        block,
5283                        gap_is_certified,
5284                        parentless_replacement_is_proven,
5285                        &mut reports_to_dispatch,
5286                    )
5287                } else {
5288                    None
5289                }
5290            } else {
5291                None
5292            };
5293            let recovered_reorg_for_input = recovered_reorg.is_some();
5294            if let Some(reorg_report) = recovered_reorg {
5295                self.metrics
5296                    .reorgs_recovered
5297                    .fetch_add(1, Ordering::Relaxed);
5298                remove_canceled_resyncs_from_batch(
5299                    &mut batch_report.resyncs,
5300                    &reorg_report.canceled_resyncs,
5301                );
5302                reports_to_dispatch.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
5303            }
5304
5305            // Removed/reorged records are lifecycle signals, never handler
5306            // data. Canonical scopes may roll back state; owner-only catch-up
5307            // scopes deliberately cannot, but both must suppress ordinary
5308            // decoding even when the referenced block is unknown, aged out of
5309            // the journal, or has already been removed once.
5310            if reorg_signal_block(&record).is_some() {
5311                if delivery_scope.advances_canonical_state()
5312                    && let Some(reorg_report) = self.recover_for_reorged_input(
5313                        cache,
5314                        &record,
5315                        &mut batch_dropped,
5316                        &mut reports_to_dispatch,
5317                    )
5318                {
5319                    self.metrics
5320                        .reorgs_recovered
5321                        .fetch_add(1, Ordering::Relaxed);
5322                    remove_canceled_resyncs_from_batch(
5323                        &mut batch_report.resyncs,
5324                        &reorg_report.canceled_resyncs,
5325                    );
5326                    reports_to_dispatch.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
5327                }
5328                continue;
5329            }
5330
5331            // Preflight validates owner history against the journal state at
5332            // batch entry. A canonical record earlier in this same transaction
5333            // may legitimately replace and drain that block, so close the
5334            // resulting TOCTOU window immediately before any owner handler can
5335            // mutate the cache. The outer transaction guard restores every
5336            // earlier record in the batch on failure.
5337            if delivery_scope == DeliveryScope::OwnerCatchup {
5338                self.validate_owner_catchup_record_against_current_journal(&record)?;
5339            }
5340
5341            if delivery_scope.advances_canonical_state()
5342                && let Some(block) = canonical_block.as_ref()
5343            {
5344                // Phase-8 step 4: remember the batch's canonical block (the last
5345                // canonical record wins) so the root gate probes at that height.
5346                canonical_batch_block = Some(block.number);
5347                self.record_journal_input(block, input_ref);
5348            }
5349
5350            // Keep every lazy provider read pinned to the exact event block
5351            // before handlers run. A full header installs the complete EVM env;
5352            // compact log-only progress installs NUMBER/timestamp and clears
5353            // unknown header-only fields. A later record for the same retained
5354            // canonical block can preserve an already-installed full env.
5355            if delivery_scope.advances_canonical_state()
5356                && let Some(block) = canonical_block.as_ref()
5357            {
5358                match advance_block_for_canonical_record(cache, &record) {
5359                    Some(Ok(())) => {
5360                        cache.advance_compact_block(block.number, block.hash, block.timestamp, true)
5361                    }
5362                    Some(Err(err)) => {
5363                        cache.advance_compact_block(
5364                            block.number,
5365                            block.hash,
5366                            block.timestamp,
5367                            false,
5368                        );
5369                        reports_to_dispatch.push(Arc::new(ReactiveReport::Error(
5370                            ReactiveErrorReport {
5371                                input_ref: Some(input_ref),
5372                                message: err.to_string(),
5373                                _network: PhantomData,
5374                            },
5375                        )));
5376                    }
5377                    None => cache.advance_compact_block(
5378                        block.number,
5379                        block.hash,
5380                        block.timestamp,
5381                        !recovered_reorg_for_input,
5382                    ),
5383                }
5384            }
5385
5386            let executions = self.execute_handlers(cache, &record, input_ref, &audience)?;
5387            if executions.is_empty() {
5388                continue;
5389            }
5390
5391            reports_to_dispatch.push(Arc::new(ReactiveReport::Decoded(DecodedReport {
5392                input_ref,
5393                handler_ids: executions
5394                    .iter()
5395                    .map(|execution| execution.handler_id.clone())
5396                    .collect(),
5397                _network: PhantomData,
5398            })));
5399
5400            detect_conflicts(input_ref, &executions)?;
5401
5402            // Phase-8 step 3: canonical block number for freshness stamping.
5403            // Copied out as a plain `u64` (dropping the borrow of `record`) so it
5404            // can be used while `self.freshness_mut()` mutably borrows `self`
5405            // inside the execution loop. `None` for pending/removed/reorged
5406            // records — those never stamp canonical freshness.
5407            let canonical_block_number = delivery_scope
5408                .advances_canonical_state()
5409                .then_some(canonical_block)
5410                .flatten()
5411                .map(|block| block.number);
5412
5413            for execution in executions {
5414                let diff = if execution.state_updates.is_empty() {
5415                    StateDiff::default()
5416                } else {
5417                    cache.apply_updates(&execution.state_updates)
5418                };
5419
5420                batch_report
5421                    .resyncs
5422                    .extend(execution.resyncs.iter().cloned());
5423                self.pending_resyncs
5424                    .extend(execution.resyncs.iter().cloned());
5425                batch_report
5426                    .speculative
5427                    .extend(execution.speculative.iter().cloned());
5428
5429                let applied = AppliedReport {
5430                    input_ref,
5431                    handler_id: execution.handler_id,
5432                    quality: execution.quality,
5433                    tags: execution.tags,
5434                    diff,
5435                    state_updates: execution.state_updates,
5436                    invalidations: execution.invalidations,
5437                    resyncs: execution.resyncs,
5438                    speculative: execution.speculative,
5439                    hook_signals: execution.hook_signals,
5440                    _network: PhantomData,
5441                };
5442                // Phase-8 step 3 (opt-in): stamp every touched `(address, slot)`
5443                // from this canonical handler write as `ValidThrough(N)`, so an
5444                // event-maintained slot stops being re-verified until the clock
5445                // passes its write block. Read the changed slots straight off
5446                // `applied.diff` (which borrows the local, not `self`) and stamp
5447                // via `self.freshness`, done before `applied` is moved into the
5448                // journal/batch below. Only genuinely-changed slots appear here,
5449                // since a no-op re-write records no `SlotChange`.
5450                if let (Some(number), Some(registry)) =
5451                    (canonical_block_number, self.freshness.as_mut())
5452                {
5453                    for change in &applied.diff.slots {
5454                        registry.valid_through_slot(change.address, change.slot, number);
5455                    }
5456                }
5457
5458                // Phase-8 step 4: record every address this decoder actually wrote
5459                // (or attempted to write) so the root gate can tell a
5460                // decoder-covered root move from an uncovered coverage gap. Fold in
5461                // the full `StateDiff` address footprint — real changes
5462                // (`slots`/`accounts`/`purged`) and cold-skipped attempts alike, so
5463                // a decoder that tried to write a cold slot still counts as
5464                // covering the account.
5465                if delivery_scope.advances_canonical_state() {
5466                    collect_diff_addresses(&applied.diff, &mut touched_addrs);
5467                }
5468
5469                let report = Arc::new(ReactiveReport::Applied(applied.clone()));
5470                reports_to_dispatch.push(report);
5471                if let Some(block) = canonical_block.as_ref() {
5472                    if delivery_scope.advances_canonical_state() {
5473                        self.record_journal_applied(block, applied.clone());
5474                    } else {
5475                        self.record_journal_applied_if_present(block, applied.clone());
5476                    }
5477                }
5478                batch_report.applied.push(applied);
5479            }
5480        }
5481
5482        // Coverage/finality controls certify the records that precede them.
5483        // Applying them here also leaves the live cache pinned to a certified
5484        // zero-event tail rather than the last block that happened to emit a
5485        // matching log. Reorg controls were applied before the record loop.
5486        for control in post_record_controls.iter().cloned() {
5487            if let Some(block) = canonical_coverage_control_block(&control) {
5488                canonical_batch_block = Some(
5489                    canonical_batch_block.map_or(block.number, |current| current.max(block.number)),
5490                );
5491            }
5492            self.apply_chain_control(cache, control, &mut batch_report, &mut reports_to_dispatch);
5493        }
5494
5495        // Phase-8 step 4 + §6.2 cadence: accumulate this batch's touched
5496        // addresses (after all handler effects, so the set is complete), then
5497        // fire the root gate only on cadence boundaries. The gate diffs
5498        // against persisted baselines, so skipped blocks lose no detection —
5499        // but the touched set must be the union since the last firing, or a
5500        // decoder-covered write in a skipped block would false-positive as a
5501        // CoverageGap. Fired resyncs surface in `batch_report.resyncs` (so
5502        // callers see them and `ingest_batch_with_resync` executes them) and
5503        // coverage reports go into the dispatched reports.
5504        if self.root_gate_runnable(cache) {
5505            self.touched_since_gate
5506                .extend(touched_addrs.iter().copied());
5507            if self.root_gate_due(canonical_batch_block) {
5508                let accumulated = std::mem::take(&mut self.touched_since_gate);
5509                self.run_root_gate(
5510                    cache,
5511                    canonical_batch_block,
5512                    &accumulated,
5513                    &mut batch_report.resyncs,
5514                    &mut reports_to_dispatch,
5515                );
5516                self.last_gate_block = canonical_batch_block;
5517            }
5518        } else {
5519            // A gate that cannot run (disabled, nothing root-gated, or no
5520            // proof fetcher) must not grow the accumulator unboundedly.
5521            // Dropping it is safe: without a runnable gate no baselines exist
5522            // (a fetcher cannot be uninstalled, and untracking drops the
5523            // baseline), so there is nothing a lost touched set could falsely
5524            // gap against later.
5525            self.touched_since_gate.clear();
5526        }
5527
5528        batch_report.reports = reports_to_dispatch;
5529        Ok(batch_report)
5530    }
5531
5532    /// Prove that every owner-only historical effect can be attached to an
5533    /// compatible retained canonical journal entry before any chain control or
5534    /// handler mutation is applied. Number/hash are exact. Parent/timestamp are
5535    /// optional enrichment, but two present values must agree; this matches the
5536    /// [`BlockRef`] compatibility rule used for cross-source deduplication.
5537    ///
5538    /// Owner catch-up deliberately does not advance canonical coverage. Its
5539    /// effects are appended to the already-existing journal entry so a later
5540    /// reorg can roll them back with the rest of that block. Accepting a block
5541    /// outside the journal would make the cache mutation irreversible. A reorg
5542    /// control in the same batch also invalidates entries above its ancestor,
5543    /// so those entries are rejected even though they still exist at this
5544    /// preflight point.
5545    fn validate_owner_catchup_against_journal(
5546        &self,
5547        control_state: &ChainControlState,
5548        records: &[(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)],
5549    ) -> Result<(), ReactiveError> {
5550        for (record, _, delivery_scope) in records {
5551            if *delivery_scope != DeliveryScope::OwnerCatchup {
5552                continue;
5553            }
5554            // Removed/reorged inputs are lifecycle signals only. Owner catch-up
5555            // cannot make them canonical and the record loop deliberately skips
5556            // handler execution, so there is no effect that needs attaching to
5557            // a rollback journal entry.
5558            if reorg_signal_block(record).is_some() {
5559                continue;
5560            }
5561            let context_block = canonical_record_block(record).ok_or_else(|| {
5562                ReactiveError::InvalidChainControl {
5563                    message: "owner catch-up input has no canonical block identity".into(),
5564                }
5565            })?;
5566            let block = resolve_record_block_payload_metadata(record, *context_block)?;
5567            let invalidated_by_control = control_state
5568                .journal_invalidated_from
5569                .is_some_and(|from| block.number >= from);
5570            let rollbackable = !invalidated_by_control
5571                && self.journal.iter().any(|entry| {
5572                    optional_block_refs_are_compatible(Some(&entry.block), Some(&block))
5573                });
5574            if !rollbackable {
5575                return Err(ReactiveError::OwnerCatchupOutsideJournal {
5576                    number: block.number,
5577                    hash: block.hash,
5578                });
5579            }
5580        }
5581        Ok(())
5582    }
5583
5584    fn validate_owner_catchup_record_against_current_journal(
5585        &self,
5586        record: &ReactiveInputRecord<N>,
5587    ) -> Result<(), ReactiveError> {
5588        let context_block =
5589            canonical_record_block(record).ok_or_else(|| ReactiveError::InvalidChainControl {
5590                message: "owner catch-up input has no canonical block identity".into(),
5591            })?;
5592        let block = resolve_record_block_payload_metadata(record, *context_block)?;
5593        if self
5594            .journal
5595            .iter()
5596            .any(|entry| optional_block_refs_are_compatible(Some(&entry.block), Some(&block)))
5597        {
5598            return Ok(());
5599        }
5600        Err(ReactiveError::OwnerCatchupOutsideJournal {
5601            number: block.number,
5602            hash: block.hash,
5603        })
5604    }
5605
5606    /// Whether the root gate could produce any signal at all: some tracked
5607    /// account is root-gated (`Slots` never is) and a proof fetcher exists.
5608    /// When this is false the touched accumulator is dropped rather than
5609    /// grown (see the ingest call site for why that is safe).
5610    fn root_gate_runnable(&self, cache: &EvmCache) -> bool {
5611        if matches!(self.root_gate_cadence, RootGateCadence::Disabled) {
5612            return false;
5613        }
5614        let has_gated_targets = self
5615            .tracking
5616            .values()
5617            .any(|policy| !matches!(policy, TrackingPolicy::Slots { .. }));
5618        has_gated_targets && cache.account_proof_fetcher().is_some()
5619    }
5620
5621    /// Whether the root gate is due at this batch's canonical block (§6.2):
5622    /// the first canonical block ever seen always fires (baseline adoption
5623    /// must not wait a full window), then at most once every `n` blocks.
5624    fn root_gate_due(&self, canonical_block: Option<u64>) -> bool {
5625        let Some(block) = canonical_block else {
5626            return false;
5627        };
5628        match self.root_gate_cadence {
5629            RootGateCadence::Disabled => false,
5630            RootGateCadence::EveryNBlocks(n) => match self.last_gate_block {
5631                None => true,
5632                Some(last) => block >= last.saturating_add(n.get()),
5633            },
5634        }
5635    }
5636
5637    /// The `storageHash` root gate (Phase-8 step 4), fired per
5638    /// [`RootGateCadence`] window (§6.2).
5639    ///
5640    /// Runs at the firing batch's canonical block, with `touched` carrying the
5641    /// union of decoder-touched addresses since the previous firing. For each tracked
5642    /// [`WholeAccount`](TrackingPolicy::WholeAccount) / [`Scalars`](TrackingPolicy::Scalars)
5643    /// account, probe the root (and account fields) via the account-proof seam and
5644    /// apply the spec §4 table:
5645    ///
5646    /// - No baseline yet ⇒ **adopt** (no gap, no resync — adoption is not a gap).
5647    /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root unchanged ⇒ nothing.
5648    /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root moved, `addr ∈ touched`
5649    ///   ⇒ a decoder covered it; re-adopt, no gap.
5650    /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root moved, `addr ∉ touched`
5651    ///   ⇒ emit [`ReactiveReport::CoverageGap`], count it, schedule a
5652    ///   [`ResyncReason::RootMoved`] account resync, re-adopt.
5653    /// - [`Scalars`](TrackingPolicy::Scalars) ⇒ compare balance/nonce/code-hash to
5654    ///   the baseline (native field changes never move the storage root); on a move
5655    ///   with `addr ∉ touched`, schedule a [`ResyncReason::RootMoved`] account
5656    ///   resync for the changed fields and re-adopt.
5657    ///
5658    /// No-op when the tracking registry is empty, when the batch has no canonical
5659    /// block, or when the cache has no account-proof fetcher installed.
5660    /// [`Slots`](TrackingPolicy::Slots) accounts are never root-gated (spec
5661    /// Decision 3).
5662    fn run_root_gate(
5663        &mut self,
5664        cache: &EvmCache,
5665        canonical_block: Option<u64>,
5666        touched: &HashSet<Address>,
5667        resyncs: &mut Vec<ResyncRequest>,
5668        reports: &mut Vec<Arc<ReactiveReport<N>>>,
5669    ) {
5670        if self.tracking.is_empty() {
5671            return;
5672        }
5673        let Some(block) = canonical_block else {
5674            return;
5675        };
5676        let Some(fetcher) = cache.account_proof_fetcher().cloned() else {
5677            return;
5678        };
5679
5680        // Collect the root-gated targets (Slots opts out) in a stable order so a
5681        // single-block sequence of resyncs/reports is deterministic.
5682        let mut targets: Vec<(Address, bool)> = self
5683            .tracking
5684            .iter()
5685            .filter_map(|(address, policy)| match policy {
5686                TrackingPolicy::Slots { .. } => None,
5687                TrackingPolicy::WholeAccount => Some((*address, true)),
5688                TrackingPolicy::Scalars => Some((*address, false)),
5689            })
5690            .collect();
5691        if targets.is_empty() {
5692            return;
5693        }
5694        targets.sort_by_key(|(address, _)| *address);
5695
5696        let block_id = BlockId::number(block);
5697        // ONE seam invocation carries every root-gated target (root-only
5698        // probes: no storage keys needed). eth_getProof is single-address at
5699        // the RPC level, so batching here lets the fetcher fan the requests
5700        // out concurrently instead of paying N sequential round trips.
5701        let mut probes: HashMap<Address, StorageFetchResult<AccountProof>> = (fetcher)(
5702            targets
5703                .iter()
5704                .map(|&(address, _)| (address, vec![]))
5705                .collect(),
5706            block_id,
5707        )
5708        .into_iter()
5709        .collect();
5710        for (address, whole_account) in targets {
5711            let Some(Ok(proof)) = probes.remove(&address) else {
5712                // A failed/omitted probe carries no signal; leave the baseline
5713                // untouched and try again next block.
5714                continue;
5715            };
5716
5717            let baseline = self.tracked_roots.get(&address).cloned();
5718            let Some(baseline) = baseline else {
5719                // First observation: adopt the baseline. Not a coverage gap.
5720                self.adopt_root(address, block, &proof);
5721                continue;
5722            };
5723
5724            // A stale probe (a batch whose canonical block is not newer than the
5725            // last one we baselined this account against) carries no forward
5726            // signal: skip it rather than diff against — or clobber — a newer
5727            // baseline.
5728            if block <= baseline.last_block {
5729                continue;
5730            }
5731
5732            if whole_account {
5733                if proof.storage_hash == baseline.last_root {
5734                    // Tight steady-state path: unchanged root ⇒ nothing.
5735                    continue;
5736                }
5737                // Root moved.
5738                if !touched.contains(&address) {
5739                    // Moved with no covering decoder — the coverage gap.
5740                    reports.push(Arc::new(ReactiveReport::CoverageGap(CoverageGapReport {
5741                        address,
5742                        block,
5743                        _network: PhantomData,
5744                    })));
5745                    self.metrics.coverage_gaps.fetch_add(1, Ordering::Relaxed);
5746                    resyncs.push(root_moved_account_resync(
5747                        address,
5748                        block,
5749                        AccountFieldMask {
5750                            balance: true,
5751                            nonce: true,
5752                            code: true,
5753                        },
5754                    ));
5755                }
5756                // Adopt the new root whether or not a decoder covered it.
5757                self.adopt_root(address, block, &proof);
5758            } else {
5759                // Scalars: compare the account fields directly (native changes do
5760                // not move the storage root).
5761                let balance_moved = proof.balance != baseline.balance;
5762                let nonce_moved = proof.nonce != baseline.nonce;
5763                let code_moved = proof.code_hash != baseline.code_hash;
5764                if (balance_moved || nonce_moved || code_moved) && !touched.contains(&address) {
5765                    resyncs.push(root_moved_account_resync(
5766                        address,
5767                        block,
5768                        AccountFieldMask {
5769                            balance: balance_moved,
5770                            nonce: nonce_moved,
5771                            code: code_moved,
5772                        },
5773                    ));
5774                }
5775                self.adopt_root(address, block, &proof);
5776            }
5777        }
5778    }
5779
5780    /// Adopt (or re-adopt) `proof` as the baseline for `address` at `block`.
5781    fn adopt_root(&mut self, address: Address, block: u64, proof: &AccountProof) {
5782        self.tracked_roots.insert(
5783            address,
5784            TrackedRoot {
5785                last_root: proof.storage_hash,
5786                last_block: block,
5787                balance: proof.balance,
5788                nonce: proof.nonce,
5789                code_hash: proof.code_hash,
5790            },
5791        );
5792    }
5793
5794    fn execute_handlers(
5795        &self,
5796        cache: &EvmCache,
5797        record: &ReactiveInputRecord<N>,
5798        input_ref: InputRef,
5799        audience: &DeliveryAudience,
5800    ) -> Result<Vec<HandlerExecution>, ReactiveError> {
5801        let mut executions = Vec::new();
5802        let candidates: Vec<_> = match &record.input {
5803            ReactiveInput::Log(log) => self.registry.log_handler_candidates(log),
5804            ReactiveInput::BlockHeader(_)
5805            | ReactiveInput::FullBlock(_)
5806            | ReactiveInput::PendingTxHash(_)
5807            | ReactiveInput::PendingTx(_) => self.registry.handlers().collect(),
5808        };
5809        for registered in candidates {
5810            match audience {
5811                DeliveryAudience::Owners(owners) if !owners.contains(&registered.id) => continue,
5812                DeliveryAudience::AllExcept(excluded) if excluded.contains(&registered.id) => {
5813                    continue;
5814                }
5815                DeliveryAudience::All
5816                | DeliveryAudience::Owners(_)
5817                | DeliveryAudience::AllExcept(_) => {}
5818            }
5819            if !registered.matches(&record.input) {
5820                continue;
5821            }
5822
5823            let outcome = registered
5824                .handler
5825                .handle(&record.context, &record.input, cache)
5826                .map_err(|source| ReactiveError::HandlerFailed {
5827                    handler_id: registered.id.clone(),
5828                    source,
5829                })?;
5830
5831            if let Err(error) =
5832                validate_effects(input_ref, &record.context, &registered.id, &outcome.effects)
5833            {
5834                if matches!(error, ReactiveError::InvalidPendingEffect { .. }) {
5835                    self.metrics
5836                        .pending_contamination
5837                        .fetch_add(1, Ordering::Relaxed);
5838                }
5839                return Err(error);
5840            }
5841            executions.push(HandlerExecution::from_outcome(
5842                registered.id.clone(),
5843                input_ref,
5844                outcome,
5845                matches!(
5846                    record.context.chain_status,
5847                    ChainStatus::Preconfirmed { .. }
5848                ),
5849            ));
5850        }
5851        Ok(executions)
5852    }
5853
5854    fn dispatch_reports(&self, reports: &[Arc<ReactiveReport<N>>]) {
5855        for report in reports {
5856            for hook in &self.hooks {
5857                hook.on_report(report.clone());
5858            }
5859        }
5860    }
5861
5862    fn apply_chain_control(
5863        &mut self,
5864        cache: &mut EvmCache,
5865        control: ChainControl,
5866        batch_report: &mut ReactiveBatchReport<N>,
5867        reports: &mut Vec<Arc<ReactiveReport<N>>>,
5868    ) {
5869        match &control {
5870            ChainControl::Safe(block) => set_or_enrich_block_ref(&mut self.safe_head, block),
5871            ChainControl::Finalized(block) => {
5872                set_or_enrich_block_ref(&mut self.finalized_head, block);
5873            }
5874            ChainControl::CanonicalProgress(block)
5875            | ChainControl::Barrier {
5876                block: Some(block), ..
5877            } => {
5878                let preserve_env = self.coverage_head.as_ref().is_some_and(|current| {
5879                    optional_block_refs_are_compatible(Some(current), Some(block))
5880                });
5881                cache.advance_compact_block(
5882                    block.number,
5883                    block.hash,
5884                    block.timestamp,
5885                    preserve_env,
5886                );
5887                advance_or_enrich_coverage(&mut self.coverage_head, block);
5888                let enriched = self.journal_entry_mut(block).block;
5889                advance_or_enrich_coverage(&mut self.coverage_head, &enriched);
5890                self.trim_journal();
5891            }
5892            ChainControl::LogCoverage(block) => {
5893                // An attestation, not progress: never advances the pinned block
5894                // or the canonical coverage head.
5895                set_or_enrich_block_ref(&mut self.log_coverage_head, block);
5896            }
5897            ChainControl::Barrier { block: None, .. } => {}
5898            ChainControl::Reorg {
5899                common_ancestor,
5900                old_tip,
5901                ..
5902            } => {
5903                cache.invalidate_cached_block_hashes_from(common_ancestor.number.saturating_add(1));
5904                self.rebase_validation_state_from(common_ancestor.number.saturating_add(1));
5905                let dropped = if let Some(ancestor_index) = self.journal.iter().rposition(|entry| {
5906                    entry.block.number == common_ancestor.number
5907                        && entry.block.hash == common_ancestor.hash
5908                }) {
5909                    self.drain_journal_after(ancestor_index)
5910                } else {
5911                    // Sparse journals are expected for blocks with no matching
5912                    // events. If the oldest retained entry is at or below the
5913                    // ancestor, every effect above it is still present and the
5914                    // rollback is complete even without an exact anchor.
5915                    if self
5916                        .journal
5917                        .front()
5918                        .is_none_or(|entry| entry.block.number > common_ancestor.number)
5919                    {
5920                        reports.extend(
5921                            self.warn_under_recovery(common_ancestor.number.saturating_add(1)),
5922                        );
5923                    }
5924                    self.drain_journal_from_number(common_ancestor.number.saturating_add(1))
5925                };
5926
5927                let reorg_report = self
5928                    .recover_dropped_journals(cache, dropped, ReorgReason::Explicit)
5929                    .unwrap_or_else(|| ReorgReport {
5930                        dropped: Some(*old_tip),
5931                        dropped_blocks: Vec::new(),
5932                        dropped_inputs: Vec::new(),
5933                        rollback_updates: Vec::new(),
5934                        rollback_diff: StateDiff::default(),
5935                        purge_updates: Vec::new(),
5936                        purge_diff: StateDiff::default(),
5937                        canceled_resyncs: self
5938                            .cancel_resyncs_for_dropped_blocks(std::slice::from_ref(old_tip)),
5939                        reason: ReorgReason::Explicit,
5940                        _network: PhantomData,
5941                    });
5942                remove_canceled_resyncs_from_batch(
5943                    &mut batch_report.resyncs,
5944                    &reorg_report.canceled_resyncs,
5945                );
5946                self.metrics
5947                    .reorgs_recovered
5948                    .fetch_add(1, Ordering::Relaxed);
5949                reports.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
5950
5951                if self.safe_head.as_ref().is_some_and(|head| {
5952                    head.number > common_ancestor.number
5953                        || (head.number == common_ancestor.number
5954                            && head.hash != common_ancestor.hash)
5955                }) {
5956                    self.safe_head = None;
5957                }
5958                if self.finalized_head.as_ref().is_some_and(|head| {
5959                    head.number > common_ancestor.number
5960                        || (head.number == common_ancestor.number
5961                            && head.hash != common_ancestor.hash)
5962                }) {
5963                    self.finalized_head = None;
5964                }
5965                let mut enriched_ancestor = *common_ancestor;
5966                if let Some(entry) = self.journal.iter().find(|entry| {
5967                    entry.block.number == common_ancestor.number
5968                        && entry.block.hash == common_ancestor.hash
5969                }) {
5970                    enrich_block_ref(&mut enriched_ancestor, &entry.block);
5971                }
5972                if let Some(current) = self.coverage_head.as_ref()
5973                    && current.number == common_ancestor.number
5974                    && current.hash == common_ancestor.hash
5975                {
5976                    enrich_block_ref(&mut enriched_ancestor, current);
5977                }
5978                self.coverage_head = Some(enriched_ancestor);
5979                cache.advance_compact_block(
5980                    enriched_ancestor.number,
5981                    enriched_ancestor.hash,
5982                    enriched_ancestor.timestamp,
5983                    false,
5984                );
5985                let enriched_ancestor = self.journal_entry_mut(&enriched_ancestor).block;
5986                self.coverage_head = Some(enriched_ancestor);
5987                self.trim_journal();
5988            }
5989        }
5990        reports.push(Arc::new(ReactiveReport::ChainControl(ChainControlReport {
5991            control,
5992        })));
5993    }
5994
5995    fn validate_ingest_sequence(
5996        &self,
5997        pre_record_controls: &[ChainControl],
5998        post_record_controls: &[ChainControl],
5999        records: &[(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)],
6000    ) -> Result<ChainControlState, ReactiveError> {
6001        let mut controls =
6002            Vec::with_capacity(pre_record_controls.len() + post_record_controls.len());
6003        controls.extend_from_slice(pre_record_controls);
6004        controls.extend_from_slice(post_record_controls);
6005        let state = CanonicalSequenceState::new(
6006            self.journal.iter().map(|entry| entry.block).collect(),
6007            self.coverage_head,
6008            self.safe_head,
6009            self.finalized_head,
6010        )
6011        .with_log_coverage_head(self.log_coverage_head);
6012        let record_metadata = records
6013            .iter()
6014            .map(|(record, _, scope)| (record, *scope))
6015            .collect::<Vec<_>>();
6016        let validation = validate_canonical_sequence_parts(
6017            &state,
6018            &controls,
6019            &record_metadata,
6020            CanonicalSequenceValidationPolicy::ObserveIncompleteRollback,
6021        )
6022        .map_err(CanonicalSequenceError::into_reactive_error)?;
6023        let mut resolved_canonical_blocks = HashMap::new();
6024        for mutation in validation.mutations() {
6025            if let CanonicalSequenceMutation::Canonical(block) = mutation {
6026                resolved_canonical_blocks
6027                    .entry((block.number, block.hash))
6028                    .and_modify(|known| enrich_block_ref(known, block))
6029                    .or_insert(*block);
6030            }
6031        }
6032        Ok(ChainControlState {
6033            journal_invalidated_from: pre_record_controls
6034                .iter()
6035                .filter_map(|control| match control {
6036                    ChainControl::Reorg {
6037                        common_ancestor, ..
6038                    } => Some(common_ancestor.number.saturating_add(1)),
6039                    _ => None,
6040                })
6041                .min(),
6042            resolved_canonical_blocks,
6043        })
6044    }
6045
6046    fn recover_for_canonical_input(
6047        &mut self,
6048        cache: &mut EvmCache,
6049        block: &BlockRef,
6050        gap_is_certified: bool,
6051        parentless_replacement_is_proven: bool,
6052        health_reports: &mut Vec<Arc<ReactiveReport<N>>>,
6053    ) -> Option<ReorgReport<N>> {
6054        let latest = self
6055            .coverage_head
6056            .or_else(|| self.journal.back().map(|entry| entry.block))?;
6057
6058        if latest.number == block.number && latest.hash == block.hash {
6059            return None;
6060        }
6061
6062        if self
6063            .journal
6064            .iter()
6065            .any(|entry| entry.block.hash == block.hash && entry.block.number == block.number)
6066        {
6067            return None;
6068        }
6069
6070        if latest.number.checked_add(1) == Some(block.number)
6071            && (block.parent_hash == Some(latest.hash)
6072                || (parentless_replacement_is_proven && block.parent_hash.is_none()))
6073        {
6074            return None;
6075        }
6076
6077        if latest
6078            .number
6079            .checked_add(1)
6080            .is_some_and(|next| block.number > next)
6081        {
6082            // A forward gap: blocks between the journaled head and the arriving
6083            // block were never observed (e.g. a disconnect). A historical
6084            // canonical-progress delivery can instead be covered by a
6085            // compatible post-record progress/barrier certificate proving the
6086            // sparse interval contained no matching events. Live canonical
6087            // gaps remain observable and escalate health.
6088            if !gap_is_certified {
6089                self.metrics.missed_ranges.fetch_add(1, Ordering::Relaxed);
6090                health_reports.extend(self.escalate_trust(block.number));
6091                health_reports.push(Arc::new(ReactiveReport::MissedBlockRange(
6092                    MissedRangeReport {
6093                        from: latest.number + 1,
6094                        to: block.number - 1,
6095                        block: block.number,
6096                        _network: PhantomData,
6097                    },
6098                )));
6099            }
6100            return None;
6101        }
6102
6103        let (dropped, authenticated_anchor) = if let Some(parent_hash) = block.parent_hash {
6104            if let Some(parent_index) = self.journal.iter().rposition(|entry| {
6105                entry.block.number.checked_add(1) == Some(block.number)
6106                    && entry.block.hash == parent_hash
6107            }) {
6108                let parent = self.journal[parent_index].block;
6109                cache.invalidate_cached_block_hashes_from(parent.number.saturating_add(1));
6110                (self.drain_journal_after(parent_index), Some(parent))
6111            } else {
6112                // An unknown immediate parent proves exactly N-1 and nothing
6113                // earlier. Preserve a prefix only when the accepted path is an
6114                // immediate child of the runtime's exact finalized anchor;
6115                // otherwise every cached BLOCKHASH may belong to the displaced
6116                // branch and must be cleared fail-closed.
6117                let proven_finalized_anchor = self.finalized_head.filter(|finalized| {
6118                    finalized.number.checked_add(1) == Some(block.number)
6119                        && parent_hash == finalized.hash
6120                });
6121                let invalidated_from = proven_finalized_anchor
6122                    .map_or(0, |finalized| finalized.number.saturating_add(1));
6123                cache.invalidate_cached_block_hashes_from(invalidated_from);
6124                if block.number > 0 {
6125                    // Even when the parent falls outside the retained journal,
6126                    // the arriving child authenticates its exact hash. Restore
6127                    // that one known value after clearing the displaced branch.
6128                    cache.set_cached_block_hash(block.number.saturating_sub(1), parent_hash);
6129                }
6130                health_reports.extend(self.warn_under_recovery(block.number));
6131                let dropped = if let Some(finalized) = proven_finalized_anchor {
6132                    self.drain_journal_from_number(finalized.number.saturating_add(1))
6133                } else {
6134                    self.drain_journal_from_number(0)
6135                };
6136                (dropped, proven_finalized_anchor)
6137            }
6138        } else {
6139            // No parent identity authenticates any prefix of the arriving path.
6140            cache.invalidate_cached_block_hashes_from(0);
6141            health_reports.extend(self.warn_under_recovery(block.number));
6142            (self.drain_journal_from_number(0), None)
6143        };
6144
6145        self.rebase_validation_state_from(
6146            authenticated_anchor.map_or(0, |anchor| anchor.number.saturating_add(1)),
6147        );
6148        let report = self
6149            .recover_dropped_journals(cache, dropped, ReorgReason::ParentMismatch)
6150            .or_else(|| {
6151                Some(ReorgReport {
6152                    dropped: Some(latest),
6153                    dropped_blocks: Vec::new(),
6154                    dropped_inputs: Vec::new(),
6155                    rollback_updates: Vec::new(),
6156                    rollback_diff: StateDiff::default(),
6157                    purge_updates: Vec::new(),
6158                    purge_diff: StateDiff::default(),
6159                    canceled_resyncs: self
6160                        .cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&latest)),
6161                    reason: ReorgReason::ParentMismatch,
6162                    _network: PhantomData,
6163                })
6164            });
6165        self.coverage_head = authenticated_anchor;
6166        for head in [&mut self.safe_head, &mut self.finalized_head] {
6167            if head.is_some_and(|head| {
6168                authenticated_anchor.is_none_or(|anchor| {
6169                    head.number > anchor.number
6170                        || (head.number == anchor.number && head.hash != anchor.hash)
6171                })
6172            }) {
6173                *head = None;
6174            }
6175        }
6176        if let Some(anchor) = authenticated_anchor {
6177            cache.advance_compact_block(anchor.number, anchor.hash, anchor.timestamp, false);
6178        }
6179        report
6180    }
6181
6182    fn recover_for_reorged_input(
6183        &mut self,
6184        cache: &mut EvmCache,
6185        record: &ReactiveInputRecord<N>,
6186        batch_dropped: &mut BatchDroppedCanonical,
6187        health_reports: &mut Vec<Arc<ReactiveReport<N>>>,
6188    ) -> Option<ReorgReport<N>> {
6189        let (incoming_dropped_block, reason) = reorg_signal_block(record)?;
6190        if batch_dropped.contains(&incoming_dropped_block) {
6191            // A previous signal in this atomic batch already drained this
6192            // block/span. Preserve the lifecycle input report, but do not
6193            // repeat rollback or classify the provider's per-log removals as a
6194            // deep reorg. Exact hash-pinned repairs still need cancellation.
6195            let canceled_resyncs = self
6196                .cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&incoming_dropped_block));
6197            return (!canceled_resyncs.is_empty()).then(|| ReorgReport {
6198                dropped: Some(incoming_dropped_block),
6199                dropped_blocks: vec![incoming_dropped_block],
6200                dropped_inputs: Vec::new(),
6201                rollback_updates: Vec::new(),
6202                rollback_diff: StateDiff::default(),
6203                purge_updates: Vec::new(),
6204                purge_diff: StateDiff::default(),
6205                canceled_resyncs,
6206                reason,
6207                _network: PhantomData,
6208            });
6209        }
6210        let exact_index = self.journal.iter().position(|entry| {
6211            entry.block.number == incoming_dropped_block.number
6212                && entry.block.hash == incoming_dropped_block.hash
6213        });
6214        let mut dropped_block = exact_index
6215            .map(|index| self.journal[index].block)
6216            .or_else(|| {
6217                self.coverage_head.filter(|known| {
6218                    known.number == incoming_dropped_block.number
6219                        && known.hash == incoming_dropped_block.hash
6220                })
6221            })
6222            .unwrap_or(incoming_dropped_block);
6223        enrich_block_ref(&mut dropped_block, &incoming_dropped_block);
6224        let replacement_is_known = exact_index.is_none()
6225            && (self.journal.iter().any(|entry| {
6226                entry.block.number == dropped_block.number && entry.block.hash != dropped_block.hash
6227            }) || self.coverage_head.is_some_and(|head| {
6228                head.number == dropped_block.number && head.hash != dropped_block.hash
6229            }));
6230
6231        if replacement_is_known {
6232            // A delayed/duplicate removed log for the displaced hash is
6233            // idempotent. Draining by number here would destroy the already
6234            // installed replacement branch at the same height.
6235            let canceled_resyncs =
6236                self.cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&dropped_block));
6237            return (!canceled_resyncs.is_empty()).then(|| ReorgReport {
6238                dropped: Some(dropped_block),
6239                dropped_blocks: vec![dropped_block],
6240                dropped_inputs: Vec::new(),
6241                rollback_updates: Vec::new(),
6242                rollback_diff: StateDiff::default(),
6243                purge_updates: Vec::new(),
6244                purge_diff: StateDiff::default(),
6245                canceled_resyncs,
6246                reason,
6247                _network: PhantomData,
6248            });
6249        }
6250
6251        let authenticated_anchor = exact_index.and_then(|index| {
6252            let ancestor_number = dropped_block.number.checked_sub(1)?;
6253            let retained = self
6254                .journal
6255                .iter()
6256                .take(index)
6257                .rev()
6258                .find(|entry| entry.block.number == ancestor_number)
6259                .map(|entry| entry.block);
6260            let synthetic_parent = dropped_block.parent_hash.map(|hash| BlockRef {
6261                number: ancestor_number,
6262                hash,
6263                parent_hash: None,
6264                timestamp: None,
6265            });
6266            let finalized_fallback = self
6267                .finalized_head
6268                .filter(|head| head.number == ancestor_number);
6269            let mut anchor = retained.or(synthetic_parent).or(finalized_fallback)?;
6270            for head in [self.safe_head.as_ref(), self.finalized_head.as_ref()]
6271                .into_iter()
6272                .flatten()
6273            {
6274                if head.number == anchor.number && head.hash == anchor.hash {
6275                    enrich_block_ref(&mut anchor, head);
6276                }
6277            }
6278            Some(anchor)
6279        });
6280
6281        cache.invalidate_cached_block_hashes_from(dropped_block.number);
6282        let dropped = if let Some(index) = exact_index {
6283            self.drain_journal_from(index)
6284        } else {
6285            health_reports.extend(self.warn_under_recovery(dropped_block.number));
6286            self.drain_journal_from_number(dropped_block.number)
6287        };
6288        let drained_blocks = dropped.iter().map(|entry| entry.block).collect::<Vec<_>>();
6289        batch_dropped.record_drained(&drained_blocks);
6290        batch_dropped.record_identity(&dropped_block);
6291        self.rebase_validation_state_from(dropped_block.number);
6292
6293        let recovered_journal = !dropped.is_empty();
6294        let report = if !recovered_journal {
6295            let canceled_resyncs =
6296                self.cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&dropped_block));
6297            Some(ReorgReport {
6298                dropped: Some(dropped_block),
6299                dropped_blocks: Vec::new(),
6300                dropped_inputs: Vec::new(),
6301                rollback_updates: Vec::new(),
6302                rollback_diff: StateDiff::default(),
6303                purge_updates: Vec::new(),
6304                purge_diff: StateDiff::default(),
6305                canceled_resyncs,
6306                reason,
6307                _network: PhantomData,
6308            })
6309        } else {
6310            self.recover_dropped_journals(cache, dropped, reason)
6311        };
6312
6313        if recovered_journal {
6314            if let Some(anchor) = authenticated_anchor {
6315                self.coverage_head = Some(anchor);
6316            }
6317            let coverage = self.coverage_head;
6318            for head in [&mut self.safe_head, &mut self.finalized_head] {
6319                if head.is_some_and(|head| {
6320                    coverage.is_none_or(|coverage| {
6321                        head.number > coverage.number
6322                            || (head.number == coverage.number && head.hash != coverage.hash)
6323                    })
6324                }) {
6325                    *head = None;
6326                }
6327            }
6328        }
6329
6330        if recovered_journal
6331            && report.is_some()
6332            && let Some(head) = self.coverage_head
6333        {
6334            cache.advance_compact_block(head.number, head.hash, head.timestamp, false);
6335        }
6336        report
6337    }
6338
6339    /// Warn that a reorg references a block no longer resident in the journal, so
6340    /// recovery is limited to the blocks still journaled — effects from aged-out
6341    /// blocks are neither rolled back nor purged (the freshness/validation loop is
6342    /// the backstop). Makes the under-recovery observable instead of silent.
6343    ///
6344    /// This is a deep reorg: it increments the `deep_reorgs` counter and escalates
6345    /// health along the trust-loss ladder via [`escalate_trust`](Self::escalate_trust)
6346    /// (a first event degrades to [`CacheHealth::Degraded`], a second escalates to
6347    /// [`CacheHealth::Unhealthy`]). Any resulting [`ReactiveReport::Health`]
6348    /// transition is returned so the caller can thread it into the ingest cycle's
6349    /// dispatched reports.
6350    fn warn_under_recovery(&mut self, reorg_number: u64) -> Option<Arc<ReactiveReport<N>>> {
6351        let oldest_journaled = self.journal.front().map(|entry| entry.block.number);
6352        tracing::warn!(
6353            reorg_block = reorg_number,
6354            oldest_journaled = ?oldest_journaled,
6355            journal_depth = self.config.journal_depth,
6356            "reactive reorg recovery is incomplete: the reorged block is no longer \
6357             in the journal, so effects from blocks aged out of the journal are \
6358             neither rolled back nor purged (the freshness/validation loop is the \
6359             backstop). Increase ReactiveConfig::journal_depth to recover deeper \
6360             reorgs precisely."
6361        );
6362
6363        self.metrics.deep_reorgs.fetch_add(1, Ordering::Relaxed);
6364
6365        self.escalate_trust(reorg_number)
6366    }
6367
6368    fn record_journal_input(&mut self, block: &BlockRef, input_ref: InputRef) {
6369        advance_or_enrich_coverage(&mut self.coverage_head, block);
6370        let entry = self.journal_entry_mut(block);
6371        let enriched = entry.block;
6372        if !entry.inputs.contains(&input_ref) {
6373            entry.inputs.push(input_ref);
6374        }
6375        advance_or_enrich_coverage(&mut self.coverage_head, &enriched);
6376        self.trim_journal();
6377    }
6378
6379    fn record_journal_applied(&mut self, block: &BlockRef, applied: AppliedReport<N>) {
6380        let entry = self.journal_entry_mut(block);
6381        if !entry.handler_ids.contains(&applied.handler_id) {
6382            entry.handler_ids.push(applied.handler_id.clone());
6383        }
6384        entry.rollback_diffs.push(applied.diff.clone());
6385        entry.applied.push(applied);
6386        self.trim_journal();
6387    }
6388
6389    fn record_journal_applied_if_present(&mut self, block: &BlockRef, applied: AppliedReport<N>) {
6390        let Some(entry) = self
6391            .journal
6392            .iter_mut()
6393            .find(|entry| entry.block.number == block.number && entry.block.hash == block.hash)
6394        else {
6395            return;
6396        };
6397        if !entry.handler_ids.contains(&applied.handler_id) {
6398            entry.handler_ids.push(applied.handler_id.clone());
6399        }
6400        entry.rollback_diffs.push(applied.diff.clone());
6401        entry.applied.push(applied);
6402    }
6403
6404    fn record_journal_resync(&mut self, report: &ResyncReport) {
6405        if report.diff.is_empty() {
6406            return;
6407        }
6408        let Some(block) = single_hash_pinned_resync_block(report) else {
6409            return;
6410        };
6411        let entry = self.journal_entry_mut(&block);
6412        entry.rollback_diffs.push(report.diff.clone());
6413        entry.resynced.push(report.clone());
6414        self.trim_journal();
6415    }
6416
6417    fn journal_entry_mut(&mut self, block: &BlockRef) -> &mut BlockJournal<N> {
6418        if let Some(index) = self
6419            .journal
6420            .iter()
6421            .position(|entry| entry.block.hash == block.hash && entry.block.number == block.number)
6422        {
6423            enrich_block_ref(&mut self.journal[index].block, block);
6424            return &mut self.journal[index];
6425        }
6426
6427        self.journal.push_back(BlockJournal {
6428            block: *block,
6429            inputs: Vec::new(),
6430            applied: Vec::new(),
6431            handler_ids: Vec::new(),
6432            resynced: Vec::new(),
6433            rollback_diffs: Vec::new(),
6434        });
6435        let index = self.journal.len() - 1;
6436        &mut self.journal[index]
6437    }
6438
6439    fn trim_journal(&mut self) {
6440        if self.config.journal_depth == 0 {
6441            self.journal.clear();
6442            return;
6443        }
6444        while self.journal.len() > self.config.journal_depth {
6445            self.journal.pop_front();
6446        }
6447    }
6448
6449    fn drain_journal_after(&mut self, index: usize) -> Vec<BlockJournal<N>> {
6450        self.journal.drain((index + 1)..).collect()
6451    }
6452
6453    fn drain_journal_from(&mut self, index: usize) -> Vec<BlockJournal<N>> {
6454        self.journal.drain(index..).collect()
6455    }
6456
6457    fn drain_journal_from_number(&mut self, number: u64) -> Vec<BlockJournal<N>> {
6458        let Some(index) = self
6459            .journal
6460            .iter()
6461            .position(|entry| entry.block.number >= number)
6462        else {
6463            return Vec::new();
6464        };
6465        self.drain_journal_from(index)
6466    }
6467
6468    fn recover_dropped_journals(
6469        &mut self,
6470        cache: &mut EvmCache,
6471        dropped: Vec<BlockJournal<N>>,
6472        reason: ReorgReason,
6473    ) -> Option<ReorgReport<N>> {
6474        if dropped.is_empty() {
6475            return None;
6476        }
6477
6478        let first_dropped_block = dropped
6479            .iter()
6480            .map(|entry| entry.block.number)
6481            .min()
6482            .expect("non-empty dropped journal set");
6483        self.rebase_validation_state_from(first_dropped_block);
6484        if self
6485            .safe_head
6486            .is_some_and(|head| head.number >= first_dropped_block)
6487        {
6488            self.safe_head = None;
6489        }
6490
6491        let dropped_blocks: Vec<_> = dropped.iter().map(|entry| entry.block).collect();
6492        let dropped_inputs: Vec<_> = dropped
6493            .iter()
6494            .flat_map(|entry| entry.inputs.iter().copied())
6495            .collect();
6496        let canceled_resyncs = self.cancel_resyncs_for_dropped_blocks(&dropped_blocks);
6497        let purge_scopes = purge_scopes_for_dropped_journals(&dropped);
6498        let rollback_updates = rollback_updates_for_dropped_journals(&dropped, &purge_scopes);
6499        let purge_updates: Vec<_> = purge_scopes
6500            .iter()
6501            .map(|(address, scope)| StateUpdate::purge(*address, scope.clone()))
6502            .collect();
6503
6504        let rollback_diff = if rollback_updates.is_empty() {
6505            StateDiff::default()
6506        } else {
6507            cache.apply_updates(&rollback_updates)
6508        };
6509        let purge_diff = if purge_updates.is_empty() {
6510            StateDiff::default()
6511        } else {
6512            cache.apply_updates(&purge_updates)
6513        };
6514        self.coverage_head = self.journal.back().map(|entry| entry.block);
6515
6516        Some(ReorgReport {
6517            dropped: dropped_blocks.first().cloned(),
6518            dropped_blocks,
6519            dropped_inputs,
6520            rollback_updates,
6521            rollback_diff,
6522            purge_updates,
6523            purge_diff,
6524            canceled_resyncs,
6525            reason,
6526            _network: PhantomData,
6527        })
6528    }
6529
6530    fn rebase_validation_state_from(&mut self, first_dropped_block: u64) {
6531        if let Some(freshness) = self.freshness.as_mut() {
6532            freshness.invalidate_valid_through_from(first_dropped_block);
6533        }
6534        self.tracked_roots
6535            .retain(|_, baseline| baseline.last_block < first_dropped_block);
6536        if self
6537            .last_gate_block
6538            .is_some_and(|block| block >= first_dropped_block)
6539        {
6540            self.last_gate_block = self
6541                .tracked_roots
6542                .values()
6543                .map(|baseline| baseline.last_block)
6544                .max();
6545        }
6546        // Touch provenance is window-relative. Once any block in that window
6547        // is dropped, retaining the union could incorrectly mark a replacement
6548        // branch root move as decoder-covered.
6549        self.touched_since_gate.clear();
6550    }
6551
6552    fn cancel_resyncs_for_dropped_blocks(
6553        &mut self,
6554        dropped_blocks: &[BlockRef],
6555    ) -> Vec<ResyncRequest> {
6556        let mut canceled = Vec::new();
6557        self.pending_resyncs.retain(|request| {
6558            let should_cancel = resync_request_targets_dropped_block(request, dropped_blocks);
6559            if should_cancel {
6560                canceled.push(request.clone());
6561            }
6562            !should_cancel
6563        });
6564        canceled
6565    }
6566
6567    fn remove_pending_resyncs<'a>(&mut self, ids: impl IntoIterator<Item = &'a ResyncId>) {
6568        let ids: HashSet<_> = ids.into_iter().cloned().collect();
6569        self.pending_resyncs
6570            .retain(|request| !ids.contains(&request.id));
6571    }
6572}
6573
6574fn install_preconfirmed_cache_context(cache: &mut EvmCache, flashblock: &FlashblockRef) {
6575    cache.set_block(BlockId::pending());
6576    cache.set_block_context(Some(flashblock.block_number), flashblock.base_fee_per_gas);
6577    cache.set_coinbase(flashblock.beneficiary);
6578    cache.set_prevrandao(flashblock.prevrandao);
6579    cache.set_block_gas_limit(flashblock.gas_limit);
6580    cache.set_timestamp(flashblock.timestamp);
6581}
6582
6583/// Validate one provider-neutral delivery envelope without mutating runtime or
6584/// cache state.
6585///
6586/// This is the canonical metadata contract shared by [`ReactiveRuntime`] and
6587/// composite/remote subscribers. It validates explicit reorg controls before
6588/// records, canonical record identity and implicit-reorg finality, then
6589/// progress/barrier/safe/finalized controls. All identity assertions in the
6590/// envelope must agree at each height. Retained history may be sparse; an
6591/// explicit common ancestor need not itself be retained when the oldest
6592/// retained entry is at or below it. Ancestors and removed blocks outside that
6593/// rollback horizon are rejected, so a durable caller cannot persist a partial
6594/// rollback. The runtime uses this same implementation with an internal
6595/// observable-deep-reorg policy for its deliberately non-durable ingest path.
6596///
6597/// The returned state and mutations are cache-free. Callers that durably stage
6598/// delivery should publish/persist them only at their own acknowledgement
6599/// boundary.
6600///
6601/// This validator is deliberately chain-agnostic and does not compare
6602/// [`ReactiveInputBatch::chain_id`] because [`CanonicalSequenceState`] carries
6603/// no chain id. Cross-service/composite callers must bind one authoritative
6604/// chain identity outside this state before sharing or advancing it; runtime
6605/// ingestion separately checks the batch id against [`EvmCache`].
6606///
6607/// # Errors
6608///
6609/// Returns [`ReactiveError::InvalidInputRecord`] when record identity/payload
6610/// metadata is malformed or conflicting, and
6611/// [`ReactiveError::InvalidChainControl`] when the snapshot or envelope has an
6612/// invalid canonical transition, incomplete rollback proof, contradictory
6613/// identity, or invalid coverage/finality relationship.
6614pub fn validate_canonical_sequence<N: Network>(
6615    state: &CanonicalSequenceState,
6616    batch: &ReactiveInputBatch<N>,
6617) -> Result<CanonicalSequenceValidation, ReactiveError> {
6618    validate_canonical_sequence_diagnostic(state, batch)
6619        .map_err(CanonicalSequenceError::into_reactive_error)
6620}
6621
6622/// Validate one provider-neutral delivery envelope and retain structured
6623/// rollback diagnostics.
6624///
6625/// This is the diagnostic counterpart to [`validate_canonical_sequence`]. Use
6626/// it at durable/composite source boundaries that need to distinguish malformed
6627/// input from an otherwise valid transition whose rollback ancestor has aged
6628/// out of the retained history. Callers should branch on
6629/// [`CanonicalSequenceError`] rather than parsing error text.
6630///
6631/// # Errors
6632///
6633/// Returns [`CanonicalSequenceError::Invalid`] for malformed or contradictory
6634/// state/input and [`CanonicalSequenceError::IncompleteRollback`] when more
6635/// retained canonical history is required to prove the transition.
6636pub fn validate_canonical_sequence_diagnostic<N: Network>(
6637    state: &CanonicalSequenceState,
6638    batch: &ReactiveInputBatch<N>,
6639) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
6640    validate_canonical_sequence_internal(
6641        state,
6642        batch,
6643        CanonicalSequenceValidationPolicy::RequireCompleteRollback,
6644    )
6645}
6646
6647/// Validate a composite-source envelope and normalize harmless coverage
6648/// overlap.
6649///
6650/// This has the same fail-closed rollback/finality/identity contract as
6651/// [`validate_canonical_sequence`]. In addition, an equal or older
6652/// [`ChainControl::CanonicalProgress`] whose exact compatible identity is
6653/// retained is omitted from [`CanonicalSequenceValidation::normalized_chain_controls`].
6654/// A compatible stale blockful [`ChainControl::Barrier`] is retained with the
6655/// same opaque id and `block: None`, preserving the synchronization event
6656/// without forwarding regressive coverage. An equal-height control that fills
6657/// absent parent/timestamp metadata is retained and applied. Older compatible
6658/// metadata enrichment is deliberately dropped together with its non-forwarded
6659/// control so the returned state remains identical to what the runtime will
6660/// observe. Unknown or conflicting stale identities remain errors.
6661///
6662/// # Errors
6663///
6664/// Returns [`ReactiveError::InvalidInputRecord`] for malformed or conflicting
6665/// record identity/payload metadata, and
6666/// [`ReactiveError::InvalidChainControl`] when canonical overlap cannot be
6667/// proven redundant or when rollback, adjacency, identity, coverage, or
6668/// finality validation fails.
6669pub fn normalize_and_validate_canonical_sequence<N: Network>(
6670    state: &CanonicalSequenceState,
6671    batch: &ReactiveInputBatch<N>,
6672) -> Result<CanonicalSequenceValidation, ReactiveError> {
6673    normalize_and_validate_canonical_sequence_diagnostic(state, batch)
6674        .map_err(CanonicalSequenceError::into_reactive_error)
6675}
6676
6677/// Validate and normalize one composite-source envelope while retaining
6678/// structured rollback diagnostics.
6679///
6680/// This is the diagnostic counterpart to
6681/// [`normalize_and_validate_canonical_sequence`]. It has identical transition
6682/// and normalization semantics, but reports history exhaustion as
6683/// [`CanonicalSequenceError::IncompleteRollback`] instead of folding it into a
6684/// prose [`ReactiveError::InvalidChainControl`].
6685///
6686/// # Errors
6687///
6688/// Returns [`CanonicalSequenceError::Invalid`] for malformed, contradictory, or
6689/// non-normalizable input and [`CanonicalSequenceError::IncompleteRollback`]
6690/// when the retained history cannot prove a complete rollback.
6691pub fn normalize_and_validate_canonical_sequence_diagnostic<N: Network>(
6692    state: &CanonicalSequenceState,
6693    batch: &ReactiveInputBatch<N>,
6694) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
6695    validate_canonical_sequence_internal(
6696        state,
6697        batch,
6698        CanonicalSequenceValidationPolicy::RequireCompleteRollbackNormalizeCoverage,
6699    )
6700}
6701
6702fn validate_canonical_sequence_internal<N: Network>(
6703    state: &CanonicalSequenceState,
6704    batch: &ReactiveInputBatch<N>,
6705    policy: CanonicalSequenceValidationPolicy,
6706) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
6707    let records = batch
6708        .records()
6709        .iter()
6710        .enumerate()
6711        .map(|(index, record)| {
6712            (
6713                record.clone(),
6714                DeliveryAudience::All,
6715                batch
6716                    .record_delivery_scope(index)
6717                    .expect("enumerated record always has a delivery scope"),
6718            )
6719        })
6720        .collect::<Vec<_>>();
6721    let records = sort_scoped_records(dedupe_scoped_records(records)?);
6722    let records = records
6723        .iter()
6724        .map(|(record, _, scope)| (record, *scope))
6725        .collect::<Vec<_>>();
6726    validate_canonical_sequence_parts(state, batch.chain_controls(), &records, policy)
6727}
6728
6729#[derive(Clone, Copy)]
6730enum CanonicalSequenceValidationPolicy {
6731    RequireCompleteRollback,
6732    RequireCompleteRollbackNormalizeCoverage,
6733    ObserveIncompleteRollback,
6734}
6735
6736/// Stable category for a canonical transition that needs older retained
6737/// history before it can be durably accepted.
6738#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6739#[non_exhaustive]
6740pub enum CanonicalRollbackKind {
6741    /// An explicit reorg control names an ancestor outside retained history.
6742    Explicit,
6743    /// A removed/reorged record names a block outside retained history.
6744    Removed,
6745    /// An implicit canonical replacement has no retained parent proof.
6746    ImplicitParent,
6747    /// A removed block is not followed by a provable replacement/anchor.
6748    MissingReplacement,
6749}
6750
6751/// Structured failure returned by canonical-sequence diagnostic validation.
6752///
6753/// This type is intentionally independent of diagnostic prose so remote and
6754/// composite subscribers can select recovery behavior without string matching.
6755#[derive(Debug, thiserror::Error)]
6756#[non_exhaustive]
6757pub enum CanonicalSequenceError {
6758    /// The snapshot or envelope is intrinsically malformed or contradictory.
6759    #[error(transparent)]
6760    Invalid(#[from] ReactiveError),
6761    /// The transition may be valid, but its rollback proof lies outside the
6762    /// supplied retained canonical history.
6763    #[error(
6764        "{kind:?} rollback after block {common_ancestor} exceeds retained canonical history starting at {oldest_retained:?}"
6765    )]
6766    IncompleteRollback {
6767        /// Last ancestor height required to prove the rollback.
6768        common_ancestor: u64,
6769        /// Oldest retained canonical height supplied by the caller.
6770        oldest_retained: Option<u64>,
6771        /// Stable reason the history window is insufficient.
6772        kind: CanonicalRollbackKind,
6773    },
6774}
6775
6776#[derive(Clone, Copy, Debug)]
6777struct RequiredReorgAnchor {
6778    number: u64,
6779    block: Option<BlockRef>,
6780    permits_missing_child_parent: bool,
6781    must_be_consumed: bool,
6782}
6783
6784#[derive(Debug)]
6785struct SequenceRewind {
6786    common_ancestor: Option<BlockRef>,
6787    dropped: Vec<BlockRef>,
6788}
6789
6790impl RequiredReorgAnchor {
6791    const fn hash(self) -> Option<B256> {
6792        match self.block {
6793            Some(block) => Some(block.hash),
6794            None => None,
6795        }
6796    }
6797}
6798
6799impl CanonicalSequenceError {
6800    /// Whether retrying with an older retained history window may prove this
6801    /// same transition.
6802    pub const fn requires_history(&self) -> bool {
6803        matches!(self, Self::IncompleteRollback { .. })
6804    }
6805
6806    /// Fold this structured diagnostic into the legacy ergonomic runtime error.
6807    pub fn into_reactive_error(self) -> ReactiveError {
6808        match self {
6809            Self::Invalid(error) => error,
6810            Self::IncompleteRollback {
6811                common_ancestor,
6812                oldest_retained,
6813                kind,
6814            } => ReactiveError::InvalidChainControl {
6815                message: format!(
6816                    "{kind:?} rollback after block {common_ancestor} exceeds retained canonical history starting at {oldest_retained:?}"
6817                ),
6818            },
6819        }
6820    }
6821}
6822
6823impl CanonicalSequenceValidationPolicy {
6824    const fn requires_complete_rollback(self) -> bool {
6825        matches!(
6826            self,
6827            Self::RequireCompleteRollback | Self::RequireCompleteRollbackNormalizeCoverage
6828        )
6829    }
6830
6831    const fn normalizes_coverage(self) -> bool {
6832        matches!(self, Self::RequireCompleteRollbackNormalizeCoverage)
6833    }
6834}
6835
6836fn validate_canonical_sequence_parts<N: Network>(
6837    initial: &CanonicalSequenceState,
6838    controls: &[ChainControl],
6839    records: &[(&ReactiveInputRecord<N>, DeliveryScope)],
6840    policy: CanonicalSequenceValidationPolicy,
6841) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
6842    validate_canonical_sequence_snapshot(initial)?;
6843    let control_split = validate_control_phase_order(controls)?;
6844    let (pre_record_controls, post_record_controls) = controls.split_at(control_split);
6845    let mut state = initial.clone();
6846    let mut asserted_blocks = HashMap::<u64, BlockRef>::new();
6847    let mut mutations = Vec::new();
6848    let mut normalized_chain_controls = Vec::with_capacity(controls.len());
6849    let mut batch_dropped = BatchDroppedCanonical::default();
6850    let mut removed_assertions = HashMap::<(u64, B256), BlockRef>::new();
6851    let mut removed_heights_by_hash = HashMap::<B256, u64>::new();
6852    let mut record_proof_control_identities = HashSet::<(u64, B256)>::new();
6853    let rollback_oldest = initial
6854        .retained_canonical_history
6855        .first()
6856        .map(|block| block.number);
6857
6858    for control in pre_record_controls {
6859        normalized_chain_controls.push(control.clone());
6860        validate_sequence_control(&state, control)?;
6861        assert_chain_control_identities(&mut asserted_blocks, control)?;
6862        let ChainControl::Reorg {
6863            common_ancestor,
6864            old_tip,
6865            ..
6866        } = control
6867        else {
6868            unreachable!("phase validation leaves only reorg controls before records")
6869        };
6870        let exact_ancestor = state.retained_canonical_history.iter().any(|block| {
6871            block.number == common_ancestor.number && block.hash == common_ancestor.hash
6872        });
6873        let rollback_horizon_covers_ancestor = state
6874            .retained_canonical_history
6875            .first()
6876            .is_some_and(|oldest| oldest.number <= common_ancestor.number);
6877        if policy.requires_complete_rollback()
6878            && !exact_ancestor
6879            && !rollback_horizon_covers_ancestor
6880        {
6881            return Err(CanonicalSequenceError::IncompleteRollback {
6882                common_ancestor: common_ancestor.number,
6883                oldest_retained: rollback_oldest,
6884                kind: CanonicalRollbackKind::Explicit,
6885            });
6886        }
6887        let dropped = state
6888            .retained_canonical_history
6889            .iter()
6890            .copied()
6891            .filter(|block| block.number > common_ancestor.number)
6892            .collect::<Vec<_>>();
6893        state
6894            .retained_canonical_history
6895            .retain(|block| block.number <= common_ancestor.number);
6896        upsert_sequence_history(&mut state.retained_canonical_history, common_ancestor)?;
6897        let mut enriched_ancestor = *common_ancestor;
6898        if let Some(retained) = state.retained_canonical_history.iter().find(|block| {
6899            block.number == common_ancestor.number && block.hash == common_ancestor.hash
6900        }) {
6901            enrich_block_ref(&mut enriched_ancestor, retained);
6902        }
6903        if let Some(coverage) = state.coverage_head.as_ref()
6904            && coverage.number == common_ancestor.number
6905            && coverage.hash == common_ancestor.hash
6906        {
6907            enrich_block_ref(&mut enriched_ancestor, coverage);
6908        }
6909        upsert_sequence_history(&mut state.retained_canonical_history, &enriched_ancestor)?;
6910        state.coverage_head = Some(enriched_ancestor);
6911        clear_sequence_heads_above(&mut state, &enriched_ancestor);
6912        batch_dropped.record_explicit(common_ancestor, old_tip);
6913        batch_dropped.record_drained(&dropped);
6914        mutations.push(CanonicalSequenceMutation::Rewind {
6915            common_ancestor: Some(enriched_ancestor),
6916            dropped,
6917        });
6918    }
6919    let pre_record_state = state.clone();
6920    let mut required_reorg_anchor = None::<RequiredReorgAnchor>;
6921
6922    for (record, scope) in records {
6923        if !scope.advances_canonical_state() {
6924            continue;
6925        }
6926        if let Some((incoming_dropped_block, _)) = reorg_signal_block(record) {
6927            let incoming_dropped_block =
6928                resolve_record_block_payload_metadata(record, incoming_dropped_block)?;
6929            validate_sequence_matching_metadata(&state, &incoming_dropped_block, "removed record")?;
6930            validate_sequence_adjacent_parent_identity(
6931                &state,
6932                &incoming_dropped_block,
6933                "removed record",
6934            )?;
6935            let mut dropped_block = state
6936                .retained_canonical_history
6937                .iter()
6938                .find(|known| {
6939                    known.number == incoming_dropped_block.number
6940                        && known.hash == incoming_dropped_block.hash
6941                })
6942                .copied()
6943                .or_else(|| {
6944                    state.coverage_head.filter(|known| {
6945                        known.number == incoming_dropped_block.number
6946                            && known.hash == incoming_dropped_block.hash
6947                    })
6948                })
6949                .unwrap_or(incoming_dropped_block);
6950            enrich_block_ref(&mut dropped_block, &incoming_dropped_block);
6951            validate_sequence_implicit_finality(&state, record, None)?;
6952            if dropped_block.number == 0 {
6953                return Err(ReactiveError::InvalidChainControl {
6954                    message: "a removed/reorged genesis block has no canonical parent anchor"
6955                        .into(),
6956                }
6957                .into());
6958            }
6959            let removed_identity = (dropped_block.number, dropped_block.hash);
6960            if let Some(previous_number) =
6961                removed_heights_by_hash.insert(dropped_block.hash, dropped_block.number)
6962                && previous_number != dropped_block.number
6963            {
6964                return Err(ReactiveError::InvalidChainControl {
6965                    message: format!(
6966                        "removed hash {:?} is reused at heights {} and {}",
6967                        dropped_block.hash, previous_number, dropped_block.number
6968                    ),
6969                }
6970                .into());
6971            }
6972            if let Some(previous) = removed_assertions.get_mut(&removed_identity) {
6973                if !optional_block_refs_are_compatible(Some(previous), Some(&dropped_block)) {
6974                    return Err(ReactiveError::InvalidChainControl {
6975                        message: format!(
6976                            "duplicate removed block {}:{:?} carries conflicting metadata",
6977                            dropped_block.number, dropped_block.hash
6978                        ),
6979                    }
6980                    .into());
6981                }
6982                enrich_block_ref(previous, &dropped_block);
6983            } else {
6984                removed_assertions.insert(removed_identity, dropped_block);
6985            }
6986            if asserted_blocks
6987                .get(&dropped_block.number)
6988                .is_some_and(|asserted| asserted.hash == dropped_block.hash)
6989            {
6990                return Err(ReactiveError::InvalidChainControl {
6991                    message: format!(
6992                        "removed block {}:{:?} is asserted canonical by the same envelope",
6993                        dropped_block.number, dropped_block.hash
6994                    ),
6995                }
6996                .into());
6997            }
6998            if batch_dropped.contains(&dropped_block) {
6999                continue;
7000            }
7001            if let Some(index) = state.retained_canonical_history.iter().position(|block| {
7002                block.number == dropped_block.number && block.hash == dropped_block.hash
7003            }) {
7004                let dropped = state.retained_canonical_history.split_off(index);
7005                batch_dropped.record_drained(&dropped);
7006                let ancestor_number = dropped_block
7007                    .number
7008                    .checked_sub(1)
7009                    .expect("genesis removal was rejected above");
7010                let retained_anchor = state
7011                    .retained_canonical_history
7012                    .iter()
7013                    .rev()
7014                    .find(|head| head.number == ancestor_number)
7015                    .copied();
7016                let authenticated_anchor = retained_anchor
7017                    .or_else(|| {
7018                        dropped_block.parent_hash.map(|hash| BlockRef {
7019                            number: ancestor_number,
7020                            hash,
7021                            parent_hash: None,
7022                            timestamp: None,
7023                        })
7024                    })
7025                    .or_else(|| {
7026                        state
7027                            .finalized_head
7028                            .filter(|head| head.number == ancestor_number)
7029                    });
7030                let authenticated_anchor = authenticated_anchor.map(|mut anchor| {
7031                    for head in [state.safe_head.as_ref(), state.finalized_head.as_ref()]
7032                        .into_iter()
7033                        .flatten()
7034                    {
7035                        if head.number == anchor.number && head.hash == anchor.hash {
7036                            enrich_block_ref(&mut anchor, head);
7037                        }
7038                    }
7039                    anchor
7040                });
7041                required_reorg_anchor = Some(RequiredReorgAnchor {
7042                    number: ancestor_number,
7043                    block: authenticated_anchor,
7044                    permits_missing_child_parent: retained_anchor.is_some(),
7045                    must_be_consumed: authenticated_anchor.is_none()
7046                        && state.retained_canonical_history.is_empty(),
7047                });
7048                state.coverage_head = authenticated_anchor
7049                    .or_else(|| state.retained_canonical_history.last().copied());
7050                if let Some(head) = state.coverage_head {
7051                    clear_sequence_heads_above(&mut state, &head);
7052                } else {
7053                    state.safe_head = None;
7054                    state.finalized_head = None;
7055                }
7056                mutations.push(CanonicalSequenceMutation::Rewind {
7057                    common_ancestor: state.coverage_head,
7058                    dropped,
7059                });
7060            } else {
7061                let replacement_is_known = state.retained_canonical_history.iter().any(|block| {
7062                    block.number == dropped_block.number && block.hash != dropped_block.hash
7063                }) || state.coverage_head.is_some_and(|head| {
7064                    head.number == dropped_block.number && head.hash != dropped_block.hash
7065                });
7066                if !replacement_is_known {
7067                    // Ordinary runtime ingestion deliberately keeps an unknown
7068                    // deep removal observable and lets the recovery path
7069                    // degrade health. With no exact retained rollback proof,
7070                    // this validator must not fabricate a new canonical head.
7071                    if policy.requires_complete_rollback() {
7072                        return Err(CanonicalSequenceError::IncompleteRollback {
7073                            common_ancestor: dropped_block
7074                                .number
7075                                .checked_sub(1)
7076                                .expect("genesis removal was rejected above"),
7077                            oldest_retained: rollback_oldest,
7078                            kind: CanonicalRollbackKind::Removed,
7079                        });
7080                    }
7081                    continue;
7082                }
7083            }
7084            continue;
7085        }
7086
7087        let Some(context_block) = canonical_record_block(record) else {
7088            continue;
7089        };
7090        let incoming_block = resolve_record_block_payload_metadata(record, *context_block)?;
7091        if post_record_controls
7092            .iter()
7093            .filter_map(canonical_coverage_control_block)
7094            .any(|asserted| {
7095                asserted.number == incoming_block.number
7096                    && asserted.hash == incoming_block.hash
7097                    && optional_block_refs_are_compatible(Some(asserted), Some(&incoming_block))
7098                    && ((incoming_block.parent_hash.is_none() && asserted.parent_hash.is_some())
7099                        || (incoming_block.timestamp.is_none() && asserted.timestamp.is_some()))
7100            })
7101        {
7102            record_proof_control_identities.insert((incoming_block.number, incoming_block.hash));
7103        }
7104        let mut resolved_block = incoming_block;
7105        if let Some(asserted) = asserted_blocks
7106            .get(&incoming_block.number)
7107            .filter(|asserted| asserted.hash == incoming_block.hash)
7108        {
7109            if !optional_block_refs_are_compatible(Some(asserted), Some(&incoming_block)) {
7110                return Err(ReactiveError::InvalidChainControl {
7111                    message: format!(
7112                        "canonical record {}:{:?} conflicts with the same envelope's asserted metadata",
7113                        incoming_block.number, incoming_block.hash
7114                    ),
7115                }
7116                .into());
7117            }
7118            enrich_block_ref(&mut resolved_block, asserted);
7119        }
7120        for asserted in post_record_controls
7121            .iter()
7122            .filter_map(chain_control_canonical_assertion)
7123            .filter(|asserted| {
7124                asserted.number == incoming_block.number && asserted.hash == incoming_block.hash
7125            })
7126        {
7127            if !optional_block_refs_are_compatible(Some(&resolved_block), Some(asserted)) {
7128                return Err(ReactiveError::InvalidChainControl {
7129                    message: format!(
7130                        "canonical record {}:{:?} conflicts with the same envelope's asserted metadata",
7131                        incoming_block.number, incoming_block.hash
7132                    ),
7133                }
7134                .into());
7135            }
7136            enrich_block_ref(&mut resolved_block, asserted);
7137        }
7138        let replacement_anchor =
7139            required_reorg_anchor.filter(|required| resolved_block.number > required.number);
7140        if resolved_block.parent_hash.is_none()
7141            && replacement_anchor.is_some_and(|anchor| {
7142                anchor.permits_missing_child_parent
7143                    && anchor.number.checked_add(1) == Some(resolved_block.number)
7144            })
7145        {
7146            resolved_block.parent_hash = replacement_anchor.and_then(RequiredReorgAnchor::hash);
7147        }
7148        let block = &resolved_block;
7149        if removed_assertions.contains_key(&(block.number, block.hash)) {
7150            return Err(ReactiveError::InvalidChainControl {
7151                message: format!(
7152                    "canonical block {}:{:?} is also removed by the same envelope",
7153                    block.number, block.hash
7154                ),
7155            }
7156            .into());
7157        }
7158        if let Some(removed_number) = removed_heights_by_hash.get(&block.hash)
7159            && *removed_number != block.number
7160        {
7161            return Err(ReactiveError::InvalidChainControl {
7162                message: format!(
7163                    "canonical hash {:?} at height {} is removed at height {} by the same envelope",
7164                    block.hash, block.number, removed_number
7165                ),
7166            }
7167            .into());
7168        }
7169        let replacement_proven_by_removal =
7170            validate_replacement_reorg_anchor(replacement_anchor, block, policy, rollback_oldest)?;
7171        if replacement_anchor.is_some() {
7172            required_reorg_anchor = None;
7173        }
7174        validate_sequence_matching_metadata(&state, block, "canonical record")?;
7175        validate_sequence_implicit_finality(&state, record, Some(block))?;
7176        let implicit_replacement_requires_history = if replacement_proven_by_removal {
7177            false
7178        } else {
7179            sequence_implicit_replacement_requires_history(&state, block, policy)?
7180        };
7181        if implicit_replacement_requires_history && policy.requires_complete_rollback() {
7182            return Err(CanonicalSequenceError::IncompleteRollback {
7183                common_ancestor: block.number.saturating_sub(1),
7184                oldest_retained: rollback_oldest,
7185                kind: CanonicalRollbackKind::ImplicitParent,
7186            });
7187        }
7188        assert_canonical_block_identity(&mut asserted_blocks, block, "canonical record")?;
7189        let allow_parentless_extension = replacement_anchor.is_some_and(|anchor| {
7190            anchor.permits_missing_child_parent
7191                && anchor.number.checked_add(1) == Some(block.number)
7192        });
7193        if let Some(rewind) =
7194            apply_sequence_canonical_block(&mut state, block, allow_parentless_extension)?
7195        {
7196            mutations.push(CanonicalSequenceMutation::Rewind {
7197                common_ancestor: rewind.common_ancestor,
7198                dropped: rewind.dropped,
7199            });
7200        }
7201        mutations.push(CanonicalSequenceMutation::Canonical(*block));
7202    }
7203
7204    for control in post_record_controls {
7205        if let Some(block) = chain_control_canonical_assertion(control)
7206            && removed_assertions.contains_key(&(block.number, block.hash))
7207        {
7208            return Err(ReactiveError::InvalidChainControl {
7209                message: format!(
7210                    "canonical block {}:{:?} is also removed by the same envelope",
7211                    block.number, block.hash
7212                ),
7213            }
7214            .into());
7215        }
7216        if let Some(block) = chain_control_canonical_assertion(control)
7217            && let Some(removed_number) = removed_heights_by_hash.get(&block.hash)
7218            && *removed_number != block.number
7219        {
7220            return Err(ReactiveError::InvalidChainControl {
7221                message: format!(
7222                    "canonical hash {:?} at height {} is removed at height {} by the same envelope",
7223                    block.hash, block.number, removed_number
7224                ),
7225            }
7226            .into());
7227        }
7228        let replacement_anchor = canonical_coverage_control_block(control).and_then(|block| {
7229            required_reorg_anchor.filter(|required| block.number > required.number)
7230        });
7231        if let Some(block) = canonical_coverage_control_block(control) {
7232            validate_replacement_reorg_anchor(replacement_anchor, block, policy, rollback_oldest)?;
7233            if replacement_anchor.is_some() {
7234                required_reorg_anchor = None;
7235            }
7236        }
7237        assert_chain_control_identities(&mut asserted_blocks, control)?;
7238        let preserves_record_proof =
7239            canonical_coverage_control_block(control).is_some_and(|block| {
7240                record_proof_control_identities.contains(&(block.number, block.hash))
7241            });
7242        if policy.normalizes_coverage()
7243            && !preserves_record_proof
7244            && let Some(block) = canonical_coverage_control_block(control)
7245            && state
7246                .coverage_head
7247                .is_some_and(|head| block.number <= head.number)
7248        {
7249            let is_equal_coverage = state
7250                .coverage_head
7251                .is_some_and(|head| block.number == head.number);
7252            let known = state
7253                .coverage_head
7254                .as_ref()
7255                .filter(|head| head.number == block.number && head.hash == block.hash)
7256                .or_else(|| {
7257                    state
7258                        .retained_canonical_history
7259                        .iter()
7260                        .find(|entry| entry.number == block.number && entry.hash == block.hash)
7261                });
7262            if let Some(known) = known
7263                && optional_block_refs_are_compatible(Some(known), Some(block))
7264                && (!is_equal_coverage || !sequence_block_adds_metadata(&state, block))
7265            {
7266                if let ChainControl::Barrier { id, .. } = control {
7267                    normalized_chain_controls.push(ChainControl::Barrier {
7268                        id: id.clone(),
7269                        block: None,
7270                    });
7271                }
7272                continue;
7273            }
7274        }
7275        validate_sequence_control(&state, control)?;
7276        normalized_chain_controls.push(control.clone());
7277        match control {
7278            ChainControl::Safe(block) => {
7279                set_or_enrich_block_ref(&mut state.safe_head, block);
7280                mutations.push(CanonicalSequenceMutation::Safe(
7281                    state.safe_head.expect("safe head was just installed"),
7282                ));
7283            }
7284            ChainControl::Finalized(block) => {
7285                set_or_enrich_block_ref(&mut state.finalized_head, block);
7286                mutations.push(CanonicalSequenceMutation::Finalized(
7287                    state
7288                        .finalized_head
7289                        .expect("finalized head was just installed"),
7290                ));
7291            }
7292            ChainControl::CanonicalProgress(block)
7293            | ChainControl::Barrier {
7294                block: Some(block), ..
7295            } => {
7296                let allow_parentless_extension = replacement_anchor.is_some_and(|anchor| {
7297                    anchor.permits_missing_child_parent
7298                        && anchor.number.checked_add(1) == Some(block.number)
7299                }) || (replacement_anchor.is_none()
7300                    && block.parent_hash.is_none()
7301                    && state
7302                        .coverage_head
7303                        .is_some_and(|head| head.number.checked_add(1) == Some(block.number)));
7304                if let Some(rewind) =
7305                    apply_sequence_canonical_block(&mut state, block, allow_parentless_extension)?
7306                {
7307                    mutations.push(CanonicalSequenceMutation::Rewind {
7308                        common_ancestor: rewind.common_ancestor,
7309                        dropped: rewind.dropped,
7310                    });
7311                }
7312                mutations.push(CanonicalSequenceMutation::Canonical(*block));
7313            }
7314            ChainControl::LogCoverage(block) => {
7315                set_or_enrich_block_ref(&mut state.log_coverage_head, block);
7316                mutations.push(CanonicalSequenceMutation::LogCoverage(
7317                    state
7318                        .log_coverage_head
7319                        .expect("log coverage head was just installed"),
7320                ));
7321            }
7322            ChainControl::Barrier { block: None, .. } => {}
7323            ChainControl::Reorg { .. } => {
7324                unreachable!("phase validation excludes post-record reorg controls")
7325            }
7326        }
7327    }
7328
7329    if let Some(required) = required_reorg_anchor
7330        && required.must_be_consumed
7331        && policy.requires_complete_rollback()
7332    {
7333        return Err(CanonicalSequenceError::IncompleteRollback {
7334            common_ancestor: required.number,
7335            oldest_retained: rollback_oldest,
7336            kind: CanonicalRollbackKind::MissingReplacement,
7337        });
7338    }
7339
7340    validate_canonical_sequence_snapshot(&state)?;
7341    Ok(CanonicalSequenceValidation {
7342        pre_record_state,
7343        next_state: state,
7344        mutations,
7345        normalized_chain_controls,
7346    })
7347}
7348
7349fn validate_canonical_sequence_snapshot(
7350    state: &CanonicalSequenceState,
7351) -> Result<(), ReactiveError> {
7352    let invalid = |message: String| ReactiveError::InvalidChainControl { message };
7353    let supplied_blocks = state
7354        .retained_canonical_history
7355        .iter()
7356        .chain(state.coverage_head.iter())
7357        .chain(state.safe_head.iter())
7358        .chain(state.finalized_head.iter())
7359        .collect::<Vec<_>>();
7360    validate_known_parent_hash_heights(&supplied_blocks)?;
7361    let mut prior = None::<BlockRef>;
7362    for block in &state.retained_canonical_history {
7363        if let Some(previous) = prior {
7364            if block.number < previous.number {
7365                return Err(invalid(
7366                    "retained canonical history is not ordered by block number".into(),
7367                ));
7368            }
7369            if block.number == previous.number {
7370                let qualifier = if optional_block_refs_are_compatible(Some(&previous), Some(block))
7371                {
7372                    "duplicate"
7373                } else {
7374                    "conflicting"
7375                };
7376                return Err(invalid(format!(
7377                    "retained canonical history contains {qualifier} identities at block {}",
7378                    block.number
7379                )));
7380            }
7381            if previous.number.checked_add(1) == Some(block.number)
7382                && block.parent_hash.is_some()
7383                && block.parent_hash != Some(previous.hash)
7384            {
7385                return Err(invalid(format!(
7386                    "adjacent retained block {}:{:?} does not descend from {}:{:?}",
7387                    block.number, block.hash, previous.number, previous.hash
7388                )));
7389            }
7390        }
7391        prior = Some(*block);
7392    }
7393    if state.coverage_head.is_none() && !state.retained_canonical_history.is_empty() {
7394        return Err(invalid(
7395            "retained canonical history requires an authoritative coverage head".into(),
7396        ));
7397    }
7398    if let Some(head) = state.coverage_head.as_ref() {
7399        if let Some(retained) = state
7400            .retained_canonical_history
7401            .iter()
7402            .find(|entry| entry.number == head.number)
7403            && !optional_block_refs_are_compatible(Some(retained), Some(head))
7404        {
7405            return Err(invalid(format!(
7406                "coverage head {}:{:?} conflicts with retained identity {:?}",
7407                head.number, head.hash, retained
7408            )));
7409        }
7410        if state
7411            .retained_canonical_history
7412            .last()
7413            .is_some_and(|retained| retained.number > head.number)
7414        {
7415            return Err(invalid(
7416                "retained canonical history advances beyond the coverage head".into(),
7417            ));
7418        }
7419        if let Some(retained) = state.retained_canonical_history.last()
7420            && retained.number.checked_add(1) == Some(head.number)
7421            && head.parent_hash.is_some()
7422            && head.parent_hash != Some(retained.hash)
7423        {
7424            return Err(invalid(format!(
7425                "coverage head {}:{:?} does not descend from adjacent retained block {}:{:?}",
7426                head.number, head.hash, retained.number, retained.hash
7427            )));
7428        }
7429    }
7430    if let Some(safe) = state.safe_head.as_ref() {
7431        validate_sequence_known_identity(state, safe, "safe")?;
7432        validate_sequence_head_within_coverage(state, safe, "safe")?;
7433        validate_coverage_descends_from_adjacent_head(state.coverage_head.as_ref(), safe, "safe")?;
7434    }
7435    if let Some(finalized) = state.finalized_head.as_ref() {
7436        validate_sequence_known_identity(state, finalized, "finalized")?;
7437        validate_sequence_head_within_coverage(state, finalized, "finalized")?;
7438        validate_coverage_descends_from_adjacent_head(
7439            state.coverage_head.as_ref(),
7440            finalized,
7441            "finalized",
7442        )?;
7443    }
7444    validate_adjacent_finality(state.finalized_head.as_ref(), state.safe_head.as_ref())?;
7445    if let (Some(finalized), Some(safe)) = (state.finalized_head, state.safe_head)
7446        && (finalized.number > safe.number
7447            || (finalized.number == safe.number && finalized.hash != safe.hash))
7448    {
7449        return Err(invalid(
7450            "finalized head cannot advance beyond or conflict with safe head".into(),
7451        ));
7452    }
7453    Ok(())
7454}
7455
7456fn validate_known_parent_hash_heights(blocks: &[&BlockRef]) -> Result<(), ReactiveError> {
7457    let mut heights_by_hash = HashMap::<B256, u64>::with_capacity(blocks.len());
7458    let mut resolved_by_height = HashMap::<u64, BlockRef>::with_capacity(blocks.len());
7459    for block in blocks.iter().copied() {
7460        if let Some(previous_height) = heights_by_hash.insert(block.hash, block.number)
7461            && previous_height != block.number
7462        {
7463            return Err(ReactiveError::InvalidChainControl {
7464                message: format!(
7465                    "canonical hash {:?} is reused at heights {} and {}",
7466                    block.hash, previous_height, block.number
7467                ),
7468            });
7469        }
7470        if let Some(resolved) = resolved_by_height.get_mut(&block.number) {
7471            if !optional_block_refs_are_compatible(Some(resolved), Some(block)) {
7472                return Err(ReactiveError::InvalidChainControl {
7473                    message: format!(
7474                        "canonical aliases at height {} carry conflicting identities or metadata",
7475                        block.number
7476                    ),
7477                });
7478            }
7479            enrich_block_ref(resolved, block);
7480        } else {
7481            resolved_by_height.insert(block.number, *block);
7482        }
7483    }
7484    for child in resolved_by_height.values() {
7485        let Some(parent_hash) = child.parent_hash else {
7486            continue;
7487        };
7488        if let Some(parent_number) = heights_by_hash.get(&parent_hash)
7489            && parent_number.checked_add(1) != Some(child.number)
7490        {
7491            return Err(ReactiveError::InvalidChainControl {
7492                message: format!(
7493                    "block {}:{:?} names hash {:?} from known height {} as a non-adjacent parent",
7494                    child.number, child.hash, parent_hash, parent_number
7495                ),
7496            });
7497        }
7498        if let Some(parent_number) = child.number.checked_sub(1)
7499            && let Some(parent) = resolved_by_height.get(&parent_number)
7500            && parent.hash != parent_hash
7501        {
7502            return Err(ReactiveError::InvalidChainControl {
7503                message: format!(
7504                    "block {}:{:?} does not descend from supplied adjacent identity {}:{:?}",
7505                    child.number, child.hash, parent.number, parent.hash
7506                ),
7507            });
7508        }
7509    }
7510    Ok(())
7511}
7512
7513fn validate_coverage_descends_from_adjacent_head(
7514    coverage: Option<&BlockRef>,
7515    head: &BlockRef,
7516    label: &str,
7517) -> Result<(), ReactiveError> {
7518    let Some(coverage) = coverage else {
7519        return Ok(());
7520    };
7521    if head.number.checked_add(1) == Some(coverage.number)
7522        && coverage
7523            .parent_hash
7524            .is_some_and(|parent| parent != head.hash)
7525    {
7526        return Err(ReactiveError::InvalidChainControl {
7527            message: format!(
7528                "canonical coverage {}:{:?} does not descend from adjacent {label} head {}:{:?}",
7529                coverage.number, coverage.hash, head.number, head.hash
7530            ),
7531        });
7532    }
7533    Ok(())
7534}
7535
7536fn validate_sequence_control(
7537    state: &CanonicalSequenceState,
7538    control: &ChainControl,
7539) -> Result<(), ReactiveError> {
7540    let invalid = |message: String| ReactiveError::InvalidChainControl { message };
7541    match control {
7542        ChainControl::Safe(block) => {
7543            validate_sequence_known_identity(state, block, "safe")?;
7544            validate_sequence_head_within_coverage(state, block, "safe")?;
7545            if let Some(current) = state.safe_head.as_ref()
7546                && (block.number < current.number
7547                    || (block.number == current.number
7548                        && (block.hash != current.hash
7549                            || !optional_block_refs_are_compatible(Some(block), Some(current)))))
7550            {
7551                return Err(invalid(format!(
7552                    "safe head {}:{:?} conflicts with current {}:{:?}",
7553                    block.number, block.hash, current.number, current.hash
7554                )));
7555            }
7556            if let Some(finalized) = state.finalized_head.as_ref()
7557                && (block.number < finalized.number
7558                    || (block.number == finalized.number && block.hash != finalized.hash))
7559            {
7560                return Err(invalid(
7561                    "safe head cannot precede or conflict with finalized head".into(),
7562                ));
7563            }
7564            validate_adjacent_finality(state.finalized_head.as_ref(), Some(block))?;
7565        }
7566        ChainControl::Finalized(block) => {
7567            validate_sequence_known_identity(state, block, "finalized")?;
7568            validate_sequence_head_within_coverage(state, block, "finalized")?;
7569            if let Some(current) = state.finalized_head.as_ref()
7570                && (block.number < current.number
7571                    || (block.number == current.number
7572                        && (block.hash != current.hash
7573                            || !optional_block_refs_are_compatible(Some(block), Some(current)))))
7574            {
7575                return Err(invalid(format!(
7576                    "finalized head {}:{:?} conflicts with current {}:{:?}",
7577                    block.number, block.hash, current.number, current.hash
7578                )));
7579            }
7580            if let Some(safe) = state.safe_head.as_ref()
7581                && (block.number > safe.number
7582                    || (block.number == safe.number && block.hash != safe.hash))
7583            {
7584                return Err(invalid(
7585                    "finalized head cannot advance beyond or conflict with safe head".into(),
7586                ));
7587            }
7588            validate_adjacent_finality(Some(block), state.safe_head.as_ref())?;
7589        }
7590        ChainControl::CanonicalProgress(block)
7591        | ChainControl::Barrier {
7592            block: Some(block), ..
7593        } => {
7594            validate_sequence_known_identity(state, block, "canonical coverage")?;
7595            if let Some(current) = state.coverage_head.as_ref()
7596                && (block.number < current.number
7597                    || (block.number == current.number && block.hash != current.hash))
7598            {
7599                return Err(invalid(format!(
7600                    "canonical coverage {}:{:?} conflicts with current {}:{:?}",
7601                    block.number, block.hash, current.number, current.hash
7602                )));
7603            }
7604            if let Some(current) = state.coverage_head.as_ref()
7605                && current.number.checked_add(1) == Some(block.number)
7606                && block.parent_hash.is_some()
7607                && block.parent_hash != Some(current.hash)
7608            {
7609                return Err(invalid(format!(
7610                    "canonical coverage {}:{:?} does not descend from current {}:{:?}",
7611                    block.number, block.hash, current.number, current.hash
7612                )));
7613            }
7614        }
7615        ChainControl::LogCoverage(block) => {
7616            validate_sequence_known_identity(state, block, "log coverage")?;
7617            // Monotonic: an attestation may be re-sent for the same block but
7618            // must never retreat, or a consumer could widen a window it had
7619            // already narrowed.
7620            if let Some(current) = state.log_coverage_head.as_ref()
7621                && (block.number < current.number
7622                    || (block.number == current.number && block.hash != current.hash))
7623            {
7624                return Err(invalid(format!(
7625                    "log coverage {}:{:?} conflicts with current {}:{:?}",
7626                    block.number, block.hash, current.number, current.hash
7627                )));
7628            }
7629            // Logs cannot be attested complete for a block the source has not
7630            // established canonical coverage for.
7631            if let Some(coverage) = state.coverage_head.as_ref()
7632                && block.number > coverage.number
7633            {
7634                return Err(invalid(format!(
7635                    "log coverage {}:{:?} is ahead of canonical coverage {}:{:?}",
7636                    block.number, block.hash, coverage.number, coverage.hash
7637                )));
7638            }
7639        }
7640        ChainControl::Barrier { block: None, .. } => {}
7641        ChainControl::Reorg {
7642            common_ancestor,
7643            old_tip,
7644            new_tip,
7645        } => {
7646            validate_sequence_known_identity(state, common_ancestor, "reorg common ancestor")?;
7647            validate_reorg_ancestor_against_retained_branch(state, common_ancestor)?;
7648            validate_sequence_known_hash_height(state, old_tip, "reorg old tip")?;
7649            validate_sequence_known_hash_height(state, new_tip, "reorg new tip")?;
7650            validate_sequence_known_parent_height(state, old_tip, "reorg old tip")?;
7651            validate_sequence_known_parent_height(state, new_tip, "reorg new tip")?;
7652            validate_sequence_adjacent_parent_identity(state, old_tip, "reorg old tip")?;
7653            if let Some(current) = state.coverage_head.as_ref()
7654                && (old_tip.number != current.number
7655                    || old_tip.hash != current.hash
7656                    || !optional_block_refs_are_compatible(Some(old_tip), Some(current)))
7657            {
7658                return Err(invalid(format!(
7659                    "reorg old tip {}:{:?} does not exactly match current metadata {}:{:?}",
7660                    old_tip.number, old_tip.hash, current.number, current.hash
7661                )));
7662            }
7663            if common_ancestor.number > old_tip.number || common_ancestor.number > new_tip.number {
7664                return Err(invalid(
7665                    "reorg common ancestor cannot be above either branch tip".into(),
7666                ));
7667            }
7668            if common_ancestor.number == old_tip.number || common_ancestor.number == new_tip.number
7669            {
7670                return Err(invalid(
7671                    "reorg must replace non-empty old and new branches above the common ancestor"
7672                        .into(),
7673                ));
7674            }
7675            if old_tip.number == new_tip.number && old_tip.hash == new_tip.hash {
7676                return Err(invalid(
7677                    "reorg old and new tips cannot have the same canonical identity".into(),
7678                ));
7679            }
7680            for (label, tip) in [("old", old_tip), ("new", new_tip)] {
7681                if common_ancestor.number.checked_add(1) == Some(tip.number)
7682                    && tip.parent_hash != Some(common_ancestor.hash)
7683                {
7684                    return Err(invalid(format!(
7685                        "reorg {label} tip does not descend from the common ancestor"
7686                    )));
7687                }
7688            }
7689            if let Some(finalized) = state.finalized_head.as_ref()
7690                && (common_ancestor.number < finalized.number
7691                    || (common_ancestor.number == finalized.number
7692                        && common_ancestor.hash != finalized.hash))
7693            {
7694                return Err(invalid(
7695                    "reorg would cross or conflict with the finalized head".into(),
7696                ));
7697            }
7698        }
7699    }
7700    Ok(())
7701}
7702
7703fn validate_sequence_known_identity(
7704    state: &CanonicalSequenceState,
7705    block: &BlockRef,
7706    label: &str,
7707) -> Result<(), ReactiveError> {
7708    validate_sequence_known_hash_height(state, block, label)?;
7709    validate_sequence_known_parent_height(state, block, label)?;
7710    let known = state
7711        .coverage_head
7712        .as_ref()
7713        .filter(|head| head.number == block.number)
7714        .or_else(|| {
7715            state
7716                .retained_canonical_history
7717                .iter()
7718                .find(|entry| entry.number == block.number)
7719        });
7720    if let Some(known) = known
7721        && !optional_block_refs_are_compatible(Some(known), Some(block))
7722    {
7723        return Err(ReactiveError::InvalidChainControl {
7724            message: format!(
7725                "{label} block {}:{:?} conflicts with known canonical block {:?}",
7726                block.number, block.hash, known
7727            ),
7728        });
7729    }
7730    Ok(())
7731}
7732
7733fn validate_sequence_known_parent_height(
7734    state: &CanonicalSequenceState,
7735    block: &BlockRef,
7736    label: &str,
7737) -> Result<(), ReactiveError> {
7738    let Some(parent_hash) = block.parent_hash else {
7739        return Ok(());
7740    };
7741    let known_parent = state
7742        .retained_canonical_history
7743        .iter()
7744        .chain(state.coverage_head.iter())
7745        .chain(state.safe_head.iter())
7746        .chain(state.finalized_head.iter())
7747        .find(|known| known.hash == parent_hash);
7748    if let Some(parent) = known_parent
7749        && parent.number.checked_add(1) != Some(block.number)
7750    {
7751        return Err(ReactiveError::InvalidChainControl {
7752            message: format!(
7753                "{label} block {}:{:?} names hash {:?} from known height {} as a non-adjacent parent",
7754                block.number, block.hash, parent.hash, parent.number
7755            ),
7756        });
7757    }
7758    Ok(())
7759}
7760
7761fn validate_sequence_head_within_coverage(
7762    state: &CanonicalSequenceState,
7763    block: &BlockRef,
7764    label: &str,
7765) -> Result<(), ReactiveError> {
7766    let Some(coverage) = state.coverage_head.as_ref() else {
7767        return Err(ReactiveError::InvalidChainControl {
7768            message: format!("{label} head requires an authoritative coverage head"),
7769        });
7770    };
7771    if block.number > coverage.number
7772        || (block.number == coverage.number
7773            && !optional_block_refs_are_compatible(Some(block), Some(coverage)))
7774    {
7775        return Err(ReactiveError::InvalidChainControl {
7776            message: format!(
7777                "{label} head {}:{:?} advances beyond or conflicts with coverage {}:{:?}",
7778                block.number, block.hash, coverage.number, coverage.hash
7779            ),
7780        });
7781    }
7782    Ok(())
7783}
7784
7785fn validate_sequence_matching_metadata(
7786    state: &CanonicalSequenceState,
7787    block: &BlockRef,
7788    label: &str,
7789) -> Result<(), ReactiveError> {
7790    validate_sequence_known_hash_height(state, block, label)?;
7791    validate_sequence_known_parent_height(state, block, label)?;
7792    let known = state
7793        .coverage_head
7794        .as_ref()
7795        .filter(|head| head.number == block.number && head.hash == block.hash)
7796        .or_else(|| {
7797            state
7798                .retained_canonical_history
7799                .iter()
7800                .find(|entry| entry.number == block.number && entry.hash == block.hash)
7801        });
7802    if let Some(known) = known
7803        && !optional_block_refs_are_compatible(Some(known), Some(block))
7804    {
7805        return Err(ReactiveError::InvalidChainControl {
7806            message: format!(
7807                "{label} block {}:{:?} carries metadata conflicting with known canonical block {:?}",
7808                block.number, block.hash, known
7809            ),
7810        });
7811    }
7812    Ok(())
7813}
7814
7815fn validate_sequence_known_hash_height(
7816    state: &CanonicalSequenceState,
7817    block: &BlockRef,
7818    label: &str,
7819) -> Result<(), ReactiveError> {
7820    let known = state
7821        .retained_canonical_history
7822        .iter()
7823        .chain(state.coverage_head.iter())
7824        .chain(state.safe_head.iter())
7825        .chain(state.finalized_head.iter())
7826        .find(|known| known.hash == block.hash);
7827    if let Some(known) = known
7828        && known.number != block.number
7829    {
7830        return Err(ReactiveError::InvalidChainControl {
7831            message: format!(
7832                "{label} block {}:{:?} reuses a canonical hash already known at height {}",
7833                block.number, block.hash, known.number
7834            ),
7835        });
7836    }
7837    Ok(())
7838}
7839
7840fn validate_reorg_ancestor_against_retained_branch(
7841    state: &CanonicalSequenceState,
7842    ancestor: &BlockRef,
7843) -> Result<(), ReactiveError> {
7844    let adjacent_number = ancestor.number.checked_add(1);
7845    for retained in state
7846        .retained_canonical_history
7847        .iter()
7848        .chain(state.coverage_head.iter())
7849        .chain(state.safe_head.iter())
7850        .chain(state.finalized_head.iter())
7851    {
7852        if Some(retained.number) == adjacent_number
7853            && retained
7854                .parent_hash
7855                .is_some_and(|parent| parent != ancestor.hash)
7856        {
7857            return Err(ReactiveError::InvalidChainControl {
7858                message: format!(
7859                    "reorg common ancestor {}:{:?} conflicts with retained child {}:{:?} parent {:?}",
7860                    ancestor.number,
7861                    ancestor.hash,
7862                    retained.number,
7863                    retained.hash,
7864                    retained.parent_hash
7865                ),
7866            });
7867        }
7868        if retained.parent_hash == Some(ancestor.hash) && Some(retained.number) != adjacent_number {
7869            return Err(ReactiveError::InvalidChainControl {
7870                message: format!(
7871                    "reorg common ancestor {}:{:?} is named as the non-adjacent parent of retained block {}:{:?}",
7872                    ancestor.number, ancestor.hash, retained.number, retained.hash
7873                ),
7874            });
7875        }
7876    }
7877    Ok(())
7878}
7879
7880fn validate_sequence_adjacent_parent_identity(
7881    state: &CanonicalSequenceState,
7882    block: &BlockRef,
7883    label: &str,
7884) -> Result<(), ReactiveError> {
7885    let Some(parent_hash) = block.parent_hash else {
7886        return Ok(());
7887    };
7888    let Some(parent_number) = block.number.checked_sub(1) else {
7889        return Ok(());
7890    };
7891    let known_parent = state
7892        .retained_canonical_history
7893        .iter()
7894        .chain(state.coverage_head.iter())
7895        .chain(state.safe_head.iter())
7896        .chain(state.finalized_head.iter())
7897        .find(|known| known.number == parent_number);
7898    if let Some(known_parent) = known_parent
7899        && known_parent.hash != parent_hash
7900    {
7901        return Err(ReactiveError::InvalidChainControl {
7902            message: format!(
7903                "{label} block {}:{:?} names parent {:?}, which conflicts with known adjacent block {}:{:?}",
7904                block.number, block.hash, parent_hash, known_parent.number, known_parent.hash
7905            ),
7906        });
7907    }
7908    Ok(())
7909}
7910
7911fn sequence_block_adds_metadata(state: &CanonicalSequenceState, incoming: &BlockRef) -> bool {
7912    state
7913        .coverage_head
7914        .iter()
7915        .chain(state.retained_canonical_history.iter())
7916        .filter(|known| known.number == incoming.number && known.hash == incoming.hash)
7917        .any(|known| {
7918            (known.parent_hash.is_none() && incoming.parent_hash.is_some())
7919                || (known.timestamp.is_none() && incoming.timestamp.is_some())
7920        })
7921}
7922
7923fn validate_sequence_implicit_finality<N: Network>(
7924    state: &CanonicalSequenceState,
7925    record: &ReactiveInputRecord<N>,
7926    resolved_canonical_block: Option<&BlockRef>,
7927) -> Result<(), ReactiveError> {
7928    let Some(finalized) = state.finalized_head.as_ref() else {
7929        return Ok(());
7930    };
7931    if let Some((dropped, _)) = reorg_signal_block(record) {
7932        if dropped.number <= finalized.number {
7933            return Err(ReactiveError::InvalidChainControl {
7934                message: format!(
7935                    "implicit reorg at {}:{:?} would cross finalized head {}:{:?}",
7936                    dropped.number, dropped.hash, finalized.number, finalized.hash
7937                ),
7938            });
7939        }
7940        return Ok(());
7941    }
7942    let Some(block) = resolved_canonical_block.or_else(|| canonical_record_block(record)) else {
7943        return Ok(());
7944    };
7945    let Some(latest) = state.coverage_head.as_ref() else {
7946        return Ok(());
7947    };
7948    if (block.number == latest.number && block.hash == latest.hash)
7949        || state
7950            .retained_canonical_history
7951            .iter()
7952            .any(|entry| entry.number == block.number && entry.hash == block.hash)
7953        || (latest.number.checked_add(1) == Some(block.number)
7954            && block.parent_hash == Some(latest.hash))
7955        || latest
7956            .number
7957            .checked_add(1)
7958            .is_some_and(|next| block.number > next)
7959    {
7960        return Ok(());
7961    }
7962    let crosses_finalized = if block.number <= finalized.number {
7963        true
7964    } else if let Some(parent_hash) = block.parent_hash {
7965        if finalized.number.checked_add(1) == Some(block.number) && parent_hash == finalized.hash {
7966            false
7967        } else if let Some(parent_index) =
7968            state.retained_canonical_history.iter().rposition(|entry| {
7969                entry.number.checked_add(1) == Some(block.number) && entry.hash == parent_hash
7970            })
7971        {
7972            state
7973                .retained_canonical_history
7974                .iter()
7975                .skip(parent_index + 1)
7976                .any(|entry| entry.number <= finalized.number)
7977        } else {
7978            true
7979        }
7980    } else {
7981        true
7982    };
7983    if crosses_finalized {
7984        return Err(ReactiveError::InvalidChainControl {
7985            message: format!(
7986                "canonical input {}:{:?} would replace finalized head {}:{:?}",
7987                block.number, block.hash, finalized.number, finalized.hash
7988            ),
7989        });
7990    }
7991    Ok(())
7992}
7993
7994fn validate_required_reorg_anchor(
7995    required: Option<RequiredReorgAnchor>,
7996    block: &BlockRef,
7997) -> Result<(), ReactiveError> {
7998    let Some(required) = required else {
7999        return Ok(());
8000    };
8001    let ancestor_hash = required.hash();
8002    let restores_ancestor =
8003        block.number == required.number && ancestor_hash.is_some_and(|hash| block.hash == hash);
8004    let replaces_removed_child = required.number.checked_add(1) == Some(block.number)
8005        && ancestor_hash.is_some()
8006        && (block.parent_hash == ancestor_hash
8007            || (block.parent_hash.is_none() && required.permits_missing_child_parent));
8008    if restores_ancestor || replaces_removed_child {
8009        return Ok(());
8010    }
8011    Err(ReactiveError::InvalidChainControl {
8012        message: format!(
8013            "canonical replacement {}:{:?} does not prove the removed tip's parent at block {}",
8014            block.number, block.hash, required.number
8015        ),
8016    })
8017}
8018
8019fn validate_replacement_reorg_anchor(
8020    required: Option<RequiredReorgAnchor>,
8021    block: &BlockRef,
8022    policy: CanonicalSequenceValidationPolicy,
8023    oldest_retained: Option<u64>,
8024) -> Result<bool, CanonicalSequenceError> {
8025    let Some(required) = required else {
8026        return Ok(false);
8027    };
8028    match validate_required_reorg_anchor(Some(required), block) {
8029        Ok(()) => Ok(true),
8030        Err(error) if required.block.is_some() => Err(error.into()),
8031        Err(_) if policy.requires_complete_rollback() => {
8032            Err(CanonicalSequenceError::IncompleteRollback {
8033                common_ancestor: required.number,
8034                oldest_retained,
8035                kind: CanonicalRollbackKind::MissingReplacement,
8036            })
8037        }
8038        Err(_) => Ok(false),
8039    }
8040}
8041
8042fn apply_sequence_canonical_block(
8043    state: &mut CanonicalSequenceState,
8044    block: &BlockRef,
8045    allow_parentless_adjacent_extension: bool,
8046) -> Result<Option<SequenceRewind>, ReactiveError> {
8047    let latest = state.coverage_head;
8048    let already_known = state
8049        .retained_canonical_history
8050        .iter()
8051        .any(|entry| entry.number == block.number && entry.hash == block.hash);
8052    let repeats_tip =
8053        latest.is_some_and(|head| head.number == block.number && head.hash == block.hash);
8054    let extends_tip = latest.is_some_and(|head| {
8055        head.number.checked_add(1) == Some(block.number)
8056            && (block.parent_hash == Some(head.hash)
8057                || (allow_parentless_adjacent_extension && block.parent_hash.is_none()))
8058    });
8059    let forward_gap = latest.is_some_and(|head| {
8060        head.number
8061            .checked_add(1)
8062            .is_some_and(|next| block.number > next)
8063    });
8064    let mut rewind = None;
8065
8066    if latest.is_some() && !already_known && !repeats_tip && !extends_tip && !forward_gap {
8067        let retained_parent = block.parent_hash.and_then(|parent_hash| {
8068            state
8069                .retained_canonical_history
8070                .iter()
8071                .rposition(|entry| {
8072                    entry.number.checked_add(1) == Some(block.number) && entry.hash == parent_hash
8073                })
8074                .map(|index| (index, state.retained_canonical_history[index]))
8075        });
8076        let finalized_parent = block.parent_hash.and_then(|parent_hash| {
8077            state.finalized_head.filter(|finalized| {
8078                finalized.number.checked_add(1) == Some(block.number)
8079                    && finalized.hash == parent_hash
8080            })
8081        });
8082        let (common_ancestor, dropped) = if let Some((parent_index, parent)) = retained_parent {
8083            let dropped = state.retained_canonical_history.split_off(parent_index + 1);
8084            (Some(parent), dropped)
8085        } else if let Some(finalized) = finalized_parent {
8086            let dropped = state
8087                .retained_canonical_history
8088                .iter()
8089                .position(|entry| entry.number > finalized.number)
8090                .map_or_else(Vec::new, |index| {
8091                    state.retained_canonical_history.split_off(index)
8092                });
8093            (Some(finalized), dropped)
8094        } else {
8095            // The observable runtime policy may continue after an incomplete
8096            // rollback proof so it can degrade health and repair. The metadata
8097            // validator must nevertheless avoid claiming any old prefix is an
8098            // ancestor of the arriving branch: without the exact N-1 parent,
8099            // no retained identity is authenticated.
8100            (None, std::mem::take(&mut state.retained_canonical_history))
8101        };
8102        state.coverage_head = common_ancestor;
8103        if let Some(common_ancestor) = common_ancestor {
8104            clear_sequence_heads_above(state, &common_ancestor);
8105        } else {
8106            state.safe_head = None;
8107            state.finalized_head = None;
8108        }
8109        rewind = Some(SequenceRewind {
8110            common_ancestor,
8111            dropped,
8112        });
8113    }
8114    upsert_sequence_history(&mut state.retained_canonical_history, block)?;
8115    advance_or_enrich_coverage(&mut state.coverage_head, block);
8116    Ok(rewind)
8117}
8118
8119fn sequence_implicit_replacement_requires_history(
8120    state: &CanonicalSequenceState,
8121    block: &BlockRef,
8122    policy: CanonicalSequenceValidationPolicy,
8123) -> Result<bool, ReactiveError> {
8124    let Some(latest) = state.coverage_head else {
8125        return Ok(false);
8126    };
8127    let already_known = state
8128        .retained_canonical_history
8129        .iter()
8130        .any(|entry| entry.number == block.number && entry.hash == block.hash);
8131    let repeats_tip = block.number == latest.number && block.hash == latest.hash;
8132    let extends_tip = latest.number.checked_add(1) == Some(block.number)
8133        && block.parent_hash == Some(latest.hash);
8134    let forward_gap = latest
8135        .number
8136        .checked_add(1)
8137        .is_some_and(|next| block.number > next);
8138    if already_known || repeats_tip || extends_tip || forward_gap {
8139        return Ok(false);
8140    }
8141    let Some(parent_hash) = block.parent_hash else {
8142        if policy.requires_complete_rollback() {
8143            return Err(ReactiveError::InvalidChainControl {
8144                message: format!(
8145                    "implicit canonical replacement {}:{:?} must identify its parent",
8146                    block.number, block.hash
8147                ),
8148            });
8149        }
8150        return Ok(true);
8151    };
8152    let known_adjacent_parent = block.number.checked_sub(1).and_then(|parent_number| {
8153        state
8154            .retained_canonical_history
8155            .iter()
8156            .chain(state.coverage_head.iter())
8157            .chain(state.safe_head.iter())
8158            .chain(state.finalized_head.iter())
8159            .find(|known| known.number == parent_number)
8160    });
8161    if let Some(known_parent) = known_adjacent_parent
8162        && known_parent.hash != parent_hash
8163        && policy.requires_complete_rollback()
8164    {
8165        return Err(ReactiveError::InvalidChainControl {
8166            message: format!(
8167                "implicit canonical replacement {}:{:?} names parent {:?}, which conflicts with known adjacent block {}:{:?}",
8168                block.number, block.hash, parent_hash, known_parent.number, known_parent.hash
8169            ),
8170        });
8171    }
8172    let retained_parent = state.retained_canonical_history.iter().any(|entry| {
8173        entry.number.checked_add(1) == Some(block.number) && entry.hash == parent_hash
8174    });
8175    let finalized_parent = state.finalized_head.is_some_and(|finalized| {
8176        finalized.number.checked_add(1) == Some(block.number) && parent_hash == finalized.hash
8177    });
8178    Ok(!retained_parent && !finalized_parent)
8179}
8180
8181fn upsert_sequence_history(
8182    history: &mut Vec<BlockRef>,
8183    block: &BlockRef,
8184) -> Result<(), ReactiveError> {
8185    if let Some(existing) = history
8186        .iter_mut()
8187        .find(|entry| entry.number == block.number)
8188    {
8189        if existing.hash != block.hash {
8190            return Err(ReactiveError::InvalidChainControl {
8191                message: format!(
8192                    "canonical block {}:{:?} conflicts with retained identity {:?}",
8193                    block.number, block.hash, existing
8194                ),
8195            });
8196        }
8197        if !optional_block_refs_are_compatible(Some(existing), Some(block)) {
8198            return Err(ReactiveError::InvalidChainControl {
8199                message: format!(
8200                    "canonical block {}:{:?} carries conflicting retained metadata",
8201                    block.number, block.hash
8202                ),
8203            });
8204        }
8205        enrich_block_ref(existing, block);
8206    } else {
8207        history.push(*block);
8208        history.sort_by_key(|entry| entry.number);
8209    }
8210    Ok(())
8211}
8212
8213fn clear_sequence_heads_above(state: &mut CanonicalSequenceState, ancestor: &BlockRef) {
8214    if state.safe_head.as_ref().is_some_and(|head| {
8215        head.number > ancestor.number
8216            || (head.number == ancestor.number && head.hash != ancestor.hash)
8217    }) {
8218        state.safe_head = None;
8219    }
8220    if state.finalized_head.as_ref().is_some_and(|head| {
8221        head.number > ancestor.number
8222            || (head.number == ancestor.number && head.hash != ancestor.hash)
8223    }) {
8224        state.finalized_head = None;
8225    }
8226}
8227
8228fn validate_control_phase_order(controls: &[ChainControl]) -> Result<usize, ReactiveError> {
8229    let split = controls
8230        .iter()
8231        .position(|control| !matches!(control, ChainControl::Reorg { .. }))
8232        .unwrap_or(controls.len());
8233    if controls[split..]
8234        .iter()
8235        .any(|control| matches!(control, ChainControl::Reorg { .. }))
8236    {
8237        return Err(ReactiveError::InvalidChainControl {
8238            message: "reorg controls must precede records and all post-record controls in a batch"
8239                .into(),
8240        });
8241    }
8242    Ok(split)
8243}
8244
8245fn canonical_coverage_control_block(control: &ChainControl) -> Option<&BlockRef> {
8246    match control {
8247        ChainControl::CanonicalProgress(block)
8248        | ChainControl::Barrier {
8249            block: Some(block), ..
8250        } => Some(block),
8251        // An attestation names a canonical block but claims no progress to it.
8252        ChainControl::Reorg { .. }
8253        | ChainControl::Safe(_)
8254        | ChainControl::Finalized(_)
8255        | ChainControl::LogCoverage(_)
8256        | ChainControl::Barrier { block: None, .. } => None,
8257    }
8258}
8259
8260fn chain_control_canonical_assertion(control: &ChainControl) -> Option<&BlockRef> {
8261    match control {
8262        ChainControl::Safe(block)
8263        | ChainControl::Finalized(block)
8264        | ChainControl::CanonicalProgress(block)
8265        | ChainControl::LogCoverage(block)
8266        | ChainControl::Barrier {
8267            block: Some(block), ..
8268        } => Some(block),
8269        ChainControl::Reorg { .. } | ChainControl::Barrier { block: None, .. } => None,
8270    }
8271}
8272
8273fn assert_chain_control_identities(
8274    asserted_blocks: &mut HashMap<u64, BlockRef>,
8275    control: &ChainControl,
8276) -> Result<(), ReactiveError> {
8277    match control {
8278        ChainControl::Safe(block)
8279        | ChainControl::Finalized(block)
8280        | ChainControl::CanonicalProgress(block)
8281        | ChainControl::LogCoverage(block)
8282        | ChainControl::Barrier {
8283            block: Some(block), ..
8284        } => assert_canonical_block_identity(asserted_blocks, block, "chain control"),
8285        ChainControl::Barrier { block: None, .. } => Ok(()),
8286        ChainControl::Reorg {
8287            common_ancestor,
8288            new_tip,
8289            ..
8290        } => {
8291            asserted_blocks.retain(|number, _| *number <= common_ancestor.number);
8292            assert_canonical_block_identity(
8293                asserted_blocks,
8294                common_ancestor,
8295                "reorg common ancestor",
8296            )?;
8297            assert_canonical_block_identity(asserted_blocks, new_tip, "reorg new tip")
8298        }
8299    }
8300}
8301
8302fn assert_canonical_block_identity(
8303    asserted_blocks: &mut HashMap<u64, BlockRef>,
8304    block: &BlockRef,
8305    label: &str,
8306) -> Result<(), ReactiveError> {
8307    for asserted in asserted_blocks.values() {
8308        if asserted.hash == block.hash && asserted.number != block.number {
8309            return Err(ReactiveError::InvalidChainControl {
8310                message: format!(
8311                    "{label} hash {:?} is already asserted at height {}, not {}",
8312                    block.hash, asserted.number, block.number
8313                ),
8314            });
8315        }
8316        if block
8317            .parent_hash
8318            .is_some_and(|parent| parent == asserted.hash)
8319            && asserted.number.checked_add(1) != Some(block.number)
8320        {
8321            return Err(ReactiveError::InvalidChainControl {
8322                message: format!(
8323                    "{label} block {}:{:?} names hash {:?} from known height {} as a non-adjacent parent",
8324                    block.number, block.hash, asserted.hash, asserted.number
8325                ),
8326            });
8327        }
8328        if asserted
8329            .parent_hash
8330            .is_some_and(|parent| parent == block.hash)
8331            && block.number.checked_add(1) != Some(asserted.number)
8332        {
8333            return Err(ReactiveError::InvalidChainControl {
8334                message: format!(
8335                    "block {}:{:?} asserted earlier names {label} hash {:?} from non-adjacent height {} as its parent",
8336                    asserted.number, asserted.hash, block.hash, block.number
8337                ),
8338            });
8339        }
8340    }
8341    if let Some(known) = asserted_blocks.get_mut(&block.number) {
8342        if !optional_block_refs_are_compatible(Some(known), Some(block)) {
8343            return Err(ReactiveError::InvalidChainControl {
8344                message: format!(
8345                    "{label} block {}:{:?} conflicts with block identity {:?} asserted earlier in the batch",
8346                    block.number, block.hash, known
8347                ),
8348            });
8349        }
8350        enrich_block_ref(known, block);
8351    } else {
8352        asserted_blocks.insert(block.number, *block);
8353    }
8354    Ok(())
8355}
8356
8357fn set_or_enrich_block_ref(current: &mut Option<BlockRef>, incoming: &BlockRef) {
8358    match current {
8359        Some(current) if current.number == incoming.number && current.hash == incoming.hash => {
8360            enrich_block_ref(current, incoming);
8361        }
8362        _ => *current = Some(*incoming),
8363    }
8364}
8365
8366fn advance_or_enrich_coverage(current: &mut Option<BlockRef>, incoming: &BlockRef) {
8367    match current {
8368        Some(current) if current.number == incoming.number && current.hash == incoming.hash => {
8369            enrich_block_ref(current, incoming);
8370        }
8371        Some(current) if current.number >= incoming.number => {}
8372        _ => *current = Some(*incoming),
8373    }
8374}
8375
8376fn validate_adjacent_finality(
8377    finalized: Option<&BlockRef>,
8378    safe: Option<&BlockRef>,
8379) -> Result<(), ReactiveError> {
8380    let Some((finalized, safe)) = finalized.zip(safe) else {
8381        return Ok(());
8382    };
8383    if finalized.number.checked_add(1) == Some(safe.number)
8384        && safe.parent_hash != Some(finalized.hash)
8385    {
8386        return Err(ReactiveError::InvalidChainControl {
8387            message: "adjacent safe head does not descend from finalized head".into(),
8388        });
8389    }
8390    Ok(())
8391}
8392
8393/// Fold every address a [`StateDiff`] references — genuine changes
8394/// (`slots`/`accounts`/`purged`) and cold-skipped attempts (`skipped*`) alike —
8395/// into `into`. Used by the per-block root gate to accumulate the batch's
8396/// decoder-touched address set: an account a decoder wrote (or tried to write) is
8397/// "covered," so a subsequent root move for it is not a coverage gap.
8398fn collect_diff_addresses(diff: &StateDiff, into: &mut HashSet<Address>) {
8399    into.extend(diff.slots.iter().map(|change| change.address));
8400    into.extend(diff.accounts.iter().map(|change| change.address));
8401    into.extend(diff.purged.iter().map(|purge| purge.address));
8402    into.extend(diff.skipped.iter().map(|skipped| skipped.address));
8403    into.extend(diff.skipped_balances.iter().map(|skipped| skipped.address));
8404    into.extend(diff.skipped_masks.iter().map(|skipped| skipped.address));
8405    into.extend(diff.skipped_accounts.iter().map(|skipped| skipped.address));
8406}
8407
8408/// Build the [`ResyncReason::RootMoved`] account resync the root gate schedules
8409/// for an uncovered move. Re-reads `address`'s `fields` at `block` through the
8410/// existing account-resync path (Wave 2). The id is derived from the address and
8411/// block so a repeated move on the same account/block coalesces deterministically.
8412fn root_moved_account_resync(
8413    address: Address,
8414    block: u64,
8415    fields: AccountFieldMask,
8416) -> ResyncRequest {
8417    ResyncRequest {
8418        id: ResyncId::new(format!("root-moved:{address:#x}:{block}")),
8419        reason: ResyncReason::RootMoved,
8420        block: ResyncBlock::Number(block),
8421        targets: vec![ResyncTarget::Account { address, fields }],
8422        priority: ResyncPriority::Normal,
8423    }
8424}
8425
8426fn batch_preconfirmation<N: Network>(
8427    batch: &ReactiveInputBatch<N>,
8428) -> Result<Option<FlashblockRef>, ReactiveError> {
8429    let mut flashblock: Option<FlashblockRef> = None;
8430    let mut has_non_preconfirmed = false;
8431    for (index, record) in batch.records().iter().enumerate() {
8432        match &record.context.chain_status {
8433            ChainStatus::Preconfirmed {
8434                flashblock: current,
8435            } => {
8436                if batch.record_delivery_scope(index) != Some(DeliveryScope::Preconfirmed) {
8437                    return Err(ReactiveError::InvalidInputRecord {
8438                        message: "pre-confirmed input requires pre-confirmed delivery scope".into(),
8439                    });
8440                }
8441                if flashblock
8442                    .as_ref()
8443                    .is_some_and(|known| known != current.as_ref())
8444                {
8445                    return Err(ReactiveError::InvalidInputRecord {
8446                        message: "one batch cannot mix distinct Flashblock snapshots".into(),
8447                    });
8448                }
8449                flashblock.get_or_insert_with(|| current.as_ref().clone());
8450            }
8451            _ => has_non_preconfirmed = true,
8452        }
8453    }
8454    if flashblock.is_some() && (has_non_preconfirmed || !batch.chain_controls().is_empty()) {
8455        return Err(ReactiveError::InvalidInputRecord {
8456            message: "pre-confirmed delivery cannot mix canonical inputs or chain controls".into(),
8457        });
8458    }
8459    Ok(flashblock)
8460}
8461
8462fn canonical_record_block<N: Network>(record: &ReactiveInputRecord<N>) -> Option<&BlockRef> {
8463    if matches!(&record.input, ReactiveInput::Log(log) if log.removed) {
8464        return None;
8465    }
8466    if is_canonical_status(&record.context.chain_status) {
8467        return context_block_ref(&record.context);
8468    }
8469    None
8470}
8471
8472fn resolve_record_block_payload_metadata<N: Network>(
8473    record: &ReactiveInputRecord<N>,
8474    mut block: BlockRef,
8475) -> Result<BlockRef, ReactiveError> {
8476    let ReactiveInput::Log(log) = &record.input else {
8477        return Ok(block);
8478    };
8479    if log.block_number != Some(block.number) || log.block_hash != Some(block.hash) {
8480        return Err(ReactiveError::InvalidInputRecord {
8481            message: "log payload and canonical context carry different block identities".into(),
8482        });
8483    }
8484    if let Some(timestamp) = log.block_timestamp {
8485        if block.timestamp.is_some_and(|known| known != timestamp) {
8486            return Err(ReactiveError::InvalidInputRecord {
8487                message: "log payload and canonical context carry different block timestamps"
8488                    .into(),
8489            });
8490        }
8491        block.timestamp = Some(timestamp);
8492    }
8493    Ok(block)
8494}
8495
8496fn validate_input_record<N: Network>(record: &ReactiveInputRecord<N>) -> Result<(), ReactiveError> {
8497    let invalid = |message: String| ReactiveError::InvalidInputRecord { message };
8498    if let ChainStatus::Preconfirmed { flashblock } = &record.context.chain_status
8499        && record.context.block != Some(flashblock.block_ref())
8500    {
8501        return Err(invalid(
8502            "pre-confirmed status and context carry different partial block identities".into(),
8503        ));
8504    }
8505    let status_block = match &record.context.chain_status {
8506        ChainStatus::Included { block, .. }
8507        | ChainStatus::Safe { block }
8508        | ChainStatus::Finalized { block }
8509        | ChainStatus::Reorged {
8510            dropped_from: block,
8511        } => Some(block),
8512        ChainStatus::Preconfirmed { .. } => record.context.block.as_ref(),
8513        ChainStatus::Pending => None,
8514    };
8515    match (status_block, record.context.block.as_ref()) {
8516        (Some(status), Some(context)) if status == context => {}
8517        (Some(_), Some(_)) => {
8518            return Err(invalid(
8519                "chain status and context carry different block identities".into(),
8520            ));
8521        }
8522        (Some(_), None) => {
8523            return Err(invalid(
8524                "included or reorged input is missing its context block".into(),
8525            ));
8526        }
8527        (None, Some(_)) => {
8528            return Err(invalid(
8529                "pending input cannot carry a canonical context block".into(),
8530            ));
8531        }
8532        (None, None) => {}
8533    }
8534
8535    match &record.input {
8536        ReactiveInput::Log(log) => {
8537            let Some(block) = status_block else {
8538                return Err(invalid(
8539                    "log input must carry an included or reorged block identity".into(),
8540                ));
8541            };
8542            if log.removed && !matches!(record.context.chain_status, ChainStatus::Reorged { .. }) {
8543                return Err(invalid(
8544                    "removed log must carry reorged chain status".into(),
8545                ));
8546            }
8547            let block_number = log
8548                .block_number
8549                .ok_or_else(|| invalid("log is missing its block number".into()))?;
8550            let block_hash = log
8551                .block_hash
8552                .ok_or_else(|| invalid("log is missing its block hash".into()))?;
8553            log.transaction_hash
8554                .ok_or_else(|| invalid("log is missing its transaction hash".into()))?;
8555            let transaction_index = log
8556                .transaction_index
8557                .ok_or_else(|| invalid("log is missing its transaction index".into()))?;
8558            let log_index = log
8559                .log_index
8560                .ok_or_else(|| invalid("log is missing its log index".into()))?;
8561            if block_number != block.number
8562                || block_hash != block.hash
8563                || !optional_metadata_compatible(
8564                    log.block_timestamp.as_ref(),
8565                    block.timestamp.as_ref(),
8566                )
8567            {
8568                return Err(invalid(
8569                    "log payload and context carry different block identities".into(),
8570                ));
8571            }
8572            if record.context.transaction_index != Some(transaction_index)
8573                || record.context.log_index != Some(log_index)
8574            {
8575                return Err(invalid(
8576                    "log payload and context carry different transaction/log positions".into(),
8577                ));
8578            }
8579        }
8580        ReactiveInput::BlockHeader(header) => {
8581            if let Some(block) = status_block {
8582                if header.number() != block.number
8583                    || header.hash() != block.hash
8584                    || Some(header.parent_hash()) != block.parent_hash
8585                    || Some(header.timestamp()) != block.timestamp
8586                {
8587                    return Err(invalid(
8588                        "block header payload and context carry different block identities".into(),
8589                    ));
8590                }
8591            } else if !matches!(record.context.chain_status, ChainStatus::Pending) {
8592                return Err(invalid("block header has an unsupported lifecycle".into()));
8593            }
8594            if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
8595                return Err(invalid(
8596                    "block header context cannot carry transaction/log positions".into(),
8597                ));
8598            }
8599        }
8600        ReactiveInput::FullBlock(block_response) => {
8601            let header = block_response.header();
8602            if let Some(block) = status_block {
8603                if header.number() != block.number
8604                    || header.hash() != block.hash
8605                    || Some(header.parent_hash()) != block.parent_hash
8606                    || Some(header.timestamp()) != block.timestamp
8607                {
8608                    return Err(invalid(
8609                        "full-block payload and context carry different block identities".into(),
8610                    ));
8611                }
8612            } else if !matches!(record.context.chain_status, ChainStatus::Pending) {
8613                return Err(invalid("full block has an unsupported lifecycle".into()));
8614            }
8615            if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
8616                return Err(invalid(
8617                    "full-block context cannot carry transaction/log positions".into(),
8618                ));
8619            }
8620            if let Some(transactions) = block_response.transactions().as_transactions() {
8621                for (index, transaction) in transactions.iter().enumerate() {
8622                    if transaction
8623                        .block_hash()
8624                        .is_some_and(|hash| hash != header.hash())
8625                        || transaction
8626                            .block_number()
8627                            .is_some_and(|number| number != header.number())
8628                        || transaction
8629                            .transaction_index()
8630                            .is_some_and(|position| position != index as u64)
8631                    {
8632                        return Err(invalid(format!(
8633                            "full-block transaction {index} carries contradictory inclusion metadata"
8634                        )));
8635                    }
8636                    if transaction
8637                        .chain_id()
8638                        .zip(record.context.chain_id)
8639                        .is_some_and(|(transaction, context)| transaction != context)
8640                    {
8641                        return Err(invalid(format!(
8642                            "full-block transaction {index} carries a chain id conflicting with its context"
8643                        )));
8644                    }
8645                }
8646            }
8647        }
8648        ReactiveInput::PendingTxHash(_) => {
8649            if !matches!(record.context.chain_status, ChainStatus::Pending) {
8650                return Err(invalid(
8651                    "pending transaction input must carry pending chain status".into(),
8652                ));
8653            }
8654            if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
8655                return Err(invalid(
8656                    "pending transaction context cannot carry canonical positions".into(),
8657                ));
8658            }
8659        }
8660        ReactiveInput::PendingTx(transaction) => {
8661            if !matches!(record.context.chain_status, ChainStatus::Pending) {
8662                return Err(invalid(
8663                    "pending transaction input must carry pending chain status".into(),
8664                ));
8665            }
8666            if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
8667                return Err(invalid(
8668                    "pending transaction context cannot carry canonical positions".into(),
8669                ));
8670            }
8671            if transaction.block_hash().is_some()
8672                || transaction.block_number().is_some()
8673                || transaction.transaction_index().is_some()
8674            {
8675                return Err(invalid(
8676                    "hydrated pending transaction cannot carry inclusion metadata".into(),
8677                ));
8678            }
8679            if transaction
8680                .chain_id()
8681                .zip(record.context.chain_id)
8682                .is_some_and(|(transaction, context)| transaction != context)
8683            {
8684                return Err(invalid(
8685                    "pending transaction carries a chain id conflicting with its context".into(),
8686                ));
8687            }
8688        }
8689    }
8690    Ok(())
8691}
8692
8693/// Best-effort per-block env refresh (Phase-8 step 2).
8694///
8695/// For a canonical record carrying a full header — a
8696/// [`ReactiveInput::BlockHeader`] or [`ReactiveInput::FullBlock`] — refresh the
8697/// cache's block env from that header via [`EvmCache::advance_block`]. Returns
8698/// `Some(result)` when a header was present (so the caller can surface a strict
8699/// validation error), and `None` for pending/reorged records or non-header
8700/// inputs, which must never drive a canonical env refresh.
8701fn advance_block_for_canonical_record<N: Network>(
8702    cache: &mut EvmCache,
8703    record: &ReactiveInputRecord<N>,
8704) -> Option<Result<(), BlockContextError>> {
8705    if !is_canonical_status(&record.context.chain_status) {
8706        return None;
8707    }
8708    match &record.input {
8709        ReactiveInput::BlockHeader(header) => Some(cache.advance_block(header)),
8710        ReactiveInput::FullBlock(block) => Some(cache.advance_block(block.header())),
8711        _ => None,
8712    }
8713}
8714
8715fn context_block_ref(ctx: &ReactiveContext) -> Option<&BlockRef> {
8716    match &ctx.chain_status {
8717        ChainStatus::Included { block, .. }
8718        | ChainStatus::Safe { block }
8719        | ChainStatus::Finalized { block } => Some(block),
8720        ChainStatus::Reorged { dropped_from } => Some(dropped_from),
8721        ChainStatus::Preconfirmed { .. } => ctx.block.as_ref(),
8722        ChainStatus::Pending => ctx.block.as_ref(),
8723    }
8724}
8725
8726fn reorg_signal_block<N: Network>(
8727    record: &ReactiveInputRecord<N>,
8728) -> Option<(BlockRef, ReorgReason)> {
8729    if matches!(&record.input, ReactiveInput::Log(log) if log.removed) {
8730        return block_ref_from_record(record).map(|block| (block, ReorgReason::RemovedLog));
8731    }
8732
8733    if let ChainStatus::Reorged { dropped_from } = &record.context.chain_status {
8734        return Some((*dropped_from, ReorgReason::ReorgedInput));
8735    }
8736
8737    None
8738}
8739
8740fn block_ref_from_record<N: Network>(record: &ReactiveInputRecord<N>) -> Option<BlockRef> {
8741    context_block_ref(&record.context)
8742        .cloned()
8743        .or_else(|| match &record.input {
8744            ReactiveInput::Log(log) => Some(BlockRef {
8745                number: log.block_number?,
8746                hash: log.block_hash?,
8747                parent_hash: None,
8748                timestamp: log.block_timestamp,
8749            }),
8750            ReactiveInput::BlockHeader(header) => Some(BlockRef {
8751                number: header.number(),
8752                hash: header.hash(),
8753                parent_hash: Some(header.parent_hash()),
8754                timestamp: Some(header.timestamp()),
8755            }),
8756            ReactiveInput::FullBlock(block) => {
8757                let header = block.header();
8758                Some(BlockRef {
8759                    number: header.number(),
8760                    hash: header.hash(),
8761                    parent_hash: Some(header.parent_hash()),
8762                    timestamp: Some(header.timestamp()),
8763                })
8764            }
8765            ReactiveInput::PendingTxHash(_) | ReactiveInput::PendingTx(_) => None,
8766        })
8767}
8768
8769fn remove_canceled_resyncs_from_batch(
8770    resyncs: &mut Vec<ResyncRequest>,
8771    canceled: &[ResyncRequest],
8772) {
8773    if canceled.is_empty() {
8774        return;
8775    }
8776    let canceled_ids: HashSet<_> = canceled.iter().map(|request| request.id.clone()).collect();
8777    resyncs.retain(|request| !canceled_ids.contains(&request.id));
8778}
8779
8780fn resync_target_address(target: &ResyncTarget) -> Address {
8781    match target {
8782        ResyncTarget::StorageSlot { address, .. }
8783        | ResyncTarget::StorageSlots { address, .. }
8784        | ResyncTarget::Account { address, .. } => *address,
8785    }
8786}
8787
8788fn resync_request_targets_dropped_block(
8789    request: &ResyncRequest,
8790    dropped_blocks: &[BlockRef],
8791) -> bool {
8792    let ResyncBlock::Hash { number, hash, .. } = &request.block else {
8793        return false;
8794    };
8795    dropped_blocks
8796        .iter()
8797        .any(|block| block.hash == *hash && block.number == *number)
8798}
8799
8800fn single_hash_pinned_resync_block(report: &ResyncReport) -> Option<BlockRef> {
8801    let first = report.requested.first()?.block.clone();
8802    if !report
8803        .requested
8804        .iter()
8805        .all(|request| request.block == first)
8806    {
8807        return None;
8808    }
8809
8810    let ResyncBlock::Hash { number, hash, .. } = first else {
8811        return None;
8812    };
8813
8814    Some(BlockRef {
8815        number,
8816        hash,
8817        parent_hash: None,
8818        timestamp: None,
8819    })
8820}
8821
8822fn purge_scopes_for_dropped_journals<N: Network>(
8823    dropped: &[BlockJournal<N>],
8824) -> Vec<(Address, PurgeScope)> {
8825    let mut scopes: Vec<(Address, PurgeScope)> = Vec::new();
8826    for entry in dropped.iter().rev() {
8827        for diff in entry.rollback_diffs.iter().rev() {
8828            merge_purge_scopes_for_diff(&mut scopes, diff);
8829        }
8830    }
8831    scopes
8832}
8833
8834fn rollback_updates_for_dropped_journals<N: Network>(
8835    dropped: &[BlockJournal<N>],
8836    purge_scopes: &[(Address, PurgeScope)],
8837) -> Vec<StateUpdate> {
8838    let purge_addresses: HashSet<_> = purge_scopes
8839        .iter()
8840        .map(|(address, _scope)| *address)
8841        .collect();
8842    let mut updates = Vec::new();
8843    for entry in dropped.iter().rev() {
8844        for diff in entry.rollback_diffs.iter().rev() {
8845            push_rollback_updates_for_diff(&mut updates, diff, &purge_addresses);
8846        }
8847    }
8848    updates
8849}
8850
8851fn merge_purge_scopes_for_diff(scopes: &mut Vec<(Address, PurgeScope)>, diff: &StateDiff) {
8852    for change in &diff.accounts {
8853        merge_purge_scope(scopes, change.address, PurgeScope::Account);
8854    }
8855    for record in &diff.purged {
8856        merge_purge_scope(scopes, record.address, record.scope.clone());
8857    }
8858}
8859
8860fn push_rollback_updates_for_diff(
8861    updates: &mut Vec<StateUpdate>,
8862    diff: &StateDiff,
8863    purge_addresses: &HashSet<Address>,
8864) {
8865    for change in diff.slots.iter().rev() {
8866        if purge_addresses.contains(&change.address) {
8867            continue;
8868        }
8869        updates.push(StateUpdate::slot(change.address, change.slot, change.old));
8870    }
8871}
8872
8873fn merge_purge_scope(scopes: &mut Vec<(Address, PurgeScope)>, address: Address, scope: PurgeScope) {
8874    if let Some((_existing_address, existing_scope)) = scopes
8875        .iter_mut()
8876        .find(|(existing_address, _scope)| *existing_address == address)
8877    {
8878        *existing_scope = merged_purge_scope(existing_scope.clone(), scope);
8879    } else {
8880        scopes.push((address, scope));
8881    }
8882}
8883
8884fn merged_purge_scope(left: PurgeScope, right: PurgeScope) -> PurgeScope {
8885    match (left, right) {
8886        (PurgeScope::Account, _) | (_, PurgeScope::Account) => PurgeScope::Account,
8887        (PurgeScope::AllStorage, _) | (_, PurgeScope::AllStorage) => PurgeScope::AllStorage,
8888        (PurgeScope::Slots(mut left), PurgeScope::Slots(right)) => {
8889            for slot in right {
8890                if !left.contains(&slot) {
8891                    left.push(slot);
8892                }
8893            }
8894            PurgeScope::Slots(left)
8895        }
8896    }
8897}
8898
8899#[derive(Clone, Debug)]
8900struct StorageFetchSlot {
8901    address: Address,
8902    slot: U256,
8903    origins: Vec<StorageFetchOrigin>,
8904}
8905
8906#[derive(Clone, Debug)]
8907struct StorageFetchOrigin {
8908    request_id: ResyncId,
8909    target: ResyncTarget,
8910}
8911
8912#[derive(Clone, Debug)]
8913struct StorageFetchGroup {
8914    block: ResyncBlock,
8915    slots: Vec<StorageFetchSlot>,
8916    seen: HashSet<(Address, U256)>,
8917}
8918
8919/// One account-target resync collected during request scanning, resolved through
8920/// the account proof fetcher after storage groups are processed.
8921#[derive(Clone, Debug)]
8922struct AccountResyncTarget {
8923    request_id: ResyncId,
8924    block: ResyncBlock,
8925    address: Address,
8926    fields: AccountFieldMask,
8927}
8928
8929fn resolve_trace_resyncs(
8930    cache: &EvmCache,
8931    storage_groups: &mut Vec<StorageFetchGroup>,
8932    account_targets: &mut Vec<AccountResyncTarget>,
8933    state_updates: &mut Vec<StateUpdate>,
8934) {
8935    let Some(fetcher) = cache.block_state_diff_fetcher().cloned() else {
8936        return;
8937    };
8938
8939    let mut blocks = Vec::new();
8940    let mut seen = HashSet::new();
8941    for block in storage_groups
8942        .iter()
8943        .map(|group| group.block.clone())
8944        .chain(account_targets.iter().map(|target| target.block.clone()))
8945    {
8946        if seen.insert(block.clone()) {
8947            blocks.push(block);
8948        }
8949    }
8950
8951    let mut traces = HashMap::new();
8952    for block in blocks {
8953        match (fetcher)(resync_block_to_block_id(&block)) {
8954            Ok(diff) => {
8955                traces.insert(block, diff);
8956            }
8957            Err(error) => {
8958                tracing::debug!(
8959                    block = ?block,
8960                    error = %error,
8961                    "block trace resync source failed; falling back to point resync"
8962                );
8963            }
8964        }
8965    }
8966
8967    for group in storage_groups.iter_mut() {
8968        let Some(trace) = traces.get(&group.block) else {
8969            continue;
8970        };
8971        group.slots.retain(|slot| {
8972            if let Some(value) = trace_storage_value(trace, slot.address, slot.slot) {
8973                state_updates.push(StateUpdate::slot(slot.address, slot.slot, value));
8974                return false;
8975            }
8976            cache
8977                .cached_storage_value(slot.address, slot.slot)
8978                .is_none()
8979        });
8980        group.seen = group
8981            .slots
8982            .iter()
8983            .map(|slot| (slot.address, slot.slot))
8984            .collect();
8985    }
8986    storage_groups.retain(|group| !group.slots.is_empty());
8987
8988    let mut unresolved_accounts = Vec::new();
8989    for mut account in account_targets.drain(..) {
8990        let Some(trace) = traces.get(&account.block) else {
8991            unresolved_accounts.push(account);
8992            continue;
8993        };
8994        let Some(trace_account) = trace
8995            .accounts
8996            .iter()
8997            .find(|diff| diff.address == account.address)
8998        else {
8999            unresolved_accounts.push(account);
9000            continue;
9001        };
9002
9003        let mut patch = AccountPatch::default();
9004        let mut unresolved = AccountFieldMask::default();
9005        if account.fields.balance {
9006            if let Some(balance) = trace_account.balance {
9007                patch = patch.balance(balance);
9008            } else {
9009                unresolved.balance = true;
9010            }
9011        }
9012        if account.fields.nonce {
9013            if let Some(nonce) = trace_account.nonce {
9014                patch = patch.nonce(nonce);
9015            } else {
9016                unresolved.nonce = true;
9017            }
9018        }
9019        if account.fields.code {
9020            if let Some(code) = &trace_account.code {
9021                patch = patch.code(code.clone());
9022            } else {
9023                unresolved.code = true;
9024            }
9025        }
9026
9027        if patch.balance.is_some() || patch.nonce.is_some() || patch.code.is_some() {
9028            state_updates.push(StateUpdate::account_upsert(account.address, patch));
9029        }
9030        if !account_field_mask_empty(unresolved) {
9031            account.fields = unresolved;
9032            unresolved_accounts.push(account);
9033        }
9034    }
9035    *account_targets = unresolved_accounts;
9036}
9037
9038fn trace_storage_value(trace: &BlockStateDiff, address: Address, slot: U256) -> Option<U256> {
9039    trace
9040        .accounts
9041        .iter()
9042        .find(|account| account.address == address)
9043        .and_then(|account| {
9044            account
9045                .storage
9046                .iter()
9047                .find(|entry| entry.slot == slot)
9048                .map(|entry| entry.value)
9049        })
9050}
9051
9052fn account_field_mask_empty(mask: AccountFieldMask) -> bool {
9053    !mask.balance && !mask.nonce && !mask.code
9054}
9055
9056fn execute_resync_requests(cache: &mut EvmCache, requests: &[ResyncRequest]) -> ResyncReport {
9057    let mut failed = Vec::new();
9058    let mut storage_groups: Vec<StorageFetchGroup> = Vec::new();
9059    let mut account_targets: Vec<AccountResyncTarget> = Vec::new();
9060
9061    for request in requests {
9062        for target in &request.targets {
9063            match target {
9064                ResyncTarget::StorageSlot { address, slot } => {
9065                    push_storage_resync_slot(
9066                        &mut storage_groups,
9067                        &request.id,
9068                        &request.block,
9069                        *address,
9070                        *slot,
9071                    );
9072                }
9073                ResyncTarget::StorageSlots { address, slots } => {
9074                    for slot in slots {
9075                        push_storage_resync_slot(
9076                            &mut storage_groups,
9077                            &request.id,
9078                            &request.block,
9079                            *address,
9080                            *slot,
9081                        );
9082                    }
9083                }
9084                ResyncTarget::Account { address, fields } => {
9085                    account_targets.push(AccountResyncTarget {
9086                        request_id: request.id.clone(),
9087                        block: request.block.clone(),
9088                        address: *address,
9089                        fields: *fields,
9090                    });
9091                }
9092            }
9093        }
9094    }
9095
9096    let mut state_updates = Vec::new();
9097    resolve_trace_resyncs(
9098        cache,
9099        &mut storage_groups,
9100        &mut account_targets,
9101        &mut state_updates,
9102    );
9103
9104    if !storage_groups.is_empty() {
9105        if let Some(fetcher) = cache.storage_batch_fetcher().cloned() {
9106            for group in storage_groups {
9107                let block = group.block.clone();
9108                let fetches: Vec<(Address, U256)> = group
9109                    .slots
9110                    .iter()
9111                    .map(|slot| (slot.address, slot.slot))
9112                    .collect();
9113                let results = (fetcher)(fetches, resync_block_to_block_id(&block));
9114                let mut pending: HashMap<(Address, U256), StorageFetchSlot> = group
9115                    .slots
9116                    .iter()
9117                    .cloned()
9118                    .map(|slot| ((slot.address, slot.slot), slot))
9119                    .collect();
9120
9121                for (address, slot, fetched) in results {
9122                    let Some(requested_slot) = pending.remove(&(address, slot)) else {
9123                        continue;
9124                    };
9125                    match fetched {
9126                        Ok(value) => state_updates.push(StateUpdate::slot(address, slot, value)),
9127                        Err(error) => {
9128                            let message = error.to_string();
9129                            push_resync_failures(
9130                                &mut failed,
9131                                &block,
9132                                requested_slot.origins,
9133                                ResyncFailureKind::StorageFetchFailed,
9134                                message,
9135                            );
9136                        }
9137                    }
9138                }
9139
9140                for requested_slot in group.slots {
9141                    if pending
9142                        .remove(&(requested_slot.address, requested_slot.slot))
9143                        .is_some()
9144                    {
9145                        push_resync_failures(
9146                            &mut failed,
9147                            &block,
9148                            requested_slot.origins,
9149                            ResyncFailureKind::StorageFetchOmitted,
9150                            "storage batch fetcher did not return a value for slot".to_string(),
9151                        );
9152                    }
9153                }
9154            }
9155        } else {
9156            for group in storage_groups {
9157                let block = group.block.clone();
9158                for slot in group.slots {
9159                    push_resync_failures(
9160                        &mut failed,
9161                        &block,
9162                        slot.origins,
9163                        ResyncFailureKind::MissingStorageFetcher,
9164                        "storage resync requires a storage batch fetcher".to_string(),
9165                    );
9166                }
9167            }
9168        }
9169    }
9170
9171    if !account_targets.is_empty() {
9172        if let Some(fetcher) = cache.account_proof_fetcher().cloned() {
9173            // ONE seam invocation per distinct resync block (targets may pin
9174            // different blocks): eth_getProof is single-address at the RPC
9175            // level, so batching the addresses lets the fetcher fan the
9176            // requests out concurrently instead of paying one round trip per
9177            // account. Root-only probes: account fields need no storage keys.
9178            let mut groups: Vec<(BlockId, Vec<_>)> = Vec::new();
9179            for account in account_targets {
9180                let block_id = resync_block_to_block_id(&account.block);
9181                match groups
9182                    .iter_mut()
9183                    .find(|(group_block, _)| *group_block == block_id)
9184                {
9185                    Some((_, group)) => group.push(account),
9186                    None => groups.push((block_id, vec![account])),
9187                }
9188            }
9189            for (block_id, group) in groups {
9190                let probes: HashMap<Address, StorageFetchResult<AccountProof>> = (fetcher)(
9191                    group
9192                        .iter()
9193                        .map(|account| (account.address, vec![]))
9194                        .collect(),
9195                    block_id,
9196                )
9197                .into_iter()
9198                .collect();
9199                for account in group {
9200                    // `get` + clone rather than `remove`: two targets for the
9201                    // same address in one group must both resolve from the
9202                    // single probe.
9203                    match probes.get(&account.address).cloned() {
9204                        Some(Ok(proof)) => {
9205                            // Build an authoritative account update from the requested
9206                            // field mask. Use the MATERIALIZING `account_upsert` so a
9207                            // resync applies even to a cold account (a partial `Account`
9208                            // patch on a cold address is silently skipped).
9209                            let mut patch = AccountPatch::default();
9210                            if account.fields.balance {
9211                                patch = patch.balance(proof.balance);
9212                            }
9213                            if account.fields.nonce {
9214                                patch = patch.nonce(proof.nonce);
9215                            }
9216                            // Note: `AccountProof` carries `code_hash`, not code bytes;
9217                            // the `eth_getProof` seam cannot supply runtime code, so a
9218                            // code-field resync is a no-op here (code freshness is
9219                            // handled by a later wave). We still materialize the account
9220                            // so requested balance/nonce fields take effect.
9221                            state_updates.push(StateUpdate::account_upsert(account.address, patch));
9222                        }
9223                        Some(Err(error)) => {
9224                            failed.push(ResyncFailure {
9225                                request_id: account.request_id,
9226                                block: account.block,
9227                                target: ResyncTarget::Account {
9228                                    address: account.address,
9229                                    fields: account.fields,
9230                                },
9231                                kind: ResyncFailureKind::AccountFetchFailed,
9232                                message: error.to_string(),
9233                            });
9234                        }
9235                        None => {
9236                            failed.push(ResyncFailure {
9237                                request_id: account.request_id,
9238                                block: account.block,
9239                                target: ResyncTarget::Account {
9240                                    address: account.address,
9241                                    fields: account.fields,
9242                                },
9243                                kind: ResyncFailureKind::AccountFetchOmitted,
9244                                message:
9245                                    "account proof fetcher did not return a result for address"
9246                                        .to_string(),
9247                            });
9248                        }
9249                    }
9250                }
9251            }
9252        } else {
9253            for account in account_targets {
9254                failed.push(ResyncFailure {
9255                    request_id: account.request_id,
9256                    block: account.block,
9257                    target: ResyncTarget::Account {
9258                        address: account.address,
9259                        fields: account.fields,
9260                    },
9261                    kind: ResyncFailureKind::MissingAccountFetcher,
9262                    message: "account resync requires an account proof fetcher".to_string(),
9263                });
9264            }
9265        }
9266    }
9267
9268    let diff = if state_updates.is_empty() {
9269        StateDiff::default()
9270    } else {
9271        cache.apply_updates(&state_updates)
9272    };
9273
9274    ResyncReport {
9275        requested: requests.to_vec(),
9276        state_updates,
9277        diff,
9278        failed,
9279    }
9280}
9281
9282fn push_resync_failures(
9283    failed: &mut Vec<ResyncFailure>,
9284    block: &ResyncBlock,
9285    origins: Vec<StorageFetchOrigin>,
9286    kind: ResyncFailureKind,
9287    message: String,
9288) {
9289    for origin in origins {
9290        failed.push(ResyncFailure {
9291            request_id: origin.request_id,
9292            block: block.clone(),
9293            target: origin.target,
9294            kind,
9295            message: message.clone(),
9296        });
9297    }
9298}
9299
9300fn push_storage_resync_slot(
9301    groups: &mut Vec<StorageFetchGroup>,
9302    request_id: &ResyncId,
9303    block: &ResyncBlock,
9304    address: Address,
9305    slot: U256,
9306) {
9307    let group_index = if let Some(index) = groups.iter().position(|group| group.block == *block) {
9308        index
9309    } else {
9310        groups.push(StorageFetchGroup {
9311            block: block.clone(),
9312            slots: Vec::new(),
9313            seen: HashSet::new(),
9314        });
9315        groups.len() - 1
9316    };
9317
9318    let group = &mut groups[group_index];
9319    let origin = StorageFetchOrigin {
9320        request_id: request_id.clone(),
9321        target: ResyncTarget::StorageSlot { address, slot },
9322    };
9323    if group.seen.insert((address, slot)) {
9324        group.slots.push(StorageFetchSlot {
9325            address,
9326            slot,
9327            origins: vec![origin],
9328        });
9329    } else if let Some(existing) = group
9330        .slots
9331        .iter_mut()
9332        .find(|existing| existing.address == address && existing.slot == slot)
9333    {
9334        existing.origins.push(origin);
9335    }
9336}
9337
9338fn resync_block_to_block_id(block: &ResyncBlock) -> BlockId {
9339    match block {
9340        ResyncBlock::Latest => BlockId::latest(),
9341        ResyncBlock::Pending => BlockId::pending(),
9342        ResyncBlock::Safe => BlockId::safe(),
9343        ResyncBlock::Finalized => BlockId::finalized(),
9344        ResyncBlock::Number(number) => BlockId::number(*number),
9345        ResyncBlock::Hash {
9346            number: _,
9347            hash,
9348            require_canonical,
9349        } => BlockId::from((*hash, Some(*require_canonical))),
9350    }
9351}
9352
9353impl<N: Network> RegisteredHandler<N> {
9354    fn matches(&self, input: &ReactiveInput<N>) -> bool {
9355        self.interests
9356            .iter()
9357            .any(|interest| interest_matches(interest, input))
9358    }
9359
9360    fn route_log(&self, log: &Log) -> Option<ReactiveLogRoute> {
9361        self.interests.iter().find_map(|interest| match interest {
9362            ReactiveInterest::Logs(interest) if interest.matches(log) => Some(ReactiveLogRoute {
9363                handler_id: self.id.clone(),
9364                route_key: interest.route_key(log),
9365            }),
9366            ReactiveInterest::Logs(_)
9367            | ReactiveInterest::Blocks(_)
9368            | ReactiveInterest::PendingTransactions(_) => None,
9369        })
9370    }
9371}
9372
9373fn merge_log_subscription_filter(filters: &mut Vec<Filter>, next: &Filter) {
9374    let mut candidate = next.clone();
9375    let mut insertion_index = filters.len();
9376    let mut index = 0;
9377    while index < filters.len() {
9378        if filters[index].block_option != candidate.block_option {
9379            index += 1;
9380            continue;
9381        }
9382        if let Some(merged) = exact_filter_union(&candidate, &filters[index]) {
9383            candidate = merged;
9384            insertion_index = insertion_index.min(index);
9385            filters.remove(index);
9386            index = 0;
9387        } else {
9388            index += 1;
9389        }
9390    }
9391    filters.insert(insertion_index.min(filters.len()), candidate);
9392}
9393
9394fn exact_filter_union(left: &Filter, right: &Filter) -> Option<Filter> {
9395    if filter_subsumes(left, right) {
9396        return Some(left.clone());
9397    }
9398    if filter_subsumes(right, left) {
9399        return Some(right.clone());
9400    }
9401    let differing_dimensions = usize::from(left.address != right.address)
9402        + left
9403            .topics
9404            .iter()
9405            .zip(right.topics.iter())
9406            .filter(|(left, right)| left != right)
9407            .count();
9408    if differing_dimensions != 1 {
9409        return None;
9410    }
9411
9412    let mut merged = left.clone();
9413    if merged.address != right.address {
9414        merge_filter_set(&mut merged.address, &right.address);
9415    } else {
9416        for (merged_topic, right_topic) in merged.topics.iter_mut().zip(right.topics.iter()) {
9417            if merged_topic != right_topic {
9418                merge_filter_set(merged_topic, right_topic);
9419                break;
9420            }
9421        }
9422    }
9423    Some(merged)
9424}
9425
9426fn filter_subsumes(left: &Filter, right: &Filter) -> bool {
9427    filter_set_subsumes(&left.address, &right.address)
9428        && left
9429            .topics
9430            .iter()
9431            .zip(right.topics.iter())
9432            .all(|(left, right)| filter_set_subsumes(left, right))
9433}
9434
9435fn filter_set_subsumes<T: Eq + Hash>(left: &FilterSet<T>, right: &FilterSet<T>) -> bool {
9436    left.is_empty()
9437        || (!right.is_empty()
9438            && right
9439                .iter()
9440                .all(|value| left.iter().any(|known| known == value)))
9441}
9442
9443fn merge_filter_set<T: Clone + Eq + Hash>(target: &mut FilterSet<T>, source: &FilterSet<T>) {
9444    if target.is_empty() {
9445        return;
9446    }
9447    if source.is_empty() {
9448        *target = FilterSet::default();
9449        return;
9450    }
9451    for value in source.iter() {
9452        target.insert(value.clone());
9453    }
9454}
9455
9456#[derive(Clone, Debug)]
9457struct HandlerExecution {
9458    handler_id: HandlerId,
9459    quality: StateEffectQuality,
9460    tags: Vec<ReportTag>,
9461    state_updates: Vec<StateUpdate>,
9462    invalidations: Vec<InvalidationRequest>,
9463    resyncs: Vec<ResyncRequest>,
9464    speculative: Vec<SpeculativeRequest>,
9465    hook_signals: Vec<HookSignal>,
9466}
9467
9468impl HandlerExecution {
9469    fn from_outcome(
9470        handler_id: HandlerId,
9471        input_ref: InputRef,
9472        outcome: HandlerOutcome,
9473        preconfirmed: bool,
9474    ) -> Self {
9475        let mut state_updates = Vec::new();
9476        let mut invalidations = Vec::new();
9477        let mut resyncs = Vec::new();
9478        let mut speculative = Vec::new();
9479        let mut hook_signals = Vec::new();
9480
9481        for effect in outcome.effects {
9482            match effect {
9483                ReactiveEffect::StateUpdate(update) => state_updates.push(update),
9484                ReactiveEffect::Invalidate(invalidation) => {
9485                    state_updates.push(StateUpdate::purge(
9486                        invalidation.address,
9487                        invalidation.scope.clone(),
9488                    ));
9489                    invalidations.push(invalidation);
9490                }
9491                ReactiveEffect::Resync(mut request) => {
9492                    if preconfirmed {
9493                        request.block = ResyncBlock::Pending;
9494                    }
9495                    resyncs.push(request);
9496                }
9497                ReactiveEffect::Hook(signal) => hook_signals.push(signal),
9498                ReactiveEffect::Speculative(mut request) => {
9499                    request.input_ref = input_ref;
9500                    speculative.push(request);
9501                }
9502            }
9503        }
9504
9505        Self {
9506            handler_id,
9507            quality: outcome.quality,
9508            tags: outcome.tags,
9509            state_updates,
9510            invalidations,
9511            resyncs,
9512            speculative,
9513            hook_signals,
9514        }
9515    }
9516}
9517
9518fn dedupe_records<N: Network>(
9519    records: Vec<ReactiveInputRecord<N>>,
9520) -> Result<Vec<ReactiveInputRecord<N>>, ReactiveError> {
9521    let mut positions = HashMap::<ReactiveInputIdentity, usize>::new();
9522    let mut deduped = Vec::with_capacity(records.len());
9523    for record in records {
9524        let identity = record.validated_identity()?;
9525        if !record.is_payload_deduplicable() {
9526            deduped.push(record);
9527            continue;
9528        }
9529        if let Some(index) = positions.get(&identity).copied() {
9530            let merged = deduped[index].merge_compatible_duplicate(&record)?;
9531            debug_assert!(merged, "same indexed identity is deduplicable");
9532        } else {
9533            positions.insert(identity, deduped.len());
9534            deduped.push(record);
9535        }
9536    }
9537    Ok(deduped)
9538}
9539
9540fn dedupe_scoped_records<N: Network>(
9541    records: Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>,
9542) -> Result<Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>, ReactiveError> {
9543    let mut positions: HashMap<ReactiveInputIdentity, usize> = HashMap::new();
9544    let mut deduped: Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)> =
9545        Vec::with_capacity(records.len());
9546    for (record, audience, delivery_scope) in records {
9547        let identity = record.validated_identity()?;
9548        if !record.is_payload_deduplicable() {
9549            deduped.push((record, audience, delivery_scope));
9550            continue;
9551        }
9552        if let Some(index) = positions.get(&identity).copied() {
9553            let merged = deduped[index].0.merge_compatible_duplicate(&record)?;
9554            debug_assert!(merged, "same indexed identity is deduplicable");
9555            merge_delivery_audience(&mut deduped[index].1, audience);
9556            merge_delivery_scope(&mut deduped[index].2, delivery_scope);
9557        } else {
9558            positions.insert(identity, deduped.len());
9559            deduped.push((record, audience, delivery_scope));
9560        }
9561    }
9562    Ok(deduped)
9563}
9564
9565fn merge_delivery_scope(into: &mut DeliveryScope, incoming: DeliveryScope) {
9566    *into = match (*into, incoming) {
9567        (DeliveryScope::Canonical, _) | (_, DeliveryScope::Canonical) => DeliveryScope::Canonical,
9568        (DeliveryScope::CanonicalProgress, _) | (_, DeliveryScope::CanonicalProgress) => {
9569            DeliveryScope::CanonicalProgress
9570        }
9571        (DeliveryScope::Preconfirmed, DeliveryScope::Preconfirmed)
9572        | (DeliveryScope::Preconfirmed, DeliveryScope::OwnerCatchup)
9573        | (DeliveryScope::OwnerCatchup, DeliveryScope::Preconfirmed) => DeliveryScope::Preconfirmed,
9574        (DeliveryScope::OwnerCatchup, DeliveryScope::OwnerCatchup) => DeliveryScope::OwnerCatchup,
9575    };
9576}
9577
9578fn merge_delivery_audience(into: &mut DeliveryAudience, incoming: DeliveryAudience) {
9579    match (&mut *into, incoming) {
9580        (DeliveryAudience::All, _) => {}
9581        (current, DeliveryAudience::All) => *current = DeliveryAudience::All,
9582        (DeliveryAudience::Owners(current), DeliveryAudience::Owners(incoming)) => {
9583            for owner in incoming {
9584                if !current.contains(&owner) {
9585                    current.push(owner);
9586                }
9587            }
9588        }
9589        (DeliveryAudience::AllExcept(current), DeliveryAudience::AllExcept(incoming)) => {
9590            current.retain(|owner| incoming.contains(owner));
9591        }
9592        (DeliveryAudience::AllExcept(excluded), DeliveryAudience::Owners(included)) => {
9593            excluded.retain(|owner| !included.contains(owner));
9594        }
9595        (current @ DeliveryAudience::Owners(_), DeliveryAudience::AllExcept(mut excluded)) => {
9596            let DeliveryAudience::Owners(included) = current else {
9597                unreachable!("match arm restricts the audience variant")
9598            };
9599            excluded.retain(|owner| !included.contains(owner));
9600            *current = DeliveryAudience::AllExcept(excluded);
9601        }
9602    }
9603}
9604
9605fn sort_records<N: Network>(records: Vec<ReactiveInputRecord<N>>) -> Vec<ReactiveInputRecord<N>> {
9606    let mut indexed: Vec<(usize, ReactiveInputRecord<N>)> =
9607        records.into_iter().enumerate().collect();
9608    indexed.sort_by_key(|(index, record)| record_sort_key(*index, record));
9609    indexed.into_iter().map(|(_, record)| record).collect()
9610}
9611
9612fn sort_scoped_records<N: Network>(
9613    records: Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>,
9614) -> Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)> {
9615    let mut indexed: Vec<_> = records.into_iter().enumerate().collect();
9616    indexed.sort_by_key(|(index, (record, _, _))| record_sort_key(*index, record));
9617    indexed
9618        .into_iter()
9619        .map(|(_, scoped_record)| scoped_record)
9620        .collect()
9621}
9622
9623fn record_sort_key<N: Network>(index: usize, record: &ReactiveInputRecord<N>) -> RecordSortKey {
9624    if let Some((block, _)) = reorg_signal_block(record) {
9625        return RecordSortKey {
9626            class: 0,
9627            block_number: block.number,
9628            record_class: 0,
9629            transaction_index: record.context.transaction_index.unwrap_or(u64::MAX),
9630            log_index: record.context.log_index.unwrap_or(u64::MAX),
9631            original_index: index,
9632        };
9633    }
9634    if is_canonical_status(&record.context.chain_status)
9635        && let Some(block) = record.context.block.as_ref()
9636    {
9637        let (record_class, transaction_index, log_index) = match &record.input {
9638            ReactiveInput::BlockHeader(_) | ReactiveInput::FullBlock(_) => (0, 0, 0),
9639            ReactiveInput::Log(log) if !log.removed => (
9640                1,
9641                log.transaction_index
9642                    .or(record.context.transaction_index)
9643                    .unwrap_or(u64::MAX),
9644                log.log_index
9645                    .or(record.context.log_index)
9646                    .unwrap_or(u64::MAX),
9647            ),
9648            ReactiveInput::Log(_)
9649            | ReactiveInput::PendingTxHash(_)
9650            | ReactiveInput::PendingTx(_) => (2, u64::MAX, u64::MAX),
9651        };
9652        return RecordSortKey {
9653            class: 1,
9654            block_number: block.number,
9655            record_class,
9656            transaction_index,
9657            log_index,
9658            original_index: index,
9659        };
9660    }
9661
9662    RecordSortKey {
9663        class: 2,
9664        block_number: 0,
9665        record_class: 0,
9666        transaction_index: 0,
9667        log_index: 0,
9668        original_index: index,
9669    }
9670}
9671
9672#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
9673struct RecordSortKey {
9674    class: u8,
9675    block_number: u64,
9676    record_class: u8,
9677    transaction_index: u64,
9678    log_index: u64,
9679    original_index: usize,
9680}
9681
9682fn interest_matches<N: Network>(interest: &ReactiveInterest<N>, input: &ReactiveInput<N>) -> bool {
9683    match (interest, input) {
9684        (ReactiveInterest::Logs(interest), ReactiveInput::Log(log)) => interest.matches(log),
9685        (
9686            ReactiveInterest::Blocks(BlockInterest {
9687                mode: BlockInterestMode::Header,
9688            }),
9689            ReactiveInput::BlockHeader(_),
9690        ) => true,
9691        (
9692            ReactiveInterest::Blocks(BlockInterest {
9693                mode: BlockInterestMode::FullBlock,
9694            }),
9695            ReactiveInput::FullBlock(_),
9696        ) => true,
9697        (ReactiveInterest::PendingTransactions(interest), ReactiveInput::PendingTxHash(_)) => {
9698            interest.matches_hash_only()
9699        }
9700        (ReactiveInterest::PendingTransactions(interest), ReactiveInput::PendingTx(tx)) => {
9701            interest.matches_tx(tx)
9702        }
9703        _ => false,
9704    }
9705}
9706
9707fn validate_effects(
9708    input_ref: InputRef,
9709    ctx: &ReactiveContext,
9710    handler_id: &HandlerId,
9711    effects: &[ReactiveEffect],
9712) -> Result<(), ReactiveError> {
9713    let pending = matches!(ctx.chain_status, ChainStatus::Pending)
9714        || matches!(input_ref, InputRef::PendingTx { .. });
9715    if !pending {
9716        return Ok(());
9717    }
9718
9719    for effect in effects {
9720        let effect_kind = match effect {
9721            ReactiveEffect::StateUpdate(_) => Some("state_update"),
9722            ReactiveEffect::Invalidate(_) => Some("invalidate"),
9723            ReactiveEffect::Resync(_) => Some("resync"),
9724            ReactiveEffect::Hook(_) | ReactiveEffect::Speculative(_) => None,
9725        };
9726        if let Some(effect_kind) = effect_kind {
9727            return Err(ReactiveError::InvalidPendingEffect {
9728                input_ref: Box::new(input_ref),
9729                handler_id: handler_id.clone(),
9730                effect_kind,
9731            });
9732        }
9733    }
9734    Ok(())
9735}
9736
9737fn detect_conflicts(
9738    input_ref: InputRef,
9739    executions: &[HandlerExecution],
9740) -> Result<(), ReactiveError> {
9741    let mut writes: HashMap<EffectTarget, (AbsoluteValue, HandlerId)> = HashMap::new();
9742    for execution in executions {
9743        for update in &execution.state_updates {
9744            for (target, value) in absolute_writes(update) {
9745                if let Some((previous_value, previous_handler)) = writes.get(&target) {
9746                    if previous_value != &value {
9747                        return Err(ReactiveError::ConflictingEffects {
9748                            input_ref: Box::new(input_ref),
9749                            target: Box::new(target),
9750                            first: previous_handler.clone(),
9751                            second: execution.handler_id.clone(),
9752                        });
9753                    }
9754                } else {
9755                    writes.insert(target, (value, execution.handler_id.clone()));
9756                }
9757            }
9758        }
9759    }
9760    Ok(())
9761}
9762
9763fn absolute_writes(update: &StateUpdate) -> Vec<(EffectTarget, AbsoluteValue)> {
9764    match update {
9765        StateUpdate::Slot {
9766            address,
9767            slot,
9768            value,
9769        } => vec![(
9770            EffectTarget::StorageSlot {
9771                address: *address,
9772                slot: *slot,
9773            },
9774            AbsoluteValue::U256(*value),
9775        )],
9776        StateUpdate::SlotMasked {
9777            address,
9778            slot,
9779            mask,
9780            value,
9781        } => vec![(
9782            EffectTarget::MaskedStorageSlot {
9783                address: *address,
9784                slot: *slot,
9785                mask: *mask,
9786            },
9787            AbsoluteValue::U256(*value),
9788        )],
9789        StateUpdate::Account { address, patch } | StateUpdate::AccountUpsert { address, patch } => {
9790            account_patch_writes(*address, patch)
9791        }
9792        StateUpdate::SlotDelta { .. }
9793        | StateUpdate::BalanceDelta { .. }
9794        | StateUpdate::Purge { .. } => Vec::new(),
9795    }
9796}
9797
9798fn account_patch_writes(
9799    address: Address,
9800    patch: &AccountPatch,
9801) -> Vec<(EffectTarget, AbsoluteValue)> {
9802    let mut writes = Vec::new();
9803    if let Some(balance) = patch.balance {
9804        writes.push((
9805            EffectTarget::AccountBalance { address },
9806            AbsoluteValue::U256(balance),
9807        ));
9808    }
9809    if let Some(nonce) = patch.nonce {
9810        writes.push((
9811            EffectTarget::AccountNonce { address },
9812            AbsoluteValue::U64(nonce),
9813        ));
9814    }
9815    if let Some(code) = &patch.code {
9816        writes.push((
9817            EffectTarget::AccountCode { address },
9818            AbsoluteValue::Bytes(code.clone()),
9819        ));
9820    }
9821    writes
9822}
9823
9824fn input_ref<N: Network>(input: &ReactiveInput<N>, ctx: &ReactiveContext) -> InputRef {
9825    match input {
9826        ReactiveInput::Log(log) => InputRef::Log {
9827            chain_id: ctx.chain_id,
9828            block_hash: log
9829                .block_hash
9830                .or(ctx.block.as_ref().map(|block| block.hash))
9831                .unwrap_or_default(),
9832            transaction_hash: log.transaction_hash.unwrap_or_default(),
9833            log_index: log.log_index.or(ctx.log_index).unwrap_or_default(),
9834        },
9835        ReactiveInput::PendingTxHash(hash) => InputRef::PendingTx {
9836            chain_id: ctx.chain_id,
9837            hash: *hash,
9838        },
9839        ReactiveInput::PendingTx(tx) => InputRef::PendingTx {
9840            chain_id: ctx.chain_id,
9841            hash: tx.tx_hash(),
9842        },
9843        ReactiveInput::BlockHeader(header) => InputRef::Block {
9844            chain_id: ctx.chain_id,
9845            hash: header.hash(),
9846            number: header.number(),
9847        },
9848        ReactiveInput::FullBlock(block) => {
9849            let header = block.header();
9850            InputRef::Block {
9851                chain_id: ctx.chain_id,
9852                hash: header.hash(),
9853                number: header.number(),
9854            }
9855        }
9856    }
9857}
9858
9859fn is_canonical_status(status: &ChainStatus) -> bool {
9860    matches!(
9861        status,
9862        ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. }
9863    )
9864}
9865
9866/// Adapter that wraps a legacy [`EventDecoder`] as a log-only reactive handler.
9867pub struct EventDecoderHandler {
9868    id: HandlerId,
9869    decoder: Arc<dyn EventDecoder>,
9870    interest: LogInterest,
9871}
9872
9873impl EventDecoderHandler {
9874    /// Create an adapter from a decoder and log interest.
9875    pub fn new(id: HandlerId, decoder: Arc<dyn EventDecoder>, interest: LogInterest) -> Self {
9876        Self {
9877            id,
9878            decoder,
9879            interest,
9880        }
9881    }
9882}
9883
9884impl<N: Network> ReactiveHandler<N> for EventDecoderHandler {
9885    fn id(&self) -> HandlerId {
9886        self.id.clone()
9887    }
9888
9889    fn interests(&self) -> Vec<ReactiveInterest<N>> {
9890        vec![ReactiveInterest::Logs(self.interest.clone())]
9891    }
9892
9893    fn handle(
9894        &self,
9895        _ctx: &ReactiveContext,
9896        input: &ReactiveInput<N>,
9897        state: &dyn StateView,
9898    ) -> Result<HandlerOutcome, HandlerError> {
9899        let ReactiveInput::Log(log) = input else {
9900            return Ok(HandlerOutcome::empty(StateEffectQuality::NoStateEffect));
9901        };
9902
9903        Ok(HandlerOutcome {
9904            effects: self
9905                .decoder
9906                .decode(&log.inner, state)
9907                .into_iter()
9908                .map(ReactiveEffect::StateUpdate)
9909                .collect(),
9910            quality: StateEffectQuality::ExactFromInput,
9911            tags: Vec::new(),
9912        })
9913    }
9914}
9915
9916/// One independently negotiable event-subscriber behavior.
9917#[derive(
9918    Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
9919)]
9920#[non_exhaustive]
9921pub enum SubscriberCapability {
9922    /// Emit EVM logs.
9923    Logs,
9924    /// Emit block headers.
9925    BlockHeaders,
9926    /// Emit full blocks with transaction bodies.
9927    FullBlocks,
9928    /// Emit pending transaction hashes.
9929    PendingTransactionHashes,
9930    /// Emit hydrated pending transactions.
9931    PendingTransactions,
9932    /// Fetch historical data from a caller-selected anchor.
9933    HistoricalBackfill,
9934    /// Follow live chain data.
9935    Live,
9936    /// Attest, via [`ChainControl::LogCoverage`], that no log-notification loss
9937    /// went unhealed at or below a stated block.
9938    ///
9939    /// Advertise this only when loss is actually detectable: a source that can
9940    /// silently drop a notification must not claim it, because a consumer treats
9941    /// the capability as licence to trust a delivered log set instead of
9942    /// re-fetching it. Absence of the capability and absence of an attestation
9943    /// mean the same thing — unknown — and neither may be read as complete.
9944    LogCoverageAttestation,
9945    /// Recover the complete committed consumer position after reconnect or
9946    /// restart, including any unacknowledged delivery.
9947    ///
9948    /// An implementation may satisfy this with native stream replay or with a
9949    /// durable cursor plus deterministic historical reconciliation of an
9950    /// ephemeral live child. The end-to-end subscriber must still prove there
9951    /// is no gap between the restored position and resumed live delivery. If an
9952    /// old delivery token is emitted again, that token must identify the same
9953    /// immutable delivery and pass the engine's witness check.
9954    DurableReplay,
9955    /// Preserve logical handler ownership on delivered batches.
9956    OwnerScopedDelivery,
9957    /// Add and remove interests without replacing the complete session.
9958    DynamicInterests,
9959    /// Emit explicit canonical branch transitions.
9960    ExplicitReorgs,
9961    /// Emit safe and finalized head updates.
9962    FinalityUpdates,
9963    /// Emit ordered synchronization or source-cutover barriers.
9964    Barriers,
9965    /// Emit sequencer pre-confirmations into a disposable state overlay.
9966    Preconfirmations,
9967}
9968
9969/// Capability set advertised by an [`EventSubscriber`].
9970///
9971/// The default is deliberately empty: callers can safely reject a topology
9972/// when an older or minimal implementation has not opted into a required
9973/// behavior.
9974#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
9975pub struct SubscriberCapabilities {
9976    supported: BTreeSet<SubscriberCapability>,
9977}
9978
9979impl SubscriberCapabilities {
9980    /// Construct a capability set from supported behaviors.
9981    pub fn new(capabilities: impl IntoIterator<Item = SubscriberCapability>) -> Self {
9982        Self {
9983            supported: capabilities.into_iter().collect(),
9984        }
9985    }
9986
9987    /// Test one independently negotiable behavior.
9988    pub fn supports(&self, capability: SubscriberCapability) -> bool {
9989        self.supported.contains(&capability)
9990    }
9991
9992    /// Iterate supported behaviors in stable order.
9993    pub fn iter(&self) -> impl Iterator<Item = SubscriberCapability> + '_ {
9994        self.supported.iter().copied()
9995    }
9996
9997    /// Whether the subscriber follows live chain data.
9998    pub fn supports_live(&self) -> bool {
9999        self.supports(SubscriberCapability::Live)
10000    }
10001
10002    /// Whether the subscriber can durably recover its committed position and
10003    /// any unacknowledged delivery without an event gap.
10004    pub fn supports_durable_replay(&self) -> bool {
10005        self.supports(SubscriberCapability::DurableReplay)
10006    }
10007
10008    /// Whether the subscriber emits explicit branch transitions.
10009    pub fn supports_explicit_reorgs(&self) -> bool {
10010        self.supports(SubscriberCapability::ExplicitReorgs)
10011    }
10012}
10013
10014impl FromIterator<SubscriberCapability> for SubscriberCapabilities {
10015    fn from_iter<T: IntoIterator<Item = SubscriberCapability>>(iter: T) -> Self {
10016        Self::new(iter)
10017    }
10018}
10019
10020/// Provider-agnostic subscriber interface.
10021pub trait EventSubscriber<N: Network = Ethereum>: Send {
10022    /// Chain identity attached to emitted records, when it has been resolved.
10023    ///
10024    /// Remote and provider-backed subscribers should cache one authoritative
10025    /// identity before exposing input. Returning `None` is reserved for
10026    /// synthetic or genuinely chain-agnostic subscribers; composite sources
10027    /// can use this hook to reject accidentally mixed networks.
10028    fn chain_id(&self) -> Option<u64> {
10029        None
10030    }
10031
10032    /// Behaviors this subscriber can uphold for topology validation.
10033    fn capabilities(&self) -> SubscriberCapabilities {
10034        SubscriberCapabilities::default()
10035    }
10036
10037    /// Replace all interests registered with the subscriber.
10038    ///
10039    /// Implementations may use this as a full setup/reset operation. The
10040    /// in-crate [`AlloySubscriber`] clears owner-scoped interest state and
10041    /// delivery/dedupe bookkeeping when this method is called.
10042    ///
10043    /// The returned operation must complete only after the replacement has
10044    /// committed to the subscriber's desired state. Remote implementations can
10045    /// use this asynchronous boundary to wait for an authoritative service-side
10046    /// acknowledgement before returning `Ok(())`. On error, or when the future
10047    /// is dropped before completion, the previously committed desired state
10048    /// must remain authoritative (or be reconciled before later delivery can
10049    /// expose the uncommitted change) so callers can safely retry.
10050    ///
10051    /// # Errors
10052    ///
10053    /// The returned operation reports [`SubscriberError`] when the replacement
10054    /// cannot be validated or committed by the underlying source.
10055    fn register_interests(
10056        &mut self,
10057        interests: &[ReactiveInterest<N>],
10058    ) -> SubscriberOperation<'_, ()>;
10059
10060    /// Return the next input batch, or `Ok(None)` when the stream is exhausted.
10061    ///
10062    /// The returned future must be cancellation-safe: dropping it while pending
10063    /// must not discard a complete input that a later call could otherwise
10064    /// deliver. Composite subscribers use this property to race historical and
10065    /// live sources without dedicating a task to each transport.
10066    ///
10067    /// # Errors
10068    ///
10069    /// The returned future reports [`SubscriberError`] for transport,
10070    /// continuity, decoding, or source-resource failures.
10071    fn next_batch(&mut self) -> SubscriberNextBatch<'_, N>;
10072
10073    /// Restore the subscriber's committed position before polling resumes.
10074    ///
10075    /// The engine invokes this synchronously from
10076    /// [`ReactiveEngine::resume_from_durable_checkpoint`] after decoding runtime
10077    /// recovery state and before publishing that state as resumed. Implementations
10078    /// should validate that provider/service cursors cannot regress and seed any
10079    /// source epoch or overlap history required for safe replay. A composite may
10080    /// rebuild an ephemeral live child from `coverage_head` plus historical
10081    /// reconciliation rather than require that child to replay bytes itself, but
10082    /// it may advertise [`SubscriberCapability::DurableReplay`] only when the
10083    /// complete restore closes that cutover gap before exposing live input. On
10084    /// error, either
10085    /// the prior position must remain authoritative, or the subscriber may retain
10086    /// this *exact* restore as pending intent; in the latter case it must block
10087    /// delivery and reject conflicting restores until retry/reconciliation commits
10088    /// the same position. This permits synchronous adapters over durable remote
10089    /// state without exposing a half-restored stream.
10090    ///
10091    /// # Errors
10092    ///
10093    /// Returns [`SubscriberError`] when the position is invalid, regresses or
10094    /// conflicts with committed source state, or cannot be restored durably.
10095    fn restore_position(
10096        &mut self,
10097        _position: &SubscriberResumePosition,
10098    ) -> Result<(), SubscriberError> {
10099        Ok(())
10100    }
10101
10102    /// Commit a subscriber-owned delivery token after runtime ingestion.
10103    ///
10104    /// Ephemeral subscribers can rely on this no-op default. Durable remote
10105    /// subscribers should make acknowledgement idempotent because cancellation
10106    /// or transport failure can cause a successfully ingested batch to replay.
10107    /// Re-emitting a token must reproduce the same immutable records, routing,
10108    /// chain controls, chain identity, and provider checkpoint; the checkpointed
10109    /// engine verifies its persisted delivery witness before skipping ingestion.
10110    ///
10111    /// # Errors
10112    ///
10113    /// The returned operation reports [`SubscriberError`] when the delivery
10114    /// token cannot be committed idempotently by the source.
10115    fn acknowledge_delivery(
10116        &mut self,
10117        _token: SubscriberDeliveryToken,
10118    ) -> SubscriberOperation<'_, ()> {
10119        Box::pin(async { Ok(()) })
10120    }
10121}
10122
10123/// Boxed, sendable future returned by subscriber lifecycle operations.
10124///
10125/// The output is generic so the same type can represent registration, removal,
10126/// and future acknowledgement values without requiring an async-trait helper.
10127pub type SubscriberOperation<'a, T> =
10128    Pin<Box<dyn Future<Output = Result<T, SubscriberError>> + Send + 'a>>;
10129
10130/// Boxed future returned by [`EventSubscriber::next_batch`].
10131pub type SubscriberNextBatch<'a, N> = Pin<
10132    Box<dyn Future<Output = Result<Option<ReactiveInputBatch<N>>, SubscriberError>> + Send + 'a>,
10133>;
10134
10135/// Boxed future returned by [`AlloySubscriber::next_scoped_batch`].
10136pub type SubscriberNextScopedBatch<'a, N> = Pin<
10137    Box<dyn Future<Output = Result<Option<SubscriberInputBatch<N>>, SubscriberError>> + Send + 'a>,
10138>;
10139
10140/// Subscriber mode requested for the Alloy subscriber.
10141#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
10142pub enum SubscriberMode {
10143    /// Prefer the default compiled transport.
10144    ///
10145    /// With the default `reactive-ws` feature this resolves to pubsub/WebSocket
10146    /// subscriptions. Without `reactive-ws`, it resolves to polling only when
10147    /// the opt-in `reactive-polling` feature is enabled.
10148    #[default]
10149    Auto,
10150    /// Use provider pubsub streams.
10151    PubSub,
10152    /// Use polling/watch APIs. Requires the `reactive-polling` feature.
10153    Polling,
10154}
10155
10156#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10157enum FlashblocksAdapter {
10158    NativeSubscriptions,
10159    PendingStatePolling,
10160}
10161
10162fn flashblocks_adapter(chain_id: u64) -> Option<FlashblocksAdapter> {
10163    match chain_id {
10164        8_453 | 84_532 => Some(FlashblocksAdapter::NativeSubscriptions),
10165        10 | 11_155_420 => Some(FlashblocksAdapter::PendingStatePolling),
10166        _ => None,
10167    }
10168}
10169
10170/// Subscriber configuration.
10171#[derive(Clone, Debug, PartialEq, Eq)]
10172pub struct SubscriberConfig {
10173    /// Flashblocks delivery policy. Provider support itself is configured by
10174    /// the transport's single `flashblocks` endpoint flag.
10175    pub preconfirmations: PreconfirmationMode,
10176    /// Cadence for certifying sealed canonical heads while connected to a
10177    /// Flashblocks endpoint whose `newHeads` stream may contain partial heads.
10178    pub canonical_head_poll_interval: Duration,
10179    /// Maximum time allowed for one provider request that certifies a
10180    /// canonical head while Flashblocks are active.
10181    pub canonical_head_request_timeout: Duration,
10182    /// Optimism pending-state sampling cadence.
10183    ///
10184    /// Base uses native `newFlashblocks` plus `pendingLogs`. Optimism providers
10185    /// currently expose the interoperable Flashblocks surface through
10186    /// `pending` RPC reads, so one generation-pinned sampler reads the
10187    /// cumulative pending block, its exact hash-addressed parent, filtered
10188    /// pending-block logs, and bounded exact transaction receipts.
10189    ///
10190    /// # Cost
10191    ///
10192    /// This is the most request-hungry setting in the crate, and unlike the
10193    /// canonical paths it cannot be made event-driven: the pending surface is
10194    /// only observable by asking. Every tick issues one pending-block read, one
10195    /// `eth_getLogs` **per provider-facing log filter**, and up to
10196    /// [`Self::max_pending_transaction_receipts_per_tick`] receipt calls. At the
10197    /// 250 ms default that is four ticks a second, bounded overall by
10198    /// [`Self::max_flashblock_rpc_requests_per_second`] — a ceiling of 40
10199    /// requests per second, or roughly 3.4 M per day on one chain.
10200    ///
10201    /// It is reached only by enabling pre-confirmations on a chain whose adapter
10202    /// samples pending state (Optimism and its testnet), which in a transport
10203    /// configuration means marking one of that chain's endpoints
10204    /// `flashblocks = true`. Raise this interval, lower
10205    /// `max_flashblock_rpc_requests_per_second`, or leave
10206    /// [`Self::preconfirmations`] disabled if that budget is not intended;
10207    /// [`AlloySubscriber::rpc_stats`] attributes the traffic to
10208    /// [`SubscriberRpcCause::PendingStateSample`] so it is visible before it
10209    /// arrives on an invoice.
10210    pub flashblock_poll_interval: Duration,
10211    /// Consecutive pending-state request failure allowance.
10212    ///
10213    /// A successful sampling tick resets this counter. Semantic integrity
10214    /// failures, such as non-monotonic transaction membership or malformed
10215    /// logs, are never retried through this allowance.
10216    pub max_consecutive_flashblock_poll_failures: usize,
10217    /// Maximum pending receipts per sampling tick.
10218    ///
10219    /// Receipts are requested by exact transaction hash in one JSON-RPC batch,
10220    /// because separate `eth_getBlockReceipts("pending")` responses can refer
10221    /// to a different cumulative Flashblock. The rolling total-method budget
10222    /// may impose a lower effective per-tick limit; with the defaults and one
10223    /// log filter, at most seven receipts are requested per tick.
10224    pub max_pending_transaction_receipts_per_tick: usize,
10225    /// Pending-state RPC method budget per rolling one-second window.
10226    ///
10227    /// The sampler reserves capacity for the pending-block, exact-parent, and
10228    /// filtered-log methods implied by its cadence and filter plan, plus the
10229    /// exact-parent canonical-head poll when block interests require it. Exact
10230    /// receipt hydration uses only an evenly apportioned remainder. Request
10231    /// timestamps enforce the ceiling across actual ticks, including delayed
10232    /// ticks. The default leaves headroom below common paid-provider limits of
10233    /// 50 requests per second.
10234    pub max_flashblock_rpc_requests_per_second: usize,
10235    /// Bounded notification capacity for pubsub log streams.
10236    ///
10237    /// `None` reuses [`Self::max_batch_size`]. Size this independently when a
10238    /// high-volume log filter shares a subscriber with small delivery batches:
10239    /// the transport drops notifications once the channel is full, and while
10240    /// that loss is now detected and healed by an exact-window refetch, each
10241    /// occurrence costs an `eth_getLogs`. Watch
10242    /// [`SubscriberStreamGapStats::lagged_notifications`] to tell whether this
10243    /// is too small.
10244    pub log_channel_size: Option<usize>,
10245    /// Hydrate pending transaction hashes into full bodies when possible.
10246    pub hydrate_pending_transactions: bool,
10247    /// Verify each canonical log's block identity through RPC and enrich its
10248    /// context with the exact parent hash before delivery.
10249    ///
10250    /// # This is not the way to trust a log stream
10251    ///
10252    /// Enabling this to decide whether delivered logs can be trusted is the
10253    /// expensive wrong answer: it costs a request per distinct canonical block
10254    /// and proves strictly less than [`ChainControl::LogCoverage`], which is
10255    /// free. Verification confirms that each log it *received* names a real
10256    /// block; it says nothing about logs that never arrived, which is the
10257    /// failure that matters. Use the attestation for completeness, and reserve
10258    /// this for a strict coordinator that needs exact parent-hash enrichment on
10259    /// log-only pubsub events.
10260    ///
10261    /// Enable this when a strict coordinator (such as a hybrid historical/live
10262    /// source) must prove canonical ancestry from log-only pubsub events.
10263    /// Verification is cached per block, so the provider is queried at most
10264    /// once for each distinct canonical block retained in the dedupe window.
10265    /// For high-volume pubsub filters, configure
10266    /// [`AlloySubscriber::with_log_verification_provider`] with a separate HTTP
10267    /// provider so verification responses cannot be starved by notifications.
10268    pub verify_log_block_context: bool,
10269    /// Maximum records to emit per batch.
10270    pub max_batch_size: usize,
10271    /// Maximum distinct contract addresses placed in one provider-side log
10272    /// subscription. Compatible logical owner filters are fanned into address
10273    /// supersets up to this limit; exact owner routing still happens locally.
10274    pub max_log_addresses_per_subscription: usize,
10275    /// Maximum records retained across the delivery queue and hidden
10276    /// transaction-aware reconcile buffer. Exceeding it fails the subscriber
10277    /// closed until a full interest reset, because dropping an event would
10278    /// create an unknowable continuity gap.
10279    pub max_pending_records: usize,
10280    /// Maximum lazy owner-backfill requests retained at once.
10281    pub max_pending_backfills: usize,
10282    /// Maximum approximate encoded bytes accepted from one historical log
10283    /// response (fixed log identity fields, topics, and data).
10284    pub max_backfill_log_bytes: usize,
10285    /// Maximum provider log requests concurrently in flight during bulk owner
10286    /// reconciliation.
10287    pub max_reconcile_requests_in_flight: usize,
10288    /// Reconnect policy for WebSocket/pubsub streams.
10289    pub reconnect: SubscriberReconnectConfig,
10290}
10291
10292impl Default for SubscriberConfig {
10293    fn default() -> Self {
10294        Self {
10295            log_channel_size: None,
10296            preconfirmations: PreconfirmationMode::Disabled,
10297            canonical_head_poll_interval: Duration::from_millis(500),
10298            canonical_head_request_timeout: Duration::from_secs(3),
10299            flashblock_poll_interval: Duration::from_millis(250),
10300            max_consecutive_flashblock_poll_failures: 10,
10301            max_pending_transaction_receipts_per_tick: 32,
10302            max_flashblock_rpc_requests_per_second: 40,
10303            hydrate_pending_transactions: false,
10304            verify_log_block_context: false,
10305            max_batch_size: 1024,
10306            max_log_addresses_per_subscription: 1024,
10307            max_pending_records: 16_384,
10308            max_pending_backfills: 4_096,
10309            max_backfill_log_bytes: 64 * 1024 * 1024,
10310            max_reconcile_requests_in_flight: 8,
10311            reconnect: SubscriberReconnectConfig::default(),
10312        }
10313    }
10314}
10315
10316/// Provider surface established for one Flashblocks generation.
10317#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10318pub enum FlashblocksDelivery {
10319    /// Native `newFlashblocks` plus filtered `pendingLogs` WebSocket streams.
10320    NativeSubscriptions,
10321    /// Generation-pinned `pending` block and log sampling.
10322    PendingStatePolling,
10323    /// Standardized updates supplied by an application-managed transport.
10324    #[cfg(feature = "raw-flashblocks-json")]
10325    ExternalUpdates,
10326}
10327
10328/// Request/response traffic issued by one Flashblocks subscriber generation.
10329#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10330pub struct FlashblocksRpcMetrics {
10331    capability_requests: u64,
10332    provider_pair_chain_requests: u64,
10333    canonical_head_requests: u64,
10334    pending_block_requests: u64,
10335    pending_log_requests: u64,
10336    pending_receipt_requests: u64,
10337    pending_receipts_completed: u64,
10338    pending_receipts_unavailable: u64,
10339    failed_requests: u64,
10340    raced_samples: u64,
10341    suppressed_canonical_head_polls: u64,
10342}
10343
10344impl FlashblocksRpcMetrics {
10345    /// Opportunistic `op_supportedCapabilities` probes attempted.
10346    pub const fn capability_requests(self) -> u64 {
10347        self.capability_requests
10348    }
10349
10350    /// Chain-identity requests used to verify an explicitly paired
10351    /// pending-state provider against the subscriber's stream provider.
10352    pub const fn provider_pair_chain_requests(self) -> u64 {
10353        self.provider_pair_chain_requests
10354    }
10355
10356    /// Exact parent-block requests used to fence pending and canonical state.
10357    pub const fn canonical_head_requests(self) -> u64 {
10358        self.canonical_head_requests
10359    }
10360
10361    /// Cumulative pending-block requests.
10362    pub const fn pending_block_requests(self) -> u64 {
10363        self.pending_block_requests
10364    }
10365
10366    /// Pending log-filter requests.
10367    pub const fn pending_log_requests(self) -> u64 {
10368        self.pending_log_requests
10369    }
10370
10371    /// Pending-state `eth_getTransactionReceipt` methods issued by exact hash.
10372    /// Several methods may share one JSON-RPC batch transport request.
10373    pub const fn pending_receipt_requests(self) -> u64 {
10374        self.pending_receipt_requests
10375    }
10376
10377    /// Exact pending transaction receipts returned successfully.
10378    pub const fn pending_receipts_completed(self) -> u64 {
10379        self.pending_receipts_completed
10380    }
10381
10382    /// Exact pending transaction receipts that were not materialized yet and remain
10383    /// eligible for retry on the next cumulative sample.
10384    pub const fn pending_receipts_unavailable(self) -> u64 {
10385        self.pending_receipts_unavailable
10386    }
10387
10388    /// Provider request failures observed by a pending-state sampler.
10389    pub const fn failed_requests(self) -> u64 {
10390        self.failed_requests
10391    }
10392
10393    /// Timer-driven canonical head polls that issued no request because the
10394    /// flashblock stream had already certified the head inside the poll window.
10395    ///
10396    /// On a chain whose flashblock cadence is faster than the poll interval,
10397    /// most ticks land here: the certification is event-driven and the timer is
10398    /// only a liveness fallback.
10399    pub const fn suppressed_canonical_head_polls(self) -> u64 {
10400        self.suppressed_canonical_head_polls
10401    }
10402
10403    /// Samples discarded because the pending-log response advanced beyond
10404    /// the separately fetched cumulative block. The next tick retries from a
10405    /// fresh block/log pair; no partial speculative view is published.
10406    pub const fn raced_samples(self) -> u64 {
10407        self.raced_samples
10408    }
10409
10410    /// Total request/response calls attributable to Flashblocks qualification
10411    /// and sampling.
10412    pub const fn total_requests(self) -> u64 {
10413        self.capability_requests
10414            .saturating_add(self.provider_pair_chain_requests)
10415            .saturating_add(self.canonical_head_requests)
10416            .saturating_add(self.pending_block_requests)
10417            .saturating_add(self.pending_log_requests)
10418            .saturating_add(self.pending_receipt_requests)
10419    }
10420}
10421
10422/// JSON-RPC method issued by the reactive stack on a consumer's behalf.
10423///
10424/// `EthSubscribe` covers every `eth_subscribe`/`eth_newFilter` handshake the
10425/// subscriber performs when it installs a stream source, including the OP Stack
10426/// `newFlashblocks` and `pendingLogs` channels. Notifications delivered over an
10427/// established subscription are not requests and are not counted here.
10428#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
10429#[non_exhaustive]
10430pub enum SubscriberRpcMethod {
10431    /// `eth_chainId`.
10432    EthChainId,
10433    /// `eth_blockNumber`.
10434    EthBlockNumber,
10435    /// `eth_getBlockByNumber`.
10436    EthGetBlockByNumber,
10437    /// `eth_getBlockByHash`.
10438    EthGetBlockByHash,
10439    /// `eth_getLogs`.
10440    EthGetLogs,
10441    /// `eth_getTransactionReceipt`.
10442    EthGetTransactionReceipt,
10443    /// Stream installation: `eth_subscribe`, or the polling transport's
10444    /// `eth_newFilter` handshake.
10445    EthSubscribe,
10446    /// `op_supportedCapabilities`.
10447    OpSupportedCapabilities,
10448}
10449
10450impl SubscriberRpcMethod {
10451    /// Every method the reactive stack can issue, in reporting order.
10452    pub const ALL: [Self; 8] = [
10453        Self::EthChainId,
10454        Self::EthBlockNumber,
10455        Self::EthGetBlockByNumber,
10456        Self::EthGetBlockByHash,
10457        Self::EthGetLogs,
10458        Self::EthGetTransactionReceipt,
10459        Self::EthSubscribe,
10460        Self::OpSupportedCapabilities,
10461    ];
10462
10463    /// Number of distinct methods.
10464    pub const COUNT: usize = Self::ALL.len();
10465
10466    /// Wire name, suitable for a metrics label.
10467    pub const fn as_str(self) -> &'static str {
10468        match self {
10469            Self::EthChainId => "eth_chainId",
10470            Self::EthBlockNumber => "eth_blockNumber",
10471            Self::EthGetBlockByNumber => "eth_getBlockByNumber",
10472            Self::EthGetBlockByHash => "eth_getBlockByHash",
10473            Self::EthGetLogs => "eth_getLogs",
10474            Self::EthGetTransactionReceipt => "eth_getTransactionReceipt",
10475            Self::EthSubscribe => "eth_subscribe",
10476            Self::OpSupportedCapabilities => "op_supportedCapabilities",
10477        }
10478    }
10479
10480    const fn index(self) -> usize {
10481        match self {
10482            Self::EthChainId => 0,
10483            Self::EthBlockNumber => 1,
10484            Self::EthGetBlockByNumber => 2,
10485            Self::EthGetBlockByHash => 3,
10486            Self::EthGetLogs => 4,
10487            Self::EthGetTransactionReceipt => 5,
10488            Self::EthSubscribe => 6,
10489            Self::OpSupportedCapabilities => 7,
10490        }
10491    }
10492}
10493
10494impl fmt::Display for SubscriberRpcMethod {
10495    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
10496        f.write_str(self.as_str())
10497    }
10498}
10499
10500/// Mechanism that caused the subscriber to issue a provider request.
10501///
10502/// This is the dimension that matters for an RPC budget: a consumer asks for
10503/// interests and reads batches, and every request below is a consequence the
10504/// consumer never named. Attributing by cause is what makes an unexpected bill
10505/// diagnosable from inside the process.
10506#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
10507#[non_exhaustive]
10508pub enum SubscriberRpcCause {
10509    /// Resolving the provider's chain identity once, before any record escapes.
10510    ChainIdentity,
10511    /// Installing a live stream source.
10512    StreamSubscription,
10513    /// Qualifying a Flashblocks endpoint: capability probe and paired
10514    /// pending-state provider verification.
10515    FlashblocksSetup,
10516    /// Certifying a sealed canonical head while Flashblocks are active, because
10517    /// a Flashblocks endpoint's `newHeads` may carry partial heads.
10518    CanonicalHeadCertification,
10519    /// Sampling OP Stack pending state on the bounded pre-confirmation cadence.
10520    PendingStateSample,
10521    /// Proving a canonical log's block identity when
10522    /// [`SubscriberConfig::verify_log_block_context`] is set.
10523    LogBlockVerification,
10524    /// Bulk owner catch-up requested through
10525    /// [`AlloySubscriber::reconcile_interest_owners`].
10526    OwnerReconcile,
10527    /// Draining a queued adoption or continuity backfill.
10528    LazyBackfill,
10529    /// Closing the window missed while a terminated stream was reconnecting.
10530    ReconnectBackfill,
10531    /// Closing the window a live stream dropped: the subscription stayed
10532    /// connected but notifications were lost, so only the missed range is
10533    /// refetched.
10534    GapBackfill,
10535}
10536
10537impl SubscriberRpcCause {
10538    /// Every cause the reactive stack can attribute a request to, in reporting
10539    /// order.
10540    pub const ALL: [Self; 10] = [
10541        Self::ChainIdentity,
10542        Self::StreamSubscription,
10543        Self::FlashblocksSetup,
10544        Self::CanonicalHeadCertification,
10545        Self::PendingStateSample,
10546        Self::LogBlockVerification,
10547        Self::OwnerReconcile,
10548        Self::LazyBackfill,
10549        Self::ReconnectBackfill,
10550        Self::GapBackfill,
10551    ];
10552
10553    /// Number of distinct causes.
10554    pub const COUNT: usize = Self::ALL.len();
10555
10556    /// Stable snake_case name, suitable for a metrics label.
10557    pub const fn as_str(self) -> &'static str {
10558        match self {
10559            Self::ChainIdentity => "chain_identity",
10560            Self::StreamSubscription => "stream_subscription",
10561            Self::FlashblocksSetup => "flashblocks_setup",
10562            Self::CanonicalHeadCertification => "canonical_head_certification",
10563            Self::PendingStateSample => "pending_state_sample",
10564            Self::LogBlockVerification => "log_block_verification",
10565            Self::OwnerReconcile => "owner_reconcile",
10566            Self::LazyBackfill => "lazy_backfill",
10567            Self::ReconnectBackfill => "reconnect_backfill",
10568            Self::GapBackfill => "gap_backfill",
10569        }
10570    }
10571
10572    const fn index(self) -> usize {
10573        match self {
10574            Self::ChainIdentity => 0,
10575            Self::StreamSubscription => 1,
10576            Self::FlashblocksSetup => 2,
10577            Self::CanonicalHeadCertification => 3,
10578            Self::PendingStateSample => 4,
10579            Self::LogBlockVerification => 5,
10580            Self::OwnerReconcile => 6,
10581            Self::LazyBackfill => 7,
10582            Self::ReconnectBackfill => 8,
10583            Self::GapBackfill => 9,
10584        }
10585    }
10586}
10587
10588impl fmt::Display for SubscriberRpcCause {
10589    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
10590        f.write_str(self.as_str())
10591    }
10592}
10593
10594/// Every provider request the reactive stack has issued, by method and by the
10595/// mechanism responsible for it.
10596///
10597/// Counts are **cumulative for the subscriber's lifetime**. They deliberately
10598/// survive reconnects, stream-topology changes, and delivery-state resets, so a
10599/// long-running process can report total RPC consumption; call
10600/// [`AlloySubscriber::reset_rpc_stats`] to measure a bounded window instead.
10601/// This is the difference from [`FlashblocksRpcMetrics`], which is scoped to one
10602/// Flashblocks subscriber generation and resets with it.
10603///
10604/// Every request the subscriber makes is counted here, including the ones also
10605/// tallied by `FlashblocksRpcMetrics` — reading both never requires adding them
10606/// together. `FlashblocksRpcMetrics` remains the place for outcomes that are not
10607/// request counts, such as receipts that were unavailable or samples discarded
10608/// for racing the pending block.
10609///
10610/// ```no_run
10611/// # use evm_fork_cache::reactive::{AlloySubscriber, SubscriberRpcCause, SubscriberRpcMethod};
10612/// # fn report<P, N: alloy_network::Network>(subscriber: &AlloySubscriber<P, N>) {
10613/// let stats = subscriber.rpc_stats();
10614/// println!("total provider requests: {}", stats.total());
10615/// println!("eth_getLogs: {}", stats.by_method(SubscriberRpcMethod::EthGetLogs));
10616/// for (cause, method, requests) in stats.nonzero() {
10617///     println!("{cause} / {method}: {requests}");
10618/// }
10619/// # }
10620/// ```
10621#[derive(Clone, Debug, PartialEq, Eq)]
10622pub struct SubscriberRpcStats {
10623    counts: [[u64; SubscriberRpcMethod::COUNT]; SubscriberRpcCause::COUNT],
10624}
10625
10626impl Default for SubscriberRpcStats {
10627    fn default() -> Self {
10628        Self {
10629            counts: [[0; SubscriberRpcMethod::COUNT]; SubscriberRpcCause::COUNT],
10630        }
10631    }
10632}
10633
10634impl SubscriberRpcStats {
10635    /// Requests issued for one exact cause/method pair.
10636    pub const fn get(&self, cause: SubscriberRpcCause, method: SubscriberRpcMethod) -> u64 {
10637        self.counts[cause.index()][method.index()]
10638    }
10639
10640    /// Requests issued for one cause, across every method.
10641    pub fn by_cause(&self, cause: SubscriberRpcCause) -> u64 {
10642        self.counts[cause.index()]
10643            .iter()
10644            .fold(0u64, |total, count| total.saturating_add(*count))
10645    }
10646
10647    /// Requests issued for one method, across every cause.
10648    pub fn by_method(&self, method: SubscriberRpcMethod) -> u64 {
10649        self.counts
10650            .iter()
10651            .fold(0u64, |total, row| total.saturating_add(row[method.index()]))
10652    }
10653
10654    /// Every provider request the subscriber has issued.
10655    pub fn total(&self) -> u64 {
10656        SubscriberRpcCause::ALL
10657            .into_iter()
10658            .fold(0u64, |total, cause| {
10659                total.saturating_add(self.by_cause(cause))
10660            })
10661    }
10662
10663    /// Every cause/method pair in reporting order, including zeroes.
10664    pub fn entries(
10665        &self,
10666    ) -> impl Iterator<Item = (SubscriberRpcCause, SubscriberRpcMethod, u64)> + '_ {
10667        SubscriberRpcCause::ALL.into_iter().flat_map(move |cause| {
10668            SubscriberRpcMethod::ALL
10669                .into_iter()
10670                .map(move |method| (cause, method, self.get(cause, method)))
10671        })
10672    }
10673
10674    /// Only the cause/method pairs that actually issued a request — the useful
10675    /// shape for logging or a metrics export.
10676    pub fn nonzero(
10677        &self,
10678    ) -> impl Iterator<Item = (SubscriberRpcCause, SubscriberRpcMethod, u64)> + '_ {
10679        self.entries().filter(|(_, _, requests)| *requests > 0)
10680    }
10681}
10682
10683/// Interior-mutable counter set behind [`SubscriberRpcStats`].
10684///
10685/// Shared through an `Arc` because provider work runs off the subscriber's
10686/// `&mut self`: bulk owner catch-up is driven as an independent future while
10687/// live events continue to drain, and the free functions it calls record without
10688/// any subscriber borrow. `Relaxed` ordering is correct for counters whose only
10689/// consumer is a diagnostic snapshot.
10690#[derive(Debug)]
10691pub(crate) struct SubscriberRpcCounters {
10692    counts: [[AtomicU64; SubscriberRpcMethod::COUNT]; SubscriberRpcCause::COUNT],
10693}
10694
10695impl Default for SubscriberRpcCounters {
10696    fn default() -> Self {
10697        Self {
10698            counts: std::array::from_fn(|_| std::array::from_fn(|_| AtomicU64::new(0))),
10699        }
10700    }
10701}
10702
10703/// Why a live subscription lost data without disconnecting.
10704///
10705/// Both cases were previously invisible: `alloy-pubsub`'s typed subscription
10706/// stream logs a lagged receiver at `debug` and continues, and discards an
10707/// undecodable notification the same way. A consumer whose contract is
10708/// *completeness* cannot build on a stream that loses records silently, so these
10709/// are surfaced and healed instead.
10710#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10711pub enum SubscriberStreamGap {
10712    /// The bounded notification channel overflowed and the transport dropped
10713    /// `skipped` notifications before this receiver observed them.
10714    ///
10715    /// This is backpressure, not a transport fault: the subscriber was not
10716    /// draining as fast as the endpoint pushed. Raising
10717    /// [`SubscriberConfig::log_channel_size`] is the direct remedy.
10718    Lagged {
10719        /// Notifications the transport dropped.
10720        skipped: u64,
10721    },
10722    /// A notification arrived but did not decode into the expected type.
10723    ///
10724    /// Treated as lost data rather than skipped, because a filter's matched set
10725    /// cannot be proven complete while one of its notifications is unreadable.
10726    Undecodable,
10727}
10728
10729impl SubscriberStreamGap {
10730    /// Notifications known to be missing, when the transport reported a count.
10731    pub const fn skipped(self) -> Option<u64> {
10732        match self {
10733            Self::Lagged { skipped } => Some(skipped),
10734            Self::Undecodable => None,
10735        }
10736    }
10737
10738    /// Stable snake_case name, suitable for a metrics label.
10739    pub const fn as_str(self) -> &'static str {
10740        match self {
10741            Self::Lagged { .. } => "lagged",
10742            Self::Undecodable => "undecodable",
10743        }
10744    }
10745}
10746
10747impl fmt::Display for SubscriberStreamGap {
10748    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
10749        match self {
10750            Self::Lagged { skipped } => write!(f, "lagged({skipped})"),
10751            Self::Undecodable => f.write_str("undecodable"),
10752        }
10753    }
10754}
10755
10756/// Notification loss observed on live subscriptions, and what it cost to heal.
10757///
10758/// A non-zero `lagged_notifications` means the subscriber could not keep up with
10759/// its endpoint. That is recoverable — the missed window is refetched — but each
10760/// occurrence buys an `eth_getLogs`, so a steadily climbing count is a signal to
10761/// raise [`SubscriberConfig::log_channel_size`] rather than to keep paying.
10762///
10763/// Counts are cumulative for the subscriber's lifetime, matching
10764/// [`SubscriberRpcStats`].
10765#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10766pub struct SubscriberStreamGapStats {
10767    lagged_notifications: u64,
10768    undecodable_notifications: u64,
10769    log_gaps_healed: u64,
10770    header_gaps: u64,
10771    preconfirmation_gaps: u64,
10772}
10773
10774impl SubscriberStreamGapStats {
10775    /// Notifications dropped by the transport because a bounded channel filled.
10776    pub const fn lagged_notifications(self) -> u64 {
10777        self.lagged_notifications
10778    }
10779
10780    /// Notifications that arrived but could not be decoded.
10781    pub const fn undecodable_notifications(self) -> u64 {
10782        self.undecodable_notifications
10783    }
10784
10785    /// Canonical log gaps closed by refetching the exact missed range.
10786    ///
10787    /// Each of these issued provider requests attributed to
10788    /// [`SubscriberRpcCause::GapBackfill`].
10789    pub const fn log_gaps_healed(self) -> u64 {
10790        self.log_gaps_healed
10791    }
10792
10793    /// Gaps observed on the canonical block-header stream.
10794    ///
10795    /// These are not refetched here: a consumer that walks a replacement
10796    /// header's parent lineage back to retained canonical history recovers the
10797    /// skipped blocks on the next header it receives. The count exists so that
10798    /// self-healing is visible rather than assumed.
10799    pub const fn header_gaps(self) -> u64 {
10800        self.header_gaps
10801    }
10802
10803    /// Gaps observed on a pre-confirmation log stream, each of which discarded
10804    /// the speculative snapshot rather than publishing an incomplete one.
10805    pub const fn preconfirmation_gaps(self) -> u64 {
10806        self.preconfirmation_gaps
10807    }
10808
10809    /// Every observed notification loss, across all stream kinds.
10810    pub const fn total_gaps(self) -> u64 {
10811        self.lagged_notifications
10812            .saturating_add(self.undecodable_notifications)
10813    }
10814}
10815
10816/// Interior-mutable counters behind [`SubscriberStreamGapStats`].
10817#[derive(Debug, Default)]
10818pub(crate) struct SubscriberStreamGapCounters {
10819    lagged_notifications: AtomicU64,
10820    undecodable_notifications: AtomicU64,
10821    log_gaps_healed: AtomicU64,
10822    header_gaps: AtomicU64,
10823    preconfirmation_gaps: AtomicU64,
10824}
10825
10826impl SubscriberStreamGapCounters {
10827    fn bump(counter: &AtomicU64, amount: u64) {
10828        counter.fetch_add(amount, Ordering::Relaxed);
10829    }
10830
10831    #[cfg(feature = "reactive-ws")]
10832    pub(crate) fn record_gap(&self, gap: SubscriberStreamGap) {
10833        match gap {
10834            SubscriberStreamGap::Lagged { skipped } => {
10835                Self::bump(&self.lagged_notifications, skipped.max(1));
10836            }
10837            SubscriberStreamGap::Undecodable => {
10838                Self::bump(&self.undecodable_notifications, 1);
10839            }
10840        }
10841    }
10842
10843    pub(crate) fn record_log_gap_healed(&self) {
10844        Self::bump(&self.log_gaps_healed, 1);
10845    }
10846
10847    pub(crate) fn record_header_gap(&self) {
10848        Self::bump(&self.header_gaps, 1);
10849    }
10850
10851    pub(crate) fn record_preconfirmation_gap(&self) {
10852        Self::bump(&self.preconfirmation_gaps, 1);
10853    }
10854
10855    pub(crate) fn snapshot(&self) -> SubscriberStreamGapStats {
10856        let load = |counter: &AtomicU64| counter.load(Ordering::Relaxed);
10857        SubscriberStreamGapStats {
10858            lagged_notifications: load(&self.lagged_notifications),
10859            undecodable_notifications: load(&self.undecodable_notifications),
10860            log_gaps_healed: load(&self.log_gaps_healed),
10861            header_gaps: load(&self.header_gaps),
10862            preconfirmation_gaps: load(&self.preconfirmation_gaps),
10863        }
10864    }
10865
10866    pub(crate) fn reset(&self) {
10867        for counter in [
10868            &self.lagged_notifications,
10869            &self.undecodable_notifications,
10870            &self.log_gaps_healed,
10871            &self.header_gaps,
10872            &self.preconfirmation_gaps,
10873        ] {
10874            counter.store(0, Ordering::Relaxed);
10875        }
10876    }
10877}
10878
10879impl SubscriberRpcCounters {
10880    /// Record one issued request.
10881    pub(crate) fn record(&self, cause: SubscriberRpcCause, method: SubscriberRpcMethod) {
10882        self.record_many(cause, method, 1);
10883    }
10884
10885    /// Record `requests` issued requests, for a batch that ships several calls
10886    /// of one method in a single round trip.
10887    pub(crate) fn record_many(
10888        &self,
10889        cause: SubscriberRpcCause,
10890        method: SubscriberRpcMethod,
10891        requests: u64,
10892    ) {
10893        self.counts[cause.index()][method.index()].fetch_add(requests, Ordering::Relaxed);
10894    }
10895
10896    /// Snapshot every counter.
10897    pub(crate) fn snapshot(&self) -> SubscriberRpcStats {
10898        SubscriberRpcStats {
10899            counts: std::array::from_fn(|cause| {
10900                std::array::from_fn(|method| self.counts[cause][method].load(Ordering::Relaxed))
10901            }),
10902        }
10903    }
10904
10905    /// Zero every counter.
10906    pub(crate) fn reset(&self) {
10907        for row in &self.counts {
10908            for counter in row {
10909                counter.store(0, Ordering::Relaxed);
10910            }
10911        }
10912    }
10913}
10914
10915/// Successful initial Flashblocks endpoint preflight.
10916///
10917/// This proves chain identity and either subscription acknowledgement for
10918/// Base's `newFlashblocks` plus every pool-filtered `pendingLogs` stream, method
10919/// support for OP's bounded pending block/log sampler, or the canonical stream
10920/// topology paired with an application-managed standardized source.
10921/// Notification liveness and active-interest coverage remain acceptance-window
10922/// checks: a successful preflight alone must not qualify a source for live use.
10923#[derive(Clone, Debug, PartialEq, Eq)]
10924pub struct FlashblocksPreflight {
10925    chain_id: u64,
10926    provider: ProviderRef,
10927    delivery: FlashblocksDelivery,
10928    pending_log_subscriptions: usize,
10929    pending_log_filters: usize,
10930    advertised_capabilities: Option<serde_json::Value>,
10931}
10932
10933impl FlashblocksPreflight {
10934    /// Chain identity read from the pinned provider lease.
10935    pub const fn chain_id(&self) -> u64 {
10936        self.chain_id
10937    }
10938
10939    /// Provider generation selected for speculative updates.
10940    ///
10941    /// Built-in profiles preflight this provider's coupled request/subscription
10942    /// surfaces. External profiles retain caller-supplied provenance while the
10943    /// application qualifies the supplemental socket separately.
10944    pub const fn provider(&self) -> &ProviderRef {
10945        &self.provider
10946    }
10947
10948    /// Provider surface selected for this chain.
10949    pub const fn delivery(&self) -> FlashblocksDelivery {
10950        self.delivery
10951    }
10952
10953    /// Number of acknowledged pool-filtered `pendingLogs` subscriptions.
10954    ///
10955    /// This is zero for sampled and externally managed delivery profiles.
10956    pub const fn pending_log_subscriptions(&self) -> usize {
10957        self.pending_log_subscriptions
10958    }
10959
10960    /// Number of provider-facing log filters whose interests must be covered by
10961    /// the selected native, sampled, or external delivery surface.
10962    pub const fn pending_log_filters(&self) -> usize {
10963        self.pending_log_filters
10964    }
10965
10966    /// Opaque response from `op_supportedCapabilities`, when the provider
10967    /// implements that optional RPC method.
10968    pub const fn advertised_capabilities(&self) -> Option<&serde_json::Value> {
10969        self.advertised_capabilities.as_ref()
10970    }
10971}
10972
10973/// WebSocket/pubsub reconnect policy.
10974///
10975/// Reconnects are applied after an established subscription stream terminates.
10976/// Initial subscription failures are still returned immediately so deployment
10977/// mistakes, unsupported transports, and bad endpoints fail fast.
10978#[derive(Clone, Debug, PartialEq, Eq)]
10979pub struct SubscriberReconnectConfig {
10980    /// Whether pubsub streams should be recreated after termination.
10981    pub enabled: bool,
10982    /// Delay before the first reconnect attempt.
10983    pub initial_delay: Duration,
10984    /// Delay before the second reconnect attempt. Later retries double this
10985    /// delay up to [`Self::max_delay`].
10986    pub retry_delay: Duration,
10987    /// Maximum delay between reconnect attempts.
10988    pub max_delay: Duration,
10989    /// Maximum reconnect attempts per terminated stream. `None` retries forever.
10990    pub max_attempts: Option<usize>,
10991    /// Number of recently emitted canonical input refs remembered to suppress
10992    /// duplicates across reconnect backfill and subscription replay.
10993    pub dedupe_window: usize,
10994}
10995
10996impl Default for SubscriberReconnectConfig {
10997    fn default() -> Self {
10998        Self {
10999            enabled: true,
11000            initial_delay: Duration::ZERO,
11001            retry_delay: Duration::from_millis(250),
11002            max_delay: Duration::from_secs(30),
11003            max_attempts: Some(3),
11004            dedupe_window: 4096,
11005        }
11006    }
11007}
11008
11009/// Historical log backfill requested when adding subscriber interests.
11010///
11011/// Backfill applies only to [`ReactiveInterest::Logs`] entries. Block and
11012/// pending-transaction interests are live-only. `AlloySubscriber` emits records
11013/// fetched through this policy as [`InputSource::Backfill`]. Continuity-safe
11014/// owner registration adopts/subscribes the desired live filter first, then
11015/// reconciles history behind that live fence; startup/global replacement commits
11016/// topology and historical work as one desired-state transaction. A drained
11017/// backfill seeds the filter's delivery anchor at its resolved upper bound (even
11018/// when the window held no logs), so the newly added filter gets the same
11019/// reconnect/catch-up protection an established one has.
11020#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11021pub struct SubscriberBackfill {
11022    from_block: u64,
11023    to_block: Option<u64>,
11024    retained_anchor: Option<BlockRef>,
11025}
11026
11027impl SubscriberBackfill {
11028    /// Backfill an inclusive block range.
11029    pub fn range(from_block: u64, to_block: u64) -> Self {
11030        Self {
11031            from_block,
11032            to_block: Some(to_block),
11033            retained_anchor: None,
11034        }
11035    }
11036
11037    /// Backfill from `from_block` through the provider's latest block.
11038    pub fn from_block(from_block: u64) -> Self {
11039        Self {
11040            from_block,
11041            to_block: None,
11042            retained_anchor: None,
11043        }
11044    }
11045
11046    /// Backfill inclusively from an exact retained canonical block.
11047    ///
11048    /// The Alloy subscriber verifies this number/hash against its provider
11049    /// before accepting any lazy catch-up response. Engine-managed mid-stream
11050    /// registration uses this form so owner replay cannot silently cross a
11051    /// reorged discovery boundary.
11052    pub fn from_canonical_block(block: BlockRef) -> Self {
11053        Self {
11054            from_block: block.number,
11055            to_block: None,
11056            retained_anchor: Some(block),
11057        }
11058    }
11059
11060    /// Backfill inclusively from an exact canonical block through an inclusive
11061    /// upper bound.
11062    ///
11063    /// # Errors
11064    ///
11065    /// Returns [`SubscriberError::InvalidConfig`] when `to_block` precedes the
11066    /// retained anchor.
11067    pub fn from_canonical_block_through(
11068        block: BlockRef,
11069        to_block: u64,
11070    ) -> Result<Self, SubscriberError> {
11071        if to_block < block.number {
11072            return Err(SubscriberError::InvalidConfig(
11073                "inclusive backfill upper bound precedes its retained anchor",
11074            ));
11075        }
11076        Ok(Self {
11077            from_block: block.number,
11078            to_block: Some(to_block),
11079            retained_anchor: Some(block),
11080        })
11081    }
11082
11083    /// Backfill strictly after an exact canonical state baseline.
11084    ///
11085    /// This is distinct from [`from_canonical_block`](Self::from_canonical_block):
11086    /// a restored cache already embodies every effect through `block`, so
11087    /// replaying that block would apply it twice. The retained block is still
11088    /// carried so the subscriber can prove that its provider is on the same
11089    /// canonical branch before accepting any post-baseline history.
11090    ///
11091    /// Returns an error at `u64::MAX`; silently saturating would turn an empty
11092    /// exclusive range into an inclusive replay of the baseline block.
11093    ///
11094    /// # Errors
11095    ///
11096    /// Returns [`SubscriberError::InvalidConfig`] when the baseline number is
11097    /// `u64::MAX` and therefore has no following block.
11098    pub fn after_canonical_block(block: BlockRef) -> Result<Self, SubscriberError> {
11099        Self::after_canonical_block_inner(block, None)
11100    }
11101
11102    /// Backfill strictly after an exact canonical baseline through an
11103    /// inclusive upper bound.
11104    ///
11105    /// `to_block == block.number` represents a deliberately empty certified
11106    /// interval. Bounds before the retained baseline are rejected.
11107    ///
11108    /// # Errors
11109    ///
11110    /// Returns [`SubscriberError::InvalidConfig`] when `to_block` precedes the
11111    /// baseline, or when a non-empty exclusive range would have to begin after
11112    /// block `u64::MAX`.
11113    pub fn after_canonical_block_through(
11114        block: BlockRef,
11115        to_block: u64,
11116    ) -> Result<Self, SubscriberError> {
11117        if to_block < block.number {
11118            return Err(SubscriberError::InvalidConfig(
11119                "exclusive backfill upper bound precedes its retained baseline",
11120            ));
11121        }
11122        Self::after_canonical_block_inner(block, Some(to_block))
11123    }
11124
11125    fn after_canonical_block_inner(
11126        block: BlockRef,
11127        to_block: Option<u64>,
11128    ) -> Result<Self, SubscriberError> {
11129        let from_block = block
11130            .number
11131            .checked_add(1)
11132            .ok_or(SubscriberError::InvalidConfig(
11133                "cannot construct an exclusive backfill after block u64::MAX",
11134            ))?;
11135        Ok(Self {
11136            from_block,
11137            to_block,
11138            retained_anchor: Some(block),
11139        })
11140    }
11141
11142    /// First block included in the backfill.
11143    pub fn start_block(&self) -> u64 {
11144        self.from_block
11145    }
11146
11147    /// Last block included in the backfill, or `None` for provider latest.
11148    pub fn end_block(&self) -> Option<u64> {
11149        self.to_block
11150    }
11151
11152    /// Exact retained start-block identity, when supplied.
11153    pub fn retained_anchor(&self) -> Option<&BlockRef> {
11154        self.retained_anchor.as_ref()
11155    }
11156}
11157
11158/// Opaque generation for one transaction-aware subscriber interest owner.
11159///
11160/// Epochs are allocated monotonically by [`AlloySubscriber`] and are never
11161/// reused, including after an aborted stage or a full interest replacement.
11162/// Lifecycle operations require the complete token so a delayed command for an
11163/// older registration cannot affect a replacement using the same [`HandlerId`].
11164#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
11165pub struct SubscriberOwnerEpoch {
11166    owner: HandlerId,
11167    sequence: u64,
11168}
11169
11170/// Delivery audience retained with a subscriber input record.
11171///
11172/// Canonical inputs are forwarded once to the runtime actor and may also name
11173/// staged epochs that need a buffered copy. Owner-only inputs are catch-up or
11174/// overlap records that must never be routed through existing canonical
11175/// handlers.
11176#[derive(Clone, Debug, PartialEq, Eq)]
11177#[non_exhaustive]
11178pub enum SubscriberInputScope {
11179    /// One canonical input plus any staged owners that matched at enqueue time.
11180    Canonical {
11181        /// Staged owner epochs that require a buffered copy.
11182        owners: Vec<SubscriberOwnerEpoch>,
11183    },
11184    /// Canonical input whose owner catch-up already delivered selected handler
11185    /// owners. The residual canonical copy must exclude those handlers while
11186    /// remaining authoritative for global chain progress.
11187    CanonicalResidual {
11188        /// Staged epoch owners that still require a buffered copy.
11189        owners: Vec<SubscriberOwnerEpoch>,
11190        /// Active compatibility owners already served by owner catch-up.
11191        excluded: Vec<HandlerId>,
11192    },
11193    /// Input delivered only to the listed staged owners.
11194    OwnerOnly {
11195        /// Exact staged owner epochs receiving the input.
11196        owners: Vec<SubscriberOwnerEpoch>,
11197    },
11198    /// Compatibility owner-only delivery keyed by stable handler id.
11199    OwnerOnlyHandlers {
11200        /// Exact active handlers receiving the catch-up input.
11201        owners: Vec<HandlerId>,
11202    },
11203    /// Flashblock input routed through ordinary matching handlers but applied
11204    /// only to the speculative overlay.
11205    Preconfirmed,
11206}
11207
11208impl SubscriberInputScope {
11209    /// Exact staged owner epochs attached to this input.
11210    pub fn owners(&self) -> &[SubscriberOwnerEpoch] {
11211        match self {
11212            Self::Canonical { owners }
11213            | Self::CanonicalResidual { owners, .. }
11214            | Self::OwnerOnly { owners } => owners,
11215            Self::OwnerOnlyHandlers { .. } | Self::Preconfirmed => &[],
11216        }
11217    }
11218
11219    /// Whether this input must be forwarded once through canonical routing.
11220    pub const fn is_canonical(&self) -> bool {
11221        matches!(
11222            self,
11223            Self::Canonical { .. } | Self::CanonicalResidual { .. }
11224        )
11225    }
11226
11227    /// Whether this input belongs only to the disposable preconfirmed overlay.
11228    pub const fn is_preconfirmed(&self) -> bool {
11229        matches!(self, Self::Preconfirmed)
11230    }
11231}
11232
11233/// Reactive input together with its canonical/owner-scoped delivery audience.
11234#[derive(Clone, Debug)]
11235pub struct SubscriberInputRecord<N: Network = Ethereum> {
11236    record: ReactiveInputRecord<N>,
11237    scope: SubscriberInputScope,
11238    preconfirmation_timing: Option<FlashblockIngressTiming>,
11239}
11240
11241impl<N: Network> SubscriberInputRecord<N> {
11242    /// Borrow the reactive input record.
11243    pub const fn record(&self) -> &ReactiveInputRecord<N> {
11244        &self.record
11245    }
11246
11247    /// Delivery audience captured when the record was enqueued.
11248    pub const fn scope(&self) -> &SubscriberInputScope {
11249        &self.scope
11250    }
11251
11252    /// Original typed source ingress when this is a preconfirmed record.
11253    pub const fn preconfirmation_timing(&self) -> Option<FlashblockIngressTiming> {
11254        self.preconfirmation_timing
11255    }
11256
11257    /// Consume the scoped value into its reactive input record.
11258    pub fn into_record(self) -> ReactiveInputRecord<N> {
11259        self.record
11260    }
11261}
11262
11263impl<N: Network> std::ops::Deref for SubscriberInputRecord<N> {
11264    type Target = ReactiveInputRecord<N>;
11265
11266    fn deref(&self) -> &Self::Target {
11267        &self.record
11268    }
11269}
11270
11271/// Batch of subscriber inputs with enqueue-time owner provenance.
11272#[derive(Clone, Debug)]
11273pub struct SubscriberInputBatch<N: Network = Ethereum> {
11274    records: Vec<SubscriberInputRecord<N>>,
11275    chain_id: Option<u64>,
11276    chain_controls: Vec<ChainControl>,
11277    preconfirmation_invalidated: bool,
11278    preconfirmation_timing: Option<FlashblockIngressTiming>,
11279}
11280
11281/// Result of polling a scoped subscriber batch against one driver control
11282/// future.
11283#[derive(Debug)]
11284#[non_exhaustive]
11285pub enum SubscriberDriverPoll<C, N: Network = Ethereum> {
11286    /// The control future completed first; subscriber delivery remains intact.
11287    Control(C),
11288    /// Subscriber polling completed first.
11289    Batch(Option<SubscriberInputBatch<N>>),
11290}
11291
11292impl<N: Network> SubscriberInputBatch<N> {
11293    /// Borrow every scoped record in delivery order.
11294    pub fn records(&self) -> &[SubscriberInputRecord<N>] {
11295        &self.records
11296    }
11297
11298    /// Consume the batch into its scoped records.
11299    pub fn into_records(self) -> Vec<SubscriberInputRecord<N>> {
11300        self.records
11301    }
11302
11303    /// Ordered chain controls committed after the preceding records.
11304    pub fn chain_controls(&self) -> &[ChainControl] {
11305        &self.chain_controls
11306    }
11307
11308    /// Whether the announcing Flashblocks generation lost continuity before
11309    /// this batch was returned.
11310    pub const fn preconfirmation_invalidated(&self) -> bool {
11311        self.preconfirmation_invalidated
11312    }
11313
11314    /// Earliest typed source ingress contributing to a preconfirmed batch.
11315    pub const fn preconfirmation_timing(&self) -> Option<FlashblockIngressTiming> {
11316        self.preconfirmation_timing
11317    }
11318
11319    /// Consume the scoped subscriber delivery into a runtime-ready batch.
11320    ///
11321    /// Delivery audiences and the preconfirmed/canonical boundary are retained,
11322    /// allowing downstream owner actors to forward a batch without rebuilding
11323    /// subscriber-internal scope metadata.
11324    pub fn into_reactive_batch(self) -> ReactiveInputBatch<N> {
11325        let chain_id = self.chain_id;
11326        let chain_controls = self.chain_controls;
11327        let preconfirmation_timing = self.preconfirmation_timing;
11328        let mut batch = ReactiveInputBatch::from_scoped_records_with_delivery_scope(
11329            self.records.into_iter().map(|scoped| {
11330                let source = scoped.record.context.source;
11331                let (audience, delivery_scope) = match scoped.scope {
11332                    SubscriberInputScope::Canonical { .. } => (
11333                        DeliveryAudience::All,
11334                        if source == InputSource::Backfill {
11335                            DeliveryScope::CanonicalProgress
11336                        } else {
11337                            DeliveryScope::Canonical
11338                        },
11339                    ),
11340                    SubscriberInputScope::CanonicalResidual { excluded, .. } => (
11341                        DeliveryAudience::AllExcept(excluded),
11342                        if source == InputSource::Backfill {
11343                            DeliveryScope::CanonicalProgress
11344                        } else {
11345                            DeliveryScope::Canonical
11346                        },
11347                    ),
11348                    SubscriberInputScope::OwnerOnly { owners } => {
11349                        let mut handler_ids = Vec::with_capacity(owners.len());
11350                        for epoch in owners {
11351                            if !handler_ids.contains(epoch.owner()) {
11352                                handler_ids.push(epoch.owner().clone());
11353                            }
11354                        }
11355                        (
11356                            DeliveryAudience::Owners(handler_ids),
11357                            DeliveryScope::OwnerCatchup,
11358                        )
11359                    }
11360                    SubscriberInputScope::OwnerOnlyHandlers { owners } => (
11361                        DeliveryAudience::Owners(owners),
11362                        DeliveryScope::OwnerCatchup,
11363                    ),
11364                    SubscriberInputScope::Preconfirmed => {
11365                        (DeliveryAudience::All, DeliveryScope::Preconfirmed)
11366                    }
11367                };
11368                (scoped.record, audience, delivery_scope)
11369            }),
11370        )
11371        .with_chain_controls(chain_controls);
11372        if let Some(chain_id) = chain_id {
11373            batch = batch.with_chain_id(chain_id);
11374        }
11375        if let Some(timing) = preconfirmation_timing {
11376            batch = batch.with_preconfirmation_timing(timing);
11377        }
11378        batch
11379    }
11380}
11381
11382impl SubscriberOwnerEpoch {
11383    /// Logical subscriber owner represented by this epoch.
11384    pub const fn owner(&self) -> &HandlerId {
11385        &self.owner
11386    }
11387
11388    /// Monotonic subscriber-local epoch sequence.
11389    pub const fn sequence(&self) -> u64 {
11390        self.sequence
11391    }
11392}
11393
11394/// Catch-up policy applied when staging a transaction-aware interest owner.
11395#[derive(Clone, Debug, PartialEq, Eq)]
11396#[non_exhaustive]
11397pub enum SubscriberOwnerStart {
11398    /// Start with live delivery only.
11399    Live,
11400    /// Start strictly after an already-applied post-block baseline.
11401    ///
11402    /// A baseline at block `N` schedules backfill from `N + 1`; block `N`
11403    /// itself is never replayed. Transaction-aware callers explicitly call
11404    /// [`AlloySubscriber::reconcile_interest_owner`] before activation; staged
11405    /// owners never use the legacy lazy-backfill queue.
11406    PostBlock(BlockRef),
11407}
11408
11409/// Transaction state of one epoch-scoped subscriber owner.
11410#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
11411#[non_exhaustive]
11412pub enum SubscriberOwnerState {
11413    /// Desired interests and owner-scoped buffering are installed but canonical
11414    /// routing has not yet committed.
11415    Staged,
11416    /// Canonical runtime routing has committed for this owner.
11417    Active,
11418    /// Removal is prepared behind a delivery fence but remains reversible.
11419    Removing,
11420}
11421
11422/// Hash-certified catch-up position reached by one subscriber owner epoch.
11423///
11424/// Progress means every owner-only record through this point has been fetched
11425/// and queued inside the subscriber. It does not mean the downstream actor has
11426/// drained or committed those records; that requires a separate delivery fence.
11427#[derive(Clone, Debug, PartialEq, Eq)]
11428pub struct SubscriberOwnerProgress {
11429    owner: SubscriberOwnerEpoch,
11430    through: BlockRef,
11431}
11432
11433impl SubscriberOwnerProgress {
11434    /// Exact owner epoch whose catch-up was reconciled.
11435    pub const fn owner(&self) -> &SubscriberOwnerEpoch {
11436        &self.owner
11437    }
11438
11439    /// Verified canonical block through which owner input was fetched.
11440    pub const fn through(&self) -> &BlockRef {
11441        &self.through
11442    }
11443}
11444
11445/// Error staging a transaction-aware subscriber owner.
11446#[derive(Debug, thiserror::Error)]
11447#[non_exhaustive]
11448pub enum SubscriberOwnerError {
11449    /// Subscriber configuration or interest validation failed.
11450    #[error(transparent)]
11451    Subscriber(#[from] SubscriberError),
11452    /// The logical owner already has desired interests installed.
11453    #[error("subscriber interest owner `{0}` is already registered")]
11454    AlreadyRegistered(HandlerId),
11455    /// A post-block baseline cannot be advanced to its first unapplied block.
11456    #[error("post-block subscriber baseline {0} has no following block")]
11457    PostBlockOverflow(u64),
11458    /// The monotonic subscriber owner epoch sequence was exhausted.
11459    #[error("subscriber owner epoch sequence exhausted")]
11460    EpochExhausted,
11461    /// The exact owner epoch is unknown or no longer staged.
11462    #[error("subscriber owner epoch is not staged")]
11463    NotStaged,
11464    /// Live-only staging has no historical baseline to reconcile.
11465    #[error("subscriber owner was staged live-only and has no catch-up baseline")]
11466    MissingBaseline,
11467    /// Post-block reconciliation currently covers log interests only.
11468    #[error("post-block subscriber owners support log interests only")]
11469    UnsupportedPostBlockInterest,
11470    /// The target block was absent from the provider.
11471    #[error("subscriber reconcile target block {0} was not found")]
11472    BlockUnavailable(u64),
11473    /// The provider's canonical identity did not match the requested target.
11474    #[error(
11475        "subscriber reconcile target mismatch: expected block {expected_number} {expected_hash}, got block {actual_number} {actual_hash}"
11476    )]
11477    BlockMismatch {
11478        /// Requested block number.
11479        expected_number: u64,
11480        /// Requested block hash.
11481        expected_hash: B256,
11482        /// Provider block number.
11483        actual_number: u64,
11484        /// Provider block hash.
11485        actual_hash: B256,
11486    },
11487    /// A reconcile target was older than the retained baseline/progress.
11488    #[error("subscriber reconcile target block {target} precedes current owner position {current}")]
11489    ProgressRegression {
11490        /// Retained baseline or progress block.
11491        current: u64,
11492        /// Rejected target block.
11493        target: u64,
11494    },
11495    /// A reconcile attempted to replace a retained block identity at the same
11496    /// height or cross an immediate parent that does not extend it.
11497    #[error(
11498        "subscriber reconcile conflicts with retained block {number} {current_hash}: target chain references {target_hash}"
11499    )]
11500    ProgressConflict {
11501        /// Retained baseline or progress block number.
11502        number: u64,
11503        /// Retained baseline or progress block hash.
11504        current_hash: B256,
11505        /// Conflicting target hash or immediate parent hash.
11506        target_hash: B256,
11507    },
11508    /// A provider returned a malformed or out-of-range catch-up log.
11509    #[error("subscriber reconcile returned an invalid catch-up log: {0}")]
11510    InvalidBackfillLog(&'static str),
11511}
11512
11513/// Extension trait for subscribers that can add and remove handler-owned
11514/// interests incrementally.
11515///
11516/// [`EventSubscriber::register_interests`] remains the full-replacement setup
11517/// API. Implement this trait when a subscriber can preserve unrelated live
11518/// sources and delivery state while one handler's interests are added or
11519/// removed. Implementations should make owner *replacement* continuity-safe:
11520/// updating an owner's interests must not silently discard delivery progress
11521/// the previous interests had already established (the in-crate
11522/// [`AlloySubscriber`] carries the owner's prior delivery anchor over to
11523/// changed filter shapes and automatically backfills the gap). Every mutating
11524/// operation is also a commit boundary: returning `Ok` means the new desired
11525/// state is authoritative, while errors or cancellation must preserve the
11526/// previous state or reconcile before exposing the uncommitted change.
11527pub trait InterestOwnerSubscriber<N: Network = Ethereum>: EventSubscriber<N> {
11528    /// Atomically add or replace several owners in one desired-state revision.
11529    ///
11530    /// Unrelated owners remain installed. Returning `Ok(())` is one commit
11531    /// boundary for the complete set; an error or cancellation must leave the
11532    /// previously committed owner topology authoritative. Durable remote
11533    /// subscribers should override this method so bootstrap creates one service
11534    /// revision and one activation barrier rather than one barrier per owner.
11535    ///
11536    /// # Errors
11537    ///
11538    /// The returned operation reports [`SubscriberError::Unsupported`] by
11539    /// default, or an implementation-specific validation or commit failure.
11540    fn upsert_interest_owners(
11541        &mut self,
11542        _owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
11543    ) -> SubscriberOperation<'_, ()> {
11544        Box::pin(async {
11545            Err(SubscriberError::Unsupported(
11546                "subscriber does not implement atomic bulk owner upsert",
11547            ))
11548        })
11549    }
11550
11551    /// Atomically replace the complete engine-managed owner topology without
11552    /// requesting history.
11553    ///
11554    /// This is the fresh-runtime bootstrap operation. Base/unowned interests,
11555    /// stale owners, queued delivery, and dedupe/source state from the prior
11556    /// topology must not survive a successful replacement. Errors and dropped
11557    /// futures leave the prior committed topology authoritative.
11558    ///
11559    /// # Errors
11560    ///
11561    /// The returned operation reports [`SubscriberError::Unsupported`] by
11562    /// default, or an implementation-specific validation or commit failure.
11563    fn replace_interest_owners(
11564        &mut self,
11565        _owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
11566    ) -> SubscriberOperation<'_, ()> {
11567        Box::pin(async {
11568            Err(SubscriberError::Unsupported(
11569                "subscriber does not implement atomic exact owner replacement",
11570            ))
11571        })
11572    }
11573
11574    /// Atomically replace the complete owner set and schedule one global
11575    /// historical log backfill in the same desired-state revision.
11576    ///
11577    /// This is the continuity-safe bootstrap operation for a runtime that has
11578    /// already processed canonical state while the subscriber's owner state is
11579    /// new or may have been lost. Implementations must commit the complete
11580    /// owner topology and all required historical work together: returning an
11581    /// error or dropping the future must leave the previously committed state
11582    /// authoritative. The default is deliberately unsupported rather than a
11583    /// sequence of partially committed single-owner updates.
11584    /// Historical records must be delivered through canonical global routing
11585    /// (`DeliveryAudience::All` / `DeliveryScope::CanonicalProgress`), not as
11586    /// owner catch-up, so their effects participate in the normal rollback
11587    /// journal before the source certifies the cutover. Base/unowned interests
11588    /// are replaced by this complete engine-managed topology. Any owner absent
11589    /// from `owners` must be removed together with its queued owner-only work, which closes
11590    /// the crash window where a subscriber committed registration but the
11591    /// runtime process died before installing the corresponding handler.
11592    ///
11593    /// # Errors
11594    ///
11595    /// The returned operation reports [`SubscriberError::Unsupported`] by
11596    /// default, or a backfill, validation, transport, or atomic-commit failure.
11597    fn replace_interest_owners_with_global_backfill(
11598        &mut self,
11599        _owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
11600        _backfill: SubscriberBackfill,
11601    ) -> SubscriberOperation<'_, ()> {
11602        Box::pin(async {
11603            Err(SubscriberError::Unsupported(
11604                "subscriber does not implement atomic owner replacement with global backfill",
11605            ))
11606        })
11607    }
11608
11609    /// Add or replace the interests owned by `owner`, awaiting the subscriber's
11610    /// commit boundary.
11611    ///
11612    /// Implementations must leave the previously committed owner state
11613    /// authoritative when the operation returns an error or is cancelled before
11614    /// completion.
11615    ///
11616    /// # Errors
11617    ///
11618    /// The returned operation reports [`SubscriberError`] when the owner update
11619    /// cannot be validated or committed.
11620    fn add_interest_owner(
11621        &mut self,
11622        owner: HandlerId,
11623        interests: &[ReactiveInterest<N>],
11624    ) -> SubscriberOperation<'_, ()>;
11625
11626    /// Add or replace owner interests and schedule log backfill for that owner,
11627    /// awaiting the subscriber's commit boundary.
11628    ///
11629    /// # Errors
11630    ///
11631    /// The returned operation reports [`SubscriberError`] when the owner update
11632    /// or requested backfill cannot be validated or committed.
11633    fn add_interest_owner_with_backfill(
11634        &mut self,
11635        owner: HandlerId,
11636        interests: &[ReactiveInterest<N>],
11637        backfill: SubscriberBackfill,
11638    ) -> SubscriberOperation<'_, ()>;
11639
11640    /// Add a handler discovered at retained canonical block `C` without
11641    /// opening a gap while registration commits.
11642    ///
11643    /// The subscriber must subscribe/adopt the new desired state first, then
11644    /// expose the new owner's matching records from `C` as owner catch-up and
11645    /// expose `C + 1` through the activation head as one globally ordered
11646    /// canonical catch-up over the complete active interest union. This split
11647    /// is deliberate: the runtime already has a rollback entry for `C`, while
11648    /// later blocks must run every handler and create normal canonical journal
11649    /// entries. Errors/cancellation preserve the prior committed topology.
11650    /// Implementations that cannot uphold this coordinated transaction must
11651    /// return `Unsupported`; emitting owner-only records past `C` is invalid.
11652    ///
11653    /// # Errors
11654    ///
11655    /// The returned operation reports [`SubscriberError::Unsupported`] by
11656    /// default, or a canonical-anchor, transport, or atomic-commit failure.
11657    fn add_interest_owner_with_canonical_catchup(
11658        &mut self,
11659        _owner: HandlerId,
11660        _interests: &[ReactiveInterest<N>],
11661        _retained: BlockRef,
11662    ) -> SubscriberOperation<'_, ()> {
11663        Box::pin(async {
11664            Err(SubscriberError::Unsupported(
11665                "subscriber does not implement coordinated canonical owner catch-up",
11666            ))
11667        })
11668    }
11669
11670    /// Remove one owner's interests, preserving unrelated interests, and await
11671    /// acknowledgement that the removal committed.
11672    ///
11673    /// On error the owner must remain authoritative, so the runtime handler is
11674    /// not removed while subscriber delivery may still target it.
11675    ///
11676    /// # Errors
11677    ///
11678    /// The returned operation reports [`SubscriberError`] when the removal
11679    /// cannot be committed while preserving unrelated owners.
11680    fn remove_interest_owner(
11681        &mut self,
11682        owner: &HandlerId,
11683    ) -> SubscriberOperation<'_, Option<Vec<ReactiveInterest<N>>>>;
11684
11685    /// Borrow the interests currently owned by `owner`.
11686    fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]>;
11687}
11688
11689/// Binds a [`ReactiveRuntime`] to an [`EventSubscriber`] for the common
11690/// subscribe-ingest lifecycle.
11691///
11692/// The engine treats the runtime registry as the single source of truth for
11693/// handler lifecycle: [`register_handler`](Self::register_handler) and
11694/// [`unregister_handler`](Self::unregister_handler) update runtime routing and
11695/// subscriber interests as one operation, keyed by the handler's stable
11696/// [`HandlerId`]. Registration is continuity-safe by default — once the runtime
11697/// has journaled canonical block *N*, a newly registered handler is live-adopted,
11698/// replayed owner-only at *N*, and then caught up globally with every handler
11699/// from *N + 1* through activation. A factory-discovered pool therefore misses
11700/// none of its own logs without making later history owner-local and
11701/// unrollbackable. The subscriber must absorb overlap that crosses batch
11702/// boundaries; the runtime validates and merges duplicate representations only
11703/// within one [`ReactiveInputBatch`].
11704///
11705/// Registration methods by intent:
11706///
11707/// | Method | Backfill |
11708/// |---|---|
11709/// | [`register_handler`](Self::register_handler) | coordinated owner replay at the last retained block plus global catch-up above it (live-only on a fresh runtime) |
11710/// | [`register_handler_with_backfill`](Self::register_handler_with_backfill) | exactly one hash-certified block still retained by the rollback journal |
11711/// | [`register_handler_live_only`](Self::register_handler_live_only) | none — future logs only |
11712///
11713/// Unregistering a handler stops future subscription routing and runtime
11714/// decode for that handler; it deliberately does not evict [`EvmCache`] state
11715/// or undo runtime side effects. See
11716/// [`unregister_handler`](Self::unregister_handler) for the complete teardown
11717/// recipe.
11718///
11719/// The runtime and subscriber stay independently accessible through
11720/// [`runtime_mut`](Self::runtime_mut) / [`subscriber_mut`](Self::subscriber_mut)
11721/// for advanced use. One caution: avoid calling
11722/// [`EventSubscriber::register_interests`] (the full-replacement setup API) on
11723/// an engine-managed subscriber — implementations may clear owner-scoped
11724/// bookkeeping, after which per-handler unregistration no longer releases the
11725/// handler's transport subscriptions. To bootstrap the subscriber from a
11726/// runtime that already has handlers, use
11727/// [`sync_handler_interests`](Self::sync_handler_interests), which registers
11728/// one owner per handler instead of one unowned blob.
11729pub struct ReactiveEngine<S, N: Network = Ethereum> {
11730    runtime: ReactiveRuntime<N>,
11731    subscriber: S,
11732    pending_acknowledgement: Option<PendingAcknowledgement<N>>,
11733    pending_checkpoint: Option<PendingCheckpoint<N>>,
11734    last_checkpoint_block: Option<DurableCheckpointBlock>,
11735    last_checkpoint_delivery_token: Option<SubscriberDeliveryToken>,
11736    last_checkpoint_delivery_witness: Option<B256>,
11737    last_subscriber_checkpoint: Option<SubscriberCheckpoint>,
11738    checkpoint_identity: Option<DurableCheckpointIdentity>,
11739}
11740
11741struct PendingAcknowledgement<N: Network> {
11742    token: SubscriberDeliveryToken,
11743    report: ReactiveBatchReport<N>,
11744}
11745
11746struct PendingCheckpoint<N: Network> {
11747    metadata: DurableCheckpointMetadata,
11748    delivery_token: Option<SubscriberDeliveryToken>,
11749    report: ReactiveBatchReport<N>,
11750    saved_to: Option<PathBuf>,
11751    staged_generation: u64,
11752}
11753
11754struct CheckpointStage<N: Network> {
11755    incoming_block: Option<DurableCheckpointBlock>,
11756    delivery_token: Option<SubscriberDeliveryToken>,
11757    delivery_witness: Option<B256>,
11758    subscriber_checkpoint: Option<SubscriberCheckpoint>,
11759    staged_generation: u64,
11760    report: ReactiveBatchReport<N>,
11761}
11762
11763struct DurableResumePlan {
11764    runtime: DurableRuntimeRestorePlan,
11765    position: SubscriberResumePosition,
11766    delivery_witness: Option<B256>,
11767}
11768
11769enum HandlerRegistrationCatchup {
11770    LiveOnly,
11771    OwnerBackfill(SubscriberBackfill),
11772    CoordinatedCanonical(BlockRef),
11773}
11774
11775// 2: `ChainControl` gained `LogCoverage`, so a witness can encode a control
11776// shape version 1 readers cannot interpret. Bumping keeps a replayed token from
11777// an older process from being matched against a newer encoding.
11778const DELIVERY_WITNESS_VERSION: u32 = 2;
11779const DELIVERY_WITNESS_DOMAIN: &[u8] = b"evm-fork-cache/reactive-delivery-witness";
11780
11781#[derive(serde::Serialize)]
11782struct DeliveryWitnessEnvelope<'a> {
11783    version: u32,
11784    chain_id: Option<u64>,
11785    records: Vec<DeliveryRecordWitness<'a>>,
11786    chain_controls: &'a [ChainControl],
11787    subscriber_checkpoint: Option<&'a [u8]>,
11788    payload_commitment: Option<B256>,
11789}
11790
11791#[derive(serde::Serialize)]
11792struct DeliveryRecordWitness<'a> {
11793    identity: ReactiveInputIdentity,
11794    context: &'a ReactiveContext,
11795    audience: &'a DeliveryAudience,
11796    scope: DeliveryScope,
11797    payload: DeliveryPayloadWitness<'a>,
11798}
11799
11800#[derive(serde::Serialize)]
11801enum DeliveryPayloadWitness<'a> {
11802    /// Logs are the primary state-bearing event representation, so retain every
11803    /// RPC payload field in addition to the validated identity/context.
11804    Log {
11805        address: Address,
11806        topics: &'a [B256],
11807        data: &'a Bytes,
11808        block_hash: Option<B256>,
11809        block_number: Option<u64>,
11810        block_timestamp: Option<u64>,
11811        transaction_hash: Option<B256>,
11812        transaction_index: Option<u64>,
11813        log_index: Option<u64>,
11814        removed: bool,
11815    },
11816    /// Network-generic response bodies do not expose one stable complete serde
11817    /// contract. Their validated identity/context are witnessed here; batches
11818    /// containing headers, full blocks, or hydrated transactions additionally
11819    /// require the source's exact canonical wire-payload commitment. A generic
11820    /// header response can expose a supplied hash without proving that every
11821    /// handler-visible inner field recomputes to it.
11822    IdentityCommitted,
11823}
11824
11825fn durable_delivery_witness<N: Network>(
11826    batch: &ReactiveInputBatch<N>,
11827) -> Result<B256, ReactiveEngineError> {
11828    let requires_payload_commitment = batch.records.iter().any(|record| {
11829        matches!(
11830            &record.input,
11831            ReactiveInput::BlockHeader(_)
11832                | ReactiveInput::FullBlock(_)
11833                | ReactiveInput::PendingTx(_)
11834        )
11835    });
11836    if requires_payload_commitment && batch.payload_commitment.is_none() {
11837        return Err(ReactiveEngineError::MissingPayloadCommitment);
11838    }
11839    let records = batch
11840        .records
11841        .iter()
11842        .enumerate()
11843        .map(|(index, record)| {
11844            let payload = match &record.input {
11845                ReactiveInput::Log(log) => DeliveryPayloadWitness::Log {
11846                    address: log.address(),
11847                    topics: log.topics(),
11848                    data: &log.inner.data.data,
11849                    block_hash: log.block_hash,
11850                    block_number: log.block_number,
11851                    block_timestamp: log.block_timestamp,
11852                    transaction_hash: log.transaction_hash,
11853                    transaction_index: log.transaction_index,
11854                    log_index: log.log_index,
11855                    removed: log.removed,
11856                },
11857                ReactiveInput::BlockHeader(_)
11858                | ReactiveInput::FullBlock(_)
11859                | ReactiveInput::PendingTxHash(_)
11860                | ReactiveInput::PendingTx(_) => DeliveryPayloadWitness::IdentityCommitted,
11861            };
11862            Ok(DeliveryRecordWitness {
11863                identity: record.validated_identity()?,
11864                context: &record.context,
11865                audience: batch
11866                    .record_audience(index)
11867                    .expect("enumerated record always has an audience"),
11868                scope: batch
11869                    .record_delivery_scope(index)
11870                    .expect("enumerated record always has a delivery scope"),
11871                payload,
11872            })
11873        })
11874        .collect::<Result<Vec<_>, ReactiveError>>()?;
11875    let envelope = DeliveryWitnessEnvelope {
11876        version: DELIVERY_WITNESS_VERSION,
11877        chain_id: batch.chain_id,
11878        records,
11879        chain_controls: &batch.chain_controls,
11880        subscriber_checkpoint: batch
11881            .subscriber_checkpoint
11882            .as_ref()
11883            .map(SubscriberCheckpoint::as_bytes),
11884        payload_commitment: batch
11885            .payload_commitment
11886            .as_ref()
11887            .map(SubscriberPayloadCommitment::digest),
11888    };
11889    let encoded = bincode::DefaultOptions::new()
11890        .with_fixint_encoding()
11891        .serialize(&envelope)
11892        .map_err(|error| ReactiveEngineError::DeliveryWitness(error.to_string()))?;
11893    let mut witness = Keccak256::new();
11894    witness.update(DELIVERY_WITNESS_DOMAIN);
11895    witness.update(encoded);
11896    Ok(witness.finalize())
11897}
11898
11899impl<S, N> ReactiveEngine<S, N>
11900where
11901    N: Network,
11902    S: EventSubscriber<N>,
11903{
11904    /// Bind a runtime and subscriber.
11905    pub fn new(runtime: ReactiveRuntime<N>, subscriber: S) -> Self {
11906        Self {
11907            runtime,
11908            subscriber,
11909            pending_acknowledgement: None,
11910            pending_checkpoint: None,
11911            last_checkpoint_block: None,
11912            last_checkpoint_delivery_token: None,
11913            last_checkpoint_delivery_witness: None,
11914            last_subscriber_checkpoint: None,
11915            checkpoint_identity: None,
11916        }
11917    }
11918
11919    /// Split the engine into its runtime and subscriber parts when no commit is
11920    /// pending.
11921    ///
11922    /// A failed delivery acknowledgement or durable checkpoint commit remains
11923    /// live protocol state: dropping it would allow the caller to lose the
11924    /// already-applied report/token pair and poll past an uncommitted batch.
11925    /// In that case this returns the intact engine so the caller can repair the
11926    /// dependency and retry through the normal ingestion method.
11927    ///
11928    /// # Errors
11929    ///
11930    /// Returns the intact boxed engine when an acknowledgement or checkpoint
11931    /// commit is pending.
11932    pub fn into_parts(self) -> Result<(ReactiveRuntime<N>, S), Box<Self>> {
11933        if self.pending_acknowledgement.is_some() || self.pending_checkpoint.is_some() {
11934            return Err(Box::new(self));
11935        }
11936        Ok((self.runtime, self.subscriber))
11937    }
11938
11939    fn durable_resume_plan(
11940        &self,
11941        metadata: &DurableCheckpointMetadata,
11942    ) -> Result<DurableResumePlan, ReactiveCheckpointRestoreError> {
11943        if !self.subscriber.capabilities().supports_durable_replay() {
11944            return Err(ReactiveCheckpointRestoreError::SubscriberNotDurable);
11945        }
11946        self.ensure_subscriber_restore_chain(metadata.identity.chain_id)?;
11947        if !self.runtime.is_pristine_for_checkpoint_restore()
11948            || self.pending_acknowledgement.is_some()
11949            || self.pending_checkpoint.is_some()
11950            || self.last_checkpoint_block.is_some()
11951            || self.last_checkpoint_delivery_token.is_some()
11952            || self.last_checkpoint_delivery_witness.is_some()
11953            || self.last_subscriber_checkpoint.is_some()
11954            || self.checkpoint_identity.is_some()
11955        {
11956            return Err(ReactiveCheckpointRestoreError::ActiveRuntime);
11957        }
11958
11959        let block = BlockRef {
11960            number: metadata.block.number,
11961            hash: metadata.block.hash,
11962            parent_hash: metadata.block.parent_hash,
11963            timestamp: metadata.block.timestamp,
11964        };
11965        let runtime = match metadata.runtime_checkpoint.as_deref() {
11966            Some(bytes) => self
11967                .runtime
11968                .plan_durable_checkpoint_restore(bytes, &block)?,
11969            None => DurableRuntimeRestorePlan {
11970                checkpoint: None,
11971                fallback_history: (self.runtime.config.journal_depth > 0)
11972                    .then_some(block)
11973                    .into_iter()
11974                    .collect(),
11975            },
11976        };
11977        let delivery_token = metadata
11978            .delivery_token
11979            .clone()
11980            .map(SubscriberDeliveryToken::new);
11981        let subscriber_checkpoint = metadata
11982            .subscriber_checkpoint
11983            .clone()
11984            .map(SubscriberCheckpoint::new);
11985        let position = SubscriberResumePosition::new(
11986            metadata.identity.chain_id,
11987            block,
11988            runtime.canonical_history(),
11989            delivery_token,
11990            subscriber_checkpoint,
11991        );
11992        Ok(DurableResumePlan {
11993            runtime,
11994            position,
11995            delivery_witness: metadata.delivery_witness,
11996        })
11997    }
11998
11999    /// Preview the exact subscriber position a durable restore will install.
12000    ///
12001    /// This read-only step exists for durable subscribers that must complete
12002    /// asynchronous source or transport preparation before the engine invokes
12003    /// the synchronous [`EventSubscriber::restore_position`] hook. It decodes
12004    /// and validates the core runtime checkpoint, applies this runtime's
12005    /// configured journal retention to the preview, and returns the same
12006    /// [`SubscriberResumePosition`] that
12007    /// [`resume_from_durable_checkpoint`](Self::resume_from_durable_checkpoint)
12008    /// will later pass to the subscriber.
12009    ///
12010    /// Call this on the same fresh engine that will perform the restore. After
12011    /// subscriber preparation completes, pass the identical `metadata` to
12012    /// `resume_from_durable_checkpoint` (or restore the same loaded checkpoint
12013    /// through [`restore_durable_checkpoint`](Self::restore_durable_checkpoint))
12014    /// without mutating engine runtime or checkpoint state in between. The
12015    /// checkpoint identity and, for non-finalized state, its canonical block
12016    /// must still be validated by the caller before external preparation.
12017    ///
12018    /// This method does not mutate the runtime, subscriber, or checkpoint
12019    /// bookkeeping.
12020    ///
12021    /// # Errors
12022    ///
12023    /// Returns [`ReactiveCheckpointRestoreError`] when the subscriber is not
12024    /// durable, its chain identity conflicts with the checkpoint, the engine is
12025    /// not fresh, or the stored runtime checkpoint is malformed, unsupported,
12026    /// or internally inconsistent.
12027    pub fn preview_durable_resume_position(
12028        &self,
12029        metadata: &DurableCheckpointMetadata,
12030    ) -> Result<SubscriberResumePosition, ReactiveCheckpointRestoreError> {
12031        Ok(self.durable_resume_plan(metadata)?.position)
12032    }
12033
12034    /// Resume delivery bookkeeping and canonical continuity from a cache
12035    /// checkpoint that has already been identity- and hash-validated and
12036    /// restored into [`EvmCache`].
12037    ///
12038    /// Call this on a fresh engine. The anchor has no rollback effects of its
12039    /// own: it represents the state baseline embodied by the checkpoint, while
12040    /// newly ingested blocks are journaled normally above it.
12041    /// The subscriber must advertise [`SubscriberCapability::DurableReplay`];
12042    /// restoring an ephemeral stream would claim a restart guarantee it cannot
12043    /// uphold and is rejected before cache or runtime mutation.
12044    ///
12045    /// Prefer [`restore_durable_checkpoint`](Self::restore_durable_checkpoint)
12046    /// when the cache has not yet been restored: that helper rolls the cache
12047    /// back as well if runtime or subscriber activation fails.
12048    ///
12049    /// # Errors
12050    ///
12051    /// Returns [`ReactiveCheckpointRestoreError`] when the subscriber is not
12052    /// durable, chain identity conflicts, the runtime is not pristine, stored
12053    /// runtime state is invalid, or the subscriber rejects the restored
12054    /// position. Runtime state is restored on subscriber failure.
12055    pub fn resume_from_durable_checkpoint(
12056        &mut self,
12057        metadata: &DurableCheckpointMetadata,
12058    ) -> Result<(), ReactiveCheckpointRestoreError> {
12059        let plan = self.durable_resume_plan(metadata)?;
12060        let prior_runtime = self.runtime.checkpoint_state();
12061
12062        let DurableResumePlan {
12063            runtime,
12064            position,
12065            delivery_witness,
12066        } = plan;
12067        self.runtime.apply_durable_checkpoint_restore(runtime);
12068        self.runtime.coverage_head = Some(position.coverage_head);
12069        if let Err(error) = self.subscriber.restore_position(&position) {
12070            self.runtime.restore_state(prior_runtime);
12071            return Err(ReactiveCheckpointRestoreError::Subscriber(error));
12072        }
12073        if let Err(error) = self.ensure_subscriber_restore_chain(metadata.identity.chain_id) {
12074            self.runtime.restore_state(prior_runtime);
12075            return Err(error);
12076        }
12077        self.last_checkpoint_block = Some(metadata.block.clone());
12078        self.last_checkpoint_delivery_token = position.delivery_token;
12079        self.last_checkpoint_delivery_witness = delivery_witness;
12080        self.last_subscriber_checkpoint = position.subscriber_checkpoint;
12081        self.checkpoint_identity = Some(metadata.identity.clone());
12082        Ok(())
12083    }
12084
12085    /// Atomically restore cache, runtime, and subscriber position from one
12086    /// validated durable checkpoint.
12087    ///
12088    /// Inspect [`LoadedDurableCheckpoint::metadata`] and validate its canonical
12089    /// block against an authoritative RPC source before calling this method when
12090    /// the block is not finalized. Identity, cache-chain, runtime-state, and
12091    /// subscriber failures leave the cache and engine runtime unchanged. The
12092    /// subscriber follows [`EventSubscriber::restore_position`]'s retry contract.
12093    /// It must advertise [`SubscriberCapability::DurableReplay`].
12094    ///
12095    /// # Errors
12096    ///
12097    /// Returns [`ReactiveCheckpointRestoreError`] for checkpoint identity,
12098    /// cache-chain, runtime-state, subscriber-capability, subscriber-chain, or
12099    /// position-restore failures. Cache and runtime state remain unchanged.
12100    pub fn restore_durable_checkpoint(
12101        &mut self,
12102        cache: &mut EvmCache,
12103        loaded: LoadedDurableCheckpoint,
12104        expected: &DurableCheckpointIdentity,
12105    ) -> Result<DurableCheckpointMetadata, ReactiveCheckpointRestoreError> {
12106        if !self.subscriber.capabilities().supports_durable_replay() {
12107            return Err(ReactiveCheckpointRestoreError::SubscriberNotDurable);
12108        }
12109        self.ensure_subscriber_restore_chain(expected.chain_id)?;
12110        if !self.runtime.is_pristine_for_checkpoint_restore()
12111            || self.pending_acknowledgement.is_some()
12112            || self.pending_checkpoint.is_some()
12113            || self.last_checkpoint_block.is_some()
12114            || self.last_checkpoint_delivery_token.is_some()
12115            || self.last_checkpoint_delivery_witness.is_some()
12116            || self.last_subscriber_checkpoint.is_some()
12117            || self.checkpoint_identity.is_some()
12118        {
12119            return Err(ReactiveCheckpointRestoreError::ActiveRuntime);
12120        }
12121
12122        let prior_cache = EvmCacheStateSnapshot::capture(cache);
12123        let metadata = loaded.restore_into(cache, expected)?;
12124        if let Err(error) = self.resume_from_durable_checkpoint(&metadata) {
12125            prior_cache.restore(cache);
12126            return Err(error);
12127        }
12128        Ok(metadata)
12129    }
12130
12131    /// Borrow the runtime.
12132    pub fn runtime(&self) -> &ReactiveRuntime<N> {
12133        &self.runtime
12134    }
12135
12136    /// Mutably borrow the runtime.
12137    pub fn runtime_mut(&mut self) -> &mut ReactiveRuntime<N> {
12138        &mut self.runtime
12139    }
12140
12141    /// Borrow the subscriber.
12142    pub fn subscriber(&self) -> &S {
12143        &self.subscriber
12144    }
12145
12146    /// Mutably borrow the subscriber.
12147    pub fn subscriber_mut(&mut self) -> &mut S {
12148        &mut self.subscriber
12149    }
12150
12151    /// Adopt a hash-pinned RPC cache snapshot as the runtime's canonical
12152    /// cold-start baseline.
12153    ///
12154    /// The cache must use the exact canonical hash selector and block-number
12155    /// context named by `baseline`; when the baseline includes a timestamp, the
12156    /// cache timestamp must match too. Cache, baseline, and any already-resolved
12157    /// subscriber identity must name the same chain. No delivery or checkpoint
12158    /// commit may be pending. After this succeeds, call
12159    /// [`sync_handler_interests_with_backfill`](Self::sync_handler_interests_with_backfill)
12160    /// before polling: it exact-replaces subscriber owners and begins event
12161    /// catch-up at `C + 1`.
12162    ///
12163    /// # Errors
12164    ///
12165    /// Returns [`ReactiveEngineError`] when commit state is pending, the runtime
12166    /// is active or already has a conflicting baseline, cache/subscriber chain
12167    /// identity differs, or the cache is not pinned to the exact baseline.
12168    pub fn adopt_canonical_baseline(
12169        &mut self,
12170        cache: &EvmCache,
12171        baseline: ReactiveCanonicalBaseline,
12172    ) -> Result<(), ReactiveEngineError> {
12173        if self.pending_acknowledgement.is_some()
12174            || self.pending_checkpoint.is_some()
12175            || self.last_checkpoint_block.is_some()
12176            || self.last_checkpoint_delivery_token.is_some()
12177            || self.last_checkpoint_delivery_witness.is_some()
12178            || self.last_subscriber_checkpoint.is_some()
12179            || self.checkpoint_identity.is_some()
12180        {
12181            return Err(ReactiveBaselineError::ActiveRuntime.into());
12182        }
12183        // Establish deterministic lifecycle/idempotency semantics before
12184        // consulting mutable cache context. A conflicting repeat is a runtime
12185        // baseline conflict even if the caller also repointed the cache.
12186        self.runtime
12187            .validate_canonical_baseline_adoption(baseline.block)?;
12188        if baseline.chain_id != cache.chain_id() {
12189            return Err(ReactiveBaselineError::CacheChainMismatch {
12190                baseline_chain_id: baseline.chain_id,
12191                cache_chain_id: cache.chain_id(),
12192            }
12193            .into());
12194        }
12195        self.ensure_subscriber_chain(cache)?;
12196        let exact_selector = BlockId::from((baseline.block.hash, Some(true)));
12197        let context_matches = cache.block_number() == Some(baseline.block.number)
12198            && baseline
12199                .block
12200                .timestamp
12201                .is_none_or(|timestamp| cache.timestamp() == Some(timestamp));
12202        if cache.block() != exact_selector || !context_matches {
12203            return Err(ReactiveBaselineError::CacheBlockMismatch {
12204                number: baseline.block.number,
12205                hash: baseline.block.hash,
12206            }
12207            .into());
12208        }
12209        self.runtime.adopt_canonical_baseline(baseline.block)?;
12210        Ok(())
12211    }
12212
12213    /// Poll the subscriber for the next batch without ingesting it.
12214    ///
12215    /// This low-level escape hatch is unavailable while the engine owes an
12216    /// acknowledgement or checkpoint commit. Callers that use it must return
12217    /// any subscriber-owned delivery metadata through a combined
12218    /// [`next_ingest`](Self::next_ingest) helper; raw ingestion deliberately
12219    /// rejects that metadata so it cannot be discarded accidentally.
12220    ///
12221    /// # Errors
12222    ///
12223    /// Returns [`ReactiveEngineError`] when an acknowledgement/checkpoint commit
12224    /// is pending or subscriber and cache chain identities conflict.
12225    pub fn next_batch(
12226        &mut self,
12227        cache: &EvmCache,
12228    ) -> Result<SubscriberNextBatch<'_, N>, ReactiveEngineError> {
12229        if self.pending_checkpoint.is_some() {
12230            return Err(ReactiveEngineError::PendingCheckpointCommit);
12231        }
12232        if self.pending_acknowledgement.is_some() {
12233            return Err(ReactiveEngineError::PendingAcknowledgementCommit);
12234        }
12235        self.ensure_subscriber_chain(cache)?;
12236        Ok(self.subscriber.next_batch())
12237    }
12238
12239    /// Ingest one already-polled batch through the runtime (direct effects
12240    /// only; surfaced resync requests are reported, not executed).
12241    ///
12242    /// # Errors
12243    ///
12244    /// Returns [`ReactiveEngineError`] when commit state is pending, the batch
12245    /// carries subscriber-owned commit metadata, chain identity conflicts, or
12246    /// runtime ingestion fails.
12247    pub fn ingest_batch(
12248        &mut self,
12249        cache: &mut EvmCache,
12250        batch: ReactiveInputBatch<N>,
12251    ) -> Result<ReactiveBatchReport<N>, ReactiveEngineError> {
12252        self.ensure_raw_ingest_is_safe(cache, &batch)?;
12253        Ok(self.runtime.ingest_batch(cache, batch)?)
12254    }
12255
12256    /// Ingest one already-polled batch and execute the storage/account resyncs
12257    /// it surfaces, exactly like
12258    /// [`ReactiveRuntime::ingest_batch_with_resync`].
12259    ///
12260    /// # Errors
12261    ///
12262    /// Returns [`ReactiveEngineError`] when commit state is pending, the batch
12263    /// carries subscriber-owned commit metadata, chain identity conflicts, or
12264    /// runtime ingestion fails.
12265    pub fn ingest_batch_with_resync(
12266        &mut self,
12267        cache: &mut EvmCache,
12268        batch: ReactiveInputBatch<N>,
12269    ) -> Result<ReactiveBatchReport<N>, ReactiveEngineError> {
12270        self.ensure_raw_ingest_is_safe(cache, &batch)?;
12271        Ok(self.runtime.ingest_batch_with_resync(cache, batch)?)
12272    }
12273
12274    fn ensure_raw_ingest_is_safe(
12275        &self,
12276        cache: &EvmCache,
12277        batch: &ReactiveInputBatch<N>,
12278    ) -> Result<(), ReactiveEngineError> {
12279        if self.pending_checkpoint.is_some() {
12280            return Err(ReactiveEngineError::PendingCheckpointCommit);
12281        }
12282        if self.pending_acknowledgement.is_some() {
12283            return Err(ReactiveEngineError::PendingAcknowledgementCommit);
12284        }
12285        if batch.delivery_token().is_some() || batch.subscriber_checkpoint().is_some() {
12286            return Err(ReactiveEngineError::UncommittedDeliveryMetadata);
12287        }
12288        self.ensure_subscriber_chain(cache)?;
12289        Ok(())
12290    }
12291
12292    fn ensure_subscriber_chain(&self, cache: &EvmCache) -> Result<(), ReactiveEngineError> {
12293        if let Some(subscriber_chain_id) = self.subscriber.chain_id()
12294            && subscriber_chain_id != cache.chain_id()
12295        {
12296            return Err(ReactiveEngineError::SubscriberChainMismatch {
12297                subscriber_chain_id,
12298                cache_chain_id: cache.chain_id(),
12299            });
12300        }
12301        Ok(())
12302    }
12303
12304    fn ensure_subscriber_restore_chain(
12305        &self,
12306        checkpoint_chain_id: u64,
12307    ) -> Result<(), ReactiveCheckpointRestoreError> {
12308        if let Some(subscriber_chain_id) = self.subscriber.chain_id()
12309            && subscriber_chain_id != checkpoint_chain_id
12310        {
12311            return Err(ReactiveCheckpointRestoreError::SubscriberChainMismatch {
12312                subscriber_chain_id,
12313                checkpoint_chain_id,
12314            });
12315        }
12316        Ok(())
12317    }
12318
12319    /// Poll the subscriber once and ingest the returned batch when present
12320    /// (direct effects only).
12321    ///
12322    /// # Errors
12323    ///
12324    /// Returns [`ReactiveEngineError`] for subscriber/cache chain mismatch,
12325    /// pending checkpoint state, subscriber polling, runtime ingestion, or
12326    /// delivery-acknowledgement failure. A failed acknowledgement remains
12327    /// pending and is retried before polling again.
12328    pub async fn next_ingest(
12329        &mut self,
12330        cache: &mut EvmCache,
12331    ) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
12332        self.ensure_subscriber_chain(cache)?;
12333        if self.pending_checkpoint.is_some() {
12334            return Err(ReactiveEngineError::PendingCheckpointCommit);
12335        }
12336        if self.pending_acknowledgement.is_some() {
12337            return self.commit_pending_acknowledgement().await.map(Some);
12338        }
12339        let batch = self.subscriber.next_batch().await?;
12340        self.ensure_subscriber_chain(cache)?;
12341        let Some(mut batch) = batch else {
12342            return Ok(None);
12343        };
12344        let delivery_token = batch.take_delivery_token();
12345        let report = self.runtime.ingest_batch(cache, batch)?;
12346        self.stage_or_return_acknowledgement(delivery_token, report)
12347            .await
12348    }
12349
12350    /// Poll the subscriber once and ingest the returned batch with resync
12351    /// execution — the loop shape for consumers that rely on coverage-gap
12352    /// repair (root-gate resyncs, handler-requested re-reads).
12353    ///
12354    /// # Errors
12355    ///
12356    /// Returns [`ReactiveEngineError`] for subscriber/cache chain mismatch,
12357    /// pending checkpoint state, subscriber polling, runtime ingestion, or
12358    /// delivery-acknowledgement failure. A failed acknowledgement remains
12359    /// pending and is retried before polling again.
12360    pub async fn next_ingest_with_resync(
12361        &mut self,
12362        cache: &mut EvmCache,
12363    ) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
12364        self.ensure_subscriber_chain(cache)?;
12365        if self.pending_checkpoint.is_some() {
12366            return Err(ReactiveEngineError::PendingCheckpointCommit);
12367        }
12368        if self.pending_acknowledgement.is_some() {
12369            return self.commit_pending_acknowledgement().await.map(Some);
12370        }
12371        let batch = self.subscriber.next_batch().await?;
12372        self.ensure_subscriber_chain(cache)?;
12373        let Some(mut batch) = batch else {
12374            return Ok(None);
12375        };
12376        let delivery_token = batch.take_delivery_token();
12377        let report = self.runtime.ingest_batch_with_resync(cache, batch)?;
12378        self.stage_or_return_acknowledgement(delivery_token, report)
12379            .await
12380    }
12381
12382    /// Poll, ingest, atomically checkpoint, then acknowledge one batch.
12383    ///
12384    /// The ordering is strict: subscriber acknowledgement is never attempted
12385    /// until the complete cache checkpoint is synced. If checkpointing or
12386    /// acknowledgement fails, the in-memory pending commit is retried before
12387    /// any later batch is polled, so a transient disk failure cannot cause the
12388    /// already-applied batch to execute twice in the same process. Across a
12389    /// process restart, [`resume_from_durable_checkpoint`](Self::resume_from_durable_checkpoint)
12390    /// uses the stored delivery token and delivery witness to recognize and
12391    /// acknowledge an identical replay without re-ingestion. Reusing a token
12392    /// for different input or cursor state fails closed. Mutating the cache while
12393    /// a commit is pending also fails closed rather than binding newer state to
12394    /// older delivery metadata. Any explicit, implicit, or removed-log reorg
12395    /// that cannot be proven from the retained effect journal is rejected before
12396    /// mutation/save/ACK; configure
12397    /// [`ReactiveConfig::journal_depth`] to cover the subscriber's reorg horizon.
12398    /// Hooks are dispatched only after checkpoint staging
12399    /// succeeds, but remain in-process observers rather than a durable outbox;
12400    /// see [`ReactiveHook`]. The subscriber must advertise
12401    /// [`SubscriberCapability::DurableReplay`]; ephemeral subscribers are
12402    /// rejected before polling.
12403    ///
12404    /// # Errors
12405    ///
12406    /// Returns [`ReactiveEngineError`] when the subscriber lacks durable replay,
12407    /// identities or replay witnesses conflict, a checkpoint/ACK is already in
12408    /// an incompatible state, polling or ingestion fails, complete rollback
12409    /// proof is unavailable, the cache changes after staging, persistence
12410    /// fails, or delivery acknowledgement fails. Pending checkpoint/ACK work is
12411    /// retained for retry before another poll.
12412    pub async fn next_ingest_checkpointed(
12413        &mut self,
12414        cache: &mut EvmCache,
12415        store: &DurableCheckpointStore,
12416        identity: &DurableCheckpointIdentity,
12417    ) -> Result<Option<CheckpointedIngest<N>>, ReactiveEngineError> {
12418        if !self.subscriber.capabilities().supports_durable_replay() {
12419            return Err(ReactiveEngineError::SubscriberNotDurable);
12420        }
12421        self.ensure_subscriber_chain(cache)?;
12422        if self.pending_acknowledgement.is_some() {
12423            return Err(ReactiveEngineError::PendingAcknowledgementCommit);
12424        }
12425        self.ensure_checkpoint_identity(cache, identity)?;
12426        if self.pending_checkpoint.is_some() {
12427            return self.commit_pending_checkpoint(cache, store).await.map(Some);
12428        }
12429
12430        let batch = self.subscriber.next_batch().await?;
12431        self.ensure_subscriber_chain(cache)?;
12432        let Some(mut batch) = batch else {
12433            return Ok(None);
12434        };
12435        if batch_preconfirmation(&batch)?.is_some() {
12436            return Err(ReactiveEngineError::PreconfirmationNotCheckpointable);
12437        }
12438        self.runtime.discard_preconfirmed_branch(cache);
12439        let delivery_witness = batch
12440            .delivery_token()
12441            .map(|_| durable_delivery_witness(&batch))
12442            .transpose()?;
12443        let delivery_token = batch.take_delivery_token();
12444        let subscriber_checkpoint = batch.take_subscriber_checkpoint();
12445        if let (Some(replay_token), Some(committed_token)) = (
12446            delivery_token.as_ref(),
12447            self.last_checkpoint_delivery_token.as_ref(),
12448        ) && replay_token == committed_token
12449        {
12450            let committed_witness = self
12451                .last_checkpoint_delivery_witness
12452                .ok_or(ReactiveEngineError::MissingReplayWitness)?;
12453            if delivery_witness != Some(committed_witness) {
12454                return Err(ReactiveEngineError::ReplayDeliveryMismatch);
12455            }
12456            self.subscriber
12457                .acknowledge_delivery(replay_token.clone())
12458                .await
12459                .map_err(ReactiveEngineError::Acknowledgement)?;
12460            return Ok(Some(CheckpointedIngest::ReplayAcknowledged));
12461        }
12462
12463        self.ensure_checkpointable_reorgs(&batch)?;
12464
12465        let incoming_block = latest_canonical_batch_block(&batch);
12466        let cache_state = EvmCacheStateSnapshot::capture(cache);
12467        let runtime_state = self.runtime.checkpoint_state();
12468        let report = match self.runtime.ingest_batch_direct(cache, batch) {
12469            Ok(report) => report,
12470            Err(error) => {
12471                cache_state.restore(cache);
12472                self.runtime.restore_transaction_state(runtime_state);
12473                return Err(error.into());
12474            }
12475        };
12476        let reports = report.reports.clone();
12477        let stage = CheckpointStage {
12478            incoming_block,
12479            delivery_token,
12480            delivery_witness,
12481            subscriber_checkpoint,
12482            staged_generation: cache.snapshot_generation(),
12483            report,
12484        };
12485        if let Err(error) = self.stage_checkpoint(identity, stage) {
12486            cache_state.restore(cache);
12487            self.runtime.restore_transaction_state(runtime_state);
12488            return Err(error);
12489        }
12490        self.runtime.dispatch_reports(&reports);
12491        self.commit_pending_checkpoint(cache, store).await.map(Some)
12492    }
12493
12494    /// Checkpointed counterpart to [`next_ingest_with_resync`](Self::next_ingest_with_resync).
12495    /// Requires [`SubscriberCapability::DurableReplay`] and rejects an
12496    /// ephemeral subscriber before polling.
12497    ///
12498    /// # Errors
12499    ///
12500    /// Returns [`ReactiveEngineError`] for the same durability, identity,
12501    /// rollback-proof, replay-witness, polling, ingestion, persistence,
12502    /// mutation-fence, and acknowledgement failures as
12503    /// [`next_ingest_checkpointed`](Self::next_ingest_checkpointed).
12504    pub async fn next_ingest_with_resync_checkpointed(
12505        &mut self,
12506        cache: &mut EvmCache,
12507        store: &DurableCheckpointStore,
12508        identity: &DurableCheckpointIdentity,
12509    ) -> Result<Option<CheckpointedIngest<N>>, ReactiveEngineError> {
12510        if !self.subscriber.capabilities().supports_durable_replay() {
12511            return Err(ReactiveEngineError::SubscriberNotDurable);
12512        }
12513        self.ensure_subscriber_chain(cache)?;
12514        if self.pending_acknowledgement.is_some() {
12515            return Err(ReactiveEngineError::PendingAcknowledgementCommit);
12516        }
12517        self.ensure_checkpoint_identity(cache, identity)?;
12518        if self.pending_checkpoint.is_some() {
12519            return self.commit_pending_checkpoint(cache, store).await.map(Some);
12520        }
12521
12522        let batch = self.subscriber.next_batch().await?;
12523        self.ensure_subscriber_chain(cache)?;
12524        let Some(mut batch) = batch else {
12525            return Ok(None);
12526        };
12527        if batch_preconfirmation(&batch)?.is_some() {
12528            return Err(ReactiveEngineError::PreconfirmationNotCheckpointable);
12529        }
12530        self.runtime.discard_preconfirmed_branch(cache);
12531        let delivery_witness = batch
12532            .delivery_token()
12533            .map(|_| durable_delivery_witness(&batch))
12534            .transpose()?;
12535        let delivery_token = batch.take_delivery_token();
12536        let subscriber_checkpoint = batch.take_subscriber_checkpoint();
12537        if let (Some(replay_token), Some(committed_token)) = (
12538            delivery_token.as_ref(),
12539            self.last_checkpoint_delivery_token.as_ref(),
12540        ) && replay_token == committed_token
12541        {
12542            let committed_witness = self
12543                .last_checkpoint_delivery_witness
12544                .ok_or(ReactiveEngineError::MissingReplayWitness)?;
12545            if delivery_witness != Some(committed_witness) {
12546                return Err(ReactiveEngineError::ReplayDeliveryMismatch);
12547            }
12548            self.subscriber
12549                .acknowledge_delivery(replay_token.clone())
12550                .await
12551                .map_err(ReactiveEngineError::Acknowledgement)?;
12552            return Ok(Some(CheckpointedIngest::ReplayAcknowledged));
12553        }
12554
12555        self.ensure_checkpointable_reorgs(&batch)?;
12556
12557        let incoming_block = latest_canonical_batch_block(&batch);
12558        let cache_state = EvmCacheStateSnapshot::capture(cache);
12559        let runtime_state = self.runtime.checkpoint_state();
12560        let report = match self.runtime.ingest_batch_with_resync_direct(cache, batch) {
12561            Ok(report) => report,
12562            Err(error) => {
12563                cache_state.restore(cache);
12564                self.runtime.restore_transaction_state(runtime_state);
12565                return Err(error.into());
12566            }
12567        };
12568        let reports = report.reports.clone();
12569        let stage = CheckpointStage {
12570            incoming_block,
12571            delivery_token,
12572            delivery_witness,
12573            subscriber_checkpoint,
12574            staged_generation: cache.snapshot_generation(),
12575            report,
12576        };
12577        if let Err(error) = self.stage_checkpoint(identity, stage) {
12578            cache_state.restore(cache);
12579            self.runtime.restore_transaction_state(runtime_state);
12580            return Err(error);
12581        }
12582        self.runtime.dispatch_reports(&reports);
12583        self.commit_pending_checkpoint(cache, store).await.map(Some)
12584    }
12585
12586    fn stage_checkpoint(
12587        &mut self,
12588        identity: &DurableCheckpointIdentity,
12589        stage: CheckpointStage<N>,
12590    ) -> Result<(), ReactiveEngineError> {
12591        let CheckpointStage {
12592            incoming_block,
12593            delivery_token,
12594            delivery_witness,
12595            subscriber_checkpoint,
12596            staged_generation,
12597            report,
12598        } = stage;
12599        if delivery_token.is_some() != delivery_witness.is_some() {
12600            return Err(ReactiveEngineError::DeliveryWitness(
12601                "delivery token and witness must be staged together".into(),
12602            ));
12603        }
12604        let runtime_checkpoint = self.runtime.durable_checkpoint_bytes()?;
12605        let block = self
12606            .runtime
12607            .last_canonical_block()
12608            .map(|block| DurableCheckpointBlock {
12609                number: block.number,
12610                hash: block.hash,
12611                parent_hash: block.parent_hash,
12612                timestamp: block.timestamp,
12613            })
12614            .or(incoming_block)
12615            .or_else(|| self.last_checkpoint_block.clone())
12616            .ok_or(ReactiveEngineError::MissingCheckpointBlock)?;
12617        let metadata = DurableCheckpointMetadata {
12618            identity: identity.clone(),
12619            block,
12620            delivery_token: delivery_token
12621                .as_ref()
12622                .or(self.last_checkpoint_delivery_token.as_ref())
12623                .map(|token| token.as_bytes().to_vec()),
12624            delivery_witness: if delivery_token.is_some() {
12625                delivery_witness
12626            } else {
12627                self.last_checkpoint_delivery_witness
12628            },
12629            subscriber_checkpoint: subscriber_checkpoint
12630                .as_ref()
12631                .or(self.last_subscriber_checkpoint.as_ref())
12632                .map(|checkpoint| checkpoint.as_bytes().to_vec()),
12633            runtime_checkpoint: Some(runtime_checkpoint),
12634        };
12635        self.pending_checkpoint = Some(PendingCheckpoint {
12636            metadata,
12637            delivery_token,
12638            report,
12639            saved_to: None,
12640            staged_generation,
12641        });
12642        Ok(())
12643    }
12644
12645    fn ensure_checkpointable_reorgs(
12646        &self,
12647        batch: &ReactiveInputBatch<N>,
12648    ) -> Result<(), ReactiveEngineError> {
12649        let state = CanonicalSequenceState::new(
12650            self.runtime
12651                .journal
12652                .iter()
12653                .map(|entry| entry.block)
12654                .collect(),
12655            self.runtime.coverage_head,
12656            self.runtime.safe_head,
12657            self.runtime.finalized_head,
12658        )
12659        .with_log_coverage_head(self.runtime.log_coverage_head);
12660        match validate_canonical_sequence_internal(
12661            &state,
12662            batch,
12663            CanonicalSequenceValidationPolicy::RequireCompleteRollback,
12664        ) {
12665            Ok(_) => Ok(()),
12666            Err(CanonicalSequenceError::Invalid(error)) => Err(error.into()),
12667            Err(CanonicalSequenceError::IncompleteRollback {
12668                common_ancestor,
12669                oldest_retained,
12670                ..
12671            }) => Err(ReactiveEngineError::CheckpointReorgOutsideJournal {
12672                common_ancestor,
12673                oldest_journaled: oldest_retained,
12674                journal_depth: self.runtime.config.journal_depth,
12675            }),
12676        }
12677    }
12678
12679    async fn stage_or_return_acknowledgement(
12680        &mut self,
12681        delivery_token: Option<SubscriberDeliveryToken>,
12682        report: ReactiveBatchReport<N>,
12683    ) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
12684        let Some(token) = delivery_token else {
12685            return Ok(Some(report));
12686        };
12687        self.pending_acknowledgement = Some(PendingAcknowledgement { token, report });
12688        self.commit_pending_acknowledgement().await.map(Some)
12689    }
12690
12691    async fn commit_pending_acknowledgement(
12692        &mut self,
12693    ) -> Result<ReactiveBatchReport<N>, ReactiveEngineError> {
12694        let token = self
12695            .pending_acknowledgement
12696            .as_ref()
12697            .expect("caller checked pending acknowledgement")
12698            .token
12699            .clone();
12700        self.subscriber
12701            .acknowledge_delivery(token)
12702            .await
12703            .map_err(ReactiveEngineError::Acknowledgement)?;
12704        Ok(self
12705            .pending_acknowledgement
12706            .take()
12707            .expect("pending acknowledgement remains until commit")
12708            .report)
12709    }
12710
12711    async fn commit_pending_checkpoint(
12712        &mut self,
12713        cache: &EvmCache,
12714        store: &DurableCheckpointStore,
12715    ) -> Result<CheckpointedIngest<N>, ReactiveEngineError> {
12716        let pending = self
12717            .pending_checkpoint
12718            .as_mut()
12719            .expect("caller checked pending checkpoint");
12720        let cache_generation = cache.snapshot_generation();
12721        if cache_generation != pending.staged_generation {
12722            return Err(ReactiveEngineError::PendingCheckpointCacheChanged {
12723                staged_generation: pending.staged_generation,
12724                current_generation: cache_generation,
12725            });
12726        }
12727        if pending.saved_to.as_deref() != Some(store.path()) {
12728            store
12729                .save_async(cache, pending.metadata.clone())
12730                .await
12731                .map_err(ReactiveEngineError::Checkpoint)?;
12732            pending.saved_to = Some(store.path().to_path_buf());
12733        }
12734        if let Some(token) = pending.delivery_token.clone() {
12735            self.subscriber
12736                .acknowledge_delivery(token)
12737                .await
12738                .map_err(ReactiveEngineError::Acknowledgement)?;
12739        }
12740
12741        let pending = self
12742            .pending_checkpoint
12743            .take()
12744            .expect("pending checkpoint remains until commit");
12745        self.last_checkpoint_block = Some(pending.metadata.block);
12746        self.checkpoint_identity = Some(pending.metadata.identity);
12747        self.last_checkpoint_delivery_token = pending
12748            .metadata
12749            .delivery_token
12750            .map(SubscriberDeliveryToken::new);
12751        self.last_checkpoint_delivery_witness = pending.metadata.delivery_witness;
12752        self.last_subscriber_checkpoint = pending
12753            .metadata
12754            .subscriber_checkpoint
12755            .map(SubscriberCheckpoint::new);
12756        Ok(CheckpointedIngest::Applied(pending.report))
12757    }
12758
12759    fn ensure_checkpoint_identity(
12760        &self,
12761        cache: &EvmCache,
12762        identity: &DurableCheckpointIdentity,
12763    ) -> Result<(), ReactiveEngineError> {
12764        if identity.chain_id != cache.chain_id() {
12765            return Err(ReactiveEngineError::Checkpoint(
12766                DurableCheckpointError::CacheChainMismatch {
12767                    cache_chain_id: cache.chain_id(),
12768                    checkpoint_chain_id: identity.chain_id,
12769                },
12770            ));
12771        }
12772        if let Some(actual) = self.checkpoint_identity.as_ref()
12773            && actual != identity
12774        {
12775            return Err(ReactiveEngineError::Checkpoint(
12776                DurableCheckpointError::IdentityMismatch {
12777                    expected: identity.clone(),
12778                    actual: actual.clone(),
12779                },
12780            ));
12781        }
12782        if let Some(pending) = self.pending_checkpoint.as_ref()
12783            && &pending.metadata.identity != identity
12784        {
12785            return Err(ReactiveEngineError::Checkpoint(
12786                DurableCheckpointError::IdentityMismatch {
12787                    expected: identity.clone(),
12788                    actual: pending.metadata.identity.clone(),
12789                },
12790            ));
12791        }
12792        Ok(())
12793    }
12794}
12795
12796fn latest_canonical_batch_block<N: Network>(
12797    batch: &ReactiveInputBatch<N>,
12798) -> Option<DurableCheckpointBlock> {
12799    let record_block = batch
12800        .records()
12801        .iter()
12802        .enumerate()
12803        .filter(|(index, _)| {
12804            batch
12805                .record_delivery_scope(*index)
12806                .is_some_and(DeliveryScope::advances_canonical_state)
12807        })
12808        .filter_map(|(_, record)| canonical_record_block(record))
12809        .max_by_key(|block| block.number)
12810        .cloned();
12811    let control_block = batch
12812        .chain_controls()
12813        .iter()
12814        .filter_map(|control| match control {
12815            ChainControl::Reorg {
12816                common_ancestor, ..
12817            } => Some(common_ancestor),
12818            ChainControl::Barrier {
12819                block: Some(block), ..
12820            }
12821            | ChainControl::CanonicalProgress(block) => Some(block),
12822            ChainControl::Safe(_)
12823            | ChainControl::Finalized(_)
12824            | ChainControl::LogCoverage(_)
12825            | ChainControl::Barrier { block: None, .. } => None,
12826        })
12827        .max_by_key(|block| block.number)
12828        .cloned();
12829
12830    record_block
12831        .into_iter()
12832        .chain(control_block)
12833        .max_by_key(|block| block.number)
12834        .map(|block| DurableCheckpointBlock {
12835            number: block.number,
12836            hash: block.hash,
12837            parent_hash: block.parent_hash,
12838            timestamp: block.timestamp,
12839        })
12840}
12841
12842impl<S, N> ReactiveEngine<S, N>
12843where
12844    N: Network,
12845    S: InterestOwnerSubscriber<N>,
12846{
12847    /// Register a handler with both the runtime and subscriber, backfilling its
12848    /// log interests from the runtime's last canonical block.
12849    ///
12850    /// This is the continuity-safe default for mid-lifecycle registration. The
12851    /// subscriber adopts the live desired state first, delivers the new owner's
12852    /// matching records at retained block `C` as owner catch-up, then delivers
12853    /// `C + 1` through activation as global canonical catch-up over the complete
12854    /// handler union. No discovery gap opens, and every effect after `C` enters
12855    /// the ordinary global rollback journal. On a runtime that has not journaled any canonical block yet
12856    /// (fresh start, or `journal_depth` 0) registration is live-only, matching
12857    /// pre-ingestion bootstrap. Use
12858    /// [`register_handler_with_backfill`](Self::register_handler_with_backfill)
12859    /// for an explicit replay of one retained block or
12860    /// [`register_handler_live_only`](Self::register_handler_live_only) to opt
12861    /// out of backfill entirely.
12862    ///
12863    /// Subscriber registration commits before runtime routing is installed. If
12864    /// the subscriber operation fails or is cancelled, the runtime remains
12865    /// unchanged.
12866    ///
12867    /// # Errors
12868    ///
12869    /// Returns [`ReactiveEngineRegisterError`] when the handler id is already
12870    /// registered or the subscriber rejects/does not support the required
12871    /// owner update or coordinated catch-up.
12872    pub async fn register_handler(
12873        &mut self,
12874        handler: Arc<dyn ReactiveHandler<N>>,
12875    ) -> Result<(), ReactiveEngineRegisterError> {
12876        let backfill = self
12877            .runtime
12878            .last_canonical_block()
12879            .filter(|retained| {
12880                self.runtime.journal.iter().any(|entry| {
12881                    optional_block_refs_are_compatible(Some(&entry.block), Some(retained))
12882                })
12883            })
12884            .map(HandlerRegistrationCatchup::CoordinatedCanonical)
12885            .unwrap_or(HandlerRegistrationCatchup::LiveOnly);
12886        self.register_handler_inner(handler, backfill).await
12887    }
12888
12889    /// Register a handler and replay its matching logs at one exact retained
12890    /// canonical block.
12891    ///
12892    /// Owner-only effects are appended to that block's existing rollback
12893    /// journal entry. Consequently this method accepts only a bounded
12894    /// [`SubscriberBackfill`] whose start, end, and hash-certified retained
12895    /// anchor all identify the same journaled block. Wider/deeper recovery must
12896    /// use ordinary global canonical ingestion (for example startup catch-up),
12897    /// where every handler sees the records and the runtime advances coverage.
12898    ///
12899    /// If subscriber registration fails or is cancelled, the runtime remains
12900    /// unchanged.
12901    ///
12902    /// # Errors
12903    ///
12904    /// Returns [`ReactiveEngineRegisterError`] when the handler id is already
12905    /// registered, the requested backfill is not exactly one hash-certified
12906    /// retained journal block, or the subscriber update fails.
12907    pub async fn register_handler_with_backfill(
12908        &mut self,
12909        handler: Arc<dyn ReactiveHandler<N>>,
12910        backfill: SubscriberBackfill,
12911    ) -> Result<(), ReactiveEngineRegisterError> {
12912        self.register_handler_inner(handler, HandlerRegistrationCatchup::OwnerBackfill(backfill))
12913            .await
12914    }
12915
12916    /// Register a handler without any log backfill — only logs delivered after
12917    /// its live subscription starts are routed to it.
12918    ///
12919    /// If subscriber registration fails or is cancelled, the runtime remains
12920    /// unchanged.
12921    ///
12922    /// # Errors
12923    ///
12924    /// Returns [`ReactiveEngineRegisterError`] when the handler id is already
12925    /// registered or the subscriber cannot commit the owner update.
12926    pub async fn register_handler_live_only(
12927        &mut self,
12928        handler: Arc<dyn ReactiveHandler<N>>,
12929    ) -> Result<(), ReactiveEngineRegisterError> {
12930        self.register_handler_inner(handler, HandlerRegistrationCatchup::LiveOnly)
12931            .await
12932    }
12933
12934    async fn register_handler_inner(
12935        &mut self,
12936        handler: Arc<dyn ReactiveHandler<N>>,
12937        catchup: HandlerRegistrationCatchup,
12938    ) -> Result<(), ReactiveEngineRegisterError> {
12939        let id = handler.id();
12940        if self.runtime.contains_handler(&id) {
12941            return Err(RegisterError::DuplicateHandler(id).into());
12942        }
12943        let interests = handler.interests();
12944
12945        if let HandlerRegistrationCatchup::OwnerBackfill(backfill) = &catchup {
12946            let retained_anchor = backfill.retained_anchor().copied();
12947            let is_exact_retained_block = retained_anchor.is_some_and(|anchor| {
12948                backfill.start_block() == anchor.number
12949                    && backfill.end_block() == Some(anchor.number)
12950                    && self.runtime.journal.iter().any(|entry| {
12951                        optional_block_refs_are_compatible(Some(&entry.block), Some(&anchor))
12952                    })
12953            });
12954            if !is_exact_retained_block {
12955                return Err(ReactiveEngineRegisterError::BackfillOutsideJournal {
12956                    start_block: backfill.start_block(),
12957                    end_block: backfill.end_block(),
12958                    retained_anchor,
12959                });
12960            }
12961        }
12962
12963        let subscribed = match catchup {
12964            HandlerRegistrationCatchup::OwnerBackfill(backfill) => {
12965                self.subscriber
12966                    .add_interest_owner_with_backfill(id.clone(), &interests, backfill)
12967                    .await
12968            }
12969            HandlerRegistrationCatchup::CoordinatedCanonical(retained) => {
12970                self.subscriber
12971                    .add_interest_owner_with_canonical_catchup(id.clone(), &interests, retained)
12972                    .await
12973            }
12974            HandlerRegistrationCatchup::LiveOnly => {
12975                self.subscriber
12976                    .add_interest_owner(id.clone(), &interests)
12977                    .await
12978            }
12979        };
12980        if let Err(error) = subscribed {
12981            return Err(error.into());
12982        }
12983
12984        // `&mut self` excludes concurrent registry mutation between the
12985        // duplicate preflight and this commit. Registration is deliberately
12986        // subscriber-first: cancelling the awaited operation cannot leave a
12987        // runtime handler active without committed subscriber interests.
12988        self.runtime
12989            .registry
12990            .insert_handler_prepared(id, handler, interests);
12991        Ok(())
12992    }
12993
12994    /// Register every handler currently in the runtime registry as a subscriber
12995    /// interest owner.
12996    ///
12997    /// This is the no-history bootstrap path for a fresh runtime/subscriber pair
12998    /// before ingestion starts, or for reattaching an already-aligned durable
12999    /// subscriber whose exact owner state was restored independently. Each
13000    /// handler becomes its own owner through one exact bulk replacement;
13001    /// crash-stale owners and unowned/base interests are removed.
13002    ///
13003    /// No backfill is requested. It is therefore **not** the restart-recovery path for a new or
13004    /// potentially stale subscriber after the runtime has processed canonical
13005    /// state: use
13006    /// [`sync_handler_interests_with_backfill`](Self::sync_handler_interests_with_backfill),
13007    /// which exact-replaces the owner set and closes continuity from the
13008    /// restored runtime position.
13009    ///
13010    /// The complete exact set commits through one subscriber operation; an
13011    /// error or cancellation leaves the previously committed topology
13012    /// authoritative.
13013    ///
13014    /// # Errors
13015    ///
13016    /// Returns [`SubscriberError`] when the subscriber cannot atomically
13017    /// replace the complete owner topology.
13018    pub async fn sync_handler_interests(&mut self) -> Result<(), SubscriberError> {
13019        let owners = self
13020            .runtime
13021            .handler_ids()
13022            .into_iter()
13023            .map(|id| {
13024                let interests = self
13025                    .runtime
13026                    .handler_interests(&id)
13027                    .map(<[ReactiveInterest<N>]>::to_vec)
13028                    .unwrap_or_default();
13029                (id, interests)
13030            })
13031            .collect();
13032        self.subscriber.replace_interest_owners(owners).await
13033    }
13034
13035    /// Rebuild subscriber owner state from a runtime that already embodies a
13036    /// canonical checkpoint.
13037    ///
13038    /// The runtime registry is authoritative: the subscriber must atomically
13039    /// replace its complete owner set, removing crash-stale owners as well as
13040    /// adding the current ones. Log catch-up is routed globally through normal
13041    /// canonical ingestion and begins strictly at `C + 1`, where
13042    /// `C` is [`ReactiveRuntime::last_canonical_block`], because the restored
13043    /// cache already contains every effect through `C`. The exact number/hash
13044    /// identity of `C` remains attached as a retained baseline and must be
13045    /// validated by the subscriber before it exposes post-baseline records.
13046    /// Global routing is essential: startup catch-up effects enter the ordinary
13047    /// canonical journal and can be rolled back if the certified branch later
13048    /// reorganizes; owner-only catch-up is reserved for a true mid-lifecycle
13049    /// handler addition.
13050    ///
13051    /// A runtime without a canonical position must use
13052    /// [`sync_handler_interests`](Self::sync_handler_interests) instead. Block
13053    /// `u64::MAX` is rejected rather than wrapping or replaying the baseline.
13054    /// The replacement is one subscriber commit boundary: errors and
13055    /// cancellation leave the previous topology authoritative.
13056    ///
13057    /// # Errors
13058    ///
13059    /// Returns [`SubscriberError::InvalidConfig`] when no canonical baseline
13060    /// exists or no exclusive successor can be represented, and otherwise
13061    /// propagates subscriber validation, transport, or atomic-commit failures.
13062    pub async fn sync_handler_interests_with_backfill(&mut self) -> Result<(), SubscriberError> {
13063        let baseline =
13064            self.runtime
13065                .last_canonical_block()
13066                .ok_or(SubscriberError::InvalidConfig(
13067                    "cannot continuity-sync handlers before a canonical runtime position exists",
13068                ))?;
13069        let backfill = SubscriberBackfill::after_canonical_block(baseline)?;
13070        let owners = self
13071            .runtime
13072            .handler_ids()
13073            .into_iter()
13074            .map(|id| {
13075                let interests = self
13076                    .runtime
13077                    .handler_interests(&id)
13078                    .map(<[ReactiveInterest<N>]>::to_vec)
13079                    .unwrap_or_default();
13080                (id, interests)
13081            })
13082            .collect();
13083        self.subscriber
13084            .replace_interest_owners_with_global_backfill(owners, backfill)
13085            .await
13086    }
13087
13088    /// Unregister a handler from both the subscriber and runtime.
13089    ///
13090    /// Subscriber interests are removed first so no new live records are routed
13091    /// to a handler after it has left the runtime registry. Returns the removed
13092    /// handler when the id was registered. If subscriber removal fails or is
13093    /// cancelled, runtime routing remains installed.
13094    ///
13095    /// This is the routing/transport half of dropping an adapter. State the
13096    /// handler accumulated is deliberately left in place; the complete teardown
13097    /// for a pool or adapter that will not return is:
13098    ///
13099    /// ```text
13100    /// engine.unregister_handler(&id).await?;
13101    /// for request_id in handler_request_ids {
13102    ///     // Drop only this handler generation's queued repair work.
13103    ///     engine.runtime_mut().cancel_pending_resync(&request_id);
13104    /// }
13105    /// for address in exclusively_owned_addresses {
13106    ///     // Shared accounts require caller-side owner reference counting.
13107    ///     engine.runtime_mut().untrack_account(address);
13108    /// }
13109    /// // optional: evict cached state via StateUpdate::purge / cache purge APIs
13110    /// ```
13111    ///
13112    /// Health, metrics, the reorg journal, hooks, and freshness stamps are
13113    /// runtime-global and are never touched by handler removal.
13114    ///
13115    /// # Errors
13116    ///
13117    /// Returns [`SubscriberError`] when the subscriber cannot commit owner
13118    /// removal. In that case runtime routing remains installed.
13119    pub async fn unregister_handler(
13120        &mut self,
13121        id: &HandlerId,
13122    ) -> Result<Option<Arc<dyn ReactiveHandler<N>>>, SubscriberError> {
13123        self.subscriber.remove_interest_owner(id).await?;
13124        Ok(self.runtime.unregister_handler(id))
13125    }
13126}
13127
13128type FlashblockReconnectFuture<N> = Pin<
13129    Box<
13130        dyn Future<
13131                Output = (
13132                    SubscriberStreamSource,
13133                    Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError>,
13134                ),
13135            > + Send,
13136    >,
13137>;
13138
13139/// Alloy-backed event subscriber.
13140///
13141/// The default transport slice drives Alloy pubsub subscriptions for logs,
13142/// block headers, and pending transaction hashes. The HTTP polling `watch_*`
13143/// transport remains available behind the opt-in `reactive-polling` feature.
13144/// Pubsub streams reconnect automatically after termination, and log
13145/// subscriptions are backfilled from the last seen block. Owner-scoped log
13146/// additions can request backfill from an explicit block anchor. Full pending
13147/// transaction hydration and full block bodies remain explicit follow-up work.
13148///
13149/// Historical log fetching is deliberately a bounded live-subscriber aid, not
13150/// a high-volume indexer: each filter/window is issued as one complete-range
13151/// `eth_getLogs` request. [`SubscriberConfig::max_backfill_log_bytes`] rejects
13152/// an oversized decoded response, but the subscriber does not adaptively split
13153/// block ranges and cannot bypass an RPC provider's result cap. Keep owner
13154/// registration and reconnect windows modest; use an indexing source such as
13155/// HyperSync behind [`EventSubscriber`] for deep or high-density catch-up.
13156///
13157/// With no registered interests, [`EventSubscriber::next_batch`] returns
13158/// `Ok(None)`.
13159pub struct AlloySubscriber<P, N: Network = Ethereum> {
13160    provider: P,
13161    /// Stable identity of an application-managed standardized Flashblock
13162    /// update source. The application owns its transport and lifecycle.
13163    #[cfg(feature = "raw-flashblocks-json")]
13164    external_flashblocks_provider: Option<ProviderRef>,
13165    /// Receiving half of the optional bounded application-to-subscriber queue.
13166    #[cfg(feature = "raw-flashblocks-json")]
13167    external_flashblock_updates:
13168        Option<tokio::sync::mpsc::Receiver<raw_json_flashblocks::QueuedFlashblockUpdate>>,
13169    /// Whether an external update queue was opened for this subscriber.
13170    #[cfg(feature = "raw-flashblocks-json")]
13171    external_flashblock_update_channel_opened: bool,
13172    /// Highest external generation rejected by subscriber-level validation.
13173    #[cfg(feature = "raw-flashblocks-json")]
13174    rejected_external_flashblock_generation: Option<u64>,
13175    /// Last accepted externally standardized snapshot, retained so callers
13176    /// cannot bypass indexed-payload continuity enforced by the raw adapter.
13177    #[cfg(feature = "raw-flashblocks-json")]
13178    last_external_flashblock_snapshot: Option<FlashblockSnapshot>,
13179    /// Optional request/response half of the same configured provider lease.
13180    /// OP Flashblocks pending reads use this transport when WebSocket JSON-RPC
13181    /// does not expose the provider's pending-state surface.
13182    flashblocks_state_provider: Option<P>,
13183    /// Stable identity for the provider session used by Flashblocks and every
13184    /// follow-up pending-state read.
13185    provider_ref: Option<ProviderRef>,
13186    /// Optional provider dedicated to canonical log-context verification.
13187    /// Keeping this separate prevents a high-volume pubsub connection from
13188    /// starving its own verification requests behind log notifications.
13189    log_verification_provider: Option<P>,
13190    /// Provider chain identity, resolved once before any record can escape.
13191    chain_id: Option<u64>,
13192    mode: SubscriberMode,
13193    config: SubscriberConfig,
13194    base_interests: Vec<ReactiveInterest<N>>,
13195    owned_interests: Vec<OwnedSubscriberInterests<N>>,
13196    next_owner_epoch: u64,
13197    interests: Vec<ReactiveInterest<N>>,
13198    /// Stable source id per distinct provider-facing log filter. Ids key
13199    /// delivery anchors and live `SubscriberEvent`s; entries are retired (and
13200    /// their anchors pruned) when no planned stream references the filter, so
13201    /// long-lived owner churn cannot grow this map unboundedly.
13202    log_source_ids: HashMap<Filter, usize>,
13203    next_log_source_id: usize,
13204    pending_backfills: VecDeque<QueuedSubscriberBackfill>,
13205    /// Successfully connected sources whose subscribe-then-backfill step has
13206    /// not committed yet. Installation happens before the backfill await, so a
13207    /// cancelled reconcile keeps the live stream and retries only the missing
13208    /// historical window.
13209    pending_source_backfills: VecDeque<SubscriberStreamSource>,
13210    /// Set when interest bookkeeping changed since the last successful stream
13211    /// reconcile, so steady-state polling skips the desired-vs-live diff.
13212    sources_dirty: bool,
13213    /// Conservative generation of desired/live stream topology. Successful
13214    /// owner progress is activatable only against the same clean revision.
13215    stream_revision: u64,
13216    state: AlloySubscriberState<N>,
13217    pending_records: VecDeque<SubscriberInputRecord<N>>,
13218    pending_chain_controls: VecDeque<ChainControl>,
13219    /// Owner copies of live records consumed during an in-flight reconcile.
13220    /// These remain hidden from subscriber output until the owning reconcile
13221    /// commits and survive cancellation so subscribe-first adoption cannot
13222    /// lose an event at an await boundary.
13223    pending_reconcile_owner_records: VecDeque<BufferedSubscriberOwnerRecord<N>>,
13224    /// Sticky fail-closed capacity error. Once an event could not be retained,
13225    /// only a full replacement registration can establish a new baseline.
13226    resource_error: Option<String>,
13227    last_seen_log_blocks: HashMap<usize, u64>,
13228    verified_log_blocks: HashMap<(u64, B256), BlockRef>,
13229    verified_log_block_order: VecDeque<(u64, B256)>,
13230    recent_input_refs: VecDeque<InputRef>,
13231    recent_input_ref_set: HashSet<InputRef>,
13232    recent_owner_input_refs: HashMap<SubscriberOwnerEpoch, VecDeque<InputRef>>,
13233    recent_owner_input_ref_sets: HashMap<SubscriberOwnerEpoch, HashSet<InputRef>>,
13234    recent_compat_owner_input_refs: HashMap<HandlerId, VecDeque<InputRef>>,
13235    recent_compat_owner_input_ref_sets: HashMap<HandlerId, HashSet<InputRef>>,
13236    base_flashblock_header: Option<(FixedBytes<8>, BaseFlashblockBase)>,
13237    base_flashblock_transactions: Option<(FixedBytes<8>, u64, Vec<B256>, Vec<B256>)>,
13238    unmatched_pending_logs: VecDeque<(usize, Log, FlashblockIngressTiming)>,
13239    latest_preconfirmation: Option<FlashblockRef>,
13240    preconfirmed_seen_logs: HashSet<(B256, u64)>,
13241    /// OP transaction receipts already proven for the active cumulative
13242    /// payload. This avoids re-querying non-matching transactions while still
13243    /// retrying receipts that were temporarily unavailable.
13244    preconfirmed_receipted_transactions: HashSet<B256>,
13245    /// OP receipt hashes that returned `null` at least once for the active
13246    /// payload. Never-attempted hashes are scheduled ahead of this retry set so
13247    /// a lagging provider cache cannot let a few transactions monopolize the
13248    /// bounded request budget.
13249    preconfirmed_unavailable_receipts: HashSet<B256>,
13250    last_certified_canonical_head: Option<BlockRef>,
13251    /// When the canonical head was last certified against the provider.
13252    ///
13253    /// A Flashblocks endpoint replaces the `newHeads` subscription with a
13254    /// fixed-interval certification poll, because its `newHeads` may carry
13255    /// partial heads. Recording the last certification lets the timer suppress
13256    /// itself when the flashblock stream already proved a block sealed, so the
13257    /// poll spends a request only when nothing else did.
13258    last_canonical_head_certification: Option<Instant>,
13259    /// Set when a `newFlashblocks` payload opens a block, which means the
13260    /// previous block sealed and its canonical head is worth certifying.
13261    sealed_block_pending_certification: bool,
13262    /// Highest canonical block observed while every log source was whole, and
13263    /// the last value attested to the consumer. Advances only through
13264    /// `note_attestable_canonical_block`, which a live gap resets.
13265    attestable_canonical_head: Option<BlockRef>,
13266    attested_log_coverage: Option<BlockRef>,
13267    pending_preconfirmation_invalidation: bool,
13268    pending_flashblock_reconnects: FuturesUnordered<FlashblockReconnectFuture<N>>,
13269    pending_flashblock_reconnect_sources: Vec<SubscriberStreamSource>,
13270    flashblocks_rpc_metrics: FlashblocksRpcMetrics,
13271    /// Every provider request this subscriber has issued, by method and cause.
13272    /// Shared so catch-up futures and the free functions they call can record
13273    /// without borrowing the subscriber; see [`SubscriberRpcCounters`].
13274    rpc_counters: Arc<SubscriberRpcCounters>,
13275    /// Notification loss observed on live subscriptions. Shared for the same
13276    /// reason as `rpc_counters`: stream adapters record without a subscriber
13277    /// borrow.
13278    gap_counters: Arc<SubscriberStreamGapCounters>,
13279    consecutive_flashblock_poll_failures: usize,
13280    flashblock_rpc_request_times: VecDeque<Instant>,
13281    _network: PhantomData<N>,
13282}
13283
13284struct OwnedSubscriberInterests<N: Network = Ethereum> {
13285    owner: HandlerId,
13286    interests: Vec<ReactiveInterest<N>>,
13287    epoch: Option<SubscriberOwnerEpoch>,
13288    state: SubscriberOwnerState,
13289    baseline: Option<BlockRef>,
13290    progress: Option<SubscriberOwnerProgress>,
13291    progress_stream_revision: Option<u64>,
13292}
13293
13294#[derive(Clone)]
13295struct SubscriberOwnerReconcilePlan<N: Network = Ethereum> {
13296    epoch: SubscriberOwnerEpoch,
13297    interests: Vec<ReactiveInterest<N>>,
13298    retained: BlockRef,
13299    from_block: u64,
13300}
13301
13302struct SubscriberOwnerCatchup {
13303    logs: Vec<Log>,
13304    certified: BlockRef,
13305}
13306
13307#[derive(Clone, Copy)]
13308struct SubscriberOwnerCatchupOptions {
13309    target_preverified: bool,
13310    max_logs: usize,
13311    max_log_bytes: usize,
13312    max_requests_in_flight: usize,
13313    /// Which mechanism asked for this catch-up, so its provider requests are
13314    /// attributed to the caller rather than to the shared fetch helper.
13315    cause: SubscriberRpcCause,
13316}
13317
13318struct SubscriberOwnerReconcileFilter {
13319    filter: Filter,
13320    from_block: u64,
13321}
13322
13323struct BufferedSubscriberOwnerRecord<N: Network = Ethereum> {
13324    record: ReactiveInputRecord<N>,
13325    owners: Vec<SubscriberOwnerEpoch>,
13326}
13327
13328const OWNER_RECONCILE_FILTERS_PER_CHUNK: usize = 256;
13329
13330struct QueuedSubscriberBackfill {
13331    /// `None` means global canonical catch-up; `Some` is compatibility
13332    /// owner-only catch-up for true mid-lifecycle additions.
13333    owner: Option<HandlerId>,
13334    epoch: Option<SubscriberOwnerEpoch>,
13335    /// Complete logical filter set for one certified, globally ordered window.
13336    filters: Vec<Filter>,
13337    backfill: SubscriberBackfill,
13338}
13339
13340/// Best-effort installation of rustls' `ring` crypto provider as the process
13341/// default, so an `wss://` TLS handshake under `reactive-ws` does not panic with
13342/// "no process-level CryptoProvider available". Runs at most once and ignores the
13343/// error if a default provider is already installed (the host app may have set
13344/// its own).
13345#[cfg(feature = "reactive-ws")]
13346fn ensure_ring_crypto_provider() {
13347    use std::sync::Once;
13348    static INSTALL: Once = Once::new();
13349    INSTALL.call_once(|| {
13350        let _ = rustls::crypto::ring::default_provider().install_default();
13351    });
13352}
13353
13354impl<P, N: Network> AlloySubscriber<P, N> {
13355    /// Create a new Alloy subscriber.
13356    pub fn new(provider: P, mode: SubscriberMode, config: SubscriberConfig) -> Self {
13357        #[cfg(feature = "reactive-ws")]
13358        ensure_ring_crypto_provider();
13359        Self {
13360            provider,
13361            #[cfg(feature = "raw-flashblocks-json")]
13362            external_flashblocks_provider: None,
13363            #[cfg(feature = "raw-flashblocks-json")]
13364            external_flashblock_updates: None,
13365            #[cfg(feature = "raw-flashblocks-json")]
13366            external_flashblock_update_channel_opened: false,
13367            #[cfg(feature = "raw-flashblocks-json")]
13368            rejected_external_flashblock_generation: None,
13369            #[cfg(feature = "raw-flashblocks-json")]
13370            last_external_flashblock_snapshot: None,
13371            flashblocks_state_provider: None,
13372            provider_ref: None,
13373            log_verification_provider: None,
13374            chain_id: None,
13375            mode,
13376            config,
13377            base_interests: Vec::new(),
13378            owned_interests: Vec::new(),
13379            next_owner_epoch: 0,
13380            interests: Vec::new(),
13381            log_source_ids: HashMap::new(),
13382            next_log_source_id: 0,
13383            pending_backfills: VecDeque::new(),
13384            pending_source_backfills: VecDeque::new(),
13385            sources_dirty: true,
13386            stream_revision: 0,
13387            state: AlloySubscriberState::Uninitialized,
13388            pending_records: VecDeque::new(),
13389            pending_chain_controls: VecDeque::new(),
13390            pending_reconcile_owner_records: VecDeque::new(),
13391            resource_error: None,
13392            last_seen_log_blocks: HashMap::new(),
13393            verified_log_blocks: HashMap::new(),
13394            verified_log_block_order: VecDeque::new(),
13395            recent_input_refs: VecDeque::new(),
13396            recent_input_ref_set: HashSet::new(),
13397            recent_owner_input_refs: HashMap::new(),
13398            recent_owner_input_ref_sets: HashMap::new(),
13399            recent_compat_owner_input_refs: HashMap::new(),
13400            recent_compat_owner_input_ref_sets: HashMap::new(),
13401            base_flashblock_header: None,
13402            base_flashblock_transactions: None,
13403            unmatched_pending_logs: VecDeque::new(),
13404            latest_preconfirmation: None,
13405            preconfirmed_seen_logs: HashSet::new(),
13406            preconfirmed_receipted_transactions: HashSet::new(),
13407            preconfirmed_unavailable_receipts: HashSet::new(),
13408            last_certified_canonical_head: None,
13409            last_canonical_head_certification: None,
13410            sealed_block_pending_certification: false,
13411            attestable_canonical_head: None,
13412            attested_log_coverage: None,
13413            pending_preconfirmation_invalidation: false,
13414            pending_flashblock_reconnects: FuturesUnordered::new(),
13415            pending_flashblock_reconnect_sources: Vec::new(),
13416            flashblocks_rpc_metrics: FlashblocksRpcMetrics::default(),
13417            rpc_counters: Arc::new(SubscriberRpcCounters::default()),
13418            gap_counters: Arc::new(SubscriberStreamGapCounters::default()),
13419            consecutive_flashblock_poll_failures: 0,
13420            flashblock_rpc_request_times: VecDeque::new(),
13421            _network: PhantomData,
13422        }
13423    }
13424
13425    /// Borrow the provider.
13426    pub fn provider(&self) -> &P {
13427        &self.provider
13428    }
13429
13430    /// Bind this subscriber to the concrete provider lease that supplies
13431    /// Flashblocks. Callers obtain the lease from a transport endpoint marked
13432    /// with the single `flashblocks = true` flag.
13433    #[must_use]
13434    pub fn with_provider_ref(mut self, provider: ProviderRef) -> Self {
13435        self.provider_ref = Some(provider);
13436        self
13437    }
13438
13439    /// Select application-managed standardized Flashblock updates before
13440    /// subscriber registration begins.
13441    ///
13442    /// This suppresses the subscriber's chain-specific native or pending-state
13443    /// Flashblocks source. Canonical logs and block headers continue through the
13444    /// configured subscriber transport. The application owns the raw socket,
13445    /// control frames, bounded queue, timeout, retry, backoff, and provider
13446    /// rotation, and passes decoded updates to
13447    /// [`Self::ingest_flashblock_update`].
13448    ///
13449    /// Call [`Self::ingest_flashblock_update`] directly while retaining mutable
13450    /// subscriber ownership, or open a bounded handoff with
13451    /// [`Self::open_external_flashblock_update_channel`] before moving the
13452    /// subscriber into another runtime owner.
13453    ///
13454    /// This is deliberately a fallible construction-time configuration method,
13455    /// not a live reconfiguration API. Replacing a source after canonical or
13456    /// speculative processing begins requires a new subscriber so existing
13457    /// streams and overlays cannot survive under ambiguous provider ownership.
13458    ///
13459    /// # Errors
13460    ///
13461    /// Returns [`SubscriberError::InvalidConfig`] when an external source was
13462    /// already selected or subscriber registration, stream installation, or
13463    /// event processing has begun.
13464    #[cfg(feature = "raw-flashblocks-json")]
13465    pub fn configure_external_flashblock_updates(
13466        &mut self,
13467        provider: ProviderRef,
13468    ) -> Result<(), SubscriberError> {
13469        if self.external_flashblocks_provider.is_some() {
13470            return Err(SubscriberError::InvalidConfig(
13471                "external Flashblock updates were already configured",
13472            ));
13473        }
13474        if self.external_flashblock_update_channel_opened
13475            || self.external_flashblock_updates.is_some()
13476            || self.chain_id.is_some()
13477            || !self.base_interests.is_empty()
13478            || !self.owned_interests.is_empty()
13479            || !self.interests.is_empty()
13480            || !self.pending_records.is_empty()
13481            || !self.pending_chain_controls.is_empty()
13482            || !self.pending_backfills.is_empty()
13483            || !matches!(self.state, AlloySubscriberState::Uninitialized)
13484        {
13485            return Err(SubscriberError::InvalidConfig(
13486                "external Flashblock updates must be configured before subscriber registration",
13487            ));
13488        }
13489        self.external_flashblocks_provider = Some(provider);
13490        Ok(())
13491    }
13492
13493    /// Open one bounded standardized-update queue and return its application handle.
13494    ///
13495    /// The queue is useful when the subscriber will be moved into a runtime
13496    /// driver: the application retains the cloneable sender while the subscriber
13497    /// continues to own all validation, speculative deduplication, and canonical
13498    /// reconciliation. Opening a queue does not create a socket or background
13499    /// task, and does not implement retry or backoff. Awaited sends complete
13500    /// only after subscriber validation; non-blocking sends return an explicit
13501    /// acknowledgement receipt.
13502    ///
13503    /// # Errors
13504    ///
13505    /// Returns [`SubscriberError::InvalidConfig`] if external updates were not
13506    /// selected first, `capacity` is zero, or a queue was already opened.
13507    #[cfg(feature = "raw-flashblocks-json")]
13508    pub fn open_external_flashblock_update_channel(
13509        &mut self,
13510        capacity: usize,
13511    ) -> Result<FlashblockUpdateSender, SubscriberError> {
13512        if capacity == 0 {
13513            return Err(SubscriberError::InvalidConfig(
13514                "external Flashblock update channel capacity must be greater than zero",
13515            ));
13516        }
13517        let provider = self.external_flashblocks_provider.clone().ok_or(
13518            SubscriberError::InvalidConfig(
13519                "external Flashblock update channel requires configure_external_flashblock_updates",
13520            ),
13521        )?;
13522        if self.external_flashblock_update_channel_opened {
13523            return Err(SubscriberError::InvalidConfig(
13524                "external Flashblock update channel was already opened",
13525            ));
13526        }
13527        let (sender, receiver) =
13528            raw_json_flashblocks::flashblock_update_channel(provider, capacity);
13529        self.external_flashblock_updates = Some(receiver);
13530        self.external_flashblock_update_channel_opened = true;
13531        self.sources_dirty = true;
13532        Ok(sender)
13533    }
13534
13535    fn uses_external_flashblock_updates(&self) -> bool {
13536        #[cfg(feature = "raw-flashblocks-json")]
13537        {
13538            self.external_flashblocks_provider.is_some()
13539        }
13540        #[cfg(not(feature = "raw-flashblocks-json"))]
13541        {
13542            false
13543        }
13544    }
13545
13546    /// Pair the subscriber's event transport with the request/response
13547    /// transport for the same configured provider ID and generation.
13548    ///
13549    /// Optimism pending block/log sampling uses this provider. Preflight reads
13550    /// its chain ID and rejects a mismatch before pending data can be emitted.
13551    /// Use type-erased Alloy providers when the WebSocket and HTTP transports
13552    /// have different concrete Rust types.
13553    #[must_use]
13554    pub fn with_flashblocks_state_provider(mut self, provider: P) -> Self {
13555        self.flashblocks_state_provider = Some(provider);
13556        self
13557    }
13558
13559    /// Use a separate provider for canonical log-context verification.
13560    ///
13561    /// This is recommended with
13562    /// [`SubscriberConfig::verify_log_block_context`] in high-volume pubsub
13563    /// deployments. The provider must target the same chain; every fetched
13564    /// block is still checked against the log's number, hash, and timestamp.
13565    #[must_use]
13566    pub fn with_log_verification_provider(mut self, provider: P) -> Self {
13567        self.log_verification_provider = Some(provider);
13568        self
13569    }
13570
13571    /// Subscriber mode.
13572    pub fn mode(&self) -> SubscriberMode {
13573        self.mode
13574    }
13575
13576    /// Subscriber config.
13577    pub fn config(&self) -> &SubscriberConfig {
13578        &self.config
13579    }
13580
13581    /// Request/response traffic issued for Flashblocks qualification and
13582    /// pending-state sampling since the last full interest reset.
13583    pub const fn flashblocks_rpc_metrics(&self) -> FlashblocksRpcMetrics {
13584        self.flashblocks_rpc_metrics
13585    }
13586
13587    /// Every provider request this subscriber has issued, attributed to the
13588    /// method and the mechanism responsible for it.
13589    ///
13590    /// Cumulative for the subscriber's lifetime: unlike
13591    /// [`flashblocks_rpc_metrics`](Self::flashblocks_rpc_metrics), these counts
13592    /// survive reconnects and delivery-state resets so a long-running process
13593    /// can report total RPC consumption. Use
13594    /// [`reset_rpc_stats`](Self::reset_rpc_stats) to measure a bounded window.
13595    pub fn rpc_stats(&self) -> SubscriberRpcStats {
13596        self.rpc_counters.snapshot()
13597    }
13598
13599    /// Zero every [`rpc_stats`](Self::rpc_stats) counter, starting a new
13600    /// measurement window. Takes `&self` so a window can be opened while
13601    /// catch-up work holds the subscriber.
13602    pub fn reset_rpc_stats(&self) {
13603        self.rpc_counters.reset();
13604    }
13605
13606    /// Notification loss observed on live subscriptions, and what healing it
13607    /// cost.
13608    ///
13609    /// A subscription that never lags reports zeroes here. That is evidence
13610    /// nothing was *lost*, which is necessary before treating the stream as
13611    /// authoritative — but it is not evidence that everything has *arrived*.
13612    /// Deciding a particular block's set is closed needs ordering evidence from
13613    /// the log stream itself; see [`ChainControl::LogCoverage`].
13614    pub fn stream_gap_stats(&self) -> SubscriberStreamGapStats {
13615        self.gap_counters.snapshot()
13616    }
13617
13618    /// Zero every [`stream_gap_stats`](Self::stream_gap_stats) counter.
13619    pub fn reset_stream_gap_stats(&self) {
13620        self.gap_counters.reset();
13621    }
13622
13623    /// Record one issued provider request against this subscriber's counters.
13624    fn record_rpc(&self, cause: SubscriberRpcCause, method: SubscriberRpcMethod) {
13625        self.rpc_counters.record(cause, method);
13626    }
13627
13628    /// Bounded notification capacity for a pubsub log stream.
13629    #[cfg(feature = "reactive-ws")]
13630    fn log_channel_size(&self) -> usize {
13631        self.config
13632            .log_channel_size
13633            .unwrap_or(self.config.max_batch_size)
13634            .max(1)
13635    }
13636
13637    /// Registered interests across base and owner-scoped registrations.
13638    pub fn registered_interests(&self) -> &[ReactiveInterest<N>] {
13639        &self.interests
13640    }
13641
13642    /// Stage a fresh, epoch-scoped interest owner without making its inputs
13643    /// canonically routable yet.
13644    ///
13645    /// The returned token is required by every later lifecycle operation. A
13646    /// staged owner participates in provider subscription planning immediately,
13647    /// while its matching input remains owner-scoped until
13648    /// [`activate_interest_owner`](Self::activate_interest_owner) succeeds.
13649    /// Post-block owners require hash-certified
13650    /// [`reconcile_interest_owner`](Self::reconcile_interest_owner) progress on
13651    /// the current clean stream revision before activation.
13652    ///
13653    /// # Errors
13654    ///
13655    /// Returns [`SubscriberOwnerError`] for invalid subscriber configuration,
13656    /// duplicate owners, unsupported post-block interests, unsupported
13657    /// transport interests, block-number overflow, or epoch exhaustion.
13658    pub fn stage_interest_owner(
13659        &mut self,
13660        owner: HandlerId,
13661        interests: &[ReactiveInterest<N>],
13662        start: SubscriberOwnerStart,
13663    ) -> Result<SubscriberOwnerEpoch, SubscriberOwnerError> {
13664        validate_subscriber_config(&self.config)?;
13665        if matches!(&start, SubscriberOwnerStart::PostBlock(_))
13666            && interests
13667                .iter()
13668                .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
13669        {
13670            return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
13671        }
13672        if self
13673            .owned_interests
13674            .iter()
13675            .any(|entry| entry.owner == owner)
13676        {
13677            return Err(SubscriberOwnerError::AlreadyRegistered(owner));
13678        }
13679
13680        let mut next_owned = self.clone_owned_interests();
13681        next_owned.push(OwnedSubscriberInterests {
13682            owner: owner.clone(),
13683            interests: interests.to_vec(),
13684            epoch: None,
13685            state: SubscriberOwnerState::Staged,
13686            baseline: None,
13687            progress: None,
13688            progress_stream_revision: None,
13689        });
13690        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
13691        validate_supported_interests(self.mode, &self.config, &next_registered)?;
13692
13693        let baseline = match start {
13694            SubscriberOwnerStart::Live => None,
13695            SubscriberOwnerStart::PostBlock(block) => {
13696                block
13697                    .number
13698                    .checked_add(1)
13699                    .ok_or(SubscriberOwnerError::PostBlockOverflow(block.number))?;
13700                Some(block)
13701            }
13702        };
13703        let sequence = self
13704            .next_owner_epoch
13705            .checked_add(1)
13706            .ok_or(SubscriberOwnerError::EpochExhausted)?;
13707        let epoch = SubscriberOwnerEpoch {
13708            owner: owner.clone(),
13709            sequence,
13710        };
13711
13712        self.next_owner_epoch = sequence;
13713        let entry = next_owned
13714            .last_mut()
13715            .expect("staged owner was appended during preflight");
13716        entry.epoch = Some(epoch.clone());
13717        entry.baseline = baseline;
13718        self.owned_interests = next_owned;
13719        self.interests = next_registered;
13720        self.sources_dirty = true;
13721
13722        Ok(epoch)
13723    }
13724
13725    /// Stage replacement interests for one currently active logical owner.
13726    ///
13727    /// The active epoch remains canonical while the replacement reconciles.
13728    /// Commit both epochs atomically with
13729    /// [`commit_interest_owner_replacement`](Self::commit_interest_owner_replacement),
13730    /// or abort the staged epoch with [`abort_interest_owner`](Self::abort_interest_owner).
13731    ///
13732    /// # Errors
13733    ///
13734    /// Returns [`SubscriberOwnerError`] for invalid subscriber configuration,
13735    /// missing/non-unique active owner state, unsupported post-block interests,
13736    /// unsupported transport interests, block-number overflow, or epoch
13737    /// exhaustion.
13738    pub fn stage_interest_owner_replacement(
13739        &mut self,
13740        owner: HandlerId,
13741        interests: &[ReactiveInterest<N>],
13742        start: SubscriberOwnerStart,
13743    ) -> Result<SubscriberOwnerEpoch, SubscriberOwnerError> {
13744        validate_subscriber_config(&self.config)?;
13745        if matches!(&start, SubscriberOwnerStart::PostBlock(_))
13746            && interests
13747                .iter()
13748                .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
13749        {
13750            return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
13751        }
13752        let active_count = self
13753            .owned_interests
13754            .iter()
13755            .filter(|entry| {
13756                entry.owner == owner
13757                    && entry.state == SubscriberOwnerState::Active
13758                    && entry.epoch.is_some()
13759            })
13760            .count();
13761        if active_count != 1
13762            || self
13763                .owned_interests
13764                .iter()
13765                .any(|entry| entry.owner == owner && entry.state != SubscriberOwnerState::Active)
13766        {
13767            return Err(SubscriberOwnerError::AlreadyRegistered(owner));
13768        }
13769
13770        let mut next_owned = self.clone_owned_interests();
13771        next_owned.push(OwnedSubscriberInterests {
13772            owner: owner.clone(),
13773            interests: interests.to_vec(),
13774            epoch: None,
13775            state: SubscriberOwnerState::Staged,
13776            baseline: None,
13777            progress: None,
13778            progress_stream_revision: None,
13779        });
13780        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
13781        validate_supported_interests(self.mode, &self.config, &next_registered)?;
13782
13783        let baseline = match start {
13784            SubscriberOwnerStart::Live => None,
13785            SubscriberOwnerStart::PostBlock(block) => {
13786                block
13787                    .number
13788                    .checked_add(1)
13789                    .ok_or(SubscriberOwnerError::PostBlockOverflow(block.number))?;
13790                Some(block)
13791            }
13792        };
13793        let sequence = self
13794            .next_owner_epoch
13795            .checked_add(1)
13796            .ok_or(SubscriberOwnerError::EpochExhausted)?;
13797        let epoch = SubscriberOwnerEpoch {
13798            owner: owner.clone(),
13799            sequence,
13800        };
13801
13802        self.next_owner_epoch = sequence;
13803        let entry = next_owned
13804            .last_mut()
13805            .expect("staged replacement owner was appended during preflight");
13806        entry.epoch = Some(epoch.clone());
13807        entry.baseline = baseline;
13808        self.owned_interests = next_owned;
13809        self.interests = next_registered;
13810        self.sources_dirty = true;
13811        Ok(epoch)
13812    }
13813
13814    /// Current transaction state for an exact owner epoch.
13815    pub fn interest_owner_state(
13816        &self,
13817        epoch: &SubscriberOwnerEpoch,
13818    ) -> Option<SubscriberOwnerState> {
13819        self.owned_interests
13820            .iter()
13821            .find(|entry| entry.epoch.as_ref() == Some(epoch))
13822            .map(|entry| entry.state)
13823    }
13824
13825    /// Latest hash-certified reconcile progress for an exact owner epoch.
13826    pub fn interest_owner_progress(
13827        &self,
13828        epoch: &SubscriberOwnerEpoch,
13829    ) -> Option<&SubscriberOwnerProgress> {
13830        self.owned_interests
13831            .iter()
13832            .find(|entry| entry.epoch.as_ref() == Some(epoch))
13833            .and_then(|entry| entry.progress.as_ref())
13834    }
13835
13836    /// Make a staged owner canonical after its actor-side installation commits.
13837    ///
13838    /// Returns `false` for stale tokens and owners not currently staged.
13839    pub fn activate_interest_owner(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
13840        let stream_revision = self.stream_revision;
13841        let sources_dirty = self.sources_dirty;
13842        let Some(entry) = self
13843            .owned_interests
13844            .iter_mut()
13845            .find(|entry| entry.epoch.as_ref() == Some(epoch))
13846        else {
13847            return false;
13848        };
13849        if entry.state != SubscriberOwnerState::Staged
13850            || (entry.baseline.is_some()
13851                && (entry.progress.is_none()
13852                    || entry.progress_stream_revision != Some(stream_revision)
13853                    || sources_dirty))
13854        {
13855            return false;
13856        }
13857        entry.state = SubscriberOwnerState::Active;
13858        true
13859    }
13860
13861    /// Atomically replace one active owner epoch with one reconciled staged epoch.
13862    pub fn commit_interest_owner_replacement(
13863        &mut self,
13864        active: &SubscriberOwnerEpoch,
13865        replacement: &SubscriberOwnerEpoch,
13866    ) -> bool {
13867        let Some(active_index) = self
13868            .owned_interests
13869            .iter()
13870            .position(|entry| entry.epoch.as_ref() == Some(active))
13871        else {
13872            return false;
13873        };
13874        let Some(replacement_index) = self
13875            .owned_interests
13876            .iter()
13877            .position(|entry| entry.epoch.as_ref() == Some(replacement))
13878        else {
13879            return false;
13880        };
13881        if active_index == replacement_index
13882            || active.owner() != replacement.owner()
13883            || self.owned_interests[active_index].state != SubscriberOwnerState::Active
13884            || self.owned_interests[replacement_index].state != SubscriberOwnerState::Staged
13885            || (self.owned_interests[replacement_index].baseline.is_some()
13886                && (self.owned_interests[replacement_index].progress.is_none()
13887                    || self.owned_interests[replacement_index].progress_stream_revision
13888                        != Some(self.stream_revision)
13889                    || self.sources_dirty))
13890        {
13891            return false;
13892        }
13893
13894        self.owned_interests[replacement_index].state = SubscriberOwnerState::Active;
13895        self.owned_interests.remove(active_index);
13896        self.purge_owner_epoch(active);
13897        self.rebuild_registered_interests();
13898        self.retire_unreferenced_filters();
13899        self.sources_dirty = true;
13900        true
13901    }
13902
13903    /// Prepare an exact active owner for removal without changing desired
13904    /// interests, streams, anchors, or queued canonical input.
13905    ///
13906    /// The caller establishes its delivery fence after this transition. Use
13907    /// [`abort_interest_owner`](Self::abort_interest_owner) to restore the owner
13908    /// on actor-side failure, or
13909    /// [`finalize_interest_owner_removal`](Self::finalize_interest_owner_removal)
13910    /// once canonical routing has been removed.
13911    pub fn prepare_interest_owner_removal(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
13912        let Some(entry) = self
13913            .owned_interests
13914            .iter_mut()
13915            .find(|entry| entry.epoch.as_ref() == Some(epoch))
13916        else {
13917            return false;
13918        };
13919        if entry.state != SubscriberOwnerState::Active {
13920            return false;
13921        }
13922        entry.state = SubscriberOwnerState::Removing;
13923        true
13924    }
13925
13926    /// Finalize a previously prepared exact owner removal.
13927    ///
13928    /// Returns the removed interests, or `None` for stale tokens and owners not
13929    /// currently in [`SubscriberOwnerState::Removing`]. Repeating finalization
13930    /// is therefore idempotent.
13931    pub fn finalize_interest_owner_removal(
13932        &mut self,
13933        epoch: &SubscriberOwnerEpoch,
13934    ) -> Option<Vec<ReactiveInterest<N>>> {
13935        let index = self.owned_interests.iter().position(|entry| {
13936            entry.epoch.as_ref() == Some(epoch) && entry.state == SubscriberOwnerState::Removing
13937        })?;
13938        let removed = self.owned_interests.remove(index).interests;
13939        self.purge_owner_epoch(epoch);
13940        self.rebuild_registered_interests();
13941        self.retire_unreferenced_filters();
13942        self.sources_dirty = true;
13943        Some(removed)
13944    }
13945
13946    /// Abort an epoch-scoped owner lifecycle operation.
13947    ///
13948    /// A staged owner is removed completely. A prepared removal is restored to
13949    /// active. Active and unknown epochs are unchanged. Repeating the same
13950    /// abort is therefore safe and returns `false` after the first effect.
13951    pub fn abort_interest_owner(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
13952        let Some(index) = self
13953            .owned_interests
13954            .iter()
13955            .position(|entry| entry.epoch.as_ref() == Some(epoch))
13956        else {
13957            return false;
13958        };
13959        match self.owned_interests[index].state {
13960            SubscriberOwnerState::Staged => {
13961                self.owned_interests.remove(index);
13962                self.purge_owner_epoch(epoch);
13963                self.rebuild_registered_interests();
13964                self.retire_unreferenced_filters();
13965                self.sources_dirty = true;
13966                true
13967            }
13968            SubscriberOwnerState::Removing => {
13969                self.owned_interests[index].state = SubscriberOwnerState::Active;
13970                true
13971            }
13972            SubscriberOwnerState::Active => false,
13973        }
13974    }
13975
13976    fn purge_owner_epoch(&mut self, epoch: &SubscriberOwnerEpoch) {
13977        self.pending_backfills
13978            .retain(|backfill| backfill.epoch.as_ref() != Some(epoch));
13979        self.pending_records
13980            .retain_mut(|pending| match &mut pending.scope {
13981                SubscriberInputScope::Canonical { owners }
13982                | SubscriberInputScope::CanonicalResidual { owners, .. } => {
13983                    owners.retain(|owner| owner != epoch);
13984                    true
13985                }
13986                SubscriberInputScope::OwnerOnly { owners } => {
13987                    owners.retain(|owner| owner != epoch);
13988                    !owners.is_empty()
13989                }
13990                SubscriberInputScope::OwnerOnlyHandlers { .. }
13991                | SubscriberInputScope::Preconfirmed => true,
13992            });
13993        self.pending_reconcile_owner_records.retain_mut(|pending| {
13994            pending.owners.retain(|owner| owner != epoch);
13995            !pending.owners.is_empty()
13996        });
13997        self.recent_owner_input_refs.remove(epoch);
13998        self.recent_owner_input_ref_sets.remove(epoch);
13999    }
14000
14001    /// Atomically add or replace several owners while preserving unrelated ones.
14002    ///
14003    /// # Errors
14004    ///
14005    /// Returns [`SubscriberError`] for invalid configuration, duplicate owners,
14006    /// mixed lifecycle APIs, unsupported interests, or backfill-capacity
14007    /// exhaustion. No owner state changes on error.
14008    pub fn upsert_interest_owners(
14009        &mut self,
14010        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
14011    ) -> Result<(), SubscriberError> {
14012        self.upsert_interest_owners_inner(owners, None)
14013    }
14014
14015    /// Atomically add or replace several owners and queue one common backfill
14016    /// policy for every log interest while preserving unrelated owners.
14017    ///
14018    /// # Errors
14019    ///
14020    /// Returns [`SubscriberError`] for invalid configuration, duplicate owners,
14021    /// mixed lifecycle APIs, unsupported interests, or backfill-capacity
14022    /// exhaustion. No owner or backfill state changes on error.
14023    pub fn upsert_interest_owners_with_backfill(
14024        &mut self,
14025        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
14026        backfill: SubscriberBackfill,
14027    ) -> Result<(), SubscriberError> {
14028        self.upsert_interest_owners_inner(owners, Some(backfill))
14029    }
14030
14031    fn upsert_interest_owners_inner(
14032        &mut self,
14033        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
14034        explicit_backfill: Option<SubscriberBackfill>,
14035    ) -> Result<(), SubscriberError> {
14036        validate_subscriber_config(&self.config)?;
14037        let mut seen = HashSet::with_capacity(owners.len());
14038        let mut next_owned = self.clone_owned_interests();
14039        for (owner, interests) in &owners {
14040            if !seen.insert(owner.clone()) {
14041                return Err(SubscriberError::InvalidConfig(
14042                    "bulk owner upsert contains a duplicate owner",
14043                ));
14044            }
14045            if self
14046                .owned_interests
14047                .iter()
14048                .any(|entry| &entry.owner == owner && entry.epoch.is_some())
14049            {
14050                return Err(SubscriberError::InvalidConfig(
14051                    "cannot mix compatibility and epoch-scoped owner lifecycle APIs",
14052                ));
14053            }
14054            if let Some(entry) = next_owned.iter_mut().find(|entry| &entry.owner == owner) {
14055                entry.interests = interests.clone();
14056                entry.state = SubscriberOwnerState::Active;
14057                entry.baseline = None;
14058                entry.progress = None;
14059                entry.progress_stream_revision = None;
14060            } else {
14061                next_owned.push(OwnedSubscriberInterests {
14062                    owner: owner.clone(),
14063                    interests: interests.clone(),
14064                    epoch: None,
14065                    state: SubscriberOwnerState::Active,
14066                    baseline: None,
14067                    progress: None,
14068                    progress_stream_revision: None,
14069                });
14070            }
14071        }
14072        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
14073        validate_supported_interests(self.mode, &self.config, &next_registered)?;
14074
14075        // Build every owner's replacement queue before the first mutation.
14076        // Besides keeping capacity failure atomic, this preserves continuity
14077        // for changed filter shapes when the caller did not provide a common
14078        // open-ended backfill that already covers the old delivery anchor.
14079        let mut replacement_backfills = Vec::new();
14080        for (owner, interests) in &owners {
14081            let previous_filters: Vec<Filter> = self
14082                .owner_interests(owner)
14083                .map(log_filters)
14084                .unwrap_or_default();
14085            let continuity_anchor = previous_filters
14086                .iter()
14087                .filter_map(|filter| self.log_anchor(filter))
14088                .min();
14089            let filters = log_filters(interests);
14090            if let Some(backfill) = explicit_backfill
14091                && !filters.is_empty()
14092            {
14093                replacement_backfills.push(QueuedSubscriberBackfill {
14094                    owner: Some(owner.clone()),
14095                    epoch: None,
14096                    filters: filters.clone(),
14097                    backfill,
14098                });
14099            }
14100            let explicit_covers = explicit_backfill.is_some_and(|explicit| {
14101                explicit.end_block().is_none()
14102                    && continuity_anchor.is_some_and(|anchor| explicit.start_block() <= anchor)
14103            });
14104            let continuity_filters: Vec<_> = filters
14105                .into_iter()
14106                .filter(|filter| !previous_filters.contains(filter))
14107                .collect();
14108            if let Some(anchor) = continuity_anchor
14109                && !continuity_filters.is_empty()
14110                && !explicit_covers
14111            {
14112                replacement_backfills.push(QueuedSubscriberBackfill {
14113                    owner: Some(owner.clone()),
14114                    epoch: None,
14115                    filters: continuity_filters,
14116                    backfill: SubscriberBackfill::from_block(anchor),
14117                });
14118            }
14119        }
14120
14121        let retained_backfills = self
14122            .pending_backfills
14123            .iter()
14124            .filter(|queued| {
14125                queued
14126                    .owner
14127                    .as_ref()
14128                    .is_none_or(|owner| !seen.contains(owner))
14129            })
14130            .map(|queued| queued.filters.len())
14131            .sum::<usize>();
14132        let replacement_units = replacement_backfills
14133            .iter()
14134            .map(|queued| queued.filters.len())
14135            .sum::<usize>();
14136        if retained_backfills.saturating_add(replacement_units) > self.config.max_pending_backfills
14137        {
14138            return Err(SubscriberError::ResourceExhausted(format!(
14139                "bulk owner update would queue more than {} lazy backfills",
14140                self.config.max_pending_backfills
14141            )));
14142        }
14143
14144        // All validation and capacity checks are complete. The remaining
14145        // assignments have no failure or cancellation point, so topology and
14146        // historical work become authoritative as one local commit.
14147        self.owned_interests = next_owned;
14148        self.interests = next_registered;
14149        for owner in &seen {
14150            self.recent_compat_owner_input_refs.remove(owner);
14151            self.recent_compat_owner_input_ref_sets.remove(owner);
14152        }
14153        self.retire_unreferenced_filters();
14154        self.sources_dirty = true;
14155        self.pending_backfills.retain(|queued| {
14156            queued
14157                .owner
14158                .as_ref()
14159                .is_none_or(|owner| !seen.contains(owner))
14160        });
14161        self.pending_backfills.extend(replacement_backfills);
14162        Ok(())
14163    }
14164
14165    /// Atomically replace every compatibility owner without requesting
14166    /// historical delivery.
14167    ///
14168    /// # Errors
14169    ///
14170    /// Returns [`SubscriberError`] for invalid configuration, duplicate owners,
14171    /// mixed lifecycle APIs, unsupported interests, or resource exhaustion.
14172    /// The previous topology remains authoritative on error.
14173    pub fn replace_interest_owners(
14174        &mut self,
14175        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
14176    ) -> Result<(), SubscriberError> {
14177        self.replace_interest_owners_inner(owners, None)
14178    }
14179
14180    /// Atomically replace every compatibility owner and queue one global
14181    /// post-baseline backfill for the resulting union of log interests.
14182    ///
14183    /// Base interests are replaced. Epoch-scoped lifecycle operations cannot
14184    /// be mixed with this compatibility replacement because silently deleting
14185    /// an in-flight epoch would violate its activation transaction.
14186    ///
14187    /// # Errors
14188    ///
14189    /// Returns [`SubscriberError`] for invalid configuration, duplicate owners,
14190    /// mixed lifecycle APIs, unsupported interests, or backfill-capacity
14191    /// exhaustion. The previous topology remains authoritative on error.
14192    pub fn replace_interest_owners_with_global_backfill(
14193        &mut self,
14194        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
14195        backfill: SubscriberBackfill,
14196    ) -> Result<(), SubscriberError> {
14197        self.replace_interest_owners_inner(owners, Some(backfill))
14198    }
14199
14200    fn replace_interest_owners_inner(
14201        &mut self,
14202        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
14203        backfill: Option<SubscriberBackfill>,
14204    ) -> Result<(), SubscriberError> {
14205        validate_subscriber_config(&self.config)?;
14206        if self
14207            .owned_interests
14208            .iter()
14209            .any(|entry| entry.epoch.is_some())
14210        {
14211            return Err(SubscriberError::InvalidConfig(
14212                "cannot replace compatibility owners while an epoch-scoped lifecycle exists",
14213            ));
14214        }
14215
14216        let mut seen = HashSet::with_capacity(owners.len());
14217        let mut next_owned = Vec::with_capacity(owners.len());
14218        for (owner, interests) in owners {
14219            if !seen.insert(owner.clone()) {
14220                return Err(SubscriberError::InvalidConfig(
14221                    "owner replacement contains a duplicate owner",
14222                ));
14223            }
14224            next_owned.push(OwnedSubscriberInterests {
14225                owner,
14226                interests,
14227                epoch: None,
14228                state: SubscriberOwnerState::Active,
14229                baseline: None,
14230                progress: None,
14231                progress_stream_revision: None,
14232            });
14233        }
14234        let next_registered = aggregate_interests(&[], &next_owned);
14235        validate_supported_interests(self.mode, &self.config, &next_registered)?;
14236        let mut filters = log_filters(&next_registered);
14237        let mut unique_filters = Vec::with_capacity(filters.len());
14238        for filter in filters.drain(..) {
14239            if !unique_filters.contains(&filter) {
14240                unique_filters.push(filter);
14241            }
14242        }
14243        let replacement_backfills: VecDeque<_> = match backfill {
14244            Some(backfill) if !unique_filters.is_empty() => {
14245                VecDeque::from([QueuedSubscriberBackfill {
14246                    owner: None,
14247                    epoch: None,
14248                    filters: unique_filters,
14249                    backfill,
14250                }])
14251            }
14252            Some(_) | None => VecDeque::new(),
14253        };
14254        let replacement_units = replacement_backfills
14255            .iter()
14256            .map(|queued| queued.filters.len())
14257            .sum::<usize>();
14258        if replacement_units > self.config.max_pending_backfills {
14259            return Err(SubscriberError::ResourceExhausted(format!(
14260                "owner replacement would queue more than {} lazy backfills",
14261                self.config.max_pending_backfills
14262            )));
14263        }
14264
14265        // No fallible work remains. The post-baseline range reconstructs every
14266        // delivery after the cache snapshot, so reset all stale delivery and
14267        // dedupe state from the prior topology before publishing the exact
14268        // replacement plus its global historical work.
14269        let revoke_preconfirmation = self.latest_preconfirmation.is_some()
14270            || self.pending_preconfirmation_invalidation
14271            || self.pending_records.iter().any(|record| {
14272                record.scope == SubscriberInputScope::Preconfirmed
14273                    || matches!(
14274                        &record.record.context.chain_status,
14275                        ChainStatus::Preconfirmed { .. }
14276                    )
14277            });
14278        self.base_interests.clear();
14279        self.owned_interests = next_owned;
14280        self.interests = next_registered;
14281        self.reset_delivery_state();
14282        self.pending_preconfirmation_invalidation = revoke_preconfirmation;
14283        self.pending_backfills = replacement_backfills;
14284        self.reset_stream_topology();
14285        Ok(())
14286    }
14287
14288    /// Add or replace the interests owned by `owner`.
14289    ///
14290    /// This preserves unrelated owners, queued/pending records, recent dedupe
14291    /// state, and last-seen log anchors. The live transport is reconciled on the
14292    /// next [`EventSubscriber::next_batch`] call so newly added log filters can
14293    /// be subscribed without rebuilding the whole subscriber object.
14294    ///
14295    /// Replacing an existing owner is continuity-safe: filters the owner
14296    /// already had keep their delivery anchors, and any changed or new filter
14297    /// shape is automatically backfilled from the owner's oldest prior anchor —
14298    /// growing a pool set on an established owner does not open a delivery gap
14299    /// for what the old subscription had already covered. A brand-new owner has
14300    /// no anchor to inherit; pass an explicit
14301    /// [`add_interest_owner_with_backfill`](Self::add_interest_owner_with_backfill)
14302    /// anchor (or register through [`ReactiveEngine::register_handler`], which
14303    /// anchors to the runtime's last canonical block).
14304    ///
14305    /// # Errors
14306    ///
14307    /// Returns [`SubscriberError`] for invalid configuration, incompatible
14308    /// lifecycle state, unsupported interests, or continuity-backfill capacity
14309    /// exhaustion. The prior owner state remains authoritative on error.
14310    pub fn add_interest_owner(
14311        &mut self,
14312        owner: HandlerId,
14313        interests: &[ReactiveInterest<N>],
14314    ) -> Result<(), SubscriberError> {
14315        self.set_interest_owner(owner, interests, None)
14316    }
14317
14318    /// Add or replace owner interests and schedule log backfill for that owner.
14319    ///
14320    /// Backfill is queued only for log interests; block and pending transaction
14321    /// interests are live-only. Queued records can be delivered immediately;
14322    /// the subsequent provider stream is then caught up from the seeded
14323    /// delivery anchor, and overlap is deduplicated — so the discovery boundary
14324    /// is closed end to end as long
14325    /// as `backfill` starts at (or before) the block the interest was
14326    /// discovered in. Continuity backfill for a replaced owner (see
14327    /// [`add_interest_owner`](Self::add_interest_owner)) is queued in addition,
14328    /// unless this explicit backfill is open-ended and already starts at or
14329    /// below the owner's prior anchor.
14330    ///
14331    /// # Errors
14332    ///
14333    /// Returns [`SubscriberError`] for invalid configuration, incompatible
14334    /// lifecycle state, unsupported interests, or backfill-capacity exhaustion.
14335    /// The prior owner state remains authoritative on error.
14336    pub fn add_interest_owner_with_backfill(
14337        &mut self,
14338        owner: HandlerId,
14339        interests: &[ReactiveInterest<N>],
14340        backfill: SubscriberBackfill,
14341    ) -> Result<(), SubscriberError> {
14342        self.set_interest_owner(owner, interests, Some(backfill))
14343    }
14344
14345    /// Add or replace one owner at retained canonical block `C`, then queue the
14346    /// coordinated cutover required by [`ReactiveEngine::register_handler`].
14347    ///
14348    /// The new owner alone receives matching records from `C` so its effects
14349    /// attach to the runtime's existing journal entry. Every matching log from
14350    /// `C + 1` through the activation head is then delivered canonically over
14351    /// the complete interest union. [`Self::next_scoped_batch`] installs the
14352    /// desired live streams before draining either window, closing the
14353    /// subscribe/backfill gap. Alloy cannot reconstruct historical block or
14354    /// pending-transaction deliveries through this log backfill path, so a
14355    /// mixed interest topology is rejected rather than silently underfilled.
14356    ///
14357    /// # Errors
14358    ///
14359    /// Returns [`SubscriberError`] for invalid configuration, incompatible
14360    /// lifecycle state, unsupported non-log catch-up, block-number overflow, or
14361    /// resource exhaustion. The prior owner state remains authoritative on
14362    /// error.
14363    pub fn add_interest_owner_with_canonical_catchup(
14364        &mut self,
14365        owner: HandlerId,
14366        interests: &[ReactiveInterest<N>],
14367        retained: BlockRef,
14368    ) -> Result<(), SubscriberError> {
14369        validate_subscriber_config(&self.config)?;
14370        if self
14371            .owned_interests
14372            .iter()
14373            .any(|entry| entry.owner == owner && entry.epoch.is_some())
14374        {
14375            return Err(SubscriberError::InvalidConfig(
14376                "cannot mix compatibility and epoch-scoped owner lifecycle APIs",
14377            ));
14378        }
14379
14380        let mut next_owned = self.clone_owned_interests();
14381        if let Some(entry) = next_owned.iter_mut().find(|entry| entry.owner == owner) {
14382            entry.interests = interests.to_vec();
14383            entry.state = SubscriberOwnerState::Active;
14384            entry.baseline = None;
14385            entry.progress = None;
14386            entry.progress_stream_revision = None;
14387            entry.epoch = None;
14388        } else {
14389            next_owned.push(OwnedSubscriberInterests {
14390                owner: owner.clone(),
14391                interests: interests.to_vec(),
14392                epoch: None,
14393                state: SubscriberOwnerState::Active,
14394                baseline: None,
14395                progress: None,
14396                progress_stream_revision: None,
14397            });
14398        }
14399        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
14400        validate_supported_interests(self.mode, &self.config, &next_registered)?;
14401        if next_registered
14402            .iter()
14403            .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
14404        {
14405            return Err(SubscriberError::Unsupported(
14406                "Alloy coordinated registration supports log-only interest topologies",
14407            ));
14408        }
14409
14410        let mut owner_filters = Vec::new();
14411        for filter in log_filters(interests) {
14412            if !owner_filters.contains(&filter) {
14413                owner_filters.push(filter);
14414            }
14415        }
14416        let mut global_filters = Vec::new();
14417        for filter in log_filters(&next_registered) {
14418            if !global_filters.contains(&filter) {
14419                global_filters.push(filter);
14420            }
14421        }
14422        let owner_backfill =
14423            SubscriberBackfill::from_canonical_block_through(retained, retained.number)?;
14424        let global_backfill = SubscriberBackfill::after_canonical_block(retained)?;
14425        let replacement_units = owner_filters.len().saturating_add(global_filters.len());
14426        let retained_units = self
14427            .pending_backfills
14428            .iter()
14429            .filter(|queued| queued.owner.as_ref() != Some(&owner))
14430            .map(|queued| queued.filters.len())
14431            .sum::<usize>();
14432        if retained_units.saturating_add(replacement_units) > self.config.max_pending_backfills {
14433            return Err(SubscriberError::ResourceExhausted(format!(
14434                "coordinated owner registration would queue more than {} lazy backfills",
14435                self.config.max_pending_backfills
14436            )));
14437        }
14438
14439        let mut replacement_backfills = VecDeque::new();
14440        if !owner_filters.is_empty() {
14441            replacement_backfills.push_back(QueuedSubscriberBackfill {
14442                owner: Some(owner.clone()),
14443                epoch: None,
14444                filters: owner_filters,
14445                backfill: owner_backfill,
14446            });
14447        }
14448        // Keep the global certification job even for an empty filter union: it
14449        // advances canonical coverage through a zero-event registration window.
14450        replacement_backfills.push_back(QueuedSubscriberBackfill {
14451            owner: None,
14452            epoch: None,
14453            filters: global_filters,
14454            backfill: global_backfill,
14455        });
14456
14457        // Every fallible preflight is complete. Publish topology and both
14458        // ordered windows as one synchronous local commit.
14459        self.owned_interests = next_owned;
14460        self.interests = next_registered;
14461        self.recent_compat_owner_input_refs.remove(&owner);
14462        self.recent_compat_owner_input_ref_sets.remove(&owner);
14463        self.pending_backfills
14464            .retain(|queued| queued.owner.as_ref() != Some(&owner));
14465        self.pending_backfills.extend(replacement_backfills);
14466        self.retire_unreferenced_filters();
14467        self.sources_dirty = true;
14468        Ok(())
14469    }
14470
14471    /// Remove one owner's interests, preserving unrelated owner/base interests.
14472    ///
14473    /// The owner's queued backfills are dropped, and source-id/anchor
14474    /// bookkeeping for filters no other owner references is retired. Live
14475    /// streams for retired filters are torn down on the next
14476    /// [`EventSubscriber::next_batch`] call (dropping an Alloy subscription
14477    /// unsubscribes provider-side); events already in flight from them stop
14478    /// matching the merged interest set and are discarded.
14479    pub fn remove_interest_owner(&mut self, owner: &HandlerId) -> Option<Vec<ReactiveInterest<N>>> {
14480        let index = self
14481            .owned_interests
14482            .iter()
14483            .position(|entry| &entry.owner == owner && entry.epoch.is_none())?;
14484        let removed = self.owned_interests.remove(index);
14485        if let Some(epoch) = &removed.epoch {
14486            self.purge_owner_epoch(epoch);
14487        } else {
14488            self.pending_backfills
14489                .retain(|backfill| backfill.owner.as_ref() != Some(owner));
14490            self.recent_compat_owner_input_refs.remove(owner);
14491            self.recent_compat_owner_input_ref_sets.remove(owner);
14492        }
14493        self.rebuild_registered_interests();
14494        self.retire_unreferenced_filters();
14495        self.sources_dirty = true;
14496        Some(removed.interests)
14497    }
14498
14499    /// Borrow the interests currently owned by `owner`.
14500    pub fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
14501        self.owned_interests
14502            .iter()
14503            .find(|entry| &entry.owner == owner)
14504            .map(|entry| entry.interests.as_slice())
14505    }
14506
14507    fn set_interest_owner(
14508        &mut self,
14509        owner: HandlerId,
14510        interests: &[ReactiveInterest<N>],
14511        backfill: Option<SubscriberBackfill>,
14512    ) -> Result<(), SubscriberError> {
14513        validate_subscriber_config(&self.config)?;
14514        if self
14515            .owned_interests
14516            .iter()
14517            .any(|entry| entry.owner == owner && entry.epoch.is_some())
14518        {
14519            return Err(SubscriberError::InvalidConfig(
14520                "cannot mix compatibility and epoch-scoped owner lifecycle APIs",
14521            ));
14522        }
14523
14524        let mut next_owned = self.clone_owned_interests();
14525        let replaced_epoch = match next_owned.iter_mut().find(|entry| entry.owner == owner) {
14526            Some(entry) => {
14527                entry.interests = interests.to_vec();
14528                entry.state = SubscriberOwnerState::Active;
14529                entry.baseline = None;
14530                entry.progress = None;
14531                entry.progress_stream_revision = None;
14532                entry.epoch.take()
14533            }
14534            None => {
14535                next_owned.push(OwnedSubscriberInterests {
14536                    owner: owner.clone(),
14537                    interests: interests.to_vec(),
14538                    epoch: None,
14539                    state: SubscriberOwnerState::Active,
14540                    baseline: None,
14541                    progress: None,
14542                    progress_stream_revision: None,
14543                });
14544                None
14545            }
14546        };
14547        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
14548        validate_supported_interests(self.mode, &self.config, &next_registered)?;
14549
14550        // Continuity capture, before the mutation lands: the owner's previous
14551        // filter shapes and the oldest delivery anchor among them. A changed
14552        // filter gets a fresh source id with no anchor, so without this
14553        // hand-off, replacing an owner's interests (the normal way to grow a
14554        // pool set) would silently discard the delivery watermark and open a
14555        // gap until some later explicit backfill.
14556        let previous_filters: Vec<Filter> = self
14557            .owner_interests(&owner)
14558            .map(log_filters)
14559            .unwrap_or_default();
14560        let continuity_anchor: Option<u64> = previous_filters
14561            .iter()
14562            .filter_map(|filter| self.log_anchor(filter))
14563            .min();
14564
14565        // Build the replacement queue before committing owner state. Capacity
14566        // failure is therefore atomic and cannot leave desired interests ahead
14567        // of the historical work required to make them continuous.
14568        let mut replacement_backfills = Vec::new();
14569        let filters = log_filters(interests);
14570        if let Some(backfill) = backfill
14571            && !filters.is_empty()
14572        {
14573            replacement_backfills.push(QueuedSubscriberBackfill {
14574                owner: Some(owner.clone()),
14575                epoch: None,
14576                filters: filters.clone(),
14577                backfill,
14578            });
14579        }
14580        let explicit_covers = backfill.is_some_and(|explicit| {
14581            explicit.end_block().is_none()
14582                && continuity_anchor.is_some_and(|anchor| explicit.start_block() <= anchor)
14583        });
14584        let continuity_filters: Vec<_> = filters
14585            .into_iter()
14586            .filter(|filter| !previous_filters.contains(filter))
14587            .collect();
14588        if let Some(anchor) = continuity_anchor
14589            && !continuity_filters.is_empty()
14590            && !explicit_covers
14591        {
14592            replacement_backfills.push(QueuedSubscriberBackfill {
14593                owner: Some(owner.clone()),
14594                epoch: None,
14595                filters: continuity_filters,
14596                backfill: SubscriberBackfill::from_block(anchor),
14597            });
14598        }
14599        let retained_backfills = self
14600            .pending_backfills
14601            .iter()
14602            .filter(|queued| queued.owner.as_ref() != Some(&owner))
14603            .map(|queued| queued.filters.len())
14604            .sum::<usize>();
14605        let replacement_units = replacement_backfills
14606            .iter()
14607            .map(|queued| queued.filters.len())
14608            .sum::<usize>();
14609        if retained_backfills.saturating_add(replacement_units) > self.config.max_pending_backfills
14610        {
14611            return Err(SubscriberError::ResourceExhausted(format!(
14612                "owner update would queue more than {} lazy backfills",
14613                self.config.max_pending_backfills
14614            )));
14615        }
14616
14617        self.owned_interests = next_owned;
14618        self.interests = next_registered;
14619        if let Some(epoch) = replaced_epoch {
14620            self.purge_owner_epoch(&epoch);
14621        } else {
14622            self.recent_compat_owner_input_refs.remove(&owner);
14623            self.recent_compat_owner_input_ref_sets.remove(&owner);
14624        }
14625        self.retire_unreferenced_filters();
14626        self.sources_dirty = true;
14627
14628        // Re-queue this owner's backfills from scratch: previously queued
14629        // entries may reference filter shapes that no longer exist.
14630        self.pending_backfills
14631            .retain(|queued| queued.owner.as_ref() != Some(&owner));
14632        self.pending_backfills.extend(replacement_backfills);
14633        Ok(())
14634    }
14635
14636    fn clone_owned_interests(&self) -> Vec<OwnedSubscriberInterests<N>> {
14637        self.owned_interests
14638            .iter()
14639            .map(|entry| OwnedSubscriberInterests {
14640                owner: entry.owner.clone(),
14641                interests: entry.interests.clone(),
14642                epoch: entry.epoch.clone(),
14643                state: entry.state,
14644                baseline: entry.baseline,
14645                progress: entry.progress.clone(),
14646                progress_stream_revision: entry.progress_stream_revision,
14647            })
14648            .collect()
14649    }
14650
14651    fn rebuild_registered_interests(&mut self) {
14652        self.interests = aggregate_interests(&self.base_interests, &self.owned_interests);
14653    }
14654
14655    /// Delivery anchor (last block known fully delivered) for `filter`, if the
14656    /// filter has a source id and has seen delivery.
14657    fn log_anchor(&self, filter: &Filter) -> Option<u64> {
14658        if let Some(anchor) = self
14659            .log_source_ids
14660            .get(filter)
14661            .and_then(|id| self.last_seen_log_blocks.get(id))
14662        {
14663            return Some(*anchor);
14664        }
14665
14666        // Logical owner filters may be represented by a broader provider
14667        // stream after fan-in. Its oldest live watermark is a conservative
14668        // continuity anchor: it can cause extra backfill, never a missed log.
14669        self.log_source_ids
14670            .values()
14671            .filter_map(|id| self.last_seen_log_blocks.get(id).copied())
14672            .min()
14673    }
14674
14675    /// Every logical log filter across base and owner interests, merged within
14676    /// each origin and deduplicated across origins. These shapes remain the
14677    /// exact routing and owner-continuity boundary; provider subscriptions may
14678    /// fan several of them into one broader filter.
14679    // `Filter` derives `Hash`/`Eq` and has no interior mutability; the
14680    // `mutable_key_type` lint is a known false positive for it.
14681    #[allow(clippy::mutable_key_type)]
14682    fn logical_log_filters(&self) -> Vec<Filter> {
14683        let mut filters = log_filters(&self.base_interests);
14684        for entry in &self.owned_interests {
14685            filters.extend(log_filters(&entry.interests));
14686        }
14687        let mut seen = HashSet::new();
14688        filters.retain(|filter| seen.insert(filter.clone()));
14689        filters
14690    }
14691
14692    /// Provider-facing log filters. Compatible logical filters fan into a
14693    /// small number of address/topic supersets, then split only when the
14694    /// configured address ceiling requires it. Exact matching remains local in
14695    /// `enqueue_event`, so this reduces subscriptions without broadening owner
14696    /// delivery.
14697    fn log_stream_filters(&self) -> Vec<Filter> {
14698        let mut merged = Vec::new();
14699        for filter in self.logical_log_filters() {
14700            merge_log_subscription_filter(&mut merged, &filter);
14701        }
14702
14703        let max_addresses = self.config.max_log_addresses_per_subscription.max(1);
14704        let mut planned = Vec::new();
14705        for filter in merged {
14706            let mut addresses: Vec<_> = filter.address.iter().copied().collect();
14707            if addresses.len() <= max_addresses {
14708                planned.push(filter);
14709                continue;
14710            }
14711            addresses.sort_unstable();
14712            for chunk in addresses.chunks(max_addresses) {
14713                let mut split = filter.clone();
14714                split.address = FilterSet::default();
14715                for address in chunk {
14716                    split.address.insert(*address);
14717                }
14718                planned.push(split);
14719            }
14720        }
14721        planned
14722    }
14723
14724    /// Drop source-id and anchor bookkeeping for filters no longer referenced
14725    /// by any base or owner interest, so long-lived owner churn cannot grow the
14726    /// maps unboundedly. Live streams for retired filters are pruned by the
14727    /// next reconcile.
14728    // `Filter` derives `Hash`/`Eq` and has no interior mutability; the
14729    // `mutable_key_type` lint is a known false positive for it.
14730    #[allow(clippy::mutable_key_type)]
14731    fn retire_unreferenced_filters(&mut self) {
14732        let mut live: HashSet<Filter> = self.log_stream_filters().into_iter().collect();
14733        if let AlloySubscriberState::Active(streams) = &self.state {
14734            for entry in &streams.entries {
14735                match &entry.source {
14736                    SubscriberStreamSource::PubSubLog { filter, .. }
14737                    | SubscriberStreamSource::BasePendingLog { filter, .. }
14738                    | SubscriberStreamSource::PollingLog { filter } => {
14739                        live.insert(filter.clone());
14740                    }
14741                    SubscriberStreamSource::BaseFlashblocks
14742                    | SubscriberStreamSource::OpPendingFlashblocks
14743                    | SubscriberStreamSource::CanonicalHeadPolling
14744                    | SubscriberStreamSource::PubSubPendingHashes
14745                    | SubscriberStreamSource::PubSubBlockHeaders
14746                    | SubscriberStreamSource::PollingPendingHashes => {}
14747                    #[cfg(feature = "raw-flashblocks-json")]
14748                    SubscriberStreamSource::ExternalFlashblockUpdates => {}
14749                }
14750            }
14751        }
14752        self.log_source_ids
14753            .retain(|filter, _| live.contains(filter));
14754        let live_ids: HashSet<usize> = self.log_source_ids.values().copied().collect();
14755        self.last_seen_log_blocks
14756            .retain(|id, _| live_ids.contains(id));
14757    }
14758
14759    /// Record a canonical header observed while every log source was whole.
14760    ///
14761    /// Called before the header is enqueued, so a gap discovered later in the
14762    /// same poll cannot retroactively attest a block whose logs it may have
14763    /// dropped: `reset_log_attestation` clears the candidate, and it only
14764    /// re-advances once a later header arrives after the gap was healed.
14765    fn note_attestable_canonical_block(&mut self, record: &ReactiveInputRecord<N>) {
14766        if !self.attests_log_coverage() {
14767            return;
14768        }
14769        let Some(block) = record.context.block.as_ref() else {
14770            return;
14771        };
14772        let advances = self
14773            .attestable_canonical_head
14774            .as_ref()
14775            .is_none_or(|current| block.number > current.number);
14776        if advances {
14777            self.attestable_canonical_head = Some(*block);
14778        }
14779    }
14780
14781    /// Withdraw the pending attestation candidate after detected loss.
14782    ///
14783    /// The healed window is refetched, but the candidate is still dropped: a
14784    /// consumer must not be told a block was whole on the strength of an
14785    /// observation made before the loss was known.
14786    fn reset_log_attestation(&mut self) {
14787        self.attestable_canonical_head = None;
14788    }
14789
14790    /// Whether this subscriber can prove the attestation it would emit.
14791    ///
14792    /// Mirrors [`SubscriberCapability::LogCoverageAttestation`]: only the pubsub
14793    /// log streams surface a dropped notification, and only a subscriber with log
14794    /// interests has anything to attest about.
14795    fn attests_log_coverage(&self) -> bool {
14796        matches!(
14797            resolve_subscriber_transport(self.mode),
14798            Ok(SubscriberTransport::PubSub)
14799        ) && self
14800            .interests
14801            .iter()
14802            .any(|interest| matches!(interest, ReactiveInterest::Logs(_)))
14803    }
14804
14805    /// Queue a `LogCoverage` control when the attested watermark advances.
14806    fn queue_log_coverage_attestation(&mut self) {
14807        if !self.attests_log_coverage() {
14808            return;
14809        }
14810        let Some(candidate) = self.attestable_canonical_head else {
14811            return;
14812        };
14813        let advances = self
14814            .attested_log_coverage
14815            .as_ref()
14816            .is_none_or(|attested| candidate.number > attested.number);
14817        if !advances {
14818            return;
14819        }
14820        self.attested_log_coverage = Some(candidate);
14821        self.pending_chain_controls
14822            .push_back(ChainControl::LogCoverage(candidate));
14823    }
14824
14825    fn drain_next_scoped_batch(&mut self) -> Option<SubscriberInputBatch<N>> {
14826        // Attest before the emptiness check: the watermark may be the only thing
14827        // this batch has to say. Controls still drain only once every record
14828        // ahead of them has left, so an attestation never precedes its header.
14829        self.queue_log_coverage_attestation();
14830        if self.pending_records.is_empty()
14831            && self.pending_chain_controls.is_empty()
14832            && !self.pending_preconfirmation_invalidation
14833        {
14834            return None;
14835        }
14836
14837        let first_preconfirmation = self.pending_records.front().and_then(|record| {
14838            if record.scope != SubscriberInputScope::Preconfirmed {
14839                return None;
14840            }
14841            match &record.record.context.chain_status {
14842                ChainStatus::Preconfirmed { flashblock } => Some(flashblock.clone()),
14843                _ => None,
14844            }
14845        });
14846        let len = self
14847            .pending_records
14848            .iter()
14849            .take(self.config.max_batch_size)
14850            .take_while(|record| match &first_preconfirmation {
14851                Some(expected) => {
14852                    record.scope == SubscriberInputScope::Preconfirmed
14853                        && matches!(
14854                            &record.record.context.chain_status,
14855                            ChainStatus::Preconfirmed { flashblock } if flashblock == expected
14856                        )
14857                }
14858                None => record.scope != SubscriberInputScope::Preconfirmed,
14859            })
14860            .count();
14861        let preconfirmation_timing = self
14862            .pending_records
14863            .iter()
14864            .take(len)
14865            .filter_map(SubscriberInputRecord::preconfirmation_timing)
14866            .reduce(FlashblockIngressTiming::earliest);
14867        let records = self.pending_records.drain(..len).collect();
14868        let chain_controls = if first_preconfirmation.is_none() && self.pending_records.is_empty() {
14869            self.pending_chain_controls.drain(..).collect()
14870        } else {
14871            Vec::new()
14872        };
14873        Some(SubscriberInputBatch {
14874            records,
14875            chain_id: self.chain_id,
14876            chain_controls,
14877            preconfirmation_invalidated: std::mem::take(
14878                &mut self.pending_preconfirmation_invalidation,
14879            ),
14880            preconfirmation_timing,
14881        })
14882    }
14883
14884    fn reset_delivery_state(&mut self) {
14885        self.pending_records.clear();
14886        self.pending_chain_controls.clear();
14887        self.pending_reconcile_owner_records.clear();
14888        self.resource_error = None;
14889        self.last_seen_log_blocks.clear();
14890        self.verified_log_blocks.clear();
14891        self.verified_log_block_order.clear();
14892        self.recent_input_refs.clear();
14893        self.recent_input_ref_set.clear();
14894        self.recent_owner_input_refs.clear();
14895        self.recent_owner_input_ref_sets.clear();
14896        self.recent_compat_owner_input_refs.clear();
14897        self.recent_compat_owner_input_ref_sets.clear();
14898        self.pending_backfills.clear();
14899        self.pending_source_backfills.clear();
14900        self.pending_preconfirmation_invalidation = false;
14901        self.pending_flashblock_reconnects.clear();
14902        self.pending_flashblock_reconnect_sources.clear();
14903        self.flashblocks_rpc_metrics = FlashblocksRpcMetrics::default();
14904        self.log_source_ids.clear();
14905        self.next_log_source_id = 0;
14906        self.sources_dirty = true;
14907        self.last_certified_canonical_head = None;
14908        self.last_canonical_head_certification = None;
14909        self.sealed_block_pending_certification = false;
14910        self.attestable_canonical_head = None;
14911        self.attested_log_coverage = None;
14912        self.reset_flashblock_tracking();
14913    }
14914
14915    fn reset_stream_topology(&mut self) {
14916        #[cfg(feature = "raw-flashblocks-json")]
14917        let external = match &mut self.state {
14918            AlloySubscriberState::Active(streams) => streams
14919                .entries
14920                .iter()
14921                .position(|entry| entry.source.is_external_flashblocks())
14922                .map(|index| streams.entries.remove(index)),
14923            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => None,
14924        };
14925
14926        #[cfg(feature = "raw-flashblocks-json")]
14927        if let Some(external) = external {
14928            let mut streams = SubscriberStreams::new();
14929            streams.entries.push(external);
14930            self.state = AlloySubscriberState::Active(streams);
14931            return;
14932        }
14933
14934        self.state = AlloySubscriberState::Uninitialized;
14935    }
14936
14937    fn reset_flashblock_tracking(&mut self) {
14938        self.base_flashblock_header = None;
14939        self.base_flashblock_transactions = None;
14940        self.unmatched_pending_logs.clear();
14941        self.latest_preconfirmation = None;
14942        self.preconfirmed_seen_logs.clear();
14943        self.preconfirmed_receipted_transactions.clear();
14944        self.preconfirmed_unavailable_receipts.clear();
14945        #[cfg(feature = "raw-flashblocks-json")]
14946        {
14947            self.last_external_flashblock_snapshot = None;
14948        }
14949        self.consecutive_flashblock_poll_failures = 0;
14950    }
14951
14952    /// Revoke only the active speculative snapshot while keeping the pinned
14953    /// provider session and its streams alive. A sampled OP pending view can
14954    /// legitimately be replaced, or a provider backend can briefly return an
14955    /// older cumulative view. Either observation makes the current signing
14956    /// authority unsafe, but does not prove that the transport generation is
14957    /// broken and should be reconnected.
14958    fn invalidate_preconfirmation_snapshot(&mut self) {
14959        self.pending_records
14960            .retain(|record| record.scope != SubscriberInputScope::Preconfirmed);
14961        self.pending_preconfirmation_invalidation = true;
14962        self.latest_preconfirmation = None;
14963        self.preconfirmed_seen_logs.clear();
14964        self.preconfirmed_receipted_transactions.clear();
14965        self.preconfirmed_unavailable_receipts.clear();
14966    }
14967
14968    fn bump_stream_revision(&mut self) {
14969        self.stream_revision = self.stream_revision.saturating_add(1);
14970    }
14971}
14972
14973impl<P, N> InterestOwnerSubscriber<N> for AlloySubscriber<P, N>
14974where
14975    P: Provider<N> + Send + Sync,
14976    N: Network + 'static,
14977    N::HeaderResponse: Send + 'static,
14978{
14979    fn upsert_interest_owners(
14980        &mut self,
14981        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
14982    ) -> SubscriberOperation<'_, ()> {
14983        Box::pin(async move {
14984            if !owners.is_empty() {
14985                self.ensure_chain_id().await?;
14986            }
14987            AlloySubscriber::upsert_interest_owners(self, owners)
14988        })
14989    }
14990
14991    fn replace_interest_owners(
14992        &mut self,
14993        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
14994    ) -> SubscriberOperation<'_, ()> {
14995        Box::pin(async move {
14996            if owners.iter().any(|(_, interests)| !interests.is_empty()) {
14997                self.ensure_chain_id().await?;
14998            }
14999            AlloySubscriber::replace_interest_owners(self, owners)
15000        })
15001    }
15002
15003    fn replace_interest_owners_with_global_backfill(
15004        &mut self,
15005        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
15006        backfill: SubscriberBackfill,
15007    ) -> SubscriberOperation<'_, ()> {
15008        Box::pin(async move {
15009            if owners.iter().any(|(_, interests)| !interests.is_empty()) {
15010                self.ensure_chain_id().await?;
15011            }
15012            AlloySubscriber::replace_interest_owners_with_global_backfill(self, owners, backfill)
15013        })
15014    }
15015
15016    fn add_interest_owner(
15017        &mut self,
15018        owner: HandlerId,
15019        interests: &[ReactiveInterest<N>],
15020    ) -> SubscriberOperation<'_, ()> {
15021        let interests = interests.to_vec();
15022        Box::pin(async move {
15023            if !interests.is_empty() {
15024                self.ensure_chain_id().await?;
15025            }
15026            AlloySubscriber::add_interest_owner(self, owner, &interests)
15027        })
15028    }
15029
15030    fn add_interest_owner_with_backfill(
15031        &mut self,
15032        owner: HandlerId,
15033        interests: &[ReactiveInterest<N>],
15034        backfill: SubscriberBackfill,
15035    ) -> SubscriberOperation<'_, ()> {
15036        let interests = interests.to_vec();
15037        Box::pin(async move {
15038            if !interests.is_empty() {
15039                self.ensure_chain_id().await?;
15040            }
15041            AlloySubscriber::add_interest_owner_with_backfill(self, owner, &interests, backfill)
15042        })
15043    }
15044
15045    fn add_interest_owner_with_canonical_catchup(
15046        &mut self,
15047        owner: HandlerId,
15048        interests: &[ReactiveInterest<N>],
15049        retained: BlockRef,
15050    ) -> SubscriberOperation<'_, ()> {
15051        let interests = interests.to_vec();
15052        Box::pin(async move {
15053            // Resolve provider identity before the synchronous topology commit;
15054            // cancellation or failure at this await leaves prior state intact.
15055            self.ensure_chain_id().await?;
15056            AlloySubscriber::add_interest_owner_with_canonical_catchup(
15057                self, owner, &interests, retained,
15058            )
15059        })
15060    }
15061
15062    fn remove_interest_owner(
15063        &mut self,
15064        owner: &HandlerId,
15065    ) -> SubscriberOperation<'_, Option<Vec<ReactiveInterest<N>>>> {
15066        let owner = owner.clone();
15067        Box::pin(async move { Ok(AlloySubscriber::remove_interest_owner(self, &owner)) })
15068    }
15069
15070    fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
15071        AlloySubscriber::owner_interests(self, owner)
15072    }
15073}
15074
15075enum AlloySubscriberState<N: Network> {
15076    Uninitialized,
15077    Active(SubscriberStreams<N>),
15078    Empty,
15079}
15080
15081struct SubscriberStreams<N: Network> {
15082    entries: Vec<SubscriberStreamEntry<N>>,
15083    next_index: usize,
15084}
15085
15086struct SubscriberStreamEntry<N: Network> {
15087    source: SubscriberStreamSource,
15088    stream: BoxStream<'static, SubscriberEvent<N>>,
15089}
15090
15091impl<N: Network> SubscriberStreams<N> {
15092    fn new() -> Self {
15093        Self {
15094            entries: Vec::new(),
15095            next_index: 0,
15096        }
15097    }
15098
15099    fn is_empty(&self) -> bool {
15100        self.entries.is_empty()
15101    }
15102
15103    fn push(
15104        &mut self,
15105        source: SubscriberStreamSource,
15106        stream: BoxStream<'static, SubscriberEvent<N>>,
15107    ) {
15108        self.entries.push(SubscriberStreamEntry { source, stream });
15109    }
15110
15111    #[cfg(all(test, any(feature = "reactive-polling", feature = "reactive-ws")))]
15112    fn len(&self) -> usize {
15113        self.entries.len()
15114    }
15115
15116    fn contains_source(&self, source: &SubscriberStreamSource) -> bool {
15117        self.entries
15118            .iter()
15119            .any(|entry| entry.source.same_key(source))
15120    }
15121
15122    fn retain_sources(&mut self, sources: &[SubscriberStreamSource]) {
15123        self.entries
15124            .retain(|entry| sources.iter().any(|source| entry.source.same_key(source)));
15125        self.normalize_next_index();
15126    }
15127
15128    fn normalize_next_index(&mut self) {
15129        if self.entries.is_empty() {
15130            self.next_index = 0;
15131        } else if self.next_index >= self.entries.len() {
15132            self.next_index %= self.entries.len();
15133        }
15134    }
15135
15136    async fn next(&mut self) -> Option<SubscriberEvent<N>> {
15137        poll_fn(|cx| {
15138            self.normalize_next_index();
15139            if self.entries.is_empty() {
15140                return std::task::Poll::Ready(None);
15141            }
15142
15143            let mut index = self.next_index;
15144            let mut checked = 0usize;
15145            while checked < self.entries.len() {
15146                if index >= self.entries.len() {
15147                    index = 0;
15148                }
15149                match self.entries[index].stream.as_mut().poll_next(cx) {
15150                    std::task::Poll::Ready(Some(event)) => {
15151                        if matches!(event, SubscriberEvent::StreamTerminated(_)) {
15152                            self.entries.remove(index);
15153                            self.next_index = if self.entries.is_empty() {
15154                                0
15155                            } else {
15156                                index % self.entries.len()
15157                            };
15158                        } else {
15159                            self.next_index = (index + 1) % self.entries.len();
15160                        }
15161                        return std::task::Poll::Ready(Some(event));
15162                    }
15163                    std::task::Poll::Ready(None) => {
15164                        self.entries.remove(index);
15165                        if self.entries.is_empty() {
15166                            self.next_index = 0;
15167                            return std::task::Poll::Ready(None);
15168                        }
15169                    }
15170                    std::task::Poll::Pending => {
15171                        checked += 1;
15172                        index += 1;
15173                    }
15174                }
15175            }
15176
15177            if self.entries.is_empty() {
15178                std::task::Poll::Ready(None)
15179            } else {
15180                self.next_index = index % self.entries.len();
15181                std::task::Poll::Pending
15182            }
15183        })
15184        .await
15185    }
15186}
15187
15188#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15189#[allow(dead_code)]
15190enum SubscriberTransport {
15191    PubSub,
15192    Polling,
15193}
15194
15195#[derive(Clone, Debug)]
15196enum SubscriberStreamSource {
15197    PubSubLog {
15198        id: usize,
15199        filter: Filter,
15200    },
15201    BasePendingLog {
15202        id: usize,
15203        filter: Filter,
15204    },
15205    BaseFlashblocks,
15206    OpPendingFlashblocks,
15207    CanonicalHeadPolling,
15208    PubSubPendingHashes,
15209    PubSubBlockHeaders,
15210    PollingLog {
15211        filter: Filter,
15212    },
15213    PollingPendingHashes,
15214    #[cfg(feature = "raw-flashblocks-json")]
15215    ExternalFlashblockUpdates,
15216}
15217
15218impl SubscriberStreamSource {
15219    fn label(&self) -> &'static str {
15220        match self {
15221            Self::PubSubLog { .. } => "pubsub log",
15222            Self::BasePendingLog { .. } => "OP Stack pendingLogs",
15223            Self::BaseFlashblocks => "OP Stack newFlashblocks",
15224            Self::OpPendingFlashblocks => "Optimism pending Flashblocks",
15225            Self::CanonicalHeadPolling => "certified canonical head",
15226            Self::PubSubPendingHashes => "pubsub pending transaction hash",
15227            Self::PubSubBlockHeaders => "pubsub block header",
15228            Self::PollingLog { .. } => "polling log",
15229            Self::PollingPendingHashes => "polling pending transaction hash",
15230            #[cfg(feature = "raw-flashblocks-json")]
15231            Self::ExternalFlashblockUpdates => "external standardized Flashblock update",
15232        }
15233    }
15234
15235    fn is_pubsub(&self) -> bool {
15236        matches!(
15237            self,
15238            Self::PubSubLog { .. }
15239                | Self::BasePendingLog { .. }
15240                | Self::BaseFlashblocks
15241                | Self::OpPendingFlashblocks
15242                | Self::PubSubPendingHashes
15243                | Self::PubSubBlockHeaders
15244        )
15245    }
15246
15247    fn is_flashblocks(&self) -> bool {
15248        matches!(
15249            self,
15250            Self::BasePendingLog { .. } | Self::BaseFlashblocks | Self::OpPendingFlashblocks
15251        )
15252    }
15253
15254    fn same_key(&self, other: &Self) -> bool {
15255        match (self, other) {
15256            (Self::PubSubLog { filter: left, .. }, Self::PubSubLog { filter: right, .. })
15257            | (
15258                Self::BasePendingLog { filter: left, .. },
15259                Self::BasePendingLog { filter: right, .. },
15260            )
15261            | (Self::PollingLog { filter: left }, Self::PollingLog { filter: right }) => {
15262                left == right
15263            }
15264            (Self::BaseFlashblocks, Self::BaseFlashblocks)
15265            | (Self::OpPendingFlashblocks, Self::OpPendingFlashblocks)
15266            | (Self::CanonicalHeadPolling, Self::CanonicalHeadPolling)
15267            | (Self::PubSubPendingHashes, Self::PubSubPendingHashes)
15268            | (Self::PubSubBlockHeaders, Self::PubSubBlockHeaders)
15269            | (Self::PollingPendingHashes, Self::PollingPendingHashes) => true,
15270            #[cfg(feature = "raw-flashblocks-json")]
15271            (Self::ExternalFlashblockUpdates, Self::ExternalFlashblockUpdates) => true,
15272            _ => false,
15273        }
15274    }
15275
15276    fn is_external_flashblocks(&self) -> bool {
15277        #[cfg(feature = "raw-flashblocks-json")]
15278        {
15279            matches!(self, Self::ExternalFlashblockUpdates)
15280        }
15281        #[cfg(not(feature = "raw-flashblocks-json"))]
15282        {
15283            false
15284        }
15285    }
15286}
15287
15288#[allow(dead_code)]
15289enum SubscriberEvent<N: Network> {
15290    Log {
15291        source_id: usize,
15292        log: Log,
15293    },
15294    BackfilledLogs {
15295        source_id: usize,
15296        logs: Vec<Log>,
15297    },
15298    Logs(Vec<Log>),
15299    BlockHeader(N::HeaderResponse),
15300    PendingHash(B256),
15301    PendingHashes(Vec<B256>),
15302    BasePendingLog {
15303        source_id: usize,
15304        log: Log,
15305    },
15306    BasePendingLogTimed {
15307        source_id: usize,
15308        log: Log,
15309        timing: FlashblockIngressTiming,
15310    },
15311    BaseFlashblock(BaseFlashblockWirePayload),
15312    BaseFlashblockTimed {
15313        payload: BaseFlashblockWirePayload,
15314        timing: FlashblockIngressTiming,
15315    },
15316    OpFlashblockTick,
15317    OpFlashblockTickTimed(FlashblockIngressTiming),
15318    CanonicalHeadTick,
15319    PreconfirmedLogs {
15320        flashblock: FlashblockRef,
15321        logs: Vec<Log>,
15322        timing: FlashblockIngressTiming,
15323    },
15324    FlashblockInvalidated,
15325    FlashblockObserved,
15326    #[cfg(feature = "raw-flashblocks-json")]
15327    ExternalFlashblockUpdate(raw_json_flashblocks::QueuedFlashblockUpdate),
15328    StreamTerminated(SubscriberStreamSource),
15329    /// A live stream lost notifications without disconnecting. The subscription
15330    /// is still installed, so only the missed window is recovered rather than
15331    /// the source being reconnected.
15332    StreamGap {
15333        source: SubscriberStreamSource,
15334        gap: SubscriberStreamGap,
15335    },
15336}
15337
15338enum SubscriberReady<N: Network> {
15339    Event(Option<SubscriberEvent<N>>),
15340    FlashblockReconnect(
15341        SubscriberStreamSource,
15342        Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError>,
15343    ),
15344}
15345
15346#[derive(Debug)]
15347enum PendingFlashblockPollError {
15348    Request(SubscriberError),
15349    Integrity(SubscriberError),
15350}
15351
15352impl PendingFlashblockPollError {
15353    fn into_subscriber(self) -> SubscriberError {
15354        match self {
15355            Self::Request(error) | Self::Integrity(error) => error,
15356        }
15357    }
15358}
15359
15360fn pending_flashblock_request_error(error: impl fmt::Display) -> PendingFlashblockPollError {
15361    PendingFlashblockPollError::Request(provider_error(error))
15362}
15363
15364fn normalize_op_pending_block<N: Network>(
15365    mut value: serde_json::Value,
15366) -> Result<N::BlockResponse, SubscriberError> {
15367    let object = value.as_object_mut().ok_or_else(|| {
15368        SubscriberError::Provider("OP pending block response is not an object".into())
15369    })?;
15370    let transactions = object
15371        .get_mut("transactions")
15372        .and_then(serde_json::Value::as_array_mut)
15373        .ok_or_else(|| {
15374            SubscriberError::Provider(
15375                "OP pending block response is missing its transaction array".into(),
15376            )
15377        })?;
15378    for transaction in transactions {
15379        if transaction.is_string() {
15380            continue;
15381        }
15382        let hash = transaction
15383            .as_object()
15384            .and_then(|object| object.get("hash"))
15385            .filter(|hash| hash.is_string())
15386            .cloned()
15387            .ok_or_else(|| {
15388                SubscriberError::Provider("OP pending block transaction is missing its hash".into())
15389            })?;
15390        *transaction = hash;
15391    }
15392    if object.get("hash").is_none_or(serde_json::Value::is_null) {
15393        object.insert(
15394            "hash".into(),
15395            serde_json::Value::String(B256::ZERO.to_string()),
15396        );
15397    }
15398    if object.get("nonce").is_none_or(serde_json::Value::is_null) {
15399        object.insert(
15400            "nonce".into(),
15401            serde_json::Value::String("0x0000000000000000".into()),
15402        );
15403    }
15404    if object.get("miner").is_none_or(serde_json::Value::is_null)
15405        || object
15406            .get("beneficiary")
15407            .is_none_or(serde_json::Value::is_null)
15408    {
15409        object.insert(
15410            "miner".into(),
15411            serde_json::Value::String(Address::ZERO.to_string()),
15412        );
15413    }
15414    serde_json::from_value(value).map_err(|error| {
15415        SubscriberError::Provider(format!(
15416            "failed to decode normalized OP pending block: {error}"
15417        ))
15418    })
15419}
15420
15421fn normalize_pending_transaction_receipt(
15422    expected_transaction_hash: B256,
15423    value: serde_json::Value,
15424) -> Result<Option<Vec<Log>>, SubscriberError> {
15425    if value.is_null() {
15426        return Ok(None);
15427    }
15428    let receipt = value.as_object().ok_or_else(|| {
15429        SubscriberError::Provider("pending transaction receipt response is not an object".into())
15430    })?;
15431    let transaction_hash: B256 =
15432        serde_json::from_value(receipt.get("transactionHash").cloned().ok_or_else(|| {
15433            SubscriberError::Provider(
15434                "pending transaction receipt is missing its transaction hash".into(),
15435            )
15436        })?)
15437        .map_err(|error| {
15438            SubscriberError::Provider(format!(
15439                "failed to decode pending transaction receipt hash: {error}"
15440            ))
15441        })?;
15442    if transaction_hash != expected_transaction_hash {
15443        return Err(SubscriberError::Provider(
15444            "pending transaction receipt hash disagrees with its request".into(),
15445        ));
15446    }
15447    let receipt_logs = receipt
15448        .get("logs")
15449        .and_then(serde_json::Value::as_array)
15450        .ok_or_else(|| {
15451            SubscriberError::Provider("pending transaction receipt is missing its log array".into())
15452        })?;
15453    let mut logs = Vec::new();
15454    for log in receipt_logs {
15455        let log: Log = serde_json::from_value(log.clone()).map_err(|error| {
15456            SubscriberError::Provider(format!(
15457                "failed to decode pending transaction receipt log: {error}"
15458            ))
15459        })?;
15460        if log.transaction_hash != Some(expected_transaction_hash) {
15461            return Err(SubscriberError::Provider(
15462                "pending transaction receipt log hash disagrees with its receipt".into(),
15463            ));
15464        }
15465        logs.push(log);
15466    }
15467    Ok(Some(logs))
15468}
15469
15470impl<P, N> EventSubscriber<N> for AlloySubscriber<P, N>
15471where
15472    P: Provider<N> + Send + Sync,
15473    N: Network + 'static,
15474    N::HeaderResponse: Send + 'static,
15475{
15476    fn chain_id(&self) -> Option<u64> {
15477        self.chain_id
15478    }
15479
15480    fn capabilities(&self) -> SubscriberCapabilities {
15481        let Ok(transport) = resolve_subscriber_transport(self.mode) else {
15482            return SubscriberCapabilities::default();
15483        };
15484        let mut capabilities = vec![
15485            SubscriberCapability::Logs,
15486            SubscriberCapability::PendingTransactionHashes,
15487            SubscriberCapability::HistoricalBackfill,
15488            SubscriberCapability::Live,
15489            SubscriberCapability::OwnerScopedDelivery,
15490            SubscriberCapability::DynamicInterests,
15491        ];
15492        if transport == SubscriberTransport::PubSub {
15493            capabilities.push(SubscriberCapability::BlockHeaders);
15494            // Only the pubsub log streams are consumed through
15495            // `gap_observing_stream`, so only they can prove no loss went
15496            // unhealed. The polling transport's watcher cannot, and must not
15497            // claim it.
15498            capabilities.push(SubscriberCapability::LogCoverageAttestation);
15499        }
15500        if self.config.preconfirmations != PreconfirmationMode::Disabled
15501            && (self.uses_external_flashblock_updates()
15502                || (self.provider_ref.is_some()
15503                    && self.chain_id.and_then(flashblocks_adapter).is_some()))
15504        {
15505            capabilities.push(SubscriberCapability::Preconfirmations);
15506        }
15507        SubscriberCapabilities::new(capabilities)
15508    }
15509
15510    fn register_interests(
15511        &mut self,
15512        interests: &[ReactiveInterest<N>],
15513    ) -> SubscriberOperation<'_, ()> {
15514        let interests = interests.to_vec();
15515        Box::pin(async move {
15516            validate_subscriber_config(&self.config)?;
15517            validate_supported_interests(self.mode, &self.config, &interests)?;
15518            if !interests.is_empty() {
15519                self.ensure_chain_id().await?;
15520            }
15521            self.validate_flashblocks_setup()?;
15522
15523            self.base_interests = interests;
15524            self.owned_interests.clear();
15525            self.rebuild_registered_interests();
15526            self.reset_delivery_state();
15527            self.reset_stream_topology();
15528            Ok(())
15529        })
15530    }
15531
15532    fn next_batch(&mut self) -> SubscriberNextBatch<'_, N> {
15533        Box::pin(async {
15534            Ok(self
15535                .next_scoped_batch()
15536                .await?
15537                .map(SubscriberInputBatch::into_reactive_batch))
15538        })
15539    }
15540}
15541
15542impl<P, N> AlloySubscriber<P, N>
15543where
15544    P: Provider<N> + Send + Sync,
15545    N: Network + 'static,
15546    N::HeaderResponse: Send + 'static,
15547{
15548    /// Validate one configured provider generation and establish its selected
15549    /// Flashblocks delivery surface.
15550    ///
15551    /// The caller must register at least one active log interest first. The
15552    /// built-in profiles require a matching chain id and stable [`ProviderRef`].
15553    /// Base additionally requires pubsub, `newFlashblocks`, and one
15554    /// `pendingLogs` acknowledgement per planned provider filter. Optimism
15555    /// probes the bounded pending block/log/receipt surface.
15556    /// `op_supportedCapabilities` is queried opportunistically and retained as
15557    /// opaque evidence because provider implementations do not expose a uniform
15558    /// capability vocabulary.
15559    ///
15560    /// With `raw-flashblocks-json` and
15561    /// [`Self::configure_external_flashblock_updates`], preflight instead verifies
15562    /// the canonical subscriber chain and installed canonical stream topology.
15563    /// The application owns supplemental-source qualification, and this method
15564    /// performs no Flashblocks request/response calls for that profile.
15565    ///
15566    /// A successful return is deliberately not a liveness qualification. The
15567    /// acceptance window must still observe a Flashblock whose pending state
15568    /// advances and a correlated log for an active pool.
15569    pub async fn establish_flashblocks_preflight(
15570        &mut self,
15571        expected_chain_id: u64,
15572    ) -> Result<FlashblocksPreflight, SubscriberError> {
15573        validate_subscriber_config(&self.config)?;
15574        if self.config.preconfirmations == PreconfirmationMode::Disabled {
15575            return Err(SubscriberError::InvalidConfig(
15576                "Flashblocks preflight requires preconfirmations",
15577            ));
15578        }
15579        if !self
15580            .interests
15581            .iter()
15582            .any(|interest| matches!(interest, ReactiveInterest::Logs(_)))
15583        {
15584            return Err(SubscriberError::InvalidConfig(
15585                "Flashblocks preflight requires at least one active log interest",
15586            ));
15587        }
15588        let chain_id = self.ensure_chain_id().await?;
15589        if chain_id != expected_chain_id {
15590            return Err(SubscriberError::ChainMismatch {
15591                expected: expected_chain_id,
15592                actual: chain_id,
15593            });
15594        }
15595        self.validate_flashblocks_setup()?;
15596        #[cfg(feature = "raw-flashblocks-json")]
15597        if let Some(provider) = self.external_flashblocks_provider.clone() {
15598            self.ensure_streams().await?;
15599            return Ok(FlashblocksPreflight {
15600                chain_id,
15601                provider,
15602                delivery: FlashblocksDelivery::ExternalUpdates,
15603                pending_log_subscriptions: 0,
15604                pending_log_filters: self.log_stream_filters().len(),
15605                advertised_capabilities: None,
15606            });
15607        }
15608        let adapter = flashblocks_adapter(chain_id).ok_or(SubscriberError::Unsupported(
15609            "Flashblocks are currently implemented for Base and OP chains",
15610        ))?;
15611        let provider = self
15612            .provider_ref
15613            .clone()
15614            .ok_or(SubscriberError::InvalidConfig(
15615                "Flashblocks preflight requires a stable provider ref",
15616            ))?;
15617        self.record_rpc(
15618            SubscriberRpcCause::FlashblocksSetup,
15619            SubscriberRpcMethod::OpSupportedCapabilities,
15620        );
15621        self.flashblocks_rpc_metrics.capability_requests = self
15622            .flashblocks_rpc_metrics
15623            .capability_requests
15624            .saturating_add(1);
15625        let capability_provider = if adapter == FlashblocksAdapter::PendingStatePolling {
15626            self.flashblocks_state_provider
15627                .as_ref()
15628                .unwrap_or(&self.provider)
15629        } else {
15630            &self.provider
15631        };
15632        let advertised_capabilities = capability_provider
15633            .client()
15634            .request::<_, serde_json::Value>("op_supportedCapabilities", ())
15635            .await
15636            .ok();
15637
15638        self.ensure_streams().await?;
15639        let pending_log_filters = self.log_stream_filters();
15640        if adapter == FlashblocksAdapter::PendingStatePolling
15641            && self.pending_receipt_requests_per_tick_capacity() == 0
15642        {
15643            return Err(SubscriberError::InvalidConfig(
15644                "Flashblocks RPC budget leaves no capacity for OP transaction receipts",
15645            ));
15646        }
15647        let (delivery, pending_log_subscriptions) = match adapter {
15648            FlashblocksAdapter::NativeSubscriptions => {
15649                if resolve_subscriber_transport(self.mode)? != SubscriberTransport::PubSub {
15650                    return Err(SubscriberError::Unsupported(
15651                        "Base Flashblocks preflight requires pubsub",
15652                    ));
15653                }
15654                let pending_sources = self
15655                    .pubsub_stream_sources()
15656                    .into_iter()
15657                    .filter(|source| {
15658                        matches!(source, SubscriberStreamSource::BasePendingLog { .. })
15659                    })
15660                    .collect::<Vec<_>>();
15661                let AlloySubscriberState::Active(streams) = &self.state else {
15662                    return Err(SubscriberError::Provider(
15663                        "Flashblocks preflight subscriptions did not become active".to_owned(),
15664                    ));
15665                };
15666                if !streams.contains_source(&SubscriberStreamSource::BaseFlashblocks)
15667                    || pending_sources
15668                        .iter()
15669                        .any(|source| !streams.contains_source(source))
15670                {
15671                    return Err(SubscriberError::Provider(
15672                        "Base Flashblocks preflight did not retain both subscription lanes"
15673                            .to_owned(),
15674                    ));
15675                }
15676                (
15677                    FlashblocksDelivery::NativeSubscriptions,
15678                    pending_sources.len(),
15679                )
15680            }
15681            FlashblocksAdapter::PendingStatePolling => {
15682                if let Some(state_provider) = self.flashblocks_state_provider.as_ref() {
15683                    self.record_rpc(
15684                        SubscriberRpcCause::FlashblocksSetup,
15685                        SubscriberRpcMethod::EthChainId,
15686                    );
15687                    self.flashblocks_rpc_metrics.provider_pair_chain_requests = self
15688                        .flashblocks_rpc_metrics
15689                        .provider_pair_chain_requests
15690                        .saturating_add(1);
15691                    let actual = state_provider
15692                        .get_chain_id()
15693                        .await
15694                        .map_err(provider_error)?;
15695                    if actual != expected_chain_id {
15696                        return Err(SubscriberError::ChainMismatch {
15697                            expected: expected_chain_id,
15698                            actual,
15699                        });
15700                    }
15701                }
15702                let AlloySubscriberState::Active(streams) = &self.state else {
15703                    return Err(SubscriberError::Provider(
15704                        "Flashblocks preflight streams did not become active".to_owned(),
15705                    ));
15706                };
15707                if !streams.contains_source(&SubscriberStreamSource::OpPendingFlashblocks) {
15708                    return Err(SubscriberError::Provider(
15709                        "Optimism Flashblocks preflight did not retain its pending-state sampler"
15710                            .to_owned(),
15711                    ));
15712                }
15713                self.probe_pending_state(&pending_log_filters).await?;
15714                (FlashblocksDelivery::PendingStatePolling, 0)
15715            }
15716        };
15717        Ok(FlashblocksPreflight {
15718            chain_id,
15719            provider,
15720            delivery,
15721            pending_log_subscriptions,
15722            pending_log_filters: pending_log_filters.len(),
15723            advertised_capabilities,
15724        })
15725    }
15726
15727    /// Ingest one standardized update from an application-managed source.
15728    ///
15729    /// This method is synchronous and performs no provider I/O. The update is
15730    /// validated against the configured source identity, normalized through
15731    /// the same preconfirmation deduplication used by provider subscriptions,
15732    /// and queued for ordinary [`EventSubscriber`] delivery. Stale provider
15733    /// generations and stale invalidations cannot revoke newer speculative
15734    /// state. Indexed snapshots must begin at zero, advance exactly one index at
15735    /// a time, preserve their base identity and cumulative transaction prefix,
15736    /// and bind delta logs only to newly appended transactions.
15737    #[cfg(feature = "raw-flashblocks-json")]
15738    pub fn ingest_flashblock_update(
15739        &mut self,
15740        update: FlashblockUpdate,
15741    ) -> Result<(), SubscriberError> {
15742        self.ingest_flashblock_update_with_ingress(
15743            update,
15744            FlashblockIngressTiming::new(Instant::now()),
15745        )
15746    }
15747
15748    /// Ingest one standardized update with its original typed source arrival.
15749    ///
15750    /// This timing is observability-only and cannot mutate canonical state or
15751    /// grant trigger authority.
15752    ///
15753    /// # Errors
15754    ///
15755    /// Returns the same validation and resource errors as
15756    /// [`Self::ingest_flashblock_update`].
15757    #[cfg(feature = "raw-flashblocks-json")]
15758    pub fn ingest_flashblock_update_with_ingress(
15759        &mut self,
15760        update: FlashblockUpdate,
15761        timing: FlashblockIngressTiming,
15762    ) -> Result<(), SubscriberError> {
15763        validate_subscriber_config(&self.config)?;
15764        self.validate_flashblocks_setup()?;
15765        let configured =
15766            self.external_flashblocks_provider
15767                .as_ref()
15768                .ok_or(SubscriberError::InvalidConfig(
15769                    "standardized Flashblock updates require configure_external_flashblock_updates",
15770                ))?;
15771
15772        match update {
15773            FlashblockUpdate::Snapshot(batch) => {
15774                if batch.flashblock.provider.endpoint != configured.endpoint {
15775                    return Err(SubscriberError::Provider(
15776                        "external Flashblock update came from an unexpected provider endpoint"
15777                            .into(),
15778                    ));
15779                }
15780                if batch.flashblock.provider.generation < configured.generation
15781                    || self.latest_preconfirmation.as_ref().is_some_and(|latest| {
15782                        latest.provider.endpoint == batch.flashblock.provider.endpoint
15783                            && latest.provider.generation > batch.flashblock.provider.generation
15784                    })
15785                {
15786                    return Ok(());
15787                }
15788                if self
15789                    .rejected_external_flashblock_generation
15790                    .is_some_and(|rejected| batch.flashblock.provider.generation <= rejected)
15791                {
15792                    return Err(SubscriberError::Provider(
15793                        "external Flashblock provider generation was previously rejected".into(),
15794                    ));
15795                }
15796                validate_standard_flashblock_snapshot(&batch)?;
15797                if self.validate_external_flashblock_sequence(&batch)? {
15798                    return Ok(());
15799                }
15800                let required = self.pending_record_count().saturating_add(batch.logs.len());
15801                if required > self.config.max_pending_records {
15802                    self.invalidate_preconfirmation_snapshot();
15803                    self.last_external_flashblock_snapshot = None;
15804                    return Err(SubscriberError::ResourceExhausted(format!(
15805                        "external preconfirmation records require {required} pending records, above the configured limit of {}",
15806                        self.config.max_pending_records
15807                    )));
15808                }
15809                let accepted_snapshot = (*batch).clone();
15810                let FlashblockSnapshot { flashblock, logs } = *batch;
15811                let logs = self.filter_preconfirmed_logs(&flashblock, logs)?;
15812                self.last_external_flashblock_snapshot = Some(accepted_snapshot);
15813                if let Some(provider) = self.external_flashblocks_provider.as_mut() {
15814                    provider.generation = provider.generation.max(flashblock.provider.generation);
15815                }
15816                if !logs.is_empty() {
15817                    self.enqueue_event(SubscriberEvent::PreconfirmedLogs {
15818                        flashblock,
15819                        logs,
15820                        timing,
15821                    });
15822                }
15823            }
15824            FlashblockUpdate::Invalidated(invalidation) => {
15825                if invalidation.provider.endpoint != configured.endpoint {
15826                    return Err(SubscriberError::Provider(
15827                        "external Flashblock invalidation came from an unexpected provider endpoint"
15828                            .into(),
15829                    ));
15830                }
15831                if self.latest_preconfirmation.as_ref().is_some_and(|latest| {
15832                    latest.provider == invalidation.provider
15833                        && latest.payload_id == Some(invalidation.payload_id)
15834                }) {
15835                    self.invalidate_preconfirmation_snapshot();
15836                    self.last_external_flashblock_snapshot = None;
15837                }
15838            }
15839        }
15840        Ok(())
15841    }
15842
15843    #[cfg(feature = "raw-flashblocks-json")]
15844    fn validate_external_flashblock_sequence(
15845        &self,
15846        snapshot: &FlashblockSnapshot,
15847    ) -> Result<bool, SubscriberError> {
15848        let Some(previous) = self.last_external_flashblock_snapshot.as_ref() else {
15849            if snapshot.flashblock.index != Some(0) {
15850                return Err(SubscriberError::Provider(
15851                    "external Flashblock payload generation must begin at index zero".into(),
15852                ));
15853            }
15854            return Ok(false);
15855        };
15856
15857        if previous.flashblock.provider == snapshot.flashblock.provider
15858            && previous.flashblock.payload_id == snapshot.flashblock.payload_id
15859        {
15860            let previous_index = previous
15861                .flashblock
15862                .index
15863                .expect("validated indexed snapshot");
15864            let current_index = snapshot
15865                .flashblock
15866                .index
15867                .expect("validated indexed snapshot");
15868            if current_index == previous_index {
15869                if previous == snapshot {
15870                    return Ok(true);
15871                }
15872                return Err(SubscriberError::Provider(
15873                    "external Flashblock repeated the same index with conflicting content".into(),
15874                ));
15875            }
15876            if current_index < previous_index {
15877                return Err(SubscriberError::Provider(format!(
15878                    "external Flashblock index regressed from {previous_index} to {current_index}"
15879                )));
15880            }
15881            if current_index > previous_index.saturating_add(1) {
15882                return Err(SubscriberError::Provider(format!(
15883                    "external Flashblock index skipped from {previous_index} to {current_index}"
15884                )));
15885            }
15886            if current_index == previous_index.saturating_add(1)
15887                && !previous.flashblock.same_base_identity(&snapshot.flashblock)
15888            {
15889                return Err(SubscriberError::Provider(
15890                    "external Flashblock base identity changed within one payload generation"
15891                        .into(),
15892                ));
15893            }
15894            if current_index == previous_index.saturating_add(1)
15895                && !snapshot
15896                    .flashblock
15897                    .transaction_hashes
15898                    .starts_with(&previous.flashblock.transaction_hashes)
15899            {
15900                return Err(SubscriberError::Provider(
15901                    "external Flashblock cumulative transaction membership changed its prior prefix"
15902                        .into(),
15903                ));
15904            }
15905            let prior_transaction_count =
15906                u64::try_from(previous.flashblock.transaction_hashes.len()).unwrap_or(u64::MAX);
15907            if current_index == previous_index.saturating_add(1)
15908                && snapshot.logs.iter().any(|log| {
15909                    log.transaction_index
15910                        .is_some_and(|index| index < prior_transaction_count)
15911                })
15912            {
15913                return Err(SubscriberError::Provider(
15914                    "external Flashblock delta log does not belong to a newly appended transaction"
15915                        .into(),
15916                ));
15917            }
15918        } else if snapshot.flashblock.index != Some(0) {
15919            return Err(SubscriberError::Provider(
15920                "external Flashblock payload generation must begin at index zero".into(),
15921            ));
15922        }
15923        Ok(false)
15924    }
15925
15926    async fn probe_pending_state(&mut self, filters: &[Filter]) -> Result<(), SubscriberError> {
15927        self.flashblocks_rpc_metrics.pending_block_requests = self
15928            .flashblocks_rpc_metrics
15929            .pending_block_requests
15930            .saturating_add(1);
15931        let pending = self
15932            .fetch_op_pending_block()
15933            .await
15934            .map_err(PendingFlashblockPollError::into_subscriber)?
15935            .ok_or_else(|| {
15936                SubscriberError::Provider(
15937                    "provider returned no pending block during Flashblocks preflight".into(),
15938                )
15939            })?;
15940        self.certify_op_pending_parent(&pending)
15941            .await
15942            .map_err(PendingFlashblockPollError::into_subscriber)?;
15943        for filter in filters {
15944            self.record_rpc(
15945                SubscriberRpcCause::PendingStateSample,
15946                SubscriberRpcMethod::EthGetLogs,
15947            );
15948            self.flashblocks_rpc_metrics.pending_log_requests = self
15949                .flashblocks_rpc_metrics
15950                .pending_log_requests
15951                .saturating_add(1);
15952            self.flashblocks_state_provider
15953                .as_ref()
15954                .unwrap_or(&self.provider)
15955                .get_logs(
15956                    &filter
15957                        .clone()
15958                        .from_block(BlockNumberOrTag::Latest)
15959                        .to_block(BlockNumberOrTag::Pending),
15960                )
15961                .await
15962                .map_err(provider_error)?;
15963        }
15964        self.record_rpc(
15965            SubscriberRpcCause::PendingStateSample,
15966            SubscriberRpcMethod::EthGetTransactionReceipt,
15967        );
15968        self.flashblocks_rpc_metrics.pending_receipt_requests = self
15969            .flashblocks_rpc_metrics
15970            .pending_receipt_requests
15971            .saturating_add(1);
15972        let _: serde_json::Value = self
15973            .flashblocks_state_provider
15974            .as_ref()
15975            .unwrap_or(&self.provider)
15976            .raw_request(Cow::Borrowed("eth_getTransactionReceipt"), (B256::ZERO,))
15977            .await
15978            .map_err(provider_error)?;
15979        Ok(())
15980    }
15981
15982    async fn certify_op_pending_parent(
15983        &mut self,
15984        pending: &N::BlockResponse,
15985    ) -> Result<N::HeaderResponse, PendingFlashblockPollError> {
15986        let pending_header = pending.header();
15987        let pending_number = pending_header.number();
15988        let parent_hash = pending_header.parent_hash();
15989        if pending_number == 0 || parent_hash.is_zero() {
15990            return Err(PendingFlashblockPollError::Integrity(
15991                SubscriberError::Provider(
15992                    "OP pending block omitted a certifiable canonical parent".into(),
15993                ),
15994            ));
15995        }
15996        self.record_rpc(
15997            SubscriberRpcCause::CanonicalHeadCertification,
15998            SubscriberRpcMethod::EthGetBlockByHash,
15999        );
16000        self.flashblocks_rpc_metrics.canonical_head_requests = self
16001            .flashblocks_rpc_metrics
16002            .canonical_head_requests
16003            .saturating_add(1);
16004        let parent = self
16005            .flashblocks_state_provider
16006            .as_ref()
16007            .unwrap_or(&self.provider)
16008            .get_block_by_hash(parent_hash)
16009            .await
16010            .map_err(pending_flashblock_request_error)?
16011            .ok_or_else(|| {
16012                PendingFlashblockPollError::Request(SubscriberError::Provider(
16013                    "Flashblocks provider returned no exact OP pending parent block".into(),
16014                ))
16015            })?;
16016        let parent_header = parent.header();
16017        if parent_header.hash() != parent_hash
16018            || parent_header.number().checked_add(1) != Some(pending_number)
16019        {
16020            return Err(PendingFlashblockPollError::Integrity(
16021                SubscriberError::Provider(
16022                    "OP pending block does not extend its exact certified parent".into(),
16023                ),
16024            ));
16025        }
16026        Ok(parent_header.clone())
16027    }
16028
16029    async fn fetch_op_pending_block(
16030        &mut self,
16031    ) -> Result<Option<N::BlockResponse>, PendingFlashblockPollError> {
16032        self.record_rpc(
16033            SubscriberRpcCause::PendingStateSample,
16034            SubscriberRpcMethod::EthGetBlockByNumber,
16035        );
16036        let state_provider = self
16037            .flashblocks_state_provider
16038            .as_ref()
16039            .unwrap_or(&self.provider);
16040        let value: Option<serde_json::Value> = state_provider
16041            .raw_request(
16042                Cow::Borrowed("eth_getBlockByNumber"),
16043                (BlockNumberOrTag::Pending, true),
16044            )
16045            .await
16046            .map_err(pending_flashblock_request_error)?;
16047        value
16048            .map(normalize_op_pending_block::<N>)
16049            .transpose()
16050            .map_err(PendingFlashblockPollError::Integrity)
16051    }
16052
16053    /// Resolve the provider's chain identity once. The assignment happens only
16054    /// after a complete RPC response, so cancelling the future leaves the
16055    /// subscriber cleanly retryable.
16056    async fn ensure_chain_id(&mut self) -> Result<u64, SubscriberError> {
16057        if let Some(chain_id) = self.chain_id {
16058            return Ok(chain_id);
16059        }
16060        self.record_rpc(
16061            SubscriberRpcCause::ChainIdentity,
16062            SubscriberRpcMethod::EthChainId,
16063        );
16064        let chain_id = self.provider.get_chain_id().await.map_err(provider_error)?;
16065        self.chain_id = Some(chain_id);
16066        Ok(chain_id)
16067    }
16068
16069    fn validate_flashblocks_setup(&self) -> Result<(), SubscriberError> {
16070        if self.config.preconfirmations == PreconfirmationMode::Disabled {
16071            if self.uses_external_flashblock_updates() {
16072                return Err(SubscriberError::InvalidConfig(
16073                    "external Flashblock updates require preconfirmations to be preferred or required",
16074                ));
16075            }
16076            return Ok(());
16077        }
16078        if self.uses_external_flashblock_updates() {
16079            return Ok(());
16080        }
16081        if self.provider_ref.is_none() {
16082            return Err(SubscriberError::InvalidConfig(
16083                "Flashblocks require a stable provider ref from a pinned provider lease",
16084            ));
16085        }
16086        let Some(chain_id) = self.chain_id else {
16087            return Ok(());
16088        };
16089        match flashblocks_adapter(chain_id) {
16090            Some(FlashblocksAdapter::NativeSubscriptions)
16091                if resolve_subscriber_transport(self.mode)? != SubscriberTransport::PubSub
16092                    && self.config.preconfirmations == PreconfirmationMode::Required =>
16093            {
16094                return Err(SubscriberError::Unsupported(
16095                    "Base Flashblocks require pubsub for newFlashblocks and pendingLogs",
16096                ));
16097            }
16098            Some(FlashblocksAdapter::NativeSubscriptions) => {}
16099            Some(_) => {}
16100            None if self.config.preconfirmations == PreconfirmationMode::Required => {
16101                return Err(SubscriberError::Unsupported(
16102                    "Flashblocks are currently implemented for Base and OP chains",
16103                ));
16104            }
16105            None => {}
16106        }
16107        Ok(())
16108    }
16109
16110    /// Subscribe first, then catch an exact staged owner up through a verified
16111    /// canonical block.
16112    ///
16113    /// This compatibility wrapper delegates to
16114    /// [`reconcile_interest_owners`](Self::reconcile_interest_owners), so a
16115    /// driver adopting several owners should call the bulk API once rather than
16116    /// invoking this method in a loop.
16117    ///
16118    /// # Errors
16119    ///
16120    /// Returns [`SubscriberOwnerError`] when the epoch is not staged, lacks a
16121    /// baseline, conflicts/regresses, provider certification or transport
16122    /// fails, returned logs are invalid, or subscriber resources are exhausted.
16123    pub async fn reconcile_interest_owner(
16124        &mut self,
16125        epoch: &SubscriberOwnerEpoch,
16126        through: BlockRef,
16127    ) -> Result<SubscriberOwnerProgress, SubscriberOwnerError>
16128    where
16129        P: Clone,
16130    {
16131        self.reconcile_interest_owners(std::slice::from_ref(epoch), through)
16132            .await?
16133            .pop()
16134            .ok_or(SubscriberOwnerError::NotStaged)
16135    }
16136
16137    /// Subscribe first, then atomically catch staged owners up through one
16138    /// verified canonical block.
16139    ///
16140    /// All epochs are preflighted before provider I/O. Live streams are
16141    /// reconciled once, compatible provider filters are merged into bounded
16142    /// chunks, and every historical request shares one double target-header
16143    /// certification. Provider-filter supersets are routed back through each
16144    /// owner's exact interests, retaining owner-scoped delivery provenance.
16145    /// Duplicate epoch tokens in `epochs` are coalesced in first-seen order.
16146    ///
16147    /// Live events are continuously drained while an independent provider
16148    /// clone performs catch-up. Fetched owner records and progress become
16149    /// visible only after every request and the final certification succeed. A
16150    /// failure leaves every target staged with its prior progress unchanged;
16151    /// live canonical delivery consumed during the attempt is preserved while
16152    /// excluding the failed target epochs from its staged-owner audience.
16153    ///
16154    /// # Errors
16155    ///
16156    /// Returns [`SubscriberOwnerError`] when an epoch is not staged, lacks a
16157    /// baseline, conflicts/regresses, provider certification or transport
16158    /// fails, returned logs are invalid, or subscriber resources are exhausted.
16159    /// Target progress remains unchanged on error.
16160    pub async fn reconcile_interest_owners(
16161        &mut self,
16162        epochs: &[SubscriberOwnerEpoch],
16163        through: BlockRef,
16164    ) -> Result<Vec<SubscriberOwnerProgress>, SubscriberOwnerError>
16165    where
16166        P: Clone,
16167    {
16168        if epochs.is_empty() {
16169            return Ok(Vec::new());
16170        }
16171
16172        self.ensure_chain_id().await?;
16173
16174        let mut seen = HashSet::new();
16175        let mut plans = Vec::with_capacity(epochs.len());
16176        for epoch in epochs {
16177            if !seen.insert(epoch.clone()) {
16178                continue;
16179            }
16180            let entry = self
16181                .owned_interests
16182                .iter()
16183                .find(|entry| {
16184                    entry.epoch.as_ref() == Some(epoch)
16185                        && entry.state == SubscriberOwnerState::Staged
16186                })
16187                .ok_or(SubscriberOwnerError::NotStaged)?;
16188            let position = entry
16189                .progress
16190                .as_ref()
16191                .map(|progress| &progress.through)
16192                .or(entry.baseline.as_ref())
16193                .ok_or(SubscriberOwnerError::MissingBaseline)?;
16194            let baseline = position.number;
16195            if through.number < baseline {
16196                return Err(SubscriberOwnerError::ProgressRegression {
16197                    current: baseline,
16198                    target: through.number,
16199                });
16200            }
16201            let from_block = baseline
16202                .checked_add(1)
16203                .ok_or(SubscriberOwnerError::PostBlockOverflow(baseline))?;
16204            if through.number == baseline && through.hash != position.hash {
16205                return Err(SubscriberOwnerError::ProgressConflict {
16206                    number: baseline,
16207                    current_hash: position.hash,
16208                    target_hash: through.hash,
16209                });
16210            }
16211            if through.number == from_block
16212                && through
16213                    .parent_hash
16214                    .is_some_and(|parent| parent != position.hash)
16215            {
16216                return Err(SubscriberOwnerError::ProgressConflict {
16217                    number: baseline,
16218                    current_hash: position.hash,
16219                    target_hash: through.parent_hash.expect("checked as present above"),
16220                });
16221            }
16222            if entry
16223                .interests
16224                .iter()
16225                .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
16226            {
16227                return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
16228            }
16229            plans.push(SubscriberOwnerReconcilePlan {
16230                epoch: epoch.clone(),
16231                interests: entry.interests.clone(),
16232                retained: *position,
16233                from_block,
16234            });
16235        }
16236
16237        // The ordering is intentional and part of the public continuity
16238        // contract: connect first, then fetch the bounded historical window.
16239        self.ensure_streams().await?;
16240        let provider = self.provider.clone();
16241        let filters = merged_owner_reconcile_filters(&plans, through.number);
16242        let retained = plans.iter().map(|plan| plan.retained).collect();
16243        let target_epochs: HashSet<_> = plans.iter().map(|plan| plan.epoch.clone()).collect();
16244        let fetch = fetch_owner_catchup::<P, N>(
16245            provider,
16246            filters,
16247            retained,
16248            through,
16249            SubscriberOwnerCatchupOptions {
16250                target_preverified: false,
16251                max_logs: self.config.max_pending_records,
16252                max_log_bytes: self.config.max_backfill_log_bytes,
16253                max_requests_in_flight: self.config.max_reconcile_requests_in_flight,
16254                cause: SubscriberRpcCause::OwnerReconcile,
16255            },
16256            Arc::clone(&self.rpc_counters),
16257        );
16258        let SubscriberOwnerCatchup { logs, certified } =
16259            self.drive_reconcile_fetch(fetch, &target_epochs).await?;
16260
16261        let records = logs
16262            .into_iter()
16263            .map(|log| log_input_record(log, InputSource::Backfill))
16264            .collect();
16265        let mut routed_records = Vec::new();
16266        for record in dedupe_records(sort_records(records)).map_err(|error| {
16267            SubscriberError::InvalidBackfill(format!(
16268                "conflicting duplicate owner catch-up record: {error}"
16269            ))
16270        })? {
16271            let block_number = match &record.input {
16272                ReactiveInput::Log(log) => log
16273                    .block_number
16274                    .expect("bulk catch-up logs were validated before commit"),
16275                _ => unreachable!("bulk owner catch-up contains log records only"),
16276            };
16277            let owners: Vec<SubscriberOwnerEpoch> = plans
16278                .iter()
16279                .filter(|plan| block_number >= plan.from_block)
16280                .filter(|plan| {
16281                    plan.interests
16282                        .iter()
16283                        .any(|interest| interest_matches(interest, &record.input))
16284                })
16285                .map(|plan| plan.epoch.clone())
16286                .collect();
16287            if !owners.is_empty() {
16288                routed_records.push((record, owners));
16289            }
16290        }
16291        self.ensure_pending_record_capacity(
16292            routed_records.len(),
16293            "owner reconciliation historical records",
16294        )?;
16295
16296        // Nothing provider-derived becomes authoritative until every record is
16297        // known to fit. In particular, preserve queued retry state and owner
16298        // progress when the bounded delivery queue cannot accept the catch-up.
16299        self.pending_backfills.retain(|queued| {
16300            queued
16301                .epoch
16302                .as_ref()
16303                .is_none_or(|epoch| !target_epochs.contains(epoch))
16304        });
16305        for (record, owners) in routed_records {
16306            self.enqueue_owner_record_for_owners_unmerged(record, owners);
16307        }
16308        self.promote_reconcile_owner_records(&target_epochs);
16309        self.seed_reconciled_filter_anchors(&plans, certified.number);
16310
16311        let stream_revision = self.stream_revision;
16312        let mut progress = Vec::with_capacity(plans.len());
16313        for plan in plans {
16314            let item = SubscriberOwnerProgress {
16315                owner: plan.epoch.clone(),
16316                through: certified,
16317            };
16318            let entry = self
16319                .owned_interests
16320                .iter_mut()
16321                .find(|entry| entry.epoch.as_ref() == Some(&plan.epoch))
16322                .expect("bulk reconcile holds exclusive access after epoch preflight");
16323            entry.progress = Some(item.clone());
16324            entry.progress_stream_revision = Some(stream_revision);
16325            progress.push(item);
16326        }
16327        Ok(progress)
16328    }
16329
16330    async fn drive_reconcile_fetch<T, F>(
16331        &mut self,
16332        fetch: F,
16333        target_epochs: &HashSet<SubscriberOwnerEpoch>,
16334    ) -> Result<T, SubscriberOwnerError>
16335    where
16336        F: Future<Output = Result<T, SubscriberOwnerError>>,
16337    {
16338        if !matches!(&self.state, AlloySubscriberState::Active(_)) {
16339            return fetch.await;
16340        }
16341        let mut fetch = Box::pin(fetch);
16342        loop {
16343            let event = {
16344                let live = Box::pin(self.next_event());
16345                match select(fetch, live).await {
16346                    Either::Left((result, pending_live)) => {
16347                        drop(pending_live);
16348                        return result;
16349                    }
16350                    Either::Right((event, pending_fetch)) => {
16351                        fetch = pending_fetch;
16352                        event
16353                    }
16354                }
16355            };
16356            let event = event?.ok_or_else(|| {
16357                SubscriberError::Provider(
16358                    "Alloy subscriber streams ended during owner reconcile".to_owned(),
16359                )
16360            })?;
16361            self.buffer_reconcile_event_for_owners(&event, target_epochs);
16362            self.enqueue_event_excluding_owners(event, target_epochs);
16363            self.check_resource_error()?;
16364        }
16365    }
16366
16367    /// Poll one driver control future with priority over the next scoped batch.
16368    ///
16369    /// This is the supported control-interleaving primitive for a subscriber
16370    /// driver. `control` is borrowed rather than consumed, so a batch win leaves
16371    /// the caller's pending control future alive. When control wins, the
16372    /// in-progress subscriber poll is cancelled at a documented safe boundary:
16373    /// queued records are removed only when a complete batch is returned,
16374    /// successful backfill steps are committed before the next await, provider
16375    /// streams created but not installed are dropped, and installed streams
16376    /// remain owned by the subscriber for the next call.
16377    ///
16378    /// The control future is polled first. Therefore a ready shutdown/removal
16379    /// command cannot starve behind a continuously ready subscriber queue.
16380    ///
16381    /// # Errors
16382    ///
16383    /// Returns [`SubscriberError`] when the subscriber poll encounters a
16384    /// transport, continuity, decoding, configuration, or resource failure.
16385    pub async fn next_scoped_batch_or<C, F>(
16386        &mut self,
16387        control: Pin<&mut F>,
16388    ) -> Result<SubscriberDriverPoll<C, N>, SubscriberError>
16389    where
16390        C: Send,
16391        F: Future<Output = C> + Send,
16392    {
16393        let batch = self.next_scoped_batch();
16394        match select(control, batch).await {
16395            Either::Left((control, pending_batch)) => {
16396                drop(pending_batch);
16397                Ok(SubscriberDriverPoll::Control(control))
16398            }
16399            Either::Right((batch, _pending_control)) => batch.map(SubscriberDriverPoll::Batch),
16400        }
16401    }
16402
16403    /// Return the next subscriber batch while retaining staged-owner delivery
16404    /// provenance captured at enqueue time.
16405    ///
16406    /// Transaction-aware drivers must use this method. The compatibility
16407    /// [`EventSubscriber::next_batch`] method flattens the same queue and keeps
16408    /// its historical behavior for existing callers.
16409    ///
16410    /// For command interleaving, prefer
16411    /// [`next_scoped_batch_or`](Self::next_scoped_batch_or), which preserves the
16412    /// cancellation-safety invariants of this poll and prioritizes ready control.
16413    pub fn next_scoped_batch(&mut self) -> SubscriberNextScopedBatch<'_, N> {
16414        Box::pin(async {
16415            self.check_resource_error()?;
16416            if self.chain_id.is_none()
16417                && (!self.pending_records.is_empty()
16418                    || !self.pending_chain_controls.is_empty()
16419                    || !self.pending_backfills.is_empty()
16420                    || !self.interests.is_empty())
16421            {
16422                self.ensure_chain_id().await?;
16423            }
16424            if let Some(batch) = self.drain_next_scoped_batch() {
16425                return Ok(Some(batch));
16426            }
16427
16428            // Subscribe/adopt the complete desired topology before resolving
16429            // any queued historical upper bound. Live streams therefore own
16430            // every event that can arrive while the bounded backfill is in
16431            // flight, including the coordinated registration window.
16432            self.ensure_streams().await?;
16433            self.check_resource_error()?;
16434            if let Some(batch) = self.drain_next_scoped_batch() {
16435                return Ok(Some(batch));
16436            }
16437
16438            self.drain_pending_backfills().await?;
16439            self.check_resource_error()?;
16440            if let Some(batch) = self.drain_next_scoped_batch() {
16441                return Ok(Some(batch));
16442            }
16443
16444            if self.interests.is_empty() {
16445                return Ok(None);
16446            }
16447
16448            loop {
16449                let Some(event) = self.next_event().await? else {
16450                    return Ok(None);
16451                };
16452
16453                self.enqueue_event(event);
16454                self.check_resource_error()?;
16455                if let Some(batch) = self.drain_next_scoped_batch() {
16456                    return Ok(Some(batch));
16457                }
16458            }
16459        })
16460    }
16461
16462    /// Bring live streams in line with the current interest set.
16463    ///
16464    /// Runs incrementally: the desired-vs-live diff only happens when interest
16465    /// bookkeeping changed since the last successful pass (`sources_dirty`), so
16466    /// steady-state polling costs nothing here. Missing sources are connected,
16467    /// sources for retired filters are dropped (dropping an Alloy subscription
16468    /// unsubscribes provider-side), and unrelated live streams — with their
16469    /// delivery and anchor state — are left untouched.
16470    ///
16471    /// A newly connected log source whose filter already has a delivery anchor
16472    /// is caught up from that anchor immediately after subscribing (the same
16473    /// subscribe-then-backfill order the reconnect path uses). Together with
16474    /// anchor seeding in [`Self::drain_pending_backfills`], that closes the
16475    /// window between an adoption backfill and live stream start.
16476    async fn ensure_streams(&mut self) -> Result<(), SubscriberError> {
16477        if !self.sources_dirty {
16478            return Ok(());
16479        }
16480        // An interest-less subscriber never touches the provider
16481        // ([`EventSubscriber::next_batch`] returns `Ok(None)`). Still certify
16482        // the empty desired topology as clean so a deliberately empty staged
16483        // epoch can reconcile and activate instead of remaining dirty forever.
16484        if matches!(self.state, AlloySubscriberState::Uninitialized) && self.interests.is_empty() {
16485            self.bump_stream_revision();
16486            self.sources_dirty = false;
16487            return Ok(());
16488        }
16489
16490        let desired = self.stream_sources()?;
16491        let missing: Vec<SubscriberStreamSource> = match &self.state {
16492            AlloySubscriberState::Active(streams) => desired
16493                .iter()
16494                .filter(|source| !streams.contains_source(source))
16495                .cloned()
16496                .collect(),
16497            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => desired.clone(),
16498        };
16499
16500        for source in missing {
16501            let stream = match self.connect_source_stream(source.clone()).await {
16502                Ok(stream) => stream,
16503                Err(error)
16504                    if source.is_flashblocks()
16505                        && self.config.preconfirmations == PreconfirmationMode::Preferred =>
16506                {
16507                    tracing::warn!(
16508                        stream = source.label(),
16509                        error = %error,
16510                        "Flashblocks source unavailable; canonical delivery remains active"
16511                    );
16512                    if self.config.reconnect.enabled {
16513                        self.schedule_flashblock_reconnect(
16514                            source,
16515                            self.config.reconnect.retry_delay,
16516                        );
16517                    }
16518                    continue;
16519                }
16520                Err(error) => return Err(error),
16521            };
16522            // Publish each successful connection before any later await. If a
16523            // second connection or anchored catch-up fails/cancels, this stream
16524            // remains live and the next reconcile skips reconnecting it.
16525            self.install_source_stream(source.clone(), stream);
16526            if self.source_requires_backfill(&source) {
16527                self.queue_source_backfill(source);
16528            }
16529        }
16530
16531        while let Some(source) = self.pending_source_backfills.front().cloned() {
16532            let desired_and_live = desired.iter().any(|item| item.same_key(&source))
16533                && matches!(
16534                    &self.state,
16535                    AlloySubscriberState::Active(streams) if streams.contains_source(&source)
16536                );
16537            if !desired_and_live {
16538                self.pending_source_backfills.pop_front();
16539                continue;
16540            }
16541
16542            // Anchored catch-up for a source with a known delivery watermark
16543            // (seeded by a drained adoption backfill, or inherited from a
16544            // filter shape that was live before): subscribe first, then fetch
16545            // the gap, so nothing lands between the two. Pop only after the
16546            // request succeeds; errors and cancellation retain retry intent.
16547            let event = self.backfill_reconnected_source(&source).await?;
16548            self.pending_source_backfills.pop_front();
16549            if let Some(event) = event {
16550                self.enqueue_event(event);
16551            }
16552        }
16553
16554        if let AlloySubscriberState::Active(streams) = &mut self.state {
16555            streams.retain_sources(&desired);
16556            if streams.is_empty() {
16557                self.state = AlloySubscriberState::Empty;
16558            }
16559        }
16560
16561        self.bump_stream_revision();
16562        self.sources_dirty = false;
16563        self.retire_unreferenced_filters();
16564        Ok(())
16565    }
16566
16567    fn install_source_stream(
16568        &mut self,
16569        source: SubscriberStreamSource,
16570        stream: BoxStream<'static, SubscriberEvent<N>>,
16571    ) {
16572        match &mut self.state {
16573            AlloySubscriberState::Active(streams) => {
16574                if streams.contains_source(&source) {
16575                    return;
16576                }
16577                streams.push(source, stream);
16578            }
16579            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
16580                let mut streams = SubscriberStreams::new();
16581                streams.push(source, stream);
16582                self.state = AlloySubscriberState::Active(streams);
16583            }
16584        }
16585        // A partially completed reconcile is still a topology change. Advance
16586        // the revision now rather than only at the final clean boundary.
16587        self.bump_stream_revision();
16588    }
16589
16590    fn schedule_flashblock_reconnect(
16591        &mut self,
16592        source: SubscriberStreamSource,
16593        first_delay: Duration,
16594    ) {
16595        if self
16596            .pending_flashblock_reconnect_sources
16597            .iter()
16598            .any(|pending| pending.same_key(&source))
16599        {
16600            return;
16601        }
16602        self.pending_flashblock_reconnect_sources
16603            .push(source.clone());
16604        self.pending_flashblock_reconnects
16605            .push(flashblock_reconnect_future(
16606                self.provider.root().clone(),
16607                source,
16608                self.config.max_batch_size,
16609                self.config.reconnect.clone(),
16610                first_delay,
16611                self.config.flashblock_poll_interval,
16612                Arc::clone(&self.rpc_counters),
16613            ));
16614    }
16615
16616    fn reschedule_preferred_flashblock(&mut self, source: SubscriberStreamSource) {
16617        if !self.config.reconnect.enabled {
16618            return;
16619        }
16620        self.schedule_flashblock_reconnect(source, self.config.reconnect.max_delay);
16621    }
16622
16623    fn source_requires_backfill(&self, source: &SubscriberStreamSource) -> bool {
16624        matches!(source, SubscriberStreamSource::PubSubLog { id, .. }
16625            if self.last_seen_log_blocks.contains_key(id))
16626    }
16627
16628    fn queue_source_backfill(&mut self, source: SubscriberStreamSource) {
16629        if !self
16630            .pending_source_backfills
16631            .iter()
16632            .any(|pending| pending.same_key(&source))
16633        {
16634            self.pending_source_backfills.push_back(source);
16635        }
16636    }
16637
16638    /// Fetch queued adoption/continuity backfills, oldest first.
16639    ///
16640    /// An entry is consumed only after its `get_logs` fetch succeeds — a
16641    /// transient RPC failure surfaces the error and leaves the entry queued for
16642    /// the next poll, so a flaky request cannot silently discard the missed
16643    /// window the backfill exists to close. Open-ended backfills resolve their
16644    /// upper bound to the provider's current head before fetching, and every
16645    /// drained backfill advances the filter's delivery anchor to that bound —
16646    /// even a zero-log window — so the filter is reconnect-protected from then
16647    /// on. Draining pauses as soon as records are ready for delivery; remaining
16648    /// entries stay queued.
16649    async fn drain_pending_backfills(&mut self) -> Result<(), SubscriberError> {
16650        while let Some(queued) = self.pending_backfills.front() {
16651            // Owner was removed while its backfill was queued.
16652            let epoch = queued.epoch.clone();
16653            let owner = queued.owner.clone();
16654            let owner_exists = match (&epoch, &owner) {
16655                (Some(epoch), _) => self.interest_owner_state(epoch).is_some(),
16656                (None, Some(owner)) => self.owner_interests(owner).is_some(),
16657                (None, None) => true,
16658            };
16659            if !owner_exists {
16660                self.pending_backfills.pop_front();
16661                continue;
16662            }
16663            let filters = queued.filters.clone();
16664            let backfill = queued.backfill;
16665
16666            let to_block = match backfill.end_block() {
16667                Some(to_block) => to_block,
16668                None => {
16669                    self.record_rpc(
16670                        SubscriberRpcCause::LazyBackfill,
16671                        SubscriberRpcMethod::EthBlockNumber,
16672                    );
16673                    self.provider
16674                        .get_block_number()
16675                        .await
16676                        .map_err(provider_error)?
16677                }
16678            };
16679            if to_block < backfill.start_block() {
16680                // An exclusive post-baseline range can be empty when the
16681                // provider is still exactly at the retained head. Consume the
16682                // work only after validating that head and seed the filter at
16683                // the proven baseline so reconnect catch-up starts at C + 1.
16684                let certified = if let Some(retained) = backfill.retained_anchor() {
16685                    let actual = fetch_provider_block_ref::<P, N>(
16686                        &self.provider,
16687                        retained.number,
16688                        &self.rpc_counters,
16689                        SubscriberRpcCause::LazyBackfill,
16690                    )
16691                    .await?;
16692                    if !block_ref_satisfies_expected(&actual, retained) {
16693                        return Err(SubscriberError::InvalidBackfill(format!(
16694                            "retained anchor {}:{:?} conflicts with provider block {}:{:?}",
16695                            retained.number, retained.hash, actual.number, actual.hash
16696                        )));
16697                    }
16698                    if to_block < retained.number {
16699                        return Err(SubscriberError::InvalidBackfill(format!(
16700                            "backfill upper bound {to_block} precedes retained anchor {}",
16701                            retained.number
16702                        )));
16703                    }
16704                    Some(actual)
16705                } else {
16706                    None
16707                };
16708                self.pending_backfills.pop_front();
16709                for filter in &filters {
16710                    let source_id = self.log_source_id(filter);
16711                    if let Some(certified) = certified {
16712                        self.last_seen_log_blocks
16713                            .entry(source_id)
16714                            .and_modify(|anchor| *anchor = (*anchor).max(certified.number))
16715                            .or_insert(certified.number);
16716                    }
16717                }
16718                if owner.is_none()
16719                    && let Some(certified) = certified
16720                {
16721                    self.pending_chain_controls
16722                        .push_back(global_backfill_barrier(backfill, certified));
16723                }
16724                if !self.pending_chain_controls.is_empty() {
16725                    break;
16726                }
16727                continue;
16728            }
16729
16730            let through = fetch_provider_block_ref::<P, N>(
16731                &self.provider,
16732                to_block,
16733                &self.rpc_counters,
16734                SubscriberRpcCause::LazyBackfill,
16735            )
16736            .await?;
16737            let request_filters =
16738                merged_lazy_backfill_filters(&filters, backfill.start_block(), through.number);
16739            let retained = backfill.retained_anchor().copied().into_iter().collect();
16740            let SubscriberOwnerCatchup {
16741                mut logs,
16742                certified,
16743            } = fetch_owner_catchup::<&P, N>(
16744                &self.provider,
16745                request_filters,
16746                retained,
16747                through,
16748                SubscriberOwnerCatchupOptions {
16749                    target_preverified: true,
16750                    max_logs: self.config.max_pending_records,
16751                    max_log_bytes: self.config.max_backfill_log_bytes,
16752                    max_requests_in_flight: self.config.max_reconcile_requests_in_flight,
16753                    cause: SubscriberRpcCause::LazyBackfill,
16754                },
16755                Arc::clone(&self.rpc_counters),
16756            )
16757            .await
16758            .map_err(lazy_backfill_error)?;
16759            logs.sort_by_key(|log| {
16760                (
16761                    log.block_number.unwrap_or_default(),
16762                    log.transaction_index.unwrap_or_default(),
16763                    log.log_index.unwrap_or_default(),
16764                )
16765            });
16766            logs.dedup();
16767            self.ensure_pending_record_capacity(logs.len(), "lazy subscriber backfill records")?;
16768
16769            // Fetch succeeded: consume the entry, deliver, and advance the
16770            // complete filter group through one globally ordered window.
16771            self.pending_backfills.pop_front();
16772            if let Some(epoch) = epoch.as_ref() {
16773                self.enqueue_backfilled_logs(logs, None, Some(epoch), Some(backfill));
16774            } else if let Some(owner) = owner.as_ref() {
16775                self.enqueue_compat_owner_backfilled_logs(logs, owner, backfill);
16776            } else {
16777                self.enqueue_backfilled_logs(logs, None, None, Some(backfill));
16778                self.pending_chain_controls
16779                    .push_back(global_backfill_barrier(backfill, certified));
16780            }
16781            for filter in &filters {
16782                let source_id = self.log_source_id(filter);
16783                let anchor = self
16784                    .last_seen_log_blocks
16785                    .entry(source_id)
16786                    .or_insert(certified.number);
16787                *anchor = (*anchor).max(certified.number);
16788            }
16789
16790            if !self.pending_records.is_empty() || !self.pending_chain_controls.is_empty() {
16791                break;
16792            }
16793        }
16794        Ok(())
16795    }
16796
16797    fn stream_sources(&mut self) -> Result<Vec<SubscriberStreamSource>, SubscriberError> {
16798        let sources = match resolve_subscriber_transport(self.mode)? {
16799            SubscriberTransport::PubSub => self.pubsub_stream_sources(),
16800            SubscriberTransport::Polling => self.polling_stream_sources(),
16801        };
16802        #[cfg(feature = "raw-flashblocks-json")]
16803        let sources = {
16804            let mut sources = sources;
16805            if self.external_flashblock_update_channel_opened {
16806                sources.push(SubscriberStreamSource::ExternalFlashblockUpdates);
16807            }
16808            sources
16809        };
16810        Ok(sources)
16811    }
16812
16813    fn pubsub_stream_sources(&mut self) -> Vec<SubscriberStreamSource> {
16814        let mut sources = Vec::new();
16815        let inherited_anchor = self.last_seen_log_blocks.values().copied().min();
16816
16817        for filter in self.log_stream_filters() {
16818            let id = self.log_source_id(&filter);
16819            if let Some(anchor) = inherited_anchor {
16820                self.last_seen_log_blocks.entry(id).or_insert(anchor);
16821            }
16822            sources.push(SubscriberStreamSource::PubSubLog { id, filter });
16823        }
16824
16825        if needs_pending_hash_stream(&self.interests) {
16826            sources.push(SubscriberStreamSource::PubSubPendingHashes);
16827        }
16828
16829        if needs_header_block_stream(&self.interests) {
16830            if !self.uses_external_flashblock_updates()
16831                && self.config.preconfirmations != PreconfirmationMode::Disabled
16832                && self.chain_id.and_then(flashblocks_adapter).is_some()
16833            {
16834                sources.push(SubscriberStreamSource::CanonicalHeadPolling);
16835            } else {
16836                sources.push(SubscriberStreamSource::PubSubBlockHeaders);
16837            }
16838        }
16839
16840        if self.config.preconfirmations != PreconfirmationMode::Disabled
16841            && !self.uses_external_flashblock_updates()
16842        {
16843            match self.chain_id.and_then(flashblocks_adapter) {
16844                Some(FlashblocksAdapter::NativeSubscriptions) => {
16845                    sources.push(SubscriberStreamSource::BaseFlashblocks);
16846                    for filter in self.log_stream_filters() {
16847                        let id = self.log_source_id(&filter);
16848                        sources.push(SubscriberStreamSource::BasePendingLog { id, filter });
16849                    }
16850                }
16851                Some(FlashblocksAdapter::PendingStatePolling) => {
16852                    sources.push(SubscriberStreamSource::OpPendingFlashblocks);
16853                }
16854                None => {}
16855            }
16856        }
16857
16858        sources
16859    }
16860
16861    fn polling_stream_sources(&self) -> Vec<SubscriberStreamSource> {
16862        let mut sources = Vec::new();
16863
16864        for filter in self.log_stream_filters() {
16865            sources.push(SubscriberStreamSource::PollingLog { filter });
16866        }
16867
16868        if needs_pending_hash_stream(&self.interests) {
16869            sources.push(SubscriberStreamSource::PollingPendingHashes);
16870        }
16871
16872        if self.config.preconfirmations != PreconfirmationMode::Disabled
16873            && !self.uses_external_flashblock_updates()
16874            && self.chain_id.and_then(flashblocks_adapter)
16875                == Some(FlashblocksAdapter::PendingStatePolling)
16876        {
16877            sources.push(SubscriberStreamSource::OpPendingFlashblocks);
16878        }
16879
16880        sources
16881    }
16882
16883    fn log_source_id(&mut self, filter: &Filter) -> usize {
16884        if let Some(id) = self.log_source_ids.get(filter) {
16885            return *id;
16886        }
16887
16888        let id = self.next_log_source_id;
16889        self.next_log_source_id = self.next_log_source_id.saturating_add(1);
16890        self.log_source_ids.insert(filter.clone(), id);
16891        id
16892    }
16893
16894    async fn connect_source_stream(
16895        &mut self,
16896        source: SubscriberStreamSource,
16897    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
16898        match source {
16899            SubscriberStreamSource::PubSubLog { id, filter } => {
16900                self.connect_pubsub_log_stream(id, filter).await
16901            }
16902            SubscriberStreamSource::BasePendingLog { id, filter } => {
16903                self.connect_base_pending_log_stream(id, filter).await
16904            }
16905            SubscriberStreamSource::BaseFlashblocks => self.connect_base_flashblock_stream().await,
16906            SubscriberStreamSource::OpPendingFlashblocks => {
16907                self.connect_op_flashblock_tick_stream()
16908            }
16909            SubscriberStreamSource::CanonicalHeadPolling => {
16910                self.connect_canonical_head_tick_stream()
16911            }
16912            SubscriberStreamSource::PubSubPendingHashes => {
16913                self.connect_pubsub_pending_hash_stream().await
16914            }
16915            SubscriberStreamSource::PubSubBlockHeaders => {
16916                self.connect_pubsub_block_header_stream().await
16917            }
16918            SubscriberStreamSource::PollingLog { filter } => {
16919                self.connect_polling_log_stream(filter).await
16920            }
16921            SubscriberStreamSource::PollingPendingHashes => {
16922                self.connect_polling_pending_hash_stream().await
16923            }
16924            #[cfg(feature = "raw-flashblocks-json")]
16925            SubscriberStreamSource::ExternalFlashblockUpdates => {
16926                let receiver = self.external_flashblock_updates.take().ok_or_else(|| {
16927                    SubscriberError::Provider(
16928                        "external Flashblock update channel receiver is unavailable".into(),
16929                    )
16930                })?;
16931                let updates = stream::unfold(receiver, |mut receiver| async move {
16932                    receiver
16933                        .recv()
16934                        .await
16935                        .map(|update| (SubscriberEvent::ExternalFlashblockUpdate(update), receiver))
16936                });
16937                Ok(stream_with_termination(
16938                    updates,
16939                    SubscriberStreamSource::ExternalFlashblockUpdates,
16940                ))
16941            }
16942        }
16943    }
16944
16945    async fn connect_pubsub_log_stream(
16946        &mut self,
16947        id: usize,
16948        filter: Filter,
16949    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
16950        #[cfg(feature = "reactive-ws")]
16951        {
16952            let source = SubscriberStreamSource::PubSubLog {
16953                id,
16954                filter: filter.clone(),
16955            };
16956            self.record_rpc(
16957                SubscriberRpcCause::StreamSubscription,
16958                SubscriberRpcMethod::EthSubscribe,
16959            );
16960            let subscription = self
16961                .provider
16962                .subscribe_logs(&filter)
16963                .channel_size(self.log_channel_size())
16964                .await
16965                .map_err(provider_error)?;
16966            Ok(gap_observing_stream(
16967                subscription,
16968                source,
16969                Arc::clone(&self.gap_counters),
16970                move |log| SubscriberEvent::Log { source_id: id, log },
16971            ))
16972        }
16973
16974        #[cfg(not(feature = "reactive-ws"))]
16975        {
16976            let _ = (id, filter);
16977            Err(SubscriberError::Unsupported(
16978                "AlloySubscriber pubsub mode requires the reactive-ws feature",
16979            ))
16980        }
16981    }
16982
16983    async fn connect_base_pending_log_stream(
16984        &mut self,
16985        id: usize,
16986        filter: Filter,
16987    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
16988        #[cfg(feature = "reactive-ws")]
16989        {
16990            let source = SubscriberStreamSource::BasePendingLog {
16991                id,
16992                filter: filter.clone(),
16993            };
16994            let params = base_pending_log_filter(&filter)?;
16995            self.record_rpc(
16996                SubscriberRpcCause::StreamSubscription,
16997                SubscriberRpcMethod::EthSubscribe,
16998            );
16999            let subscription = self
17000                .provider
17001                .subscribe::<_, Log>(("pendingLogs", params))
17002                .channel_size(self.log_channel_size())
17003                .await
17004                .map_err(provider_error)?;
17005            Ok(gap_observing_stream(
17006                subscription,
17007                source,
17008                Arc::clone(&self.gap_counters),
17009                move |log| SubscriberEvent::BasePendingLogTimed {
17010                    source_id: id,
17011                    log,
17012                    timing: FlashblockIngressTiming::new(Instant::now()),
17013                },
17014            ))
17015        }
17016
17017        #[cfg(not(feature = "reactive-ws"))]
17018        {
17019            let _ = (id, filter);
17020            Err(SubscriberError::Unsupported(
17021                "Base Flashblocks require the reactive-ws feature",
17022            ))
17023        }
17024    }
17025
17026    async fn connect_base_flashblock_stream(
17027        &mut self,
17028    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
17029        #[cfg(feature = "reactive-ws")]
17030        {
17031            self.record_rpc(
17032                SubscriberRpcCause::StreamSubscription,
17033                SubscriberRpcMethod::EthSubscribe,
17034            );
17035            let stream = self
17036                .provider
17037                .subscribe::<_, BaseFlashblockWirePayload>(("newFlashblocks",))
17038                .channel_size(self.config.max_batch_size.max(1))
17039                .await
17040                .map_err(provider_error)?
17041                .into_stream()
17042                .map(|payload| SubscriberEvent::BaseFlashblockTimed {
17043                    payload,
17044                    timing: FlashblockIngressTiming::new(Instant::now()),
17045                });
17046            Ok(stream_with_termination(
17047                stream,
17048                SubscriberStreamSource::BaseFlashblocks,
17049            ))
17050        }
17051
17052        #[cfg(not(feature = "reactive-ws"))]
17053        {
17054            Err(SubscriberError::Unsupported(
17055                "Base Flashblocks require the reactive-ws feature",
17056            ))
17057        }
17058    }
17059
17060    fn connect_canonical_head_tick_stream(
17061        &self,
17062    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
17063        let mut interval = tokio::time::interval(self.config.canonical_head_poll_interval);
17064        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
17065        let stream = stream::unfold(interval, |mut interval| async move {
17066            interval.tick().await;
17067            Some((SubscriberEvent::CanonicalHeadTick, interval))
17068        });
17069        Ok(stream_with_termination(
17070            stream,
17071            SubscriberStreamSource::CanonicalHeadPolling,
17072        ))
17073    }
17074
17075    fn connect_op_flashblock_tick_stream(
17076        &self,
17077    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
17078        let first_tick = tokio::time::Instant::now() + self.config.flashblock_poll_interval;
17079        let mut interval =
17080            tokio::time::interval_at(first_tick, self.config.flashblock_poll_interval);
17081        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
17082        let stream = stream::unfold(interval, |mut interval| async move {
17083            interval.tick().await;
17084            Some((
17085                SubscriberEvent::OpFlashblockTickTimed(
17086                    FlashblockIngressTiming::new(Instant::now()),
17087                ),
17088                interval,
17089            ))
17090        });
17091        Ok(stream_with_termination(
17092            stream,
17093            SubscriberStreamSource::OpPendingFlashblocks,
17094        ))
17095    }
17096
17097    async fn connect_pubsub_pending_hash_stream(
17098        &mut self,
17099    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
17100        #[cfg(feature = "reactive-ws")]
17101        {
17102            self.record_rpc(
17103                SubscriberRpcCause::StreamSubscription,
17104                SubscriberRpcMethod::EthSubscribe,
17105            );
17106            let stream = self
17107                .provider
17108                .subscribe_pending_transactions()
17109                .channel_size(self.config.max_batch_size.max(1))
17110                .await
17111                .map_err(provider_error)?
17112                .into_stream()
17113                .map(SubscriberEvent::PendingHash);
17114            Ok(stream_with_termination(
17115                stream,
17116                SubscriberStreamSource::PubSubPendingHashes,
17117            ))
17118        }
17119
17120        #[cfg(not(feature = "reactive-ws"))]
17121        {
17122            Err(SubscriberError::Unsupported(
17123                "AlloySubscriber pubsub mode requires the reactive-ws feature",
17124            ))
17125        }
17126    }
17127
17128    async fn connect_pubsub_block_header_stream(
17129        &mut self,
17130    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
17131        #[cfg(feature = "reactive-ws")]
17132        {
17133            self.record_rpc(
17134                SubscriberRpcCause::StreamSubscription,
17135                SubscriberRpcMethod::EthSubscribe,
17136            );
17137            let subscription = self
17138                .provider
17139                .subscribe_blocks()
17140                .channel_size(self.config.max_batch_size.max(1))
17141                .await
17142                .map_err(provider_error)?;
17143            Ok(gap_observing_stream(
17144                subscription,
17145                SubscriberStreamSource::PubSubBlockHeaders,
17146                Arc::clone(&self.gap_counters),
17147                SubscriberEvent::BlockHeader,
17148            ))
17149        }
17150
17151        #[cfg(not(feature = "reactive-ws"))]
17152        {
17153            Err(SubscriberError::Unsupported(
17154                "AlloySubscriber pubsub mode requires the reactive-ws feature",
17155            ))
17156        }
17157    }
17158
17159    async fn connect_polling_log_stream(
17160        &mut self,
17161        filter: Filter,
17162    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
17163        #[cfg(feature = "reactive-polling")]
17164        {
17165            let source = SubscriberStreamSource::PollingLog {
17166                filter: filter.clone(),
17167            };
17168            self.record_rpc(
17169                SubscriberRpcCause::StreamSubscription,
17170                SubscriberRpcMethod::EthSubscribe,
17171            );
17172            let stream = self
17173                .provider
17174                .watch_logs(&filter)
17175                .await
17176                .map_err(provider_error)?
17177                .with_channel_size(self.config.max_batch_size.max(1))
17178                .into_stream()
17179                .map(SubscriberEvent::Logs);
17180            Ok(stream_with_termination(stream, source))
17181        }
17182
17183        #[cfg(not(feature = "reactive-polling"))]
17184        {
17185            let _ = filter;
17186            Err(SubscriberError::Unsupported(
17187                "AlloySubscriber polling mode requires the reactive-polling feature",
17188            ))
17189        }
17190    }
17191
17192    async fn connect_polling_pending_hash_stream(
17193        &mut self,
17194    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
17195        #[cfg(feature = "reactive-polling")]
17196        {
17197            self.record_rpc(
17198                SubscriberRpcCause::StreamSubscription,
17199                SubscriberRpcMethod::EthSubscribe,
17200            );
17201            let stream = self
17202                .provider
17203                .watch_pending_transactions()
17204                .await
17205                .map_err(provider_error)?
17206                .with_channel_size(self.config.max_batch_size.max(1))
17207                .into_stream()
17208                .map(SubscriberEvent::PendingHashes);
17209            Ok(stream_with_termination(
17210                stream,
17211                SubscriberStreamSource::PollingPendingHashes,
17212            ))
17213        }
17214
17215        #[cfg(not(feature = "reactive-polling"))]
17216        {
17217            Err(SubscriberError::Unsupported(
17218                "AlloySubscriber polling mode requires the reactive-polling feature",
17219            ))
17220        }
17221    }
17222
17223    async fn next_event(&mut self) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
17224        loop {
17225            let ready = match &mut self.state {
17226                AlloySubscriberState::Active(streams)
17227                    if !self.pending_flashblock_reconnects.is_empty() =>
17228                {
17229                    let stream_event = Box::pin(streams.next());
17230                    let reconnect = Box::pin(self.pending_flashblock_reconnects.next());
17231                    match select(reconnect, stream_event).await {
17232                        Either::Left((reconnect, pending_event)) => {
17233                            drop(pending_event);
17234                            let Some((source, result)) = reconnect else {
17235                                continue;
17236                            };
17237                            SubscriberReady::FlashblockReconnect(source, result)
17238                        }
17239                        Either::Right((event, pending_reconnect)) => {
17240                            drop(pending_reconnect);
17241                            SubscriberReady::Event(event)
17242                        }
17243                    }
17244                }
17245                AlloySubscriberState::Active(streams) => {
17246                    SubscriberReady::Event(streams.next().await)
17247                }
17248                AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty
17249                    if !self.pending_flashblock_reconnects.is_empty() =>
17250                {
17251                    let Some((source, result)) = self.pending_flashblock_reconnects.next().await
17252                    else {
17253                        continue;
17254                    };
17255                    SubscriberReady::FlashblockReconnect(source, result)
17256                }
17257                AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
17258                    return Ok(None);
17259                }
17260            };
17261
17262            let event = match ready {
17263                SubscriberReady::Event(event) => event,
17264                SubscriberReady::FlashblockReconnect(source, result) => {
17265                    self.pending_flashblock_reconnect_sources
17266                        .retain(|pending| !pending.same_key(&source));
17267                    match result {
17268                        Ok(stream) => {
17269                            self.install_source_stream(source, stream);
17270                        }
17271                        Err(error)
17272                            if self.config.preconfirmations == PreconfirmationMode::Preferred =>
17273                        {
17274                            tracing::warn!(
17275                                stream = source.label(),
17276                                error = %error,
17277                                "Flashblocks reconnect window exhausted; canonical delivery remains active"
17278                            );
17279                            self.reschedule_preferred_flashblock(source);
17280                        }
17281                        Err(error) => return Err(error),
17282                    }
17283                    continue;
17284                }
17285            };
17286
17287            let Some(event) = event else {
17288                return Err(SubscriberError::Provider(
17289                    "Alloy subscriber streams terminated before the subscriber was stopped"
17290                        .to_owned(),
17291                ));
17292            };
17293
17294            match event {
17295                SubscriberEvent::StreamTerminated(source) => {
17296                    if source.is_external_flashblocks() {
17297                        #[cfg(feature = "raw-flashblocks-json")]
17298                        {
17299                            self.external_flashblock_update_channel_opened = false;
17300                        }
17301                        if let AlloySubscriberState::Active(streams) = &mut self.state {
17302                            streams
17303                                .entries
17304                                .retain(|entry| !entry.source.is_external_flashblocks());
17305                            streams.normalize_next_index();
17306                        }
17307                        self.invalidate_preconfirmation_snapshot();
17308                        if self.config.preconfirmations == PreconfirmationMode::Required {
17309                            return Err(SubscriberError::Provider(
17310                                "required external Flashblock update channel closed".into(),
17311                            ));
17312                        }
17313                        return Ok(Some(SubscriberEvent::FlashblockInvalidated));
17314                    }
17315                    // Persist the missing-source intent before the first await.
17316                    // If a control command cancels this poll during reconnect,
17317                    // the next poll will reconcile the desired/live diff.
17318                    if source.is_flashblocks() {
17319                        self.invalidate_flashblock_generation();
17320                        return Ok(Some(SubscriberEvent::FlashblockInvalidated));
17321                    }
17322                    self.sources_dirty = true;
17323                    self.bump_stream_revision();
17324                    if let Some(backfill_event) = self.reconnect_source_stream(source).await? {
17325                        self.sources_dirty = false;
17326                        if let Some(backfill_event) =
17327                            self.normalize_flashblock_event(backfill_event).await?
17328                        {
17329                            self.verify_event_log_blocks(&backfill_event).await?;
17330                            return Ok(Some(backfill_event));
17331                        }
17332                    }
17333                    self.sources_dirty = false;
17334                }
17335                SubscriberEvent::StreamGap { source, gap } => {
17336                    if let Some(backfill_event) = self.recover_stream_gap(&source, gap).await? {
17337                        self.verify_event_log_blocks(&backfill_event).await?;
17338                        return Ok(Some(backfill_event));
17339                    }
17340                }
17341                event => {
17342                    let Some(event) = self.normalize_flashblock_event(event).await? else {
17343                        continue;
17344                    };
17345                    self.verify_event_log_blocks(&event).await?;
17346                    return Ok(Some(event));
17347                }
17348            }
17349        }
17350    }
17351
17352    fn invalidate_flashblock_generation(&mut self) {
17353        self.pending_records
17354            .retain(|record| record.scope != SubscriberInputScope::Preconfirmed);
17355        self.pending_preconfirmation_invalidation = true;
17356        self.reset_flashblock_tracking();
17357        if let Some(provider) = self.provider_ref.as_mut() {
17358            provider.generation = provider.generation.saturating_add(1);
17359        }
17360        if let AlloySubscriberState::Active(streams) = &mut self.state {
17361            streams
17362                .entries
17363                .retain(|entry| !entry.source.is_flashblocks());
17364            streams.normalize_next_index();
17365        }
17366        let reconnect_sources = self
17367            .stream_sources()
17368            .unwrap_or_default()
17369            .into_iter()
17370            .filter(SubscriberStreamSource::is_flashblocks)
17371            .collect::<Vec<_>>();
17372        self.pending_flashblock_reconnects.clear();
17373        self.pending_flashblock_reconnect_sources.clear();
17374        if self.config.preconfirmations == PreconfirmationMode::Required
17375            || self.config.reconnect.enabled
17376        {
17377            for source in reconnect_sources {
17378                self.schedule_flashblock_reconnect(source, self.config.reconnect.initial_delay);
17379            }
17380        }
17381        self.sources_dirty = false;
17382        self.bump_stream_revision();
17383    }
17384
17385    async fn normalize_flashblock_event(
17386        &mut self,
17387        event: SubscriberEvent<N>,
17388    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
17389        let event = match event {
17390            SubscriberEvent::BasePendingLog { source_id, log } => {
17391                SubscriberEvent::BasePendingLogTimed {
17392                    source_id,
17393                    log,
17394                    timing: FlashblockIngressTiming::new(Instant::now()),
17395                }
17396            }
17397            SubscriberEvent::BaseFlashblock(payload) => SubscriberEvent::BaseFlashblockTimed {
17398                payload,
17399                timing: FlashblockIngressTiming::new(Instant::now()),
17400            },
17401            SubscriberEvent::OpFlashblockTick => {
17402                SubscriberEvent::OpFlashblockTickTimed(FlashblockIngressTiming::new(Instant::now()))
17403            }
17404            event => event,
17405        };
17406        match event {
17407            #[cfg(feature = "raw-flashblocks-json")]
17408            SubscriberEvent::ExternalFlashblockUpdate(queued) => {
17409                let provider = queued.update.provider().clone();
17410                match self.ingest_flashblock_update_with_ingress(queued.update, queued.timing) {
17411                    Ok(()) => {
17412                        let _ = queued.acknowledgement.send(Ok(()));
17413                        Ok(Some(SubscriberEvent::FlashblockObserved))
17414                    }
17415                    Err(error)
17416                        if self.config.preconfirmations == PreconfirmationMode::Preferred =>
17417                    {
17418                        let recoverable_capacity =
17419                            matches!(error, SubscriberError::ResourceExhausted(_));
17420                        if !recoverable_capacity
17421                            && let Some(configured) = self.external_flashblocks_provider.as_mut()
17422                            && configured.endpoint == provider.endpoint
17423                        {
17424                            self.rejected_external_flashblock_generation = Some(
17425                                self.rejected_external_flashblock_generation
17426                                    .map_or(provider.generation, |rejected| {
17427                                        rejected.max(provider.generation)
17428                                    }),
17429                            );
17430                            configured.generation = configured
17431                                .generation
17432                                .max(provider.generation.saturating_add(1));
17433                        }
17434                        self.invalidate_preconfirmation_snapshot();
17435                        self.last_external_flashblock_snapshot = None;
17436                        let _ = queued
17437                            .acknowledgement
17438                            .send(Err(FlashblockUpdateChannelError::Rejected));
17439                        tracing::warn!(
17440                            provider = %provider.endpoint,
17441                            generation = provider.generation,
17442                            error = %error,
17443                            "external Flashblock update rejected; canonical delivery remains active"
17444                        );
17445                        Ok(Some(SubscriberEvent::FlashblockInvalidated))
17446                    }
17447                    Err(error) => {
17448                        let _ = queued
17449                            .acknowledgement
17450                            .send(Err(FlashblockUpdateChannelError::Rejected));
17451                        Err(error)
17452                    }
17453                }
17454            }
17455            SubscriberEvent::BasePendingLogTimed {
17456                source_id,
17457                log,
17458                timing,
17459            } => {
17460                let block_number = log.block_number.ok_or_else(|| {
17461                    SubscriberError::Provider(
17462                        "pendingLogs item is missing its pending block number".into(),
17463                    )
17464                })?;
17465                let transaction_hash = log.transaction_hash.ok_or_else(|| {
17466                    SubscriberError::Provider(
17467                        "pendingLogs item is missing its transaction hash".into(),
17468                    )
17469                })?;
17470                let matching = self.latest_preconfirmation.as_ref().filter(|flashblock| {
17471                    flashblock.block_number == block_number
17472                        && flashblock.contains_transaction(&transaction_hash)
17473                });
17474                let Some(flashblock) = matching.cloned() else {
17475                    if self
17476                        .latest_preconfirmation
17477                        .as_ref()
17478                        .is_some_and(|latest| block_number < latest.block_number)
17479                    {
17480                        return Ok(None);
17481                    }
17482                    if self.unmatched_pending_logs.len() >= self.config.max_pending_records {
17483                        return Err(SubscriberError::ResourceExhausted(
17484                            "unmatched pendingLogs exceeded max_pending_records".into(),
17485                        ));
17486                    }
17487                    self.unmatched_pending_logs
17488                        .push_back((source_id, log, timing));
17489                    return Ok(None);
17490                };
17491                let logs = self.filter_preconfirmed_logs(&flashblock, vec![log])?;
17492                Ok(Some(if logs.is_empty() {
17493                    SubscriberEvent::FlashblockObserved
17494                } else {
17495                    SubscriberEvent::PreconfirmedLogs {
17496                        flashblock,
17497                        logs,
17498                        timing,
17499                    }
17500                }))
17501            }
17502            SubscriberEvent::BaseFlashblockTimed {
17503                payload,
17504                timing: source_timing,
17505            } => {
17506                let (flashblock, recover_pending_snapshot) =
17507                    self.accept_base_flashblock(payload)?;
17508                if std::mem::take(&mut self.sealed_block_pending_certification)
17509                    && let Some(header_event) =
17510                        self.certify_canonical_head_on_sealed_block().await?
17511                {
17512                    // Queued rather than returned: the flashblock event this
17513                    // arm is normalizing still has to reach the consumer.
17514                    self.enqueue_event(header_event);
17515                }
17516                let mut logs = Vec::new();
17517                let mut retained = VecDeque::new();
17518                let mut timing = source_timing;
17519                while let Some((source_id, log, log_timing)) =
17520                    self.unmatched_pending_logs.pop_front()
17521                {
17522                    let transaction_hash = log.transaction_hash;
17523                    if log.block_number == Some(flashblock.block_number)
17524                        && transaction_hash
17525                            .as_ref()
17526                            .is_some_and(|hash| flashblock.contains_transaction(hash))
17527                    {
17528                        let _ = source_id;
17529                        timing = timing.earliest(log_timing);
17530                        logs.push(log);
17531                    } else if log
17532                        .block_number
17533                        .is_some_and(|number| number >= flashblock.block_number)
17534                    {
17535                        retained.push_back((source_id, log, log_timing));
17536                    } else {
17537                        // A late log for an older speculative block can no
17538                        // longer be applied to the active cumulative branch.
17539                    }
17540                }
17541                self.unmatched_pending_logs = retained;
17542
17543                let indexed_recovery = recover_pending_snapshot.then(|| {
17544                    let payload_id = flashblock
17545                        .payload_id
17546                        .expect("indexed recovery carries a payload id");
17547                    let index = flashblock.index.expect("indexed recovery carries an index");
17548                    let last_diff = self
17549                        .base_flashblock_transactions
17550                        .as_ref()
17551                        .filter(|(known_payload, known_index, _, _)| {
17552                            *known_payload == payload_id && *known_index == index
17553                        })
17554                        .map(|(_, _, _, last_diff)| last_diff.clone())
17555                        .unwrap_or_default();
17556                    (payload_id, index, last_diff)
17557                });
17558                if recover_pending_snapshot {
17559                    if let Some(event) = self
17560                        .fetch_pending_flashblock_with_timing(indexed_recovery, timing)
17561                        .await
17562                        .map_err(PendingFlashblockPollError::into_subscriber)?
17563                    {
17564                        return Ok(Some(event));
17565                    }
17566                    self.invalidate_flashblock_generation();
17567                    return Ok(Some(SubscriberEvent::FlashblockInvalidated));
17568                }
17569                let logs = self.filter_preconfirmed_logs(&flashblock, logs)?;
17570                Ok(Some(if logs.is_empty() {
17571                    SubscriberEvent::FlashblockObserved
17572                } else {
17573                    SubscriberEvent::PreconfirmedLogs {
17574                        flashblock,
17575                        logs,
17576                        timing,
17577                    }
17578                }))
17579            }
17580            SubscriberEvent::OpFlashblockTickTimed(timing) => {
17581                self.poll_op_pending_flashblock(timing).await
17582            }
17583            SubscriberEvent::CanonicalHeadTick => {
17584                if self.canonical_head_certification_is_current() {
17585                    // The flashblock stream already drove a certification inside
17586                    // this window; polling again would buy nothing.
17587                    self.flashblocks_rpc_metrics.suppressed_canonical_head_polls = self
17588                        .flashblocks_rpc_metrics
17589                        .suppressed_canonical_head_polls
17590                        .saturating_add(1);
17591                    Ok(None)
17592                } else {
17593                    self.fetch_certified_canonical_head().await
17594                }
17595            }
17596            SubscriberEvent::PreconfirmedLogs {
17597                flashblock,
17598                logs,
17599                timing,
17600            } => {
17601                let logs = self.filter_preconfirmed_logs(&flashblock, logs)?;
17602                Ok(Some(if logs.is_empty() {
17603                    SubscriberEvent::FlashblockObserved
17604                } else {
17605                    SubscriberEvent::PreconfirmedLogs {
17606                        flashblock,
17607                        logs,
17608                        timing,
17609                    }
17610                }))
17611            }
17612            SubscriberEvent::FlashblockObserved => Ok(None),
17613            SubscriberEvent::BasePendingLog { .. }
17614            | SubscriberEvent::BaseFlashblock(_)
17615            | SubscriberEvent::OpFlashblockTick => unreachable!("normalized above"),
17616            event => Ok(Some(event)),
17617        }
17618    }
17619
17620    /// Whether a certification already happened inside the current poll window.
17621    fn canonical_head_certification_is_current(&self) -> bool {
17622        self.last_canonical_head_certification
17623            .is_some_and(|at| at.elapsed() < self.config.canonical_head_poll_interval)
17624    }
17625
17626    /// Certify the sealed canonical head because a new block just started.
17627    ///
17628    /// A `newFlashblocks` payload at index zero opens a block, which means the
17629    /// previous one sealed — the exact moment a certification is worth
17630    /// spending. Driving it from that signal instead of a blind timer costs one
17631    /// request per block rather than one per interval, and detects the head
17632    /// sooner.
17633    async fn certify_canonical_head_on_sealed_block(
17634        &mut self,
17635    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
17636        if !needs_header_block_stream(&self.interests) {
17637            return Ok(None);
17638        }
17639        if self.canonical_head_certification_is_current() {
17640            return Ok(None);
17641        }
17642        self.fetch_certified_canonical_head().await
17643    }
17644
17645    async fn fetch_certified_canonical_head(
17646        &mut self,
17647    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
17648        self.last_canonical_head_certification = Some(Instant::now());
17649        tokio::time::timeout(
17650            self.config.canonical_head_request_timeout,
17651            self.fetch_certified_canonical_head_inner(),
17652        )
17653        .await
17654        .map_err(|_| {
17655            SubscriberError::Provider(format!(
17656                "canonical head certification timed out after {:?}",
17657                self.config.canonical_head_request_timeout
17658            ))
17659        })?
17660    }
17661
17662    async fn fetch_certified_canonical_head_inner(
17663        &mut self,
17664    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
17665        if self.chain_id.and_then(flashblocks_adapter)
17666            == Some(FlashblocksAdapter::PendingStatePolling)
17667        {
17668            if !self.reserve_flashblock_rpc_methods(2) {
17669                return Ok(None);
17670            }
17671            self.flashblocks_rpc_metrics.pending_block_requests = self
17672                .flashblocks_rpc_metrics
17673                .pending_block_requests
17674                .saturating_add(1);
17675            let pending = self
17676                .fetch_op_pending_block()
17677                .await
17678                .map_err(PendingFlashblockPollError::into_subscriber)?
17679                .ok_or_else(|| {
17680                    SubscriberError::Provider(
17681                        "provider returned no OP pending block while certifying its parent".into(),
17682                    )
17683                })?;
17684            let header = self
17685                .certify_op_pending_parent(&pending)
17686                .await
17687                .map_err(PendingFlashblockPollError::into_subscriber)?;
17688            let certified = BlockRef {
17689                number: header.number(),
17690                hash: header.hash(),
17691                parent_hash: Some(header.parent_hash()),
17692                timestamp: Some(header.timestamp()),
17693            };
17694            if self.last_certified_canonical_head.as_ref() == Some(&certified) {
17695                return Ok(None);
17696            }
17697            self.last_certified_canonical_head = Some(certified);
17698            return Ok(Some(SubscriberEvent::BlockHeader(header)));
17699        }
17700        self.record_rpc(
17701            SubscriberRpcCause::CanonicalHeadCertification,
17702            SubscriberRpcMethod::EthGetBlockByNumber,
17703        );
17704        self.flashblocks_rpc_metrics.canonical_head_requests = self
17705            .flashblocks_rpc_metrics
17706            .canonical_head_requests
17707            .saturating_add(1);
17708        let block = self
17709            .provider
17710            .get_block_by_number(BlockNumberOrTag::Latest)
17711            .await
17712            .map_err(provider_error)?
17713            .ok_or_else(|| {
17714                SubscriberError::Provider(
17715                    "provider returned no latest block while certifying canonical head".into(),
17716                )
17717            })?;
17718        let header = block.header();
17719        if header.hash().is_zero() {
17720            return Err(SubscriberError::Provider(
17721                "provider returned a placeholder hash for the latest canonical head".into(),
17722            ));
17723        }
17724        let certified = BlockRef {
17725            number: header.number(),
17726            hash: header.hash(),
17727            parent_hash: Some(header.parent_hash()),
17728            timestamp: Some(header.timestamp()),
17729        };
17730        if self.last_certified_canonical_head.as_ref() == Some(&certified) {
17731            return Ok(None);
17732        }
17733        self.last_certified_canonical_head = Some(certified);
17734        Ok(Some(SubscriberEvent::BlockHeader(header.clone())))
17735    }
17736
17737    fn accept_base_flashblock(
17738        &mut self,
17739        payload: BaseFlashblockWirePayload,
17740    ) -> Result<(FlashblockRef, bool), SubscriberError> {
17741        let provider = self.provider_ref.clone().ok_or({
17742            SubscriberError::InvalidConfig(
17743                "Flashblocks require a stable provider ref from a pinned provider lease",
17744            )
17745        })?;
17746
17747        let (flashblock, recover_pending_snapshot) = match payload {
17748            BaseFlashblockWirePayload::Indexed(payload) => {
17749                if payload.index == 0 {
17750                    let base = payload.base.clone().ok_or_else(|| {
17751                        SubscriberError::Provider(
17752                            "indexed newFlashblocks item zero omitted its base header".into(),
17753                        )
17754                    })?;
17755                    // A new payload id at index zero opens a block, so the
17756                    // previous one just sealed. Certifying on that signal is
17757                    // what lets the interval timer stop guessing.
17758                    if self
17759                        .base_flashblock_header
17760                        .as_ref()
17761                        .is_none_or(|(known, _)| *known != payload.payload_id)
17762                    {
17763                        self.sealed_block_pending_certification = true;
17764                    }
17765                    self.base_flashblock_header = Some((payload.payload_id, base));
17766                }
17767
17768                let base = self
17769                    .base_flashblock_header
17770                    .as_ref()
17771                    .filter(|(payload_id, _)| *payload_id == payload.payload_id)
17772                    .map(|(_, base)| base);
17773                let block_number = base.map(|base| base.block_number).or_else(|| {
17774                    payload
17775                        .metadata
17776                        .as_ref()
17777                        .map(|metadata| metadata.block_number)
17778                });
17779                let block_number = block_number.ok_or_else(|| {
17780                    SubscriberError::Provider(
17781                        "indexed newFlashblocks payload omitted both base and metadata block number"
17782                            .into(),
17783                    )
17784                })?;
17785                let diff_transactions = flashblock_transaction_hashes(&payload.diff.transactions)?;
17786                let transaction_hashes = match self.base_flashblock_transactions.as_mut() {
17787                    Some((known_payload, known_index, transactions, last_diff))
17788                        if *known_payload == payload.payload_id =>
17789                    {
17790                        if payload.index < *known_index {
17791                            return self
17792                                .latest_preconfirmation
17793                                .clone()
17794                                .map(|flashblock| (flashblock, false))
17795                                .ok_or_else(|| {
17796                                    SubscriberError::Provider(
17797                                        "regressive indexed Flashblock arrived without an active snapshot"
17798                                            .into(),
17799                                    )
17800                                });
17801                        }
17802                        if payload.index == *known_index {
17803                            if *last_diff != diff_transactions {
17804                                return Err(SubscriberError::Provider(
17805                                    "conflicting duplicate indexed Flashblock payload".into(),
17806                                ));
17807                            }
17808                        } else {
17809                            if diff_transactions
17810                                .iter()
17811                                .any(|hash| transactions.contains(hash))
17812                            {
17813                                return Err(SubscriberError::Provider(
17814                                    "indexed Flashblock repeated a transaction from an earlier diff"
17815                                        .into(),
17816                                ));
17817                            }
17818                            transactions.extend(diff_transactions.iter().copied());
17819                            *known_index = payload.index;
17820                            *last_diff = diff_transactions;
17821                        }
17822                        transactions.clone()
17823                    }
17824                    _ => {
17825                        self.base_flashblock_transactions = Some((
17826                            payload.payload_id,
17827                            payload.index,
17828                            diff_transactions.clone(),
17829                            diff_transactions.clone(),
17830                        ));
17831                        diff_transactions
17832                    }
17833                };
17834                let partial_block_hash = non_placeholder_hash(payload.diff.block_hash);
17835                let transactions_root = payload
17836                    .diff
17837                    .transactions_root
17838                    .and_then(non_placeholder_hash);
17839                let parent_hash = base.and_then(|base| non_placeholder_hash(base.parent_hash));
17840                let state_root = non_placeholder_hash(payload.diff.state_root);
17841                let timestamp = base.map(|base| base.timestamp);
17842                let base_fee_per_gas = base.and_then(|base| base.base_fee_per_gas);
17843                let beneficiary = base.and_then(|base| base.beneficiary);
17844                let prevrandao = base
17845                    .and_then(|base| base.prevrandao)
17846                    .and_then(non_placeholder_hash);
17847                let gas_limit = base.and_then(|base| base.gas_limit);
17848                let content_hash = flashblock_content_hash(FlashblockContentCommitment {
17849                    provider: &provider,
17850                    payload_id: Some(payload.payload_id),
17851                    index: Some(payload.index),
17852                    block_number,
17853                    partial_block_hash,
17854                    parent_hash,
17855                    state_root,
17856                    transactions_root,
17857                    transaction_hashes: &transaction_hashes,
17858                    timestamp,
17859                    base_fee_per_gas,
17860                    beneficiary,
17861                    prevrandao,
17862                    gas_limit,
17863                });
17864                let flashblock = FlashblockRef {
17865                    provider,
17866                    payload_id: Some(payload.payload_id),
17867                    index: Some(payload.index),
17868                    block_number,
17869                    content_hash,
17870                    partial_block_hash,
17871                    parent_hash,
17872                    state_root,
17873                    transactions_root,
17874                    transaction_hashes,
17875                    timestamp,
17876                    base_fee_per_gas,
17877                    beneficiary,
17878                    prevrandao,
17879                    gas_limit,
17880                };
17881                if let Some(previous) = self.latest_preconfirmation.as_ref()
17882                    && previous.same_payload(&flashblock)
17883                    && previous.index == flashblock.index
17884                    && previous.content_hash != flashblock.content_hash
17885                {
17886                    return Err(SubscriberError::Provider(
17887                        "conflicting duplicate indexed Flashblock content".into(),
17888                    ));
17889                }
17890                let recover = match self.latest_preconfirmation.as_ref() {
17891                    Some(previous) if previous.same_payload(&flashblock) => {
17892                        if let (Some(previous), Some(current)) = (previous.index, flashblock.index)
17893                        {
17894                            if current < previous {
17895                                return Ok((flashblock, false));
17896                            }
17897                            current > previous.saturating_add(1)
17898                        } else {
17899                            false
17900                        }
17901                    }
17902                    Some(_) => payload.index != 0,
17903                    None => payload.index != 0,
17904                };
17905                (flashblock, recover)
17906            }
17907            BaseFlashblockWirePayload::Block(payload) => {
17908                let transaction_hashes = flashblock_transaction_hashes(&payload.transactions)?;
17909                let parent_hash = non_placeholder_hash(payload.parent_hash);
17910                let state_root = non_placeholder_hash(payload.state_root);
17911                let transactions_root = payload.transactions_root.and_then(non_placeholder_hash);
17912                let partial_block_hash = non_placeholder_hash(payload.hash);
17913                let prevrandao = payload.mix_hash.and_then(non_placeholder_hash);
17914                let content_hash = flashblock_content_hash(FlashblockContentCommitment {
17915                    provider: &provider,
17916                    payload_id: None,
17917                    index: None,
17918                    block_number: payload.number,
17919                    partial_block_hash,
17920                    parent_hash,
17921                    state_root,
17922                    transactions_root,
17923                    transaction_hashes: &transaction_hashes,
17924                    timestamp: Some(payload.timestamp),
17925                    base_fee_per_gas: payload.base_fee_per_gas,
17926                    beneficiary: payload.miner,
17927                    prevrandao,
17928                    gas_limit: payload.gas_limit,
17929                });
17930                let flashblock = FlashblockRef {
17931                    provider,
17932                    payload_id: None,
17933                    index: None,
17934                    block_number: payload.number,
17935                    content_hash,
17936                    partial_block_hash,
17937                    parent_hash,
17938                    state_root,
17939                    transactions_root,
17940                    transaction_hashes,
17941                    timestamp: Some(payload.timestamp),
17942                    base_fee_per_gas: payload.base_fee_per_gas,
17943                    beneficiary: payload.miner,
17944                    prevrandao,
17945                    gas_limit: payload.gas_limit,
17946                };
17947                if let Some(previous) = self.latest_preconfirmation.as_ref()
17948                    && flashblock.same_payload(previous)
17949                    && flashblock.content_hash != previous.content_hash
17950                    && !flashblock.is_cumulative_successor_of(previous)
17951                {
17952                    return Err(SubscriberError::Provider(
17953                        "cumulative Flashblock transaction membership is non-monotonic".into(),
17954                    ));
17955                }
17956                (flashblock, false)
17957            }
17958        };
17959        Ok((flashblock, recover_pending_snapshot))
17960    }
17961
17962    async fn poll_op_pending_flashblock(
17963        &mut self,
17964        timing: FlashblockIngressTiming,
17965    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
17966        match self
17967            .fetch_pending_flashblock_with_timing(None, timing)
17968            .await
17969        {
17970            Ok(event) => {
17971                self.consecutive_flashblock_poll_failures = 0;
17972                Ok(event)
17973            }
17974            Err(PendingFlashblockPollError::Request(error)) => {
17975                self.flashblocks_rpc_metrics.failed_requests = self
17976                    .flashblocks_rpc_metrics
17977                    .failed_requests
17978                    .saturating_add(1);
17979                self.consecutive_flashblock_poll_failures =
17980                    self.consecutive_flashblock_poll_failures.saturating_add(1);
17981                if self.consecutive_flashblock_poll_failures
17982                    >= self.config.max_consecutive_flashblock_poll_failures
17983                {
17984                    return Err(error);
17985                }
17986                tracing::warn!(
17987                    consecutive_failures = self.consecutive_flashblock_poll_failures,
17988                    failure_limit = self.config.max_consecutive_flashblock_poll_failures,
17989                    error = %error,
17990                    "Optimism pending-state Flashblocks request failed; retrying on the next tick"
17991                );
17992                Ok(None)
17993            }
17994            Err(PendingFlashblockPollError::Integrity(error)) => Err(error),
17995        }
17996    }
17997
17998    #[cfg(test)]
17999    async fn fetch_pending_flashblock(
18000        &mut self,
18001        indexed_recovery: Option<(FixedBytes<8>, u64, Vec<B256>)>,
18002    ) -> Result<Option<SubscriberEvent<N>>, PendingFlashblockPollError> {
18003        self.fetch_pending_flashblock_with_timing(
18004            indexed_recovery,
18005            FlashblockIngressTiming::new(Instant::now()),
18006        )
18007        .await
18008    }
18009
18010    async fn fetch_pending_flashblock_with_timing(
18011        &mut self,
18012        indexed_recovery: Option<(FixedBytes<8>, u64, Vec<B256>)>,
18013        timing: FlashblockIngressTiming,
18014    ) -> Result<Option<SubscriberEvent<N>>, PendingFlashblockPollError> {
18015        let samples_pending_range = self.chain_id.and_then(flashblocks_adapter)
18016            == Some(FlashblocksAdapter::PendingStatePolling);
18017        if samples_pending_range {
18018            let fixed_methods = 2_usize.saturating_add(self.log_stream_filters().len());
18019            if !self.reserve_flashblock_rpc_methods(fixed_methods) {
18020                return Ok(None);
18021            }
18022        }
18023        let state_provider = if samples_pending_range {
18024            self.flashblocks_state_provider
18025                .as_ref()
18026                .unwrap_or(&self.provider)
18027        } else {
18028            &self.provider
18029        };
18030        let latest = if samples_pending_range {
18031            None
18032        } else {
18033            self.record_rpc(
18034                SubscriberRpcCause::CanonicalHeadCertification,
18035                SubscriberRpcMethod::EthBlockNumber,
18036            );
18037            self.flashblocks_rpc_metrics.canonical_head_requests = self
18038                .flashblocks_rpc_metrics
18039                .canonical_head_requests
18040                .saturating_add(1);
18041            Some(
18042                state_provider
18043                    .get_block_number()
18044                    .await
18045                    .map_err(pending_flashblock_request_error)?,
18046            )
18047        };
18048        self.flashblocks_rpc_metrics.pending_block_requests = self
18049            .flashblocks_rpc_metrics
18050            .pending_block_requests
18051            .saturating_add(1);
18052        let pending_block = if samples_pending_range {
18053            self.fetch_op_pending_block().await?
18054        } else {
18055            self.record_rpc(
18056                SubscriberRpcCause::PendingStateSample,
18057                SubscriberRpcMethod::EthGetBlockByNumber,
18058            );
18059            self.provider
18060                .get_block_by_number(BlockNumberOrTag::Pending)
18061                .await
18062                .map_err(pending_flashblock_request_error)?
18063        };
18064        let Some(block) = pending_block else {
18065            if self.config.preconfirmations == PreconfirmationMode::Required {
18066                return Err(PendingFlashblockPollError::Request(
18067                    SubscriberError::Provider(
18068                        "Flashblocks provider returned no pending block".into(),
18069                    ),
18070                ));
18071            }
18072            return Ok(None);
18073        };
18074        let latest = if samples_pending_range {
18075            self.certify_op_pending_parent(&block).await?.number()
18076        } else {
18077            latest.expect("non-OP pending recovery fetched a canonical height")
18078        };
18079        let header = block.header();
18080        if header.number() <= latest {
18081            return Ok(None);
18082        }
18083
18084        let provider = self.provider_ref.clone().ok_or({
18085            PendingFlashblockPollError::Integrity(SubscriberError::InvalidConfig(
18086                "Flashblocks require a stable provider ref from a pinned provider lease",
18087            ))
18088        })?;
18089        let parent_hash = Some(header.parent_hash());
18090        let transaction_hashes = if let Some(hashes) = block.transactions().as_hashes() {
18091            hashes.to_vec()
18092        } else if let Some(transactions) = block.transactions().as_transactions() {
18093            transactions
18094                .iter()
18095                .map(|transaction| transaction.tx_hash())
18096                .collect()
18097        } else {
18098            Vec::new()
18099        };
18100        let state_root = non_placeholder_hash(header.state_root());
18101        let transactions_root = non_placeholder_hash(header.transactions_root());
18102        let partial_block_hash = non_placeholder_hash(header.hash());
18103        let prevrandao = header.mix_hash().and_then(non_placeholder_hash);
18104        let content_hash = flashblock_content_hash(FlashblockContentCommitment {
18105            provider: &provider,
18106            payload_id: None,
18107            index: None,
18108            block_number: header.number(),
18109            partial_block_hash,
18110            parent_hash,
18111            state_root,
18112            transactions_root,
18113            transaction_hashes: &transaction_hashes,
18114            timestamp: Some(header.timestamp()),
18115            base_fee_per_gas: header.base_fee_per_gas(),
18116            beneficiary: Some(header.beneficiary()),
18117            prevrandao,
18118            gas_limit: Some(header.gas_limit()),
18119        });
18120        let flashblock = FlashblockRef {
18121            provider,
18122            payload_id: None,
18123            index: None,
18124            block_number: header.number(),
18125            content_hash,
18126            partial_block_hash,
18127            parent_hash,
18128            state_root,
18129            transactions_root,
18130            transaction_hashes,
18131            timestamp: Some(header.timestamp()),
18132            base_fee_per_gas: header.base_fee_per_gas(),
18133            beneficiary: Some(header.beneficiary()),
18134            prevrandao,
18135            gas_limit: Some(header.gas_limit()),
18136        };
18137        if samples_pending_range
18138            && self
18139                .latest_preconfirmation
18140                .as_ref()
18141                .is_some_and(|previous| !previous.same_payload(&flashblock))
18142        {
18143            // Revoke as soon as the sampled payload changes, before any
18144            // follow-up receipt await can fail or be cancelled.
18145            self.invalidate_preconfirmation_snapshot();
18146        }
18147        if let Some((payload_id, index, last_diff)) = indexed_recovery {
18148            self.base_flashblock_transactions = Some((
18149                payload_id,
18150                index,
18151                flashblock.transaction_hashes.clone(),
18152                last_diff,
18153            ));
18154        }
18155        let repeats_pending_snapshot = self
18156            .latest_preconfirmation
18157            .as_ref()
18158            .is_some_and(|previous| previous == &flashblock);
18159        if repeats_pending_snapshot && !samples_pending_range {
18160            return Ok(None);
18161        }
18162
18163        if let Some(previous) = self.latest_preconfirmation.as_ref()
18164            && flashblock.same_payload(previous)
18165            && !flashblock.is_cumulative_successor_of(previous)
18166        {
18167            if samples_pending_range {
18168                // OP pending-state reads are not atomic and paid endpoints can
18169                // briefly expose a shorter backend view. Never publish the
18170                // regression. Revoke the active overlay and require a fresh,
18171                // internally coherent sample on a later tick instead.
18172                self.invalidate_preconfirmation_snapshot();
18173                return Ok(Some(SubscriberEvent::FlashblockInvalidated));
18174            }
18175            return Err(PendingFlashblockPollError::Integrity(
18176                SubscriberError::Provider(
18177                    "sampled cumulative Flashblock transaction membership is non-monotonic".into(),
18178                ),
18179            ));
18180        }
18181
18182        let mut logs = self.fetch_pending_logs(flashblock.block_number).await?;
18183        if samples_pending_range {
18184            let (mut receipt_logs, completed_receipts, unavailable_receipts) =
18185                self.fetch_pending_transaction_receipts(&flashblock).await?;
18186            logs.append(&mut receipt_logs);
18187            logs.retain(|log| log.block_number == Some(flashblock.block_number));
18188            for log in &logs {
18189                let transaction_hash = log.transaction_hash.ok_or_else(|| {
18190                    PendingFlashblockPollError::Integrity(SubscriberError::Provider(
18191                        "pre-confirmed log is missing its transaction hash".into(),
18192                    ))
18193                })?;
18194                if !flashblock.contains_transaction(&transaction_hash) {
18195                    self.flashblocks_rpc_metrics.raced_samples =
18196                        self.flashblocks_rpc_metrics.raced_samples.saturating_add(1);
18197                    return Ok(None);
18198                }
18199            }
18200            let logs = self
18201                .filter_preconfirmed_logs(&flashblock, logs)
18202                .map_err(PendingFlashblockPollError::Integrity)?;
18203            self.preconfirmed_unavailable_receipts
18204                .extend(unavailable_receipts);
18205            for transaction_hash in &completed_receipts {
18206                self.preconfirmed_unavailable_receipts
18207                    .remove(transaction_hash);
18208            }
18209            self.preconfirmed_receipted_transactions
18210                .extend(completed_receipts);
18211            if repeats_pending_snapshot && logs.is_empty() {
18212                return Ok(None);
18213            }
18214            return Ok(Some(if logs.is_empty() {
18215                SubscriberEvent::FlashblockObserved
18216            } else {
18217                SubscriberEvent::PreconfirmedLogs {
18218                    flashblock,
18219                    logs,
18220                    timing,
18221                }
18222            }));
18223        }
18224        let logs = self
18225            .filter_preconfirmed_logs(&flashblock, logs)
18226            .map_err(PendingFlashblockPollError::Integrity)?;
18227        Ok(Some(if logs.is_empty() {
18228            SubscriberEvent::FlashblockObserved
18229        } else {
18230            SubscriberEvent::PreconfirmedLogs {
18231                flashblock,
18232                logs,
18233                timing,
18234            }
18235        }))
18236    }
18237
18238    async fn fetch_pending_logs(
18239        &mut self,
18240        pending_block_number: u64,
18241    ) -> Result<Vec<Log>, PendingFlashblockPollError> {
18242        let mut logs = Vec::new();
18243        let samples_pending_range = self.chain_id.and_then(flashblocks_adapter)
18244            == Some(FlashblocksAdapter::PendingStatePolling);
18245        let state_provider = if samples_pending_range {
18246            self.flashblocks_state_provider
18247                .as_ref()
18248                .unwrap_or(&self.provider)
18249        } else {
18250            &self.provider
18251        };
18252        for filter in self.log_stream_filters() {
18253            self.record_rpc(
18254                SubscriberRpcCause::PendingStateSample,
18255                SubscriberRpcMethod::EthGetLogs,
18256            );
18257            self.flashblocks_rpc_metrics.pending_log_requests = self
18258                .flashblocks_rpc_metrics
18259                .pending_log_requests
18260                .saturating_add(1);
18261            let filter = if samples_pending_range {
18262                filter
18263                    .from_block(pending_block_number)
18264                    .to_block(BlockNumberOrTag::Pending)
18265            } else {
18266                filter
18267                    .from_block(BlockNumberOrTag::Pending)
18268                    .to_block(BlockNumberOrTag::Pending)
18269            };
18270            logs.extend(
18271                state_provider
18272                    .get_logs(&filter)
18273                    .await
18274                    .map_err(pending_flashblock_request_error)?,
18275            );
18276        }
18277        if samples_pending_range {
18278            logs.retain(|log| log.block_number == Some(pending_block_number));
18279        }
18280        Ok(logs)
18281    }
18282
18283    async fn fetch_pending_transaction_receipts(
18284        &mut self,
18285        flashblock: &FlashblockRef,
18286    ) -> Result<(Vec<Log>, Vec<B256>, Vec<B256>), PendingFlashblockPollError> {
18287        let receipt_allowance = self.pending_receipt_request_allowance();
18288        let receipt_limit = self
18289            .config
18290            .max_pending_transaction_receipts_per_tick
18291            .min(receipt_allowance);
18292        if receipt_limit == 0 {
18293            return Ok((Vec::new(), Vec::new(), Vec::new()));
18294        }
18295        let mut transaction_hashes = Vec::with_capacity(receipt_limit);
18296        for transaction_hash in &flashblock.transaction_hashes {
18297            if !self
18298                .preconfirmed_receipted_transactions
18299                .contains(transaction_hash)
18300                && !self
18301                    .preconfirmed_unavailable_receipts
18302                    .contains(transaction_hash)
18303            {
18304                transaction_hashes.push(*transaction_hash);
18305                if transaction_hashes.len() == receipt_limit {
18306                    break;
18307                }
18308            }
18309        }
18310        if transaction_hashes.len() < receipt_limit {
18311            for transaction_hash in &flashblock.transaction_hashes {
18312                if self
18313                    .preconfirmed_unavailable_receipts
18314                    .contains(transaction_hash)
18315                {
18316                    transaction_hashes.push(*transaction_hash);
18317                    if transaction_hashes.len() == receipt_limit {
18318                        break;
18319                    }
18320                }
18321            }
18322        }
18323        if transaction_hashes.is_empty() {
18324            return Ok((Vec::new(), Vec::new(), Vec::new()));
18325        }
18326        let reserved = self.reserve_flashblock_rpc_methods(transaction_hashes.len());
18327        debug_assert!(reserved, "receipt allowance must remain reserved until use");
18328        if !reserved {
18329            return Ok((Vec::new(), Vec::new(), Vec::new()));
18330        }
18331        self.rpc_counters.record_many(
18332            SubscriberRpcCause::PendingStateSample,
18333            SubscriberRpcMethod::EthGetTransactionReceipt,
18334            transaction_hashes.len() as u64,
18335        );
18336        self.flashblocks_rpc_metrics.pending_receipt_requests = self
18337            .flashblocks_rpc_metrics
18338            .pending_receipt_requests
18339            .saturating_add(transaction_hashes.len() as u64);
18340        let state_provider = self
18341            .flashblocks_state_provider
18342            .as_ref()
18343            .unwrap_or(&self.provider);
18344        let client = state_provider.client();
18345        let mut batch = BatchRequest::new(client);
18346        let mut waiters = Vec::with_capacity(transaction_hashes.len());
18347        for transaction_hash in transaction_hashes {
18348            let waiter = batch
18349                .add_call::<_, serde_json::Value>("eth_getTransactionReceipt", &(transaction_hash,))
18350                .map_err(pending_flashblock_request_error)?;
18351            waiters.push((transaction_hash, waiter));
18352        }
18353        batch
18354            .send()
18355            .await
18356            .map_err(pending_flashblock_request_error)?;
18357        let mut logs = Vec::new();
18358        let mut completed = Vec::new();
18359        let mut unavailable = Vec::new();
18360        for (transaction_hash, waiter) in waiters {
18361            let value = waiter.await.map_err(pending_flashblock_request_error)?;
18362            if let Some(mut receipt_logs) =
18363                normalize_pending_transaction_receipt(transaction_hash, value)
18364                    .map_err(PendingFlashblockPollError::Integrity)?
18365            {
18366                self.flashblocks_rpc_metrics.pending_receipts_completed = self
18367                    .flashblocks_rpc_metrics
18368                    .pending_receipts_completed
18369                    .saturating_add(1);
18370                logs.append(&mut receipt_logs);
18371                completed.push(transaction_hash);
18372            } else {
18373                self.flashblocks_rpc_metrics.pending_receipts_unavailable = self
18374                    .flashblocks_rpc_metrics
18375                    .pending_receipts_unavailable
18376                    .saturating_add(1);
18377                unavailable.push(transaction_hash);
18378            }
18379        }
18380        Ok((logs, completed, unavailable))
18381    }
18382
18383    fn pending_receipt_request_allowance(&mut self) -> usize {
18384        self.prune_flashblock_rpc_request_times();
18385        let rolling_capacity = self
18386            .config
18387            .max_flashblock_rpc_requests_per_second
18388            .saturating_sub(self.flashblock_rpc_request_times.len());
18389        rolling_capacity.min(self.pending_receipt_requests_per_tick_capacity())
18390    }
18391
18392    fn pending_receipt_requests_per_tick_capacity(&self) -> usize {
18393        let interval_nanos = self.config.flashblock_poll_interval.as_nanos().max(1);
18394        let ticks_per_second = Duration::from_secs(1).as_nanos().div_ceil(interval_nanos);
18395        let ticks_per_second = usize::try_from(ticks_per_second).unwrap_or(usize::MAX);
18396        self.pending_receipt_requests_per_second_capacity()
18397            .checked_div(ticks_per_second)
18398            .unwrap_or(0)
18399    }
18400
18401    fn pending_receipt_requests_per_second_capacity(&self) -> usize {
18402        let interval_nanos = self.config.flashblock_poll_interval.as_nanos().max(1);
18403        let ticks_per_second = Duration::from_secs(1).as_nanos().div_ceil(interval_nanos);
18404        let ticks_per_second = usize::try_from(ticks_per_second).unwrap_or(usize::MAX);
18405        let fixed_methods_per_tick = 2_usize.saturating_add(self.log_stream_filters().len());
18406        let mut reserved_methods = ticks_per_second.saturating_mul(fixed_methods_per_tick);
18407        if needs_header_block_stream(&self.interests) {
18408            let canonical_interval_nanos =
18409                self.config.canonical_head_poll_interval.as_nanos().max(1);
18410            let canonical_ticks = Duration::from_secs(1)
18411                .as_nanos()
18412                .div_ceil(canonical_interval_nanos);
18413            let canonical_ticks = usize::try_from(canonical_ticks).unwrap_or(usize::MAX);
18414            reserved_methods = reserved_methods.saturating_add(canonical_ticks.saturating_mul(2));
18415        }
18416        self.config
18417            .max_flashblock_rpc_requests_per_second
18418            .saturating_sub(reserved_methods)
18419    }
18420
18421    fn reserve_flashblock_rpc_methods(&mut self, methods: usize) -> bool {
18422        self.prune_flashblock_rpc_request_times();
18423        if self
18424            .flashblock_rpc_request_times
18425            .len()
18426            .saturating_add(methods)
18427            > self.config.max_flashblock_rpc_requests_per_second
18428        {
18429            return false;
18430        }
18431        let now = Instant::now();
18432        for _ in 0..methods {
18433            self.flashblock_rpc_request_times.push_back(now);
18434        }
18435        true
18436    }
18437
18438    fn prune_flashblock_rpc_request_times(&mut self) {
18439        let now = Instant::now();
18440        while self
18441            .flashblock_rpc_request_times
18442            .front()
18443            .is_some_and(|requested| now.duration_since(*requested) >= Duration::from_secs(1))
18444        {
18445            self.flashblock_rpc_request_times.pop_front();
18446        }
18447    }
18448
18449    fn filter_preconfirmed_logs(
18450        &mut self,
18451        flashblock: &FlashblockRef,
18452        mut logs: Vec<Log>,
18453    ) -> Result<Vec<Log>, SubscriberError> {
18454        let samples_pending_range = self.chain_id.and_then(flashblocks_adapter)
18455            == Some(FlashblocksAdapter::PendingStatePolling);
18456        if self
18457            .latest_preconfirmation
18458            .as_ref()
18459            .is_some_and(|previous| !previous.same_payload(flashblock))
18460        {
18461            // A new payload revokes the previous overlay even when none of the
18462            // caller's log filters matched in the replacement. Otherwise a
18463            // quiet block could leave stale speculative signing authority
18464            // active until an unrelated canonical pool event arrived.
18465            self.invalidate_preconfirmation_snapshot();
18466        }
18467        if self
18468            .latest_preconfirmation
18469            .as_ref()
18470            .is_none_or(|previous| !previous.same_payload(flashblock))
18471        {
18472            self.preconfirmed_seen_logs.clear();
18473        }
18474        if let Some(previous) = self.latest_preconfirmation.as_ref()
18475            && previous.same_payload(flashblock)
18476            && let (Some(previous_index), Some(current_index)) = (previous.index, flashblock.index)
18477            && current_index < previous_index
18478        {
18479            return Ok(Vec::new());
18480        }
18481        self.latest_preconfirmation = Some(flashblock.clone());
18482
18483        logs.sort_by_key(|log| (log.transaction_index.unwrap_or(u64::MAX), log.log_index));
18484        let mut filtered = Vec::new();
18485        for mut log in logs {
18486            if log.removed || log.block_number != Some(flashblock.block_number) {
18487                return Err(SubscriberError::Provider(
18488                    "pre-confirmed log disagrees with its Flashblock snapshot".into(),
18489                ));
18490            }
18491            let transaction_hash = log.transaction_hash.ok_or_else(|| {
18492                SubscriberError::Provider(
18493                    "pre-confirmed log is missing its transaction hash".into(),
18494                )
18495            })?;
18496            let log_index = log.log_index.ok_or_else(|| {
18497                SubscriberError::Provider("pre-confirmed log is missing its log index".into())
18498            })?;
18499            let transaction_index =
18500                flashblock
18501                    .transaction_index(&transaction_hash)
18502                    .ok_or_else(|| {
18503                        SubscriberError::Provider(
18504                        "pre-confirmed log transaction is absent from the cumulative Flashblock"
18505                            .into(),
18506                    )
18507                    })?;
18508            if log
18509                .transaction_index
18510                .is_some_and(|reported| reported != transaction_index)
18511            {
18512                return Err(SubscriberError::Provider(
18513                    "pre-confirmed log transaction index disagrees with cumulative membership"
18514                        .into(),
18515                ));
18516            }
18517            let reported_hash = log.block_hash.and_then(non_placeholder_hash);
18518            if !samples_pending_range
18519                && let Some(reported) = reported_hash
18520                && reported != flashblock.content_hash
18521                && flashblock
18522                    .partial_block_hash
18523                    .is_some_and(|expected| reported != expected)
18524            {
18525                return Err(SubscriberError::Provider(
18526                    "pre-confirmed log partial block hash disagrees with its Flashblock snapshot"
18527                        .into(),
18528                ));
18529            }
18530            log.block_hash = Some(flashblock.content_hash);
18531            log.block_timestamp = flashblock.timestamp.or(log.block_timestamp);
18532            log.transaction_index = Some(transaction_index);
18533            if self
18534                .preconfirmed_seen_logs
18535                .insert((transaction_hash, log_index))
18536                && log_matches_any_interest(&log, &self.interests)
18537            {
18538                filtered.push(log);
18539            }
18540        }
18541        Ok(filtered)
18542    }
18543
18544    async fn verify_event_log_blocks(
18545        &mut self,
18546        event: &SubscriberEvent<N>,
18547    ) -> Result<(), SubscriberError> {
18548        if !self.config.verify_log_block_context {
18549            return Ok(());
18550        }
18551        match event {
18552            SubscriberEvent::Log { log, .. } => self.verify_log_block_context(log).await,
18553            SubscriberEvent::BackfilledLogs { logs, .. } | SubscriberEvent::Logs(logs) => {
18554                for log in logs {
18555                    self.verify_log_block_context(log).await?;
18556                }
18557                Ok(())
18558            }
18559            #[cfg(feature = "raw-flashblocks-json")]
18560            SubscriberEvent::ExternalFlashblockUpdate(_) => Ok(()),
18561            SubscriberEvent::BlockHeader(_)
18562            | SubscriberEvent::PendingHash(_)
18563            | SubscriberEvent::PendingHashes(_)
18564            | SubscriberEvent::BasePendingLog { .. }
18565            | SubscriberEvent::BasePendingLogTimed { .. }
18566            | SubscriberEvent::BaseFlashblock { .. }
18567            | SubscriberEvent::BaseFlashblockTimed { .. }
18568            | SubscriberEvent::OpFlashblockTick
18569            | SubscriberEvent::OpFlashblockTickTimed(_)
18570            | SubscriberEvent::CanonicalHeadTick
18571            | SubscriberEvent::PreconfirmedLogs { .. }
18572            | SubscriberEvent::FlashblockInvalidated
18573            | SubscriberEvent::FlashblockObserved
18574            | SubscriberEvent::StreamTerminated(_)
18575            | SubscriberEvent::StreamGap { .. } => Ok(()),
18576        }
18577    }
18578
18579    async fn verify_log_block_context(&mut self, log: &Log) -> Result<(), SubscriberError> {
18580        if log.removed {
18581            return Ok(());
18582        }
18583        let number = log.block_number.ok_or_else(|| {
18584            SubscriberError::Provider(
18585                "canonical log is missing its block number during context verification".into(),
18586            )
18587        })?;
18588        let hash = log.block_hash.ok_or_else(|| {
18589            SubscriberError::Provider(
18590                "canonical log is missing its block hash during context verification".into(),
18591            )
18592        })?;
18593        let key = (number, hash);
18594        if self.verified_log_blocks.contains_key(&key) {
18595            return Ok(());
18596        }
18597        let provider = self
18598            .log_verification_provider
18599            .as_ref()
18600            .unwrap_or(&self.provider);
18601        self.rpc_counters.record(
18602            SubscriberRpcCause::LogBlockVerification,
18603            SubscriberRpcMethod::EthGetBlockByNumber,
18604        );
18605        let block = provider
18606            .get_block_by_number(BlockNumberOrTag::Number(number))
18607            .await
18608            .map_err(provider_error)?
18609            .ok_or_else(|| {
18610                SubscriberError::Provider(format!(
18611                    "canonical log block {number} is unavailable during context verification"
18612                ))
18613            })?;
18614        let header = block.header();
18615        let verified = BlockRef {
18616            number: header.number(),
18617            hash: header.hash(),
18618            parent_hash: Some(header.parent_hash()),
18619            timestamp: Some(header.timestamp()),
18620        };
18621        if verified.number != number
18622            || verified.hash != hash
18623            || log
18624                .block_timestamp
18625                .is_some_and(|timestamp| verified.timestamp != Some(timestamp))
18626        {
18627            return Err(SubscriberError::Provider(format!(
18628                "canonical log block {number}:{hash:?} disagrees with the provider's current canonical identity"
18629            )));
18630        }
18631        self.verified_log_blocks.insert(key, verified);
18632        self.verified_log_block_order.push_back(key);
18633        let capacity = self.config.reconnect.dedupe_window.max(1);
18634        while self.verified_log_block_order.len() > capacity {
18635            if let Some(evicted) = self.verified_log_block_order.pop_front() {
18636                self.verified_log_blocks.remove(&evicted);
18637            }
18638        }
18639        Ok(())
18640    }
18641
18642    fn enqueue_event(&mut self, event: SubscriberEvent<N>) {
18643        self.enqueue_event_with_excluded_owners(event, None);
18644    }
18645
18646    fn buffer_reconcile_event_for_owners(
18647        &mut self,
18648        event: &SubscriberEvent<N>,
18649        target_epochs: &HashSet<SubscriberOwnerEpoch>,
18650    ) {
18651        match event {
18652            SubscriberEvent::Log { log, .. } => {
18653                self.buffer_reconcile_log_for_owners(log, InputSource::Subscription, target_epochs)
18654            }
18655            SubscriberEvent::BackfilledLogs { logs, .. } => {
18656                for log in logs {
18657                    self.buffer_reconcile_log_for_owners(log, InputSource::Backfill, target_epochs);
18658                }
18659            }
18660            SubscriberEvent::Logs(logs) => {
18661                for log in logs {
18662                    self.buffer_reconcile_log_for_owners(log, InputSource::Poll, target_epochs);
18663                }
18664            }
18665            #[cfg(feature = "raw-flashblocks-json")]
18666            SubscriberEvent::ExternalFlashblockUpdate(_) => {}
18667            SubscriberEvent::BlockHeader(_)
18668            | SubscriberEvent::PendingHash(_)
18669            | SubscriberEvent::PendingHashes(_)
18670            | SubscriberEvent::BasePendingLog { .. }
18671            | SubscriberEvent::BasePendingLogTimed { .. }
18672            | SubscriberEvent::BaseFlashblock { .. }
18673            | SubscriberEvent::BaseFlashblockTimed { .. }
18674            | SubscriberEvent::OpFlashblockTick
18675            | SubscriberEvent::OpFlashblockTickTimed(_)
18676            | SubscriberEvent::CanonicalHeadTick
18677            | SubscriberEvent::PreconfirmedLogs { .. }
18678            | SubscriberEvent::FlashblockInvalidated
18679            | SubscriberEvent::FlashblockObserved
18680            | SubscriberEvent::StreamTerminated(_)
18681            | SubscriberEvent::StreamGap { .. } => {}
18682        }
18683    }
18684
18685    fn buffer_reconcile_log_for_owners(
18686        &mut self,
18687        log: &Log,
18688        source: InputSource,
18689        target_epochs: &HashSet<SubscriberOwnerEpoch>,
18690    ) {
18691        let record = self.with_chain_id(log_input_record(log.clone(), source));
18692        let owners = self
18693            .staged_owners_for_record(&record)
18694            .into_iter()
18695            .filter(|owner| target_epochs.contains(owner))
18696            .collect::<Vec<_>>();
18697        if !owners.is_empty() {
18698            self.push_pending_reconcile_record(BufferedSubscriberOwnerRecord { record, owners });
18699        }
18700    }
18701
18702    fn promote_reconcile_owner_records(&mut self, target_epochs: &HashSet<SubscriberOwnerEpoch>) {
18703        let mut retained = VecDeque::new();
18704        while let Some(mut buffered) = self.pending_reconcile_owner_records.pop_front() {
18705            let mut promoted = Vec::new();
18706            buffered.owners.retain(|owner| {
18707                if target_epochs.contains(owner) {
18708                    promoted.push(owner.clone());
18709                    false
18710                } else {
18711                    true
18712                }
18713            });
18714            if promoted.is_empty() {
18715                retained.push_back(buffered);
18716                continue;
18717            }
18718            let promoted_record = if buffered.owners.is_empty() {
18719                buffered.record
18720            } else {
18721                let record = buffered.record.clone();
18722                retained.push_back(buffered);
18723                record
18724            };
18725            self.enqueue_owner_record_for_owners_unmerged(promoted_record, promoted);
18726        }
18727        self.pending_reconcile_owner_records = retained;
18728    }
18729
18730    fn seed_reconciled_filter_anchors(
18731        &mut self,
18732        plans: &[SubscriberOwnerReconcilePlan<N>],
18733        through: u64,
18734    ) {
18735        for filter in plans.iter().flat_map(|plan| log_filters(&plan.interests)) {
18736            let Some(source_id) = self.log_source_ids.get(&filter).copied() else {
18737                continue;
18738            };
18739            let anchor = self
18740                .last_seen_log_blocks
18741                .entry(source_id)
18742                .or_insert(through);
18743            *anchor = (*anchor).max(through);
18744        }
18745    }
18746
18747    fn enqueue_event_excluding_owners(
18748        &mut self,
18749        event: SubscriberEvent<N>,
18750        excluded: &HashSet<SubscriberOwnerEpoch>,
18751    ) {
18752        self.enqueue_event_with_excluded_owners(event, Some(excluded));
18753    }
18754
18755    fn enqueue_event_with_excluded_owners(
18756        &mut self,
18757        event: SubscriberEvent<N>,
18758        excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
18759    ) {
18760        match event {
18761            SubscriberEvent::Log { source_id, log } => {
18762                if log_matches_any_interest(&log, &self.interests) {
18763                    let record = log_input_record(log, InputSource::Subscription);
18764                    self.note_log_block(source_id, &record);
18765                    self.enqueue_record_with_excluded_owners(record, excluded);
18766                }
18767            }
18768            SubscriberEvent::BackfilledLogs { source_id, logs } => {
18769                self.enqueue_backfilled_logs_with_excluded_owners(
18770                    logs,
18771                    Some(source_id),
18772                    None,
18773                    None,
18774                    excluded,
18775                );
18776            }
18777            SubscriberEvent::Logs(logs) => {
18778                for log in logs {
18779                    if log_matches_any_interest(&log, &self.interests) {
18780                        self.enqueue_record_with_excluded_owners(
18781                            log_input_record(log, InputSource::Poll),
18782                            excluded,
18783                        );
18784                    }
18785                }
18786            }
18787            SubscriberEvent::BlockHeader(header) => {
18788                if needs_header_block_stream(&self.interests) {
18789                    let record = block_header_input_record::<N>(header);
18790                    self.note_attestable_canonical_block(&record);
18791                    self.enqueue_record_with_excluded_owners(record, excluded);
18792                }
18793            }
18794            SubscriberEvent::PendingHash(hash) => {
18795                let record = pending_hash_input_record::<N>(hash, InputSource::Subscription);
18796                self.enqueue_record_with_excluded_owners(record, excluded);
18797            }
18798            SubscriberEvent::PendingHashes(hashes) => {
18799                for hash in hashes {
18800                    self.enqueue_record_with_excluded_owners(
18801                        pending_hash_input_record::<N>(hash, InputSource::Poll),
18802                        excluded,
18803                    );
18804                }
18805            }
18806            SubscriberEvent::PreconfirmedLogs {
18807                flashblock,
18808                logs,
18809                timing,
18810            } => {
18811                for log in logs {
18812                    let record = self
18813                        .with_chain_id(preconfirmed_log_input_record::<N>(log, flashblock.clone()));
18814                    self.push_pending_record(SubscriberInputRecord {
18815                        record,
18816                        scope: SubscriberInputScope::Preconfirmed,
18817                        preconfirmation_timing: Some(timing),
18818                    });
18819                }
18820            }
18821            SubscriberEvent::FlashblockInvalidated => {
18822                self.pending_preconfirmation_invalidation = true;
18823            }
18824            SubscriberEvent::BasePendingLog { .. }
18825            | SubscriberEvent::BasePendingLogTimed { .. }
18826            | SubscriberEvent::BaseFlashblock { .. }
18827            | SubscriberEvent::BaseFlashblockTimed { .. }
18828            | SubscriberEvent::OpFlashblockTick
18829            | SubscriberEvent::OpFlashblockTickTimed(_)
18830            | SubscriberEvent::CanonicalHeadTick
18831            | SubscriberEvent::FlashblockObserved => {}
18832            #[cfg(feature = "raw-flashblocks-json")]
18833            SubscriberEvent::ExternalFlashblockUpdate(_) => {}
18834            // `next_event` intercepts a gap and recovers it before delivery;
18835            // this arm keeps the classification exhaustive.
18836            SubscriberEvent::StreamTerminated(_) | SubscriberEvent::StreamGap { .. } => {}
18837        }
18838    }
18839
18840    fn enqueue_backfilled_logs(
18841        &mut self,
18842        logs: Vec<Log>,
18843        source_id: Option<usize>,
18844        owner: Option<&SubscriberOwnerEpoch>,
18845        range: Option<SubscriberBackfill>,
18846    ) {
18847        self.enqueue_backfilled_logs_with_excluded_owners(logs, source_id, owner, range, None);
18848    }
18849
18850    fn enqueue_backfilled_logs_with_excluded_owners(
18851        &mut self,
18852        logs: Vec<Log>,
18853        source_id: Option<usize>,
18854        owner: Option<&SubscriberOwnerEpoch>,
18855        range: Option<SubscriberBackfill>,
18856        excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
18857    ) {
18858        for log in logs {
18859            if range.as_ref().is_some_and(|range| {
18860                log.block_number.is_some_and(|block| {
18861                    block < range.start_block() || range.end_block().is_some_and(|end| block > end)
18862                })
18863            }) {
18864                continue;
18865            }
18866            let matches = match owner {
18867                Some(epoch) => self
18868                    .owned_interests
18869                    .iter()
18870                    .find(|entry| entry.epoch.as_ref() == Some(epoch))
18871                    .is_some_and(|entry| log_matches_any_interest(&log, &entry.interests)),
18872                None => log_matches_any_interest(&log, &self.interests),
18873            };
18874            if matches {
18875                let record = log_input_record(log, InputSource::Backfill);
18876                if let Some(epoch) = owner {
18877                    self.enqueue_owner_record(record, epoch.clone());
18878                } else {
18879                    if let Some(source_id) = source_id {
18880                        self.note_log_block(source_id, &record);
18881                    }
18882                    self.enqueue_record_with_excluded_owners(record, excluded);
18883                }
18884            }
18885        }
18886    }
18887
18888    fn enqueue_compat_owner_backfilled_logs(
18889        &mut self,
18890        logs: Vec<Log>,
18891        owner: &HandlerId,
18892        range: SubscriberBackfill,
18893    ) {
18894        let interests = self
18895            .owned_interests
18896            .iter()
18897            .find(|entry| {
18898                &entry.owner == owner
18899                    && entry.epoch.is_none()
18900                    && entry.state == SubscriberOwnerState::Active
18901            })
18902            .map(|entry| entry.interests.clone());
18903        let Some(interests) = interests else {
18904            return;
18905        };
18906        for log in logs {
18907            if log.block_number.is_some_and(|block| {
18908                block < range.start_block() || range.end_block().is_some_and(|end| block > end)
18909            }) || !log_matches_any_interest(&log, &interests)
18910            {
18911                continue;
18912            }
18913            let record = log_input_record(log, InputSource::Backfill);
18914            self.enqueue_compat_owner_record(record, owner.clone());
18915        }
18916    }
18917
18918    async fn reconnect_source_stream(
18919        &mut self,
18920        source: SubscriberStreamSource,
18921    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
18922        if !source.is_pubsub() {
18923            return Err(stream_terminated_error(&source));
18924        }
18925
18926        if !self.config.reconnect.enabled {
18927            return Err(SubscriberError::Provider(format!(
18928                "Alloy subscriber {} stream terminated and reconnect is disabled",
18929                source.label()
18930            )));
18931        }
18932
18933        let mut attempts = 0usize;
18934        let mut delay = self.config.reconnect.initial_delay;
18935        let mut retry_delay = self.config.reconnect.retry_delay;
18936
18937        loop {
18938            attempts = attempts.saturating_add(1);
18939            if !delay.is_zero() {
18940                tokio::time::sleep(delay).await;
18941            }
18942
18943            match self.reconnect_source_once(source.clone()).await {
18944                Ok(backfill_event) => return Ok(backfill_event),
18945                Err(error) if reconnect_attempts_exhausted(attempts, &self.config.reconnect) => {
18946                    return Err(SubscriberError::Provider(format!(
18947                        "Alloy subscriber {} stream terminated and reconnect failed after {attempts} attempt(s): {error}",
18948                        source.label()
18949                    )));
18950                }
18951                Err(error) => {
18952                    tracing::warn!(
18953                        stream = source.label(),
18954                        attempts,
18955                        error = %error,
18956                        "Alloy subscriber reconnect attempt failed"
18957                    );
18958                    delay = retry_delay;
18959                    retry_delay =
18960                        next_reconnect_delay(retry_delay, self.config.reconnect.max_delay);
18961                }
18962            }
18963        }
18964    }
18965
18966    async fn reconnect_source_once(
18967        &mut self,
18968        source: SubscriberStreamSource,
18969    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
18970        if matches!(
18971            &self.state,
18972            AlloySubscriberState::Active(streams) if streams.contains_source(&source)
18973        ) {
18974            // A prior attempt installed the stream before its catch-up await
18975            // failed or was cancelled. Retry only the unfinished historical
18976            // window; reconnecting again would create a duplicate live source.
18977            let backfill_event = self.backfill_reconnected_source(&source).await?;
18978            self.pending_source_backfills
18979                .retain(|pending| !pending.same_key(&source));
18980            return Ok(backfill_event);
18981        }
18982        let stream = self.connect_source_stream(source.clone()).await?;
18983        if !matches!(self.state, AlloySubscriberState::Active(_)) {
18984            return Err(SubscriberError::Provider(
18985                "Alloy subscriber state changed before reconnect completed".to_owned(),
18986            ));
18987        }
18988        self.install_source_stream(source.clone(), stream);
18989        if self.source_requires_backfill(&source) {
18990            self.queue_source_backfill(source.clone());
18991        }
18992        let backfill_event = self.backfill_reconnected_source(&source).await?;
18993        self.pending_source_backfills
18994            .retain(|pending| !pending.same_key(&source));
18995
18996        Ok(backfill_event)
18997    }
18998
18999    /// Recover from notification loss on a still-connected stream.
19000    ///
19001    /// The policy differs by stream because what a gap costs differs:
19002    ///
19003    /// - **Canonical logs** are authoritative and unrecoverable downstream, so
19004    ///   the exact missed range is refetched. This is the only case that spends
19005    ///   an RPC, and it spends the minimum: one bounded window per gap.
19006    /// - **Canonical headers** are self-healing at the consumer. A driver that
19007    ///   walks a replacement header's parent lineage back to retained canonical
19008    ///   history recovers the skipped blocks from the next header it receives,
19009    ///   so refetching here would duplicate that work. The gap is counted so the
19010    ///   self-healing is visible rather than assumed.
19011    /// - **Pre-confirmation logs** are speculative by construction. A punctured
19012    ///   preview must not be published, so the snapshot is discarded and the
19013    ///   next complete generation replaces it.
19014    ///
19015    /// # Errors
19016    ///
19017    /// Returns an error when a canonical log gap cannot be bounded because the
19018    /// source has no delivery anchor yet. Silently continuing would mean knowing
19019    /// that logs were lost and doing nothing, which is exactly the failure this
19020    /// machinery exists to eliminate.
19021    async fn recover_stream_gap(
19022        &mut self,
19023        source: &SubscriberStreamSource,
19024        gap: SubscriberStreamGap,
19025    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
19026        match source {
19027            SubscriberStreamSource::PubSubLog { id, .. } => {
19028                self.reset_log_attestation();
19029                if !self.last_seen_log_blocks.contains_key(id) {
19030                    return Err(SubscriberError::Provider(format!(
19031                        "Alloy subscriber {} lost notifications ({gap}) before establishing a \
19032                         delivery anchor, so the missed range cannot be bounded",
19033                        source.label()
19034                    )));
19035                }
19036                let event = self
19037                    .backfill_source_window(source, SubscriberRpcCause::GapBackfill)
19038                    .await?;
19039                self.gap_counters.record_log_gap_healed();
19040                Ok(event)
19041            }
19042            SubscriberStreamSource::PubSubBlockHeaders => {
19043                self.gap_counters.record_header_gap();
19044                Ok(None)
19045            }
19046            SubscriberStreamSource::BasePendingLog { .. } => {
19047                self.gap_counters.record_preconfirmation_gap();
19048                self.invalidate_preconfirmation_snapshot();
19049                Ok(Some(SubscriberEvent::FlashblockInvalidated))
19050            }
19051            _ => Ok(None),
19052        }
19053    }
19054
19055    async fn backfill_reconnected_source(
19056        &mut self,
19057        source: &SubscriberStreamSource,
19058    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
19059        self.backfill_source_window(source, SubscriberRpcCause::ReconnectBackfill)
19060            .await
19061    }
19062
19063    /// Refetch a log source's window from its delivery anchor to the current
19064    /// head.
19065    ///
19066    /// Shared by the two situations that lose a bounded range of canonical logs
19067    /// — a stream that terminated and reconnected, and a stream that stayed
19068    /// connected but dropped notifications. `cause` attributes the requests to
19069    /// whichever of those spent them, so
19070    /// [`rpc_stats`](Self::rpc_stats) can distinguish reconnect churn from
19071    /// backpressure loss.
19072    async fn backfill_source_window(
19073        &mut self,
19074        source: &SubscriberStreamSource,
19075        cause: SubscriberRpcCause,
19076    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
19077        if source.is_flashblocks() {
19078            return Ok(None);
19079        }
19080        let SubscriberStreamSource::PubSubLog { id, filter } = source else {
19081            return Ok(None);
19082        };
19083        let Some(from_block) = self.last_seen_log_blocks.get(id).copied() else {
19084            return Ok(None);
19085        };
19086
19087        self.record_rpc(cause, SubscriberRpcMethod::EthBlockNumber);
19088        let latest = self
19089            .provider
19090            .get_block_number()
19091            .await
19092            .map_err(provider_error)?;
19093        if latest < from_block {
19094            return Ok(None);
19095        }
19096
19097        self.record_rpc(cause, SubscriberRpcMethod::EthGetLogs);
19098        let logs = self
19099            .provider
19100            .get_logs(&filter.clone().from_block(from_block).to_block(latest))
19101            .await
19102            .map_err(provider_error)?;
19103        Ok(Some(SubscriberEvent::BackfilledLogs {
19104            source_id: *id,
19105            logs,
19106        }))
19107    }
19108
19109    fn note_log_block(&mut self, source_id: usize, record: &ReactiveInputRecord<N>) {
19110        if let Some(block) = record.context.block.as_ref() {
19111            self.last_seen_log_blocks.insert(source_id, block.number);
19112        }
19113    }
19114
19115    fn enqueue_record_with_excluded_owners(
19116        &mut self,
19117        record: ReactiveInputRecord<N>,
19118        excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
19119    ) {
19120        let record = self.with_chain_id(record);
19121        let mut owners = self.staged_owners_for_record(&record);
19122        if let Some(excluded) = excluded {
19123            owners.retain(|owner| !excluded.contains(owner));
19124        }
19125        let canonical_duplicate = self.should_skip_recent_duplicate(&record);
19126        let owners = self.filter_recent_owner_duplicates(&record, owners);
19127        let compatibility_owners = self.compatibility_owners_for_record(&record);
19128        let (already_served, newly_served): (Vec<_>, Vec<_>) = compatibility_owners
19129            .into_iter()
19130            .partition(|owner| self.compatibility_owner_has_seen(&record, owner));
19131        if canonical_duplicate {
19132            if !owners.is_empty() {
19133                self.push_pending_record(SubscriberInputRecord {
19134                    record: record.clone(),
19135                    scope: SubscriberInputScope::OwnerOnly { owners },
19136                    preconfirmation_timing: None,
19137                });
19138            }
19139            if !newly_served.is_empty() {
19140                for owner in &newly_served {
19141                    self.remember_compatibility_owner_record(&record, owner);
19142                }
19143                self.push_pending_record(SubscriberInputRecord {
19144                    record,
19145                    scope: SubscriberInputScope::OwnerOnlyHandlers {
19146                        owners: newly_served,
19147                    },
19148                    preconfirmation_timing: None,
19149                });
19150            }
19151            return;
19152        }
19153        self.remember_record(&record);
19154        for owner in already_served.iter().chain(&newly_served) {
19155            self.remember_compatibility_owner_record(&record, owner);
19156        }
19157        self.push_pending_record(SubscriberInputRecord {
19158            record,
19159            scope: if already_served.is_empty() {
19160                SubscriberInputScope::Canonical { owners }
19161            } else {
19162                SubscriberInputScope::CanonicalResidual {
19163                    owners,
19164                    excluded: already_served,
19165                }
19166            },
19167            preconfirmation_timing: None,
19168        });
19169    }
19170
19171    fn enqueue_compat_owner_record(&mut self, record: ReactiveInputRecord<N>, owner: HandlerId) {
19172        let record = self.with_chain_id(record);
19173        if self.compatibility_owner_has_seen(&record, &owner) {
19174            return;
19175        }
19176        self.remember_compatibility_owner_record(&record, &owner);
19177        self.push_pending_record(SubscriberInputRecord {
19178            record,
19179            scope: SubscriberInputScope::OwnerOnlyHandlers {
19180                owners: vec![owner],
19181            },
19182            preconfirmation_timing: None,
19183        });
19184    }
19185
19186    fn compatibility_owners_for_record(&self, record: &ReactiveInputRecord<N>) -> Vec<HandlerId> {
19187        self.owned_interests
19188            .iter()
19189            .filter(|entry| entry.epoch.is_none() && entry.state == SubscriberOwnerState::Active)
19190            .filter(|entry| {
19191                entry
19192                    .interests
19193                    .iter()
19194                    .any(|interest| interest_matches(interest, &record.input))
19195            })
19196            .map(|entry| entry.owner.clone())
19197            .collect()
19198    }
19199
19200    fn compatibility_owner_has_seen(
19201        &self,
19202        record: &ReactiveInputRecord<N>,
19203        owner: &HandlerId,
19204    ) -> bool {
19205        should_dedupe_record(record)
19206            && self
19207                .recent_compat_owner_input_ref_sets
19208                .get(owner)
19209                .is_some_and(|seen| seen.contains(&record.input_ref()))
19210    }
19211
19212    fn remember_compatibility_owner_record(
19213        &mut self,
19214        record: &ReactiveInputRecord<N>,
19215        owner: &HandlerId,
19216    ) {
19217        if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
19218            return;
19219        }
19220        let input_ref = record.input_ref();
19221        let seen = self
19222            .recent_compat_owner_input_ref_sets
19223            .entry(owner.clone())
19224            .or_default();
19225        if !seen.insert(input_ref) {
19226            return;
19227        }
19228        let recent = self
19229            .recent_compat_owner_input_refs
19230            .entry(owner.clone())
19231            .or_default();
19232        recent.push_back(input_ref);
19233        while recent.len() > self.config.reconnect.dedupe_window {
19234            if let Some(evicted) = recent.pop_front() {
19235                seen.remove(&evicted);
19236            }
19237        }
19238    }
19239
19240    fn enqueue_owner_record(
19241        &mut self,
19242        record: ReactiveInputRecord<N>,
19243        owner: SubscriberOwnerEpoch,
19244    ) {
19245        self.enqueue_owner_record_for_owners(record, vec![owner]);
19246    }
19247
19248    fn enqueue_owner_record_for_owners(
19249        &mut self,
19250        record: ReactiveInputRecord<N>,
19251        owners: Vec<SubscriberOwnerEpoch>,
19252    ) {
19253        self.enqueue_owner_record_for_owners_inner(record, owners, true);
19254    }
19255
19256    fn enqueue_owner_record_for_owners_unmerged(
19257        &mut self,
19258        record: ReactiveInputRecord<N>,
19259        owners: Vec<SubscriberOwnerEpoch>,
19260    ) {
19261        self.enqueue_owner_record_for_owners_inner(record, owners, false);
19262    }
19263
19264    fn enqueue_owner_record_for_owners_inner(
19265        &mut self,
19266        record: ReactiveInputRecord<N>,
19267        owners: Vec<SubscriberOwnerEpoch>,
19268        merge_pending: bool,
19269    ) {
19270        let record = self.with_chain_id(record);
19271        let owners = self.filter_recent_owner_duplicates(&record, owners);
19272        if owners.is_empty() {
19273            return;
19274        }
19275        if merge_pending
19276            && should_dedupe_record(&record)
19277            && self.config.reconnect.dedupe_window != 0
19278        {
19279            let input_ref = record.input_ref();
19280            if let Some(pending) = self
19281                .pending_records
19282                .iter_mut()
19283                .rev()
19284                .find(|pending| pending.record.input_ref() == input_ref)
19285            {
19286                let pending_owners = match &mut pending.scope {
19287                    SubscriberInputScope::Canonical { owners }
19288                    | SubscriberInputScope::CanonicalResidual { owners, .. }
19289                    | SubscriberInputScope::OwnerOnly { owners } => Some(owners),
19290                    SubscriberInputScope::OwnerOnlyHandlers { .. }
19291                    | SubscriberInputScope::Preconfirmed => None,
19292                };
19293                if let Some(pending_owners) = pending_owners {
19294                    for owner in owners {
19295                        if !pending_owners.contains(&owner) {
19296                            pending_owners.push(owner);
19297                        }
19298                    }
19299                    return;
19300                }
19301            }
19302        }
19303        self.push_pending_record(SubscriberInputRecord {
19304            record,
19305            scope: SubscriberInputScope::OwnerOnly { owners },
19306            preconfirmation_timing: None,
19307        });
19308    }
19309
19310    fn push_pending_record(&mut self, record: SubscriberInputRecord<N>) {
19311        if self.pending_record_count() >= self.config.max_pending_records {
19312            self.note_resource_error(format!(
19313                "pending record queues reached the configured limit of {}",
19314                self.config.max_pending_records
19315            ));
19316            return;
19317        }
19318        self.pending_records.push_back(record);
19319    }
19320
19321    fn ensure_pending_record_capacity(
19322        &mut self,
19323        additional: usize,
19324        operation: &str,
19325    ) -> Result<(), SubscriberError> {
19326        let required = self.pending_record_count().saturating_add(additional);
19327        if required > self.config.max_pending_records {
19328            self.note_resource_error(format!(
19329                "{operation} require {required} pending records, above the configured limit of {}",
19330                self.config.max_pending_records
19331            ));
19332            return self.check_resource_error();
19333        }
19334        Ok(())
19335    }
19336
19337    fn push_pending_reconcile_record(&mut self, record: BufferedSubscriberOwnerRecord<N>) {
19338        if self.pending_record_count() >= self.config.max_pending_records {
19339            self.note_resource_error(format!(
19340                "pending record queues reached the configured limit of {}",
19341                self.config.max_pending_records
19342            ));
19343            return;
19344        }
19345        self.pending_reconcile_owner_records.push_back(record);
19346    }
19347
19348    fn pending_record_count(&self) -> usize {
19349        self.pending_records
19350            .len()
19351            .saturating_add(self.pending_reconcile_owner_records.len())
19352    }
19353
19354    fn note_resource_error(&mut self, message: String) {
19355        if self.resource_error.is_none() {
19356            self.resource_error = Some(message);
19357        }
19358    }
19359
19360    fn check_resource_error(&self) -> Result<(), SubscriberError> {
19361        match &self.resource_error {
19362            Some(message) => Err(SubscriberError::ResourceExhausted(message.clone())),
19363            None => Ok(()),
19364        }
19365    }
19366
19367    fn with_chain_id(&self, mut record: ReactiveInputRecord<N>) -> ReactiveInputRecord<N> {
19368        record.context.chain_id = self.chain_id;
19369        if self.config.verify_log_block_context
19370            && let ReactiveInput::Log(log) = &record.input
19371            && !log.removed
19372            && let (Some(number), Some(hash)) = (log.block_number, log.block_hash)
19373            && let Some(verified) = self.verified_log_blocks.get(&(number, hash)).copied()
19374        {
19375            record.context.block = Some(verified);
19376            record.context.chain_status = ChainStatus::Included {
19377                block: verified,
19378                confirmations: 0,
19379            };
19380        }
19381        record
19382    }
19383
19384    fn staged_owners_for_record(
19385        &self,
19386        record: &ReactiveInputRecord<N>,
19387    ) -> Vec<SubscriberOwnerEpoch> {
19388        self.owned_interests
19389            .iter()
19390            .filter(|entry| entry.state == SubscriberOwnerState::Staged)
19391            .filter(|entry| {
19392                entry
19393                    .interests
19394                    .iter()
19395                    .any(|interest| interest_matches(interest, &record.input))
19396            })
19397            .filter_map(|entry| entry.epoch.clone())
19398            .collect()
19399    }
19400
19401    fn filter_recent_owner_duplicates(
19402        &mut self,
19403        record: &ReactiveInputRecord<N>,
19404        owners: Vec<SubscriberOwnerEpoch>,
19405    ) -> Vec<SubscriberOwnerEpoch> {
19406        if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
19407            return owners;
19408        }
19409        let input_ref = record.input_ref();
19410        let window = self.config.reconnect.dedupe_window;
19411        owners
19412            .into_iter()
19413            .filter(|owner| {
19414                let seen = self
19415                    .recent_owner_input_ref_sets
19416                    .entry(owner.clone())
19417                    .or_default();
19418                if !seen.insert(input_ref) {
19419                    return false;
19420                }
19421                let recent = self
19422                    .recent_owner_input_refs
19423                    .entry(owner.clone())
19424                    .or_default();
19425                recent.push_back(input_ref);
19426                while recent.len() > window {
19427                    if let Some(evicted) = recent.pop_front() {
19428                        seen.remove(&evicted);
19429                    }
19430                }
19431                true
19432            })
19433            .collect()
19434    }
19435
19436    fn should_skip_recent_duplicate(&self, record: &ReactiveInputRecord<N>) -> bool {
19437        if !should_dedupe_record(record) {
19438            return false;
19439        }
19440        self.recent_input_ref_set.contains(&record.input_ref())
19441    }
19442
19443    fn remember_record(&mut self, record: &ReactiveInputRecord<N>) {
19444        if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
19445            return;
19446        }
19447
19448        let input_ref = record.input_ref();
19449        if !self.recent_input_ref_set.insert(input_ref) {
19450            return;
19451        }
19452        self.recent_input_refs.push_back(input_ref);
19453
19454        while self.recent_input_refs.len() > self.config.reconnect.dedupe_window {
19455            if let Some(evicted) = self.recent_input_refs.pop_front() {
19456                self.recent_input_ref_set.remove(&evicted);
19457            }
19458        }
19459    }
19460}
19461
19462/// Turn a pubsub subscription into a stream that reports dropped notifications
19463/// instead of hiding them.
19464///
19465/// [`Subscription::into_stream`] is deliberately not used: it treats both a
19466/// lagged receiver and an undecodable payload as `continue`, logging at `debug`
19467/// and moving on, so a consumer cannot distinguish a complete stream from a
19468/// punctured one. Consuming the raw subscription makes a broadcast `Lagged` a
19469/// first-class [`SubscriberEvent::StreamGap`], while `Closed` still ends the
19470/// stream so the existing reconnect path handles a genuine disconnect unchanged.
19471///
19472/// [`Subscription::into_stream`]: alloy_pubsub::Subscription::into_stream
19473#[cfg(feature = "reactive-ws")]
19474fn gap_observing_stream<N, T, F>(
19475    subscription: alloy_pubsub::Subscription<T>,
19476    source: SubscriberStreamSource,
19477    gap_counters: Arc<SubscriberStreamGapCounters>,
19478    to_event: F,
19479) -> BoxStream<'static, SubscriberEvent<N>>
19480where
19481    N: Network + 'static,
19482    T: serde::de::DeserializeOwned + Send + 'static,
19483    F: FnMut(T) -> SubscriberEvent<N> + Send + 'static,
19484{
19485    // The decode closure and the receiver both live in the unfold state, so no
19486    // borrow is held across an await point.
19487    struct GapState<T, F> {
19488        raw: alloy_pubsub::RawSubscription,
19489        to_event: F,
19490        source: SubscriberStreamSource,
19491        gap_counters: Arc<SubscriberStreamGapCounters>,
19492        _item: PhantomData<fn() -> T>,
19493    }
19494
19495    let state = GapState {
19496        raw: subscription.into_raw(),
19497        to_event,
19498        source: source.clone(),
19499        gap_counters,
19500        _item: PhantomData::<fn() -> T>,
19501    };
19502
19503    let stream = stream::unfold(state, |mut state| async move {
19504        let gap = match state.raw.recv().await {
19505            Ok(value) => match serde_json::from_str::<T>(value.get()) {
19506                Ok(item) => {
19507                    let event = (state.to_event)(item);
19508                    return Some((event, state));
19509                }
19510                Err(error) => {
19511                    tracing::warn!(
19512                        stream = state.source.label(),
19513                        error = %error,
19514                        "pubsub notification did not decode; treating it as lost data"
19515                    );
19516                    SubscriberStreamGap::Undecodable
19517                }
19518            },
19519            Err(broadcast::error::RecvError::Lagged(skipped)) => {
19520                tracing::warn!(
19521                    stream = state.source.label(),
19522                    skipped,
19523                    "pubsub notification channel overflowed; the missed window will be recovered"
19524                );
19525                SubscriberStreamGap::Lagged { skipped }
19526            }
19527            // A closed channel is a disconnect, not a gap. Ending the stream
19528            // lets `stream_with_termination` drive the existing reconnect.
19529            Err(broadcast::error::RecvError::Closed) => return None,
19530        };
19531        state.gap_counters.record_gap(gap);
19532        let event = SubscriberEvent::StreamGap {
19533            source: state.source.clone(),
19534            gap,
19535        };
19536        Some((event, state))
19537    });
19538
19539    stream_with_termination(stream, source)
19540}
19541
19542fn stream_with_termination<N, S>(
19543    stream: S,
19544    source: SubscriberStreamSource,
19545) -> BoxStream<'static, SubscriberEvent<N>>
19546where
19547    N: Network + 'static,
19548    S: futures::Stream<Item = SubscriberEvent<N>> + Send + 'static,
19549{
19550    stream
19551        .chain(stream::once(async move {
19552            SubscriberEvent::StreamTerminated(source)
19553        }))
19554        .boxed()
19555}
19556
19557fn flashblock_reconnect_future<N>(
19558    provider: RootProvider<N>,
19559    source: SubscriberStreamSource,
19560    channel_size: usize,
19561    reconnect: SubscriberReconnectConfig,
19562    first_delay: Duration,
19563    flashblock_poll_interval: Duration,
19564    counters: Arc<SubscriberRpcCounters>,
19565) -> FlashblockReconnectFuture<N>
19566where
19567    N: Network + 'static,
19568{
19569    Box::pin(async move {
19570        if !reconnect.enabled {
19571            let error = SubscriberError::Provider(format!(
19572                "Alloy subscriber {} stream terminated and reconnect is disabled",
19573                source.label()
19574            ));
19575            return (source, Err(error));
19576        }
19577
19578        let mut attempts = 0_usize;
19579        let mut delay = first_delay;
19580        let mut retry_delay = reconnect.retry_delay;
19581        loop {
19582            attempts = attempts.saturating_add(1);
19583            if !delay.is_zero() {
19584                tokio::time::sleep(delay).await;
19585            }
19586            match connect_flashblock_source_once(
19587                &provider,
19588                source.clone(),
19589                channel_size,
19590                flashblock_poll_interval,
19591                counters.as_ref(),
19592            )
19593            .await
19594            {
19595                Ok(stream) => return (source, Ok(stream)),
19596                Err(error) if reconnect_attempts_exhausted(attempts, &reconnect) => {
19597                    return (
19598                        source.clone(),
19599                        Err(SubscriberError::Provider(format!(
19600                            "Alloy subscriber {} stream reconnect failed after {attempts} attempt(s): {error}",
19601                            source.label()
19602                        ))),
19603                    );
19604                }
19605                Err(error) => {
19606                    tracing::warn!(
19607                        stream = source.label(),
19608                        attempts,
19609                        error = %error,
19610                        "Flashblocks reconnect attempt failed"
19611                    );
19612                    delay = retry_delay;
19613                    retry_delay = next_reconnect_delay(retry_delay, reconnect.max_delay);
19614                }
19615            }
19616        }
19617    })
19618}
19619
19620async fn connect_flashblock_source_once<N>(
19621    provider: &RootProvider<N>,
19622    source: SubscriberStreamSource,
19623    channel_size: usize,
19624    flashblock_poll_interval: Duration,
19625    counters: &SubscriberRpcCounters,
19626) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError>
19627where
19628    N: Network + 'static,
19629{
19630    #[cfg(not(feature = "reactive-ws"))]
19631    let _ = (provider, counters);
19632
19633    match source {
19634        SubscriberStreamSource::BasePendingLog { id, filter } => {
19635            #[cfg(feature = "reactive-ws")]
19636            {
19637                let source = SubscriberStreamSource::BasePendingLog {
19638                    id,
19639                    filter: filter.clone(),
19640                };
19641                let params = base_pending_log_filter(&filter)?;
19642                counters.record(
19643                    SubscriberRpcCause::StreamSubscription,
19644                    SubscriberRpcMethod::EthSubscribe,
19645                );
19646                let stream = provider
19647                    .subscribe::<_, Log>(("pendingLogs", params))
19648                    .channel_size(channel_size.max(1))
19649                    .await
19650                    .map_err(provider_error)?
19651                    .into_stream()
19652                    .map(move |log| SubscriberEvent::BasePendingLogTimed {
19653                        source_id: id,
19654                        log,
19655                        timing: FlashblockIngressTiming::new(Instant::now()),
19656                    });
19657                Ok(stream_with_termination(stream, source))
19658            }
19659            #[cfg(not(feature = "reactive-ws"))]
19660            {
19661                let _ = (id, filter, channel_size);
19662                Err(SubscriberError::Unsupported(
19663                    "Base Flashblocks require the reactive-ws feature",
19664                ))
19665            }
19666        }
19667        SubscriberStreamSource::BaseFlashblocks => {
19668            #[cfg(feature = "reactive-ws")]
19669            {
19670                counters.record(
19671                    SubscriberRpcCause::StreamSubscription,
19672                    SubscriberRpcMethod::EthSubscribe,
19673                );
19674                let stream = provider
19675                    .subscribe::<_, BaseFlashblockWirePayload>(("newFlashblocks",))
19676                    .channel_size(channel_size.max(1))
19677                    .await
19678                    .map_err(provider_error)?
19679                    .into_stream()
19680                    .map(|payload| SubscriberEvent::BaseFlashblockTimed {
19681                        payload,
19682                        timing: FlashblockIngressTiming::new(Instant::now()),
19683                    });
19684                Ok(stream_with_termination(
19685                    stream,
19686                    SubscriberStreamSource::BaseFlashblocks,
19687                ))
19688            }
19689            #[cfg(not(feature = "reactive-ws"))]
19690            {
19691                let _ = channel_size;
19692                Err(SubscriberError::Unsupported(
19693                    "Base Flashblocks require the reactive-ws feature",
19694                ))
19695            }
19696        }
19697        SubscriberStreamSource::OpPendingFlashblocks => {
19698            let first_tick = tokio::time::Instant::now();
19699            let mut interval = tokio::time::interval_at(first_tick, flashblock_poll_interval);
19700            interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
19701            let stream = stream::unfold(interval, |mut interval| async move {
19702                interval.tick().await;
19703                Some((
19704                    SubscriberEvent::OpFlashblockTickTimed(FlashblockIngressTiming::new(
19705                        Instant::now(),
19706                    )),
19707                    interval,
19708                ))
19709            });
19710            Ok(stream_with_termination(
19711                stream,
19712                SubscriberStreamSource::OpPendingFlashblocks,
19713            ))
19714        }
19715        source => Err(SubscriberError::InvalidConfig(match source {
19716            SubscriberStreamSource::PubSubLog { .. }
19717            | SubscriberStreamSource::CanonicalHeadPolling
19718            | SubscriberStreamSource::PubSubPendingHashes
19719            | SubscriberStreamSource::PubSubBlockHeaders
19720            | SubscriberStreamSource::PollingLog { .. }
19721            | SubscriberStreamSource::PollingPendingHashes => {
19722                "Flashblocks reconnect received a canonical source"
19723            }
19724            SubscriberStreamSource::BasePendingLog { .. }
19725            | SubscriberStreamSource::BaseFlashblocks
19726            | SubscriberStreamSource::OpPendingFlashblocks => unreachable!(),
19727            #[cfg(feature = "raw-flashblocks-json")]
19728            SubscriberStreamSource::ExternalFlashblockUpdates => {
19729                "Flashblocks reconnect cannot own an application-managed source"
19730            }
19731        })),
19732    }
19733}
19734
19735fn aggregate_interests<N: Network>(
19736    base: &[ReactiveInterest<N>],
19737    owned: &[OwnedSubscriberInterests<N>],
19738) -> Vec<ReactiveInterest<N>> {
19739    base.iter()
19740        .cloned()
19741        .chain(
19742            owned
19743                .iter()
19744                .flat_map(|entry| entry.interests.iter().cloned()),
19745        )
19746        .collect()
19747}
19748
19749fn stream_terminated_error(source: &SubscriberStreamSource) -> SubscriberError {
19750    SubscriberError::Provider(format!(
19751        "Alloy subscriber {} stream terminated before the subscriber was stopped",
19752        source.label()
19753    ))
19754}
19755
19756fn reconnect_attempts_exhausted(attempts: usize, config: &SubscriberReconnectConfig) -> bool {
19757    config
19758        .max_attempts
19759        .is_some_and(|max_attempts| attempts >= max_attempts)
19760}
19761
19762fn next_reconnect_delay(current: Duration, max: Duration) -> Duration {
19763    if current.is_zero() {
19764        return current;
19765    }
19766    current.checked_mul(2).unwrap_or(max).min(max)
19767}
19768
19769fn should_dedupe_record<N: Network>(record: &ReactiveInputRecord<N>) -> bool {
19770    match &record.input {
19771        ReactiveInput::Log(log) => {
19772            is_canonical_status(&record.context.chain_status) && !log.removed
19773        }
19774        ReactiveInput::BlockHeader(_) | ReactiveInput::PendingTxHash(_) => true,
19775        ReactiveInput::FullBlock(_) | ReactiveInput::PendingTx(_) => false,
19776    }
19777}
19778
19779#[cfg(test)]
19780mod subscriber_helper_tests {
19781    use super::*;
19782    use alloy_json_rpc::{RequestPacket, ResponsePacket};
19783    use alloy_provider::ProviderBuilder;
19784    use alloy_rpc_client::RpcClient;
19785    use alloy_transport::{TransportError, TransportFut, mock::Asserter};
19786    use std::task::{Context, Poll};
19787    use tower::Service;
19788
19789    #[derive(Clone, Debug)]
19790    struct NeverRespondingTransport;
19791
19792    impl Service<RequestPacket> for NeverRespondingTransport {
19793        type Response = ResponsePacket;
19794        type Error = TransportError;
19795        type Future = TransportFut<'static>;
19796
19797        fn poll_ready(&mut self, _context: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
19798            Poll::Ready(Ok(()))
19799        }
19800
19801        fn call(&mut self, _request: RequestPacket) -> Self::Future {
19802            Box::pin(futures::future::pending())
19803        }
19804    }
19805
19806    fn indexed_flashblock(transaction_hash: B256, state_root: B256) -> BaseFlashblockWirePayload {
19807        BaseFlashblockWirePayload::Indexed(BaseFlashblockPayload {
19808            payload_id: FixedBytes::repeat_byte(0x11),
19809            index: 0,
19810            base: Some(BaseFlashblockBase {
19811                parent_hash: B256::repeat_byte(100),
19812                block_number: 101,
19813                timestamp: 1_700_000_101,
19814                gas_limit: Some(30_000_000),
19815                base_fee_per_gas: Some(7),
19816                beneficiary: Some(Address::repeat_byte(0xcb)),
19817                prevrandao: Some(B256::repeat_byte(0x77)),
19818            }),
19819            diff: BaseFlashblockDiff {
19820                state_root,
19821                block_hash: B256::ZERO,
19822                transactions: vec![serde_json::Value::String(format!("{transaction_hash:#x}"))],
19823                transactions_root: None,
19824            },
19825            metadata: None,
19826        })
19827    }
19828
19829    fn base_flashblock_event(payload: BaseFlashblockWirePayload) -> SubscriberEvent<Ethereum> {
19830        SubscriberEvent::BaseFlashblockTimed {
19831            payload,
19832            timing: FlashblockIngressTiming::new(Instant::now()),
19833        }
19834    }
19835
19836    #[test]
19837    fn duplicate_flashblock_transaction_membership_is_rejected() {
19838        let transaction = format!("{:#x}", B256::repeat_byte(0x41));
19839        let transactions = vec![
19840            serde_json::Value::String(transaction.clone()),
19841            serde_json::Value::String(transaction),
19842        ];
19843        assert!(matches!(
19844            flashblock_transaction_hashes(&transactions),
19845            Err(SubscriberError::Provider(ref message)) if message.contains("duplicate")
19846        ));
19847    }
19848
19849    #[test]
19850    fn conflicting_duplicate_indexed_flashblock_is_rejected() {
19851        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
19852        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19853            provider,
19854            SubscriberMode::PubSub,
19855            SubscriberConfig::default(),
19856        )
19857        .with_provider_ref(ProviderRef::new("base-paid", 7));
19858        subscriber.chain_id = Some(8_453);
19859
19860        subscriber
19861            .accept_base_flashblock(indexed_flashblock(
19862                B256::repeat_byte(0x41),
19863                B256::repeat_byte(0xa1),
19864            ))
19865            .expect("first indexed preview");
19866        assert!(matches!(
19867            subscriber.accept_base_flashblock(indexed_flashblock(
19868                B256::repeat_byte(0x42),
19869                B256::repeat_byte(0xa2),
19870            )),
19871            Err(SubscriberError::Provider(ref message))
19872                if message.contains("conflicting duplicate")
19873        ));
19874    }
19875
19876    #[tokio::test]
19877    async fn duplicate_index_with_changed_commitment_is_rejected() {
19878        let transaction = B256::repeat_byte(0x41);
19879        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
19880        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19881            provider,
19882            SubscriberMode::PubSub,
19883            SubscriberConfig::default(),
19884        )
19885        .with_provider_ref(ProviderRef::new("base-paid", 7));
19886        subscriber.chain_id = Some(8_453);
19887
19888        subscriber
19889            .normalize_flashblock_event(base_flashblock_event(indexed_flashblock(
19890                transaction,
19891                B256::repeat_byte(0xa1),
19892            )))
19893            .await
19894            .expect("first indexed preview");
19895
19896        let BaseFlashblockWirePayload::Indexed(mut conflicting) =
19897            indexed_flashblock(transaction, B256::repeat_byte(0xa1))
19898        else {
19899            unreachable!()
19900        };
19901        conflicting.diff.state_root = B256::repeat_byte(0xbb);
19902        assert!(matches!(
19903            subscriber
19904                .normalize_flashblock_event(base_flashblock_event(
19905                    BaseFlashblockWirePayload::Indexed(conflicting),
19906                ))
19907                .await,
19908            Err(SubscriberError::Provider(ref message))
19909                if message.contains("conflicting duplicate indexed Flashblock content")
19910        ));
19911    }
19912
19913    #[tokio::test]
19914    async fn indexed_gap_recovery_seeds_later_cumulative_membership() {
19915        let transaction_a = B256::repeat_byte(0x41);
19916        let transaction_b = B256::repeat_byte(0x42);
19917        let transaction_c = B256::repeat_byte(0x43);
19918        let transaction_d = B256::repeat_byte(0x44);
19919        let asserter = Asserter::new();
19920        asserter.push_success(&100_u64);
19921        let pending = rpc_block(101, B256::ZERO).with_transactions(
19922            alloy_network::primitives::BlockTransactions::Hashes(vec![
19923                transaction_a,
19924                transaction_b,
19925                transaction_c,
19926            ]),
19927        );
19928        asserter.push_success(&Some(pending));
19929        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
19930        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19931            provider,
19932            SubscriberMode::PubSub,
19933            SubscriberConfig::default(),
19934        )
19935        .with_provider_ref(ProviderRef::new("base-paid", 7));
19936        subscriber.chain_id = Some(8_453);
19937
19938        subscriber
19939            .normalize_flashblock_event(base_flashblock_event(indexed_flashblock(
19940                transaction_a,
19941                B256::repeat_byte(0xa1),
19942            )))
19943            .await
19944            .expect("index zero preview");
19945        let BaseFlashblockWirePayload::Indexed(mut gap) =
19946            indexed_flashblock(transaction_c, B256::repeat_byte(0xa3))
19947        else {
19948            unreachable!()
19949        };
19950        gap.index = 2;
19951        gap.base = None;
19952        gap.metadata = Some(BaseFlashblockMetadata { block_number: 101 });
19953        subscriber
19954            .normalize_flashblock_event(base_flashblock_event(BaseFlashblockWirePayload::Indexed(
19955                gap,
19956            )))
19957            .await
19958            .expect("the missing index is recovered from pending state");
19959
19960        let BaseFlashblockWirePayload::Indexed(mut next) =
19961            indexed_flashblock(transaction_d, B256::repeat_byte(0xa4))
19962        else {
19963            unreachable!()
19964        };
19965        next.index = 3;
19966        next.base = None;
19967        next.metadata = Some(BaseFlashblockMetadata { block_number: 101 });
19968        let (next, recover) = subscriber
19969            .accept_base_flashblock(BaseFlashblockWirePayload::Indexed(next))
19970            .expect("the next diff extends the recovered cumulative set");
19971        assert!(!recover);
19972        assert_eq!(
19973            next.transaction_hashes,
19974            vec![transaction_a, transaction_b, transaction_c, transaction_d]
19975        );
19976    }
19977
19978    #[tokio::test]
19979    async fn unrecoverable_indexed_gap_revokes_the_generation() {
19980        let asserter = Asserter::new();
19981        asserter.push_success(&100_u64);
19982        asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(0x64))));
19983        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
19984        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19985            provider,
19986            SubscriberMode::PubSub,
19987            SubscriberConfig {
19988                preconfirmations: PreconfirmationMode::Preferred,
19989                ..SubscriberConfig::default()
19990            },
19991        )
19992        .with_provider_ref(ProviderRef::new("base-paid", 7));
19993        subscriber.chain_id = Some(8_453);
19994
19995        subscriber
19996            .normalize_flashblock_event(base_flashblock_event(indexed_flashblock(
19997                B256::repeat_byte(0x41),
19998                B256::repeat_byte(0xa1),
19999            )))
20000            .await
20001            .expect("index zero preview");
20002        let BaseFlashblockWirePayload::Indexed(mut gap) =
20003            indexed_flashblock(B256::repeat_byte(0x43), B256::repeat_byte(0xa3))
20004        else {
20005            unreachable!()
20006        };
20007        gap.index = 2;
20008        gap.base = None;
20009        gap.metadata = Some(BaseFlashblockMetadata { block_number: 101 });
20010        let event = subscriber
20011            .normalize_flashblock_event(base_flashblock_event(BaseFlashblockWirePayload::Indexed(
20012                gap,
20013            )))
20014            .await
20015            .expect("preferred mode fails closed without pending recovery")
20016            .expect("generation invalidation is observable");
20017        assert!(matches!(event, SubscriberEvent::FlashblockInvalidated));
20018        assert!(subscriber.latest_preconfirmation.is_none());
20019        assert_eq!(subscriber.provider_ref.as_ref().unwrap().generation, 8);
20020    }
20021
20022    #[test]
20023    fn base_flashblock_wire_decodes_cumulative_block_shape() {
20024        let payload: BaseFlashblockWirePayload = serde_json::from_str(
20025            r#"{
20026                "hash":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
20027                "number":"0x2ef403b",
20028                "parentHash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
20029                "stateRoot":"0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
20030                "timestamp":"0x6a68dd59",
20031                "transactions":[]
20032            }"#,
20033        )
20034        .expect("decode current Base newFlashblocks shape");
20035        let BaseFlashblockWirePayload::Block(payload) = payload else {
20036            panic!("expected cumulative block-shaped payload")
20037        };
20038        assert_eq!(payload.number, 49_233_979);
20039        assert_eq!(payload.timestamp, 1_785_257_305);
20040        assert_eq!(payload.hash, B256::repeat_byte(0xaa));
20041        assert_eq!(payload.parent_hash, B256::repeat_byte(0xbb));
20042        assert_eq!(payload.state_root, B256::repeat_byte(0xcc));
20043    }
20044
20045    #[tokio::test]
20046    async fn zero_hash_pending_log_waits_for_the_preview_containing_its_transaction() {
20047        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20048        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20049            provider,
20050            SubscriberMode::PubSub,
20051            SubscriberConfig::default(),
20052        )
20053        .with_provider_ref(ProviderRef::new("base-paid", 7));
20054        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
20055            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
20056            local_matcher: None,
20057            route_key: None,
20058        })];
20059        subscriber.interests = subscriber.base_interests.clone();
20060
20061        let first: BaseFlashblockWirePayload = serde_json::from_str(
20062            r#"{
20063                "hash":"0x0000000000000000000000000000000000000000000000000000000000000000",
20064                "number":"0x65",
20065                "parentHash":"0x6464646464646464646464646464646464646464646464646464646464646464",
20066                "stateRoot":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
20067                "transactionsRoot":"0x1111111111111111111111111111111111111111111111111111111111111111",
20068                "timestamp":"0x6553f165",
20069                "transactions":["0x4141414141414141414141414141414141414141414141414141414141414141"]
20070            }"#,
20071        )
20072        .expect("decode first cumulative preview");
20073        subscriber
20074            .normalize_flashblock_event(base_flashblock_event(first))
20075            .await
20076            .expect("first preview is accepted");
20077
20078        let mut second_log = rpc_log(false);
20079        second_log.block_hash = Some(B256::ZERO);
20080        second_log.block_number = Some(102);
20081        second_log.block_timestamp = Some(1_700_000_102);
20082        second_log.transaction_hash = Some(B256::repeat_byte(0x42));
20083        second_log.transaction_index = Some(0);
20084        second_log.log_index = Some(0);
20085
20086        let pending_log_ingress = Instant::now() - Duration::from_millis(25);
20087        let before_preview = subscriber
20088            .normalize_flashblock_event(SubscriberEvent::BasePendingLogTimed {
20089                source_id: 0,
20090                log: second_log,
20091                timing: FlashblockIngressTiming::new(pending_log_ingress),
20092            })
20093            .await
20094            .expect("a zero-hash log for the next block must be buffered");
20095        assert!(before_preview.is_none());
20096
20097        let second: BaseFlashblockWirePayload = serde_json::from_str(
20098            r#"{
20099                "hash":"0x0000000000000000000000000000000000000000000000000000000000000000",
20100                "number":"0x66",
20101                "parentHash":"0x6565656565656565656565656565656565656565656565656565656565656565",
20102                "stateRoot":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
20103                "transactionsRoot":"0x2222222222222222222222222222222222222222222222222222222222222222",
20104                "timestamp":"0x6553f166",
20105                "transactions":["0x4242424242424242424242424242424242424242424242424242424242424242"]
20106            }"#,
20107        )
20108        .expect("decode second cumulative preview");
20109        let event = subscriber
20110            .normalize_flashblock_event(base_flashblock_event(second))
20111            .await
20112            .expect("second preview is accepted")
20113            .expect("the matching buffered log is released");
20114        let SubscriberEvent::PreconfirmedLogs {
20115            flashblock,
20116            logs,
20117            timing,
20118        } = event
20119        else {
20120            panic!("expected a preconfirmed log batch")
20121        };
20122        assert_eq!(timing.source_ingress(), pending_log_ingress);
20123        assert_eq!(flashblock.block_number, 102);
20124        assert_ne!(flashblock.content_hash, B256::ZERO);
20125        assert_eq!(flashblock.partial_block_hash, None);
20126        assert_eq!(logs.len(), 1);
20127        assert_eq!(logs[0].transaction_hash, Some(B256::repeat_byte(0x42)));
20128        assert_eq!(logs[0].block_hash, Some(flashblock.content_hash));
20129    }
20130
20131    #[test]
20132    fn flashblock_endpoints_certify_canonical_heads_instead_of_trusting_newheads() {
20133        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20134        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20135            provider,
20136            SubscriberMode::PubSub,
20137            SubscriberConfig {
20138                preconfirmations: PreconfirmationMode::Required,
20139                ..SubscriberConfig::default()
20140            },
20141        )
20142        .with_provider_ref(ProviderRef::new("base-paid", 7));
20143        subscriber.chain_id = Some(8_453);
20144        subscriber.interests = vec![ReactiveInterest::Blocks(BlockInterest::default())];
20145
20146        let sources = subscriber.pubsub_stream_sources();
20147        assert!(
20148            sources
20149                .iter()
20150                .any(|source| matches!(source, SubscriberStreamSource::CanonicalHeadPolling))
20151        );
20152        assert!(
20153            !sources
20154                .iter()
20155                .any(|source| matches!(source, SubscriberStreamSource::PubSubBlockHeaders))
20156        );
20157    }
20158
20159    #[test]
20160    #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
20161    fn external_flashblocks_keep_normal_canonical_pubsub_sources_on_any_chain() {
20162        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20163        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20164            provider,
20165            SubscriberMode::PubSub,
20166            SubscriberConfig {
20167                preconfirmations: PreconfirmationMode::Required,
20168                ..SubscriberConfig::default()
20169            },
20170        );
20171        subscriber
20172            .configure_external_flashblock_updates(ProviderRef::new("raw-json", 4))
20173            .expect("configure external source");
20174        subscriber.chain_id = Some(1);
20175        subscriber.base_interests = vec![
20176            ReactiveInterest::Blocks(BlockInterest::default()),
20177            log_interest_matching_rpc_log(),
20178        ];
20179        subscriber.interests = subscriber.base_interests.clone();
20180
20181        let pubsub = subscriber.pubsub_stream_sources();
20182        assert!(
20183            pubsub
20184                .iter()
20185                .any(|source| matches!(source, SubscriberStreamSource::PubSubBlockHeaders))
20186        );
20187        assert!(
20188            pubsub
20189                .iter()
20190                .any(|source| matches!(source, SubscriberStreamSource::PubSubLog { .. }))
20191        );
20192        assert!(pubsub.iter().all(|source| !matches!(
20193            source,
20194            SubscriberStreamSource::BaseFlashblocks
20195                | SubscriberStreamSource::BasePendingLog { .. }
20196                | SubscriberStreamSource::OpPendingFlashblocks
20197                | SubscriberStreamSource::CanonicalHeadPolling
20198        )));
20199        assert!(
20200            subscriber
20201                .polling_stream_sources()
20202                .iter()
20203                .all(|source| { !matches!(source, SubscriberStreamSource::OpPendingFlashblocks) })
20204        );
20205        assert!(
20206            subscriber
20207                .capabilities()
20208                .supports(SubscriberCapability::Preconfirmations)
20209        );
20210    }
20211
20212    #[test]
20213    #[cfg(feature = "raw-flashblocks-json")]
20214    fn external_flashblocks_are_rejected_when_preconfirmations_are_disabled() {
20215        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20216        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20217            provider,
20218            SubscriberMode::PubSub,
20219            SubscriberConfig::default(),
20220        );
20221        subscriber
20222            .configure_external_flashblock_updates(ProviderRef::new("raw-json", 4))
20223            .expect("configure external source");
20224        subscriber.chain_id = Some(1);
20225
20226        assert!(matches!(
20227            subscriber.validate_flashblocks_setup(),
20228            Err(SubscriberError::InvalidConfig(message))
20229                if message.contains("require preconfirmations")
20230        ));
20231    }
20232
20233    #[tokio::test]
20234    #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
20235    async fn external_flashblocks_configuration_is_rejected_after_registration_starts() {
20236        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20237        let mut fresh = AlloySubscriber::<_, Ethereum>::new(
20238            provider,
20239            SubscriberMode::PubSub,
20240            SubscriberConfig {
20241                preconfirmations: PreconfirmationMode::Preferred,
20242                ..SubscriberConfig::default()
20243            },
20244        );
20245        fresh
20246            .configure_external_flashblock_updates(ProviderRef::new("raw-json", 4))
20247            .expect("construction-time external source");
20248
20249        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20250        let mut started = AlloySubscriber::<_, Ethereum>::new(
20251            provider,
20252            SubscriberMode::PubSub,
20253            SubscriberConfig {
20254                preconfirmations: PreconfirmationMode::Preferred,
20255                ..SubscriberConfig::default()
20256            },
20257        )
20258        .with_provider_ref(ProviderRef::new("canonical", 3));
20259        started.chain_id = Some(8_453);
20260        started
20261            .register_interests(&[log_interest_matching_rpc_log()])
20262            .await
20263            .expect("register canonical topology");
20264
20265        assert!(matches!(
20266            started.configure_external_flashblock_updates(ProviderRef::new("raw-json", 4)),
20267            Err(SubscriberError::InvalidConfig(message))
20268                if message.contains("before subscriber registration")
20269        ));
20270    }
20271
20272    #[tokio::test]
20273    #[cfg(feature = "raw-flashblocks-json")]
20274    async fn external_flashblocks_preflight_performs_no_flashblocks_rpc() {
20275        let asserter = Asserter::new();
20276        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20277        let source = ProviderRef::new("raw-json", 4);
20278        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20279            provider,
20280            SubscriberMode::PubSub,
20281            SubscriberConfig {
20282                preconfirmations: PreconfirmationMode::Required,
20283                ..SubscriberConfig::default()
20284            },
20285        );
20286        subscriber
20287            .configure_external_flashblock_updates(source.clone())
20288            .expect("configure external source");
20289        subscriber.chain_id = Some(1);
20290        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20291        subscriber.interests = subscriber.base_interests.clone();
20292        let desired = subscriber.pubsub_stream_sources();
20293        let mut streams = SubscriberStreams::new();
20294        for source in desired {
20295            streams.push(source, stream::pending().boxed());
20296        }
20297        subscriber.state = AlloySubscriberState::Active(streams);
20298        subscriber.sources_dirty = false;
20299
20300        let preflight = subscriber
20301            .establish_flashblocks_preflight(1)
20302            .await
20303            .expect("external source preflight");
20304        assert_eq!(preflight.provider(), &source);
20305        assert_eq!(preflight.delivery(), FlashblocksDelivery::ExternalUpdates);
20306        assert_eq!(preflight.pending_log_subscriptions(), 0);
20307        assert_eq!(subscriber.flashblocks_rpc_metrics().total_requests(), 0);
20308        assert!(asserter.read_q().is_empty());
20309    }
20310
20311    #[tokio::test]
20312    #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
20313    async fn bounded_external_channel_survives_subscriber_move_and_closure_keeps_canonical_stream()
20314    {
20315        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20316        let source = ProviderRef::new("raw-json", 4);
20317        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20318            provider,
20319            SubscriberMode::PubSub,
20320            SubscriberConfig {
20321                preconfirmations: PreconfirmationMode::Preferred,
20322                ..SubscriberConfig::default()
20323            },
20324        );
20325        subscriber
20326            .configure_external_flashblock_updates(source.clone())
20327            .expect("configure external source");
20328        subscriber.chain_id = Some(1);
20329        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20330        subscriber.interests = subscriber.base_interests.clone();
20331        let filter = subscriber.log_stream_filters().remove(0);
20332        let source_id = subscriber.log_source_id(&filter);
20333        let mut streams = SubscriberStreams::new();
20334        streams.push(
20335            SubscriberStreamSource::PubSubLog {
20336                id: source_id,
20337                filter,
20338            },
20339            stream::pending().boxed(),
20340        );
20341        subscriber.state = AlloySubscriberState::Active(streams);
20342        subscriber.sources_dirty = false;
20343
20344        let sender = subscriber
20345            .open_external_flashblock_update_channel(2)
20346            .expect("bounded external queue");
20347        let external = SubscriberStreamSource::ExternalFlashblockUpdates;
20348        let update_stream = subscriber
20349            .connect_source_stream(external.clone())
20350            .await
20351            .expect("attach receiver as subscriber source");
20352        subscriber.install_source_stream(external, update_stream);
20353        subscriber.sources_dirty = false;
20354
20355        let mut adapter = RawJsonFlashblocksAdapter::new(source);
20356        let frame = br#"{
20357            "payload_id":"0x1111111111111111",
20358            "index":0,
20359            "base":{
20360                "parent_hash":"0x0606060606060606060606060606060606060606060606060606060606060606",
20361                "block_number":"0x7",
20362                "timestamp":"0x6553f107"
20363            },
20364            "diff":{
20365                "state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
20366                "block_hash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
20367                "transactions":["0x01"]
20368            },
20369            "metadata":{
20370                "block_number":7,
20371                "receipts":{
20372                    "0x5fe7f977e71dba2ea1a68e21057beebb9be2ac30c6410aa38d4f3fbe41dcffd2":{
20373                        "logs":[{
20374                            "address":"0x4242424242424242424242424242424242424242",
20375                            "topics":["0x0101010101010101010101010101010101010101010101010101010101010101"],
20376                            "data":"0x"
20377                        }]
20378                    }
20379                }
20380            }
20381        }"#;
20382        let update = adapter
20383            .ingest_json(frame)
20384            .expect("valid raw update")
20385            .expect("snapshot update");
20386        let valid_update = update.clone();
20387        let sending = {
20388            let sender = sender.clone();
20389            tokio::spawn(async move { sender.send(update).await })
20390        };
20391
20392        let preview = subscriber
20393            .next_scoped_batch()
20394            .await
20395            .expect("poll preview")
20396            .expect("preview batch");
20397        assert_eq!(preview.records().len(), 1);
20398        assert!(preview.records()[0].scope().is_preconfirmed());
20399        assert_eq!(
20400            preview.records()[0].context.source,
20401            InputSource::Flashblocks
20402        );
20403        assert!(subscriber.latest_preconfirmation.is_some());
20404        assert_eq!(sending.await.expect("sender task"), Ok(()));
20405
20406        let mut invalid_update = valid_update.clone();
20407        let FlashblockUpdate::Snapshot(snapshot) = &mut invalid_update else {
20408            unreachable!("fixture is a snapshot")
20409        };
20410        snapshot.logs[0].block_hash = Some(B256::repeat_byte(0xee));
20411        let rejecting = {
20412            let sender = sender.clone();
20413            tokio::spawn(async move { sender.send(invalid_update).await })
20414        };
20415        let rejected = subscriber
20416            .next_scoped_batch()
20417            .await
20418            .expect("preferred mode keeps polling")
20419            .expect("rejected update invalidation");
20420        assert!(rejected.preconfirmation_invalidated());
20421        assert!(rejected.records().is_empty());
20422        assert!(subscriber.latest_preconfirmation.is_none());
20423        assert_eq!(
20424            rejecting.await.expect("sender task"),
20425            Err(FlashblockUpdateChannelError::Rejected)
20426        );
20427        subscriber
20428            .ingest_flashblock_update(valid_update)
20429            .expect("rejected generation is ignored thereafter");
20430        assert!(subscriber.latest_preconfirmation.is_none());
20431
20432        let _reset = adapter
20433            .reset(ProviderRef::new("raw-json", 5))
20434            .expect("advance rejected source generation");
20435        let recovered_update = adapter
20436            .ingest_json(frame)
20437            .expect("valid replacement generation")
20438            .expect("replacement snapshot update");
20439        let recovering = {
20440            let sender = sender.clone();
20441            tokio::spawn(async move { sender.send(recovered_update).await })
20442        };
20443        let recovered = subscriber
20444            .next_scoped_batch()
20445            .await
20446            .expect("poll replacement generation")
20447            .expect("replacement preview batch");
20448        assert_eq!(recovered.records().len(), 1);
20449        assert!(matches!(
20450            &recovered.records()[0].context.chain_status,
20451            ChainStatus::Preconfirmed { flashblock }
20452                if flashblock.provider == ProviderRef::new("raw-json", 5)
20453        ));
20454        assert_eq!(recovering.await.expect("sender task"), Ok(()));
20455
20456        drop(sender);
20457        let invalidation = subscriber
20458            .next_scoped_batch()
20459            .await
20460            .expect("poll channel closure")
20461            .expect("closure invalidation");
20462        assert!(invalidation.preconfirmation_invalidated());
20463        assert!(invalidation.records().is_empty());
20464        assert!(subscriber.latest_preconfirmation.is_none());
20465        assert!(matches!(
20466            &subscriber.state,
20467            AlloySubscriberState::Active(streams)
20468                if streams.entries.iter().any(|entry| matches!(
20469                    entry.source,
20470                    SubscriberStreamSource::PubSubLog { id, .. } if id == source_id
20471                ))
20472        ));
20473    }
20474
20475    #[tokio::test]
20476    #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
20477    async fn required_external_channel_closure_fails_the_subscriber_closed() {
20478        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20479        let source = ProviderRef::new("raw-json", 4);
20480        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20481            provider,
20482            SubscriberMode::PubSub,
20483            SubscriberConfig {
20484                preconfirmations: PreconfirmationMode::Required,
20485                ..SubscriberConfig::default()
20486            },
20487        );
20488        subscriber
20489            .configure_external_flashblock_updates(source)
20490            .expect("configure external source");
20491        subscriber.chain_id = Some(1);
20492        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20493        subscriber.interests = subscriber.base_interests.clone();
20494        subscriber.state = AlloySubscriberState::Active(SubscriberStreams::new());
20495        subscriber.sources_dirty = false;
20496
20497        let sender = subscriber
20498            .open_external_flashblock_update_channel(1)
20499            .expect("bounded external queue");
20500        let external = SubscriberStreamSource::ExternalFlashblockUpdates;
20501        let update_stream = subscriber
20502            .connect_source_stream(external.clone())
20503            .await
20504            .expect("attach receiver as subscriber source");
20505        subscriber.install_source_stream(external, update_stream);
20506        subscriber.sources_dirty = false;
20507        drop(sender);
20508
20509        assert!(matches!(
20510            subscriber.next_scoped_batch().await,
20511            Err(SubscriberError::Provider(ref message))
20512                if message.contains("required external Flashblock update channel closed")
20513        ));
20514    }
20515
20516    #[tokio::test]
20517    #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
20518    async fn required_external_channel_rejects_a_queued_malformed_update() {
20519        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20520        let source = ProviderRef::new("raw-json", 4);
20521        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20522            provider,
20523            SubscriberMode::PubSub,
20524            SubscriberConfig {
20525                preconfirmations: PreconfirmationMode::Required,
20526                ..SubscriberConfig::default()
20527            },
20528        );
20529        subscriber
20530            .configure_external_flashblock_updates(source.clone())
20531            .expect("configure external source");
20532        subscriber.chain_id = Some(1);
20533        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20534        subscriber.interests = subscriber.base_interests.clone();
20535        subscriber.state = AlloySubscriberState::Active(SubscriberStreams::new());
20536        subscriber.sources_dirty = false;
20537
20538        let sender = subscriber
20539            .open_external_flashblock_update_channel(1)
20540            .expect("bounded external queue");
20541        let external = SubscriberStreamSource::ExternalFlashblockUpdates;
20542        let update_stream = subscriber
20543            .connect_source_stream(external.clone())
20544            .await
20545            .expect("attach receiver as subscriber source");
20546        subscriber.install_source_stream(external, update_stream);
20547        subscriber.sources_dirty = false;
20548
20549        let mut adapter = RawJsonFlashblocksAdapter::new(source);
20550        let mut update = adapter
20551            .ingest_json(
20552                br#"{
20553                    "payload_id":"0x1111111111111111",
20554                    "index":0,
20555                    "base":{
20556                        "parent_hash":"0x0606060606060606060606060606060606060606060606060606060606060606",
20557                        "block_number":"0x7",
20558                        "timestamp":"0x6553f107"
20559                    },
20560                    "diff":{
20561                        "state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
20562                        "block_hash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
20563                        "transactions":[]
20564                    },
20565                    "metadata":{"block_number":7,"receipts":{}}
20566                }"#,
20567            )
20568            .expect("valid raw frame")
20569            .expect("snapshot update");
20570        let FlashblockUpdate::Snapshot(snapshot) = &mut update else {
20571            unreachable!("fixture is a snapshot")
20572        };
20573        snapshot.flashblock.content_hash = B256::ZERO;
20574        let sending = tokio::spawn(async move { sender.send(update).await });
20575
20576        assert!(matches!(
20577            subscriber.next_scoped_batch().await,
20578            Err(SubscriberError::Provider(ref message))
20579                if message.contains("content commitment is invalid")
20580        ));
20581        assert_eq!(
20582            sending.await.expect("sender task"),
20583            Err(FlashblockUpdateChannelError::Rejected)
20584        );
20585    }
20586
20587    #[tokio::test]
20588    #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
20589    async fn bounded_external_channel_reports_capacity_rejection_and_accepts_a_new_generation() {
20590        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20591        let source = ProviderRef::new("raw-json", 4);
20592        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20593            provider,
20594            SubscriberMode::PubSub,
20595            SubscriberConfig {
20596                preconfirmations: PreconfirmationMode::Preferred,
20597                max_pending_records: 1,
20598                ..SubscriberConfig::default()
20599            },
20600        );
20601        subscriber
20602            .configure_external_flashblock_updates(source.clone())
20603            .expect("configure external source");
20604        subscriber.chain_id = Some(1);
20605        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20606        subscriber.interests = subscriber.base_interests.clone();
20607        subscriber.state = AlloySubscriberState::Active(SubscriberStreams::new());
20608        subscriber.sources_dirty = false;
20609
20610        let sender = subscriber
20611            .open_external_flashblock_update_channel(1)
20612            .expect("bounded external queue");
20613        let external = SubscriberStreamSource::ExternalFlashblockUpdates;
20614        let update_stream = subscriber
20615            .connect_source_stream(external.clone())
20616            .await
20617            .expect("attach receiver as subscriber source");
20618        subscriber.install_source_stream(external, update_stream);
20619        subscriber.sources_dirty = false;
20620
20621        let first_frame = br#"{
20622            "payload_id":"0x1111111111111111",
20623            "index":0,
20624            "base":{
20625                "parent_hash":"0x0606060606060606060606060606060606060606060606060606060606060606",
20626                "block_number":"0x7",
20627                "timestamp":"0x6553f107"
20628            },
20629            "diff":{
20630                "state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
20631                "block_hash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
20632                "transactions":["0x01"]
20633            },
20634            "metadata":{
20635                "block_number":7,
20636                "receipts":{
20637                    "0x5fe7f977e71dba2ea1a68e21057beebb9be2ac30c6410aa38d4f3fbe41dcffd2":{
20638                        "logs":[{
20639                            "address":"0x4242424242424242424242424242424242424242",
20640                            "topics":["0x0101010101010101010101010101010101010101010101010101010101010101"],
20641                            "data":"0x"
20642                        }]
20643                    }
20644                }
20645            }
20646        }"#;
20647        let second_frame = br#"{
20648            "payload_id":"0x1111111111111111",
20649            "index":1,
20650            "diff":{
20651                "state_root":"0xabababababababababababababababababababababababababababababababab",
20652                "block_hash":"0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
20653                "transactions":["0x02"]
20654            },
20655            "metadata":{
20656                "block_number":7,
20657                "receipts":{
20658                    "0xf2ee15ea639b73fa3db9b34a245bdfa015c260c598b211bf05a1ecc4b3e3b4f2":{
20659                        "logs":[
20660                            {"address":"0x4444444444444444444444444444444444444444","topics":[],"data":"0x"},
20661                            {"address":"0x4545454545454545454545454545454545454545","topics":[],"data":"0x"}
20662                        ]
20663                    }
20664                }
20665            }
20666        }"#;
20667        let mut adapter = RawJsonFlashblocksAdapter::new(source);
20668        let first = adapter
20669            .ingest_json(first_frame)
20670            .expect("valid first frame")
20671            .expect("first snapshot");
20672        let first_send = {
20673            let sender = sender.clone();
20674            tokio::spawn(async move { sender.send(first).await })
20675        };
20676        let first_batch = subscriber
20677            .next_scoped_batch()
20678            .await
20679            .expect("poll first preview")
20680            .expect("first preview batch");
20681        assert_eq!(first_batch.records().len(), 1);
20682        assert_eq!(first_send.await.expect("sender task"), Ok(()));
20683
20684        let oversized = adapter
20685            .ingest_json(second_frame)
20686            .expect("valid oversized delta")
20687            .expect("oversized standardized snapshot");
20688        let rejected_send = {
20689            let sender = sender.clone();
20690            tokio::spawn(async move { sender.send(oversized).await })
20691        };
20692        let invalidation = subscriber
20693            .next_scoped_batch()
20694            .await
20695            .expect("poll capacity rejection")
20696            .expect("capacity invalidation batch");
20697        assert!(invalidation.preconfirmation_invalidated());
20698        assert_eq!(
20699            rejected_send.await.expect("sender task"),
20700            Err(FlashblockUpdateChannelError::Rejected)
20701        );
20702        assert_eq!(subscriber.rejected_external_flashblock_generation, None);
20703
20704        let _ = adapter
20705            .reset(ProviderRef::new("raw-json", 5))
20706            .expect("advance after local capacity rejection");
20707        let recovered = adapter
20708            .ingest_json(first_frame)
20709            .expect("valid recovered frame")
20710            .expect("recovered snapshot");
20711        let recovered_send = {
20712            let sender = sender.clone();
20713            tokio::spawn(async move { sender.send(recovered).await })
20714        };
20715        let recovered_batch = subscriber
20716            .next_scoped_batch()
20717            .await
20718            .expect("poll recovered generation")
20719            .expect("recovered preview batch");
20720        assert_eq!(recovered_batch.records().len(), 1);
20721        assert!(matches!(
20722            &recovered_batch.records()[0].context.chain_status,
20723            ChainStatus::Preconfirmed { flashblock }
20724                if flashblock.provider == ProviderRef::new("raw-json", 5)
20725        ));
20726        assert_eq!(recovered_send.await.expect("sender task"), Ok(()));
20727    }
20728
20729    #[tokio::test]
20730    async fn certified_canonical_heads_are_deduplicated_and_reject_placeholder_hashes() {
20731        let asserter = Asserter::new();
20732        let certified = rpc_block(101, B256::repeat_byte(0x65));
20733        asserter.push_success(&Some(certified.clone()));
20734        asserter.push_success(&Some(certified));
20735        asserter.push_success(&Some(rpc_block(102, B256::ZERO)));
20736        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
20737        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20738            provider,
20739            SubscriberMode::PubSub,
20740            SubscriberConfig::default(),
20741        );
20742
20743        assert!(matches!(
20744            subscriber
20745                .fetch_certified_canonical_head()
20746                .await
20747                .expect("first certified head"),
20748            Some(SubscriberEvent::BlockHeader(_))
20749        ));
20750        assert!(
20751            subscriber
20752                .fetch_certified_canonical_head()
20753                .await
20754                .expect("duplicate certified head")
20755                .is_none()
20756        );
20757        assert!(matches!(
20758            subscriber.fetch_certified_canonical_head().await,
20759            Err(SubscriberError::Provider(ref message))
20760                if message.contains("placeholder hash")
20761        ));
20762    }
20763
20764    #[tokio::test]
20765    async fn canonical_head_certification_times_out_a_silent_provider() {
20766        let provider =
20767            ProviderBuilder::new().connect_client(RpcClient::new(NeverRespondingTransport, true));
20768        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20769            provider,
20770            SubscriberMode::PubSub,
20771            SubscriberConfig {
20772                preconfirmations: PreconfirmationMode::Required,
20773                canonical_head_request_timeout: Duration::from_millis(10),
20774                ..SubscriberConfig::default()
20775            },
20776        );
20777        subscriber.chain_id = Some(8_453);
20778
20779        let result = tokio::time::timeout(
20780            Duration::from_millis(100),
20781            subscriber.fetch_certified_canonical_head(),
20782        )
20783        .await
20784        .expect("subscriber must bound a silent provider request");
20785        assert!(matches!(
20786            result,
20787            Err(SubscriberError::Provider(ref message))
20788                if message.contains("canonical head certification timed out")
20789        ));
20790    }
20791
20792    #[tokio::test]
20793    async fn optimism_canonical_head_is_the_exact_parent_of_pending() {
20794        let asserter = Asserter::new();
20795        queue_op_pending(&asserter, rpc_block(101, B256::ZERO));
20796        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20797        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20798            provider,
20799            SubscriberMode::PubSub,
20800            SubscriberConfig {
20801                preconfirmations: PreconfirmationMode::Required,
20802                ..SubscriberConfig::default()
20803            },
20804        );
20805        subscriber.chain_id = Some(10);
20806        subscriber.interests = vec![ReactiveInterest::Blocks(BlockInterest::default())];
20807
20808        let event = subscriber
20809            .fetch_certified_canonical_head()
20810            .await
20811            .expect("OP pending parent can be certified")
20812            .expect("the first certified parent is emitted");
20813        let SubscriberEvent::BlockHeader(header) = event else {
20814            panic!("expected a certified canonical block header")
20815        };
20816        assert_eq!(header.number(), 100);
20817        assert_eq!(header.hash, B256::repeat_byte(0x64));
20818        assert_eq!(
20819            subscriber
20820                .flashblocks_rpc_metrics()
20821                .pending_block_requests(),
20822            1
20823        );
20824        assert_eq!(
20825            subscriber
20826                .flashblocks_rpc_metrics()
20827                .canonical_head_requests(),
20828            1
20829        );
20830        assert!(asserter.read_q().is_empty());
20831    }
20832
20833    #[test]
20834    fn optimism_uses_one_bounded_pending_state_stream() {
20835        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20836        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20837            provider,
20838            SubscriberMode::PubSub,
20839            SubscriberConfig {
20840                preconfirmations: PreconfirmationMode::Required,
20841                ..SubscriberConfig::default()
20842            },
20843        )
20844        .with_provider_ref(ProviderRef::new("op-paid", 11));
20845        subscriber.chain_id = Some(10);
20846        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
20847            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
20848            local_matcher: None,
20849            route_key: None,
20850        })];
20851        subscriber.interests = subscriber.base_interests.clone();
20852
20853        let sources = subscriber.pubsub_stream_sources();
20854        assert_eq!(
20855            sources
20856                .iter()
20857                .filter(|source| matches!(source, SubscriberStreamSource::OpPendingFlashblocks))
20858                .count(),
20859            1
20860        );
20861        assert!(sources.iter().all(|source| !matches!(
20862            source,
20863            SubscriberStreamSource::BaseFlashblocks | SubscriberStreamSource::BasePendingLog { .. }
20864        )));
20865    }
20866
20867    #[test]
20868    fn optimism_default_receipt_budget_reserves_every_fixed_sampler_method() {
20869        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20870        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20871            provider,
20872            SubscriberMode::PubSub,
20873            SubscriberConfig {
20874                preconfirmations: PreconfirmationMode::Required,
20875                ..SubscriberConfig::default()
20876            },
20877        );
20878        subscriber.chain_id = Some(10);
20879        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20880        subscriber.interests = subscriber.base_interests.clone();
20881
20882        // At 250 ms, the sampler reserves 4 * (exact parent + pending block +
20883        // one filtered log request) = 12 methods. The remaining 28 exact
20884        // receipt methods stay below the configured 40-method ceiling.
20885        assert_eq!(
20886            subscriber.pending_receipt_requests_per_second_capacity(),
20887            28
20888        );
20889        assert_eq!(subscriber.pending_receipt_requests_per_tick_capacity(), 7);
20890
20891        subscriber
20892            .interests
20893            .push(ReactiveInterest::Blocks(BlockInterest::default()));
20894        assert_eq!(
20895            subscriber.pending_receipt_requests_per_second_capacity(),
20896            24
20897        );
20898        assert_eq!(subscriber.pending_receipt_requests_per_tick_capacity(), 6);
20899        subscriber.interests.pop();
20900
20901        for _ in 0..4 {
20902            assert!(subscriber.reserve_flashblock_rpc_methods(3));
20903            assert_eq!(subscriber.pending_receipt_request_allowance(), 7);
20904            assert!(subscriber.reserve_flashblock_rpc_methods(7));
20905        }
20906        assert!(!subscriber.reserve_flashblock_rpc_methods(1));
20907        subscriber.reset_flashblock_tracking();
20908        assert!(
20909            !subscriber.reserve_flashblock_rpc_methods(1),
20910            "a reconnect must not reset an endpoint's rolling quota window"
20911        );
20912    }
20913
20914    #[test]
20915    fn flashblocks_config_rejects_a_zero_rpc_budget() {
20916        let config = SubscriberConfig {
20917            preconfirmations: PreconfirmationMode::Required,
20918            max_flashblock_rpc_requests_per_second: 0,
20919            ..SubscriberConfig::default()
20920        };
20921
20922        assert!(matches!(
20923            validate_subscriber_config(&config),
20924            Err(SubscriberError::InvalidConfig(
20925                "SubscriberConfig::max_flashblock_rpc_requests_per_second must be greater than zero"
20926            ))
20927        ));
20928    }
20929
20930    #[test]
20931    fn flashblocks_config_rejects_a_zero_canonical_head_request_timeout() {
20932        let config = SubscriberConfig {
20933            preconfirmations: PreconfirmationMode::Required,
20934            canonical_head_request_timeout: Duration::ZERO,
20935            ..SubscriberConfig::default()
20936        };
20937
20938        assert!(matches!(
20939            validate_subscriber_config(&config),
20940            Err(SubscriberError::InvalidConfig(
20941                "SubscriberConfig::canonical_head_request_timeout must be greater than zero"
20942            ))
20943        ));
20944    }
20945
20946    #[tokio::test]
20947    async fn optimism_preflight_rejects_a_budget_without_receipt_capacity() {
20948        let asserter = Asserter::new();
20949        asserter.push_success(&serde_json::json!(["flashblocksv1"]));
20950        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20951        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20952            provider,
20953            SubscriberMode::PubSub,
20954            SubscriberConfig {
20955                preconfirmations: PreconfirmationMode::Required,
20956                // Four ticks reserve three fixed methods each. Three remaining
20957                // methods cannot fund even one receipt on every tick.
20958                max_flashblock_rpc_requests_per_second: 15,
20959                ..SubscriberConfig::default()
20960            },
20961        )
20962        .with_provider_ref(ProviderRef::new("op-paid", 12));
20963        subscriber.chain_id = Some(10);
20964        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20965        subscriber.interests = subscriber.base_interests.clone();
20966        let desired = subscriber.pubsub_stream_sources();
20967        let mut streams = SubscriberStreams::new();
20968        for source in desired {
20969            streams.push(source, stream::pending().boxed());
20970        }
20971        subscriber.state = AlloySubscriberState::Active(streams);
20972        subscriber.sources_dirty = false;
20973        assert!(matches!(
20974            subscriber.establish_flashblocks_preflight(10).await,
20975            Err(SubscriberError::InvalidConfig(message))
20976                if message.contains("leaves no capacity for OP transaction receipts")
20977        ));
20978        assert!(asserter.read_q().is_empty());
20979    }
20980
20981    #[cfg(feature = "reactive-ws")]
20982    #[tokio::test]
20983    async fn flashblocks_preflight_proves_chain_and_both_subscription_lanes() {
20984        let asserter = Asserter::new();
20985        asserter.push_success(&serde_json::json!({"flashblocks": true}));
20986        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
20987        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20988            provider,
20989            SubscriberMode::PubSub,
20990            SubscriberConfig {
20991                preconfirmations: PreconfirmationMode::Required,
20992                ..SubscriberConfig::default()
20993            },
20994        )
20995        .with_provider_ref(ProviderRef::new("base-paid", 7));
20996        subscriber.chain_id = Some(8_453);
20997        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20998        subscriber.interests = subscriber.base_interests.clone();
20999        let desired = subscriber.pubsub_stream_sources();
21000        let mut streams = SubscriberStreams::new();
21001        for source in desired {
21002            streams.push(source, stream::pending().boxed());
21003        }
21004        subscriber.state = AlloySubscriberState::Active(streams);
21005        subscriber.sources_dirty = false;
21006
21007        let preflight = subscriber
21008            .establish_flashblocks_preflight(8_453)
21009            .await
21010            .expect("preflight succeeds");
21011
21012        assert_eq!(preflight.chain_id(), 8_453);
21013        assert_eq!(preflight.provider(), &ProviderRef::new("base-paid", 7));
21014        assert_eq!(
21015            preflight.delivery(),
21016            FlashblocksDelivery::NativeSubscriptions
21017        );
21018        assert_eq!(preflight.pending_log_subscriptions(), 1);
21019        assert_eq!(preflight.pending_log_filters(), 1);
21020        assert_eq!(
21021            preflight.advertised_capabilities(),
21022            Some(&serde_json::json!({"flashblocks": true}))
21023        );
21024    }
21025
21026    #[tokio::test]
21027    async fn optimism_preflight_probes_pending_state_without_native_subscriptions() {
21028        let asserter = Asserter::new();
21029        asserter.push_success(&serde_json::json!(["flashblocksv1"]));
21030        queue_op_pending(&asserter, rpc_block(101, B256::ZERO));
21031        asserter.push_success(&Vec::<Log>::new());
21032        asserter.push_success(&serde_json::json!([]));
21033        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21034        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21035            provider,
21036            SubscriberMode::PubSub,
21037            SubscriberConfig {
21038                preconfirmations: PreconfirmationMode::Required,
21039                ..SubscriberConfig::default()
21040            },
21041        )
21042        .with_provider_ref(ProviderRef::new("op-paid", 12));
21043        subscriber.chain_id = Some(10);
21044        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21045        subscriber.interests = subscriber.base_interests.clone();
21046        let desired = subscriber.pubsub_stream_sources();
21047        let mut streams = SubscriberStreams::new();
21048        for source in desired {
21049            streams.push(source, stream::pending().boxed());
21050        }
21051        subscriber.state = AlloySubscriberState::Active(streams);
21052        subscriber.sources_dirty = false;
21053
21054        let preflight = subscriber
21055            .establish_flashblocks_preflight(10)
21056            .await
21057            .expect("Optimism pending-state preflight succeeds");
21058
21059        assert_eq!(preflight.chain_id(), 10);
21060        assert_eq!(preflight.provider(), &ProviderRef::new("op-paid", 12));
21061        assert_eq!(
21062            preflight.delivery(),
21063            FlashblocksDelivery::PendingStatePolling
21064        );
21065        assert_eq!(preflight.pending_log_subscriptions(), 0);
21066        assert_eq!(preflight.pending_log_filters(), 1);
21067        assert_eq!(
21068            preflight.advertised_capabilities(),
21069            Some(&serde_json::json!(["flashblocksv1"]))
21070        );
21071        assert!(asserter.read_q().is_empty());
21072    }
21073
21074    #[test]
21075    fn optimism_full_pending_block_normalizes_op_transaction_types_to_hashes() {
21076        let transaction_hash = B256::repeat_byte(0x7e);
21077        let mut value = serde_json::to_value(rpc_block(101, B256::ZERO))
21078            .expect("serialize pending block fixture");
21079        value["transactions"] = serde_json::json!([{
21080            "type": "0x7e",
21081            "hash": transaction_hash,
21082            "sourceHash": B256::repeat_byte(0x11),
21083            "from": Address::repeat_byte(0x22),
21084            "to": Address::repeat_byte(0x33)
21085        }]);
21086
21087        let block = normalize_op_pending_block::<Ethereum>(value)
21088            .expect("OP-specific transaction bodies are reduced to hashes");
21089
21090        assert_eq!(
21091            block.transactions().as_hashes(),
21092            Some(&[transaction_hash][..])
21093        );
21094    }
21095
21096    #[tokio::test]
21097    async fn optimism_sampler_does_not_retry_malformed_pending_content() {
21098        let asserter = Asserter::new();
21099        let mut pending = serde_json::to_value(rpc_block(101, B256::ZERO))
21100            .expect("serialize pending block fixture");
21101        pending["transactions"] = serde_json::json!([{"type": "0x7e"}]);
21102        asserter.push_success(&Some(pending));
21103        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21104        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21105            provider,
21106            SubscriberMode::PubSub,
21107            SubscriberConfig {
21108                preconfirmations: PreconfirmationMode::Required,
21109                ..SubscriberConfig::default()
21110            },
21111        )
21112        .with_provider_ref(ProviderRef::new("op-paid", 12));
21113        subscriber.chain_id = Some(10);
21114
21115        let error = match subscriber
21116            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21117            .await
21118        {
21119            Err(error) => error,
21120            Ok(_) => panic!("malformed provider content must fail immediately"),
21121        };
21122        assert!(
21123            error
21124                .to_string()
21125                .contains("transaction is missing its hash")
21126        );
21127        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 0);
21128        assert!(asserter.read_q().is_empty());
21129    }
21130
21131    #[tokio::test]
21132    async fn optimism_sampler_certifies_the_pending_block_by_exact_parent_hash() {
21133        let asserter = Asserter::new();
21134        queue_op_pending(&asserter, rpc_block(101, B256::ZERO));
21135        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
21136        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21137            provider,
21138            SubscriberMode::PubSub,
21139            SubscriberConfig {
21140                preconfirmations: PreconfirmationMode::Required,
21141                ..SubscriberConfig::default()
21142            },
21143        )
21144        .with_provider_ref(ProviderRef::new("op-paid", 12));
21145        subscriber.chain_id = Some(10);
21146
21147        assert!(
21148            subscriber
21149                .fetch_pending_flashblock(None)
21150                .await
21151                .expect("the exact parent certifies the pending payload")
21152                .is_some()
21153        );
21154    }
21155
21156    #[tokio::test]
21157    async fn optimism_sampler_rejects_a_nonconsecutive_pending_parent() {
21158        let asserter = Asserter::new();
21159        asserter.push_success(&Some(rpc_block(101, B256::ZERO)));
21160        asserter.push_success(&Some(rpc_block(99, B256::repeat_byte(0x64))));
21161        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21162        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21163            provider,
21164            SubscriberMode::PubSub,
21165            SubscriberConfig {
21166                preconfirmations: PreconfirmationMode::Required,
21167                ..SubscriberConfig::default()
21168            },
21169        )
21170        .with_provider_ref(ProviderRef::new("op-paid", 12));
21171        subscriber.chain_id = Some(10);
21172
21173        assert!(matches!(
21174            subscriber.fetch_pending_flashblock(None).await,
21175            Err(PendingFlashblockPollError::Integrity(SubscriberError::Provider(
21176                ref message
21177            ))) if message.contains("does not extend its exact certified parent")
21178        ));
21179        assert!(asserter.read_q().is_empty());
21180    }
21181
21182    #[tokio::test]
21183    async fn optimism_sampler_rechecks_unchanged_content_without_republishing_logs() {
21184        let asserter = Asserter::new();
21185        let pending = rpc_block(101, B256::ZERO);
21186        queue_op_pending(&asserter, pending.clone());
21187        asserter.push_success(&Vec::<Log>::new());
21188        queue_op_pending(&asserter, pending);
21189        asserter.push_success(&Vec::<Log>::new());
21190        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21191        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21192            provider,
21193            SubscriberMode::PubSub,
21194            SubscriberConfig {
21195                preconfirmations: PreconfirmationMode::Required,
21196                ..SubscriberConfig::default()
21197            },
21198        )
21199        .with_provider_ref(ProviderRef::new("op-paid", 12));
21200        subscriber.chain_id = Some(10);
21201        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21202        subscriber.interests = subscriber.base_interests.clone();
21203
21204        assert!(
21205            subscriber
21206                .fetch_pending_flashblock(None)
21207                .await
21208                .expect("first cumulative pending view")
21209                .is_some()
21210        );
21211        assert!(
21212            subscriber
21213                .fetch_pending_flashblock(None)
21214                .await
21215                .expect("duplicate cumulative pending view")
21216                .is_none()
21217        );
21218
21219        assert_eq!(
21220            subscriber.flashblocks_rpc_metrics(),
21221            FlashblocksRpcMetrics {
21222                capability_requests: 0,
21223                provider_pair_chain_requests: 0,
21224                canonical_head_requests: 2,
21225                pending_block_requests: 2,
21226                pending_log_requests: 2,
21227                pending_receipt_requests: 0,
21228                pending_receipts_completed: 0,
21229                pending_receipts_unavailable: 0,
21230                failed_requests: 0,
21231                raced_samples: 0,
21232                suppressed_canonical_head_polls: 0,
21233            }
21234        );
21235        assert!(asserter.read_q().is_empty());
21236    }
21237
21238    #[tokio::test]
21239    async fn optimism_sampler_rechecks_logs_for_an_unchanged_pending_view() {
21240        let asserter = Asserter::new();
21241        let transaction = B256::repeat_byte(0x42);
21242        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
21243            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction]),
21244        );
21245        let mut log = rpc_log(false);
21246        log.block_number = Some(101);
21247        log.block_hash = Some(B256::repeat_byte(0xa2));
21248        log.transaction_hash = Some(transaction);
21249        log.transaction_index = Some(0);
21250        log.log_index = Some(0);
21251        queue_op_pending(&asserter, pending.clone());
21252        asserter.push_success(&Vec::<Log>::new());
21253        asserter.push_success(&serde_json::Value::Null);
21254        queue_op_pending(&asserter, pending);
21255        asserter.push_success(&vec![log]);
21256        asserter.push_success(&serde_json::Value::Null);
21257        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21258        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21259            provider,
21260            SubscriberMode::PubSub,
21261            SubscriberConfig {
21262                preconfirmations: PreconfirmationMode::Required,
21263                ..SubscriberConfig::default()
21264            },
21265        )
21266        .with_provider_ref(ProviderRef::new("op-paid", 12));
21267        subscriber.chain_id = Some(10);
21268        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21269        subscriber.interests = subscriber.base_interests.clone();
21270
21271        assert!(matches!(
21272            subscriber
21273                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21274                .await
21275                .expect("first pending view is coherent"),
21276            Some(SubscriberEvent::FlashblockObserved)
21277        ));
21278        assert!(matches!(
21279            subscriber
21280                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21281                .await
21282                .expect("the unchanged view is checked again for lagging logs"),
21283            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
21284        ));
21285        assert!(asserter.read_q().is_empty());
21286    }
21287
21288    #[tokio::test]
21289    async fn optimism_sampler_hydrates_exact_receipts_when_filtered_logs_are_empty() {
21290        let asserter = Asserter::new();
21291        let transaction = B256::repeat_byte(0x42);
21292        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
21293            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction]),
21294        );
21295        let mut log = rpc_log(false);
21296        log.block_number = Some(101);
21297        log.block_hash = Some(B256::repeat_byte(0xa2));
21298        log.transaction_hash = Some(transaction);
21299        log.transaction_index = Some(0);
21300        log.log_index = Some(0);
21301        queue_op_pending(&asserter, pending);
21302        asserter.push_success(&Vec::<Log>::new());
21303        asserter.push_success(&serde_json::json!({
21304            "transactionHash": transaction,
21305            "logs": [log]
21306        }));
21307        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21308        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21309            provider,
21310            SubscriberMode::PubSub,
21311            SubscriberConfig {
21312                preconfirmations: PreconfirmationMode::Required,
21313                ..SubscriberConfig::default()
21314            },
21315        )
21316        .with_provider_ref(ProviderRef::new("op-paid", 12));
21317        subscriber.chain_id = Some(10);
21318        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21319        subscriber.interests = subscriber.base_interests.clone();
21320
21321        let event = subscriber
21322            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21323            .await
21324            .expect("pending receipt fallback succeeds");
21325        assert!(matches!(
21326            event,
21327            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
21328        ));
21329        assert_eq!(
21330            subscriber
21331                .flashblocks_rpc_metrics()
21332                .pending_receipt_requests(),
21333            1
21334        );
21335        assert!(asserter.read_q().is_empty());
21336    }
21337
21338    #[tokio::test]
21339    async fn optimism_receipt_hydration_is_bounded_and_resumes_on_the_next_tick() {
21340        let asserter = Asserter::new();
21341        let transaction_a = B256::repeat_byte(0x41);
21342        let transaction_b = B256::repeat_byte(0x42);
21343        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
21344            alloy_network::primitives::BlockTransactions::Hashes(vec![
21345                transaction_a,
21346                transaction_b,
21347            ]),
21348        );
21349        let mut log = rpc_log(false);
21350        log.block_number = Some(101);
21351        log.transaction_hash = Some(transaction_b);
21352        log.transaction_index = Some(1);
21353        queue_op_pending(&asserter, pending.clone());
21354        asserter.push_success(&Vec::<Log>::new());
21355        asserter.push_success(&serde_json::json!({
21356            "transactionHash": transaction_a,
21357            "logs": []
21358        }));
21359        queue_op_pending(&asserter, pending);
21360        asserter.push_success(&Vec::<Log>::new());
21361        asserter.push_success(&serde_json::json!({
21362            "transactionHash": transaction_b,
21363            "logs": [log]
21364        }));
21365        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21366        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21367            provider,
21368            SubscriberMode::PubSub,
21369            SubscriberConfig {
21370                preconfirmations: PreconfirmationMode::Required,
21371                max_pending_transaction_receipts_per_tick: 1,
21372                ..SubscriberConfig::default()
21373            },
21374        )
21375        .with_provider_ref(ProviderRef::new("op-paid", 12));
21376        subscriber.chain_id = Some(10);
21377        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21378        subscriber.interests = subscriber.base_interests.clone();
21379
21380        assert!(matches!(
21381            subscriber
21382                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21383                .await
21384                .expect("the first bounded receipt is hydrated"),
21385            Some(SubscriberEvent::FlashblockObserved)
21386        ));
21387        assert!(matches!(
21388            subscriber
21389                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21390                .await
21391                .expect("the remaining receipt is hydrated on the next tick"),
21392            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
21393        ));
21394        assert_eq!(
21395            subscriber
21396                .flashblocks_rpc_metrics()
21397                .pending_receipt_requests(),
21398            2
21399        );
21400        assert_eq!(subscriber.preconfirmed_receipted_transactions.len(), 2);
21401        assert!(asserter.read_q().is_empty());
21402    }
21403
21404    #[tokio::test]
21405    async fn optimism_receipt_hydration_prioritizes_unattempted_hashes_over_null_retries() {
21406        let asserter = Asserter::new();
21407        let transaction_a = B256::repeat_byte(0x41);
21408        let transaction_b = B256::repeat_byte(0x42);
21409        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
21410            alloy_network::primitives::BlockTransactions::Hashes(vec![
21411                transaction_a,
21412                transaction_b,
21413            ]),
21414        );
21415        let mut log = rpc_log(false);
21416        log.block_number = Some(101);
21417        log.transaction_hash = Some(transaction_b);
21418        log.transaction_index = Some(1);
21419        queue_op_pending(&asserter, pending.clone());
21420        asserter.push_success(&Vec::<Log>::new());
21421        asserter.push_success(&serde_json::Value::Null);
21422        queue_op_pending(&asserter, pending);
21423        asserter.push_success(&Vec::<Log>::new());
21424        asserter.push_success(&serde_json::json!({
21425            "transactionHash": transaction_b,
21426            "logs": [log]
21427        }));
21428        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21429        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21430            provider,
21431            SubscriberMode::PubSub,
21432            SubscriberConfig {
21433                preconfirmations: PreconfirmationMode::Required,
21434                max_pending_transaction_receipts_per_tick: 1,
21435                ..SubscriberConfig::default()
21436            },
21437        )
21438        .with_provider_ref(ProviderRef::new("op-paid", 12));
21439        subscriber.chain_id = Some(10);
21440        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21441        subscriber.interests = subscriber.base_interests.clone();
21442
21443        assert!(matches!(
21444            subscriber
21445                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21446                .await
21447                .expect("the first null receipt remains retryable"),
21448            Some(SubscriberEvent::FlashblockObserved)
21449        ));
21450        assert!(matches!(
21451            subscriber
21452                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21453                .await
21454                .expect("the next unattempted receipt is not starved"),
21455            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
21456        ));
21457        assert!(
21458            subscriber
21459                .preconfirmed_unavailable_receipts
21460                .contains(&transaction_a)
21461        );
21462        assert!(
21463            subscriber
21464                .preconfirmed_receipted_transactions
21465                .contains(&transaction_b)
21466        );
21467        assert!(asserter.read_q().is_empty());
21468    }
21469
21470    #[tokio::test]
21471    async fn optimism_receipt_batch_commits_dedupe_only_after_every_response_succeeds() {
21472        let asserter = Asserter::new();
21473        let transaction_a = B256::repeat_byte(0x41);
21474        let transaction_b = B256::repeat_byte(0x42);
21475        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
21476            alloy_network::primitives::BlockTransactions::Hashes(vec![
21477                transaction_a,
21478                transaction_b,
21479            ]),
21480        );
21481        let mut log = rpc_log(false);
21482        log.block_number = Some(101);
21483        log.transaction_hash = Some(transaction_b);
21484        log.transaction_index = Some(1);
21485        queue_op_pending(&asserter, pending.clone());
21486        asserter.push_success(&Vec::<Log>::new());
21487        asserter.push_success(&serde_json::json!({
21488            "transactionHash": transaction_a,
21489            "logs": []
21490        }));
21491        asserter.push_failure_msg("receipt temporarily unavailable");
21492        queue_op_pending(&asserter, pending);
21493        asserter.push_success(&Vec::<Log>::new());
21494        asserter.push_success(&serde_json::json!({
21495            "transactionHash": transaction_a,
21496            "logs": []
21497        }));
21498        asserter.push_success(&serde_json::json!({
21499            "transactionHash": transaction_b,
21500            "logs": [log]
21501        }));
21502        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21503        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21504            provider,
21505            SubscriberMode::PubSub,
21506            SubscriberConfig {
21507                preconfirmations: PreconfirmationMode::Required,
21508                max_pending_transaction_receipts_per_tick: 2,
21509                max_consecutive_flashblock_poll_failures: 2,
21510                ..SubscriberConfig::default()
21511            },
21512        )
21513        .with_provider_ref(ProviderRef::new("op-paid", 12));
21514        subscriber.chain_id = Some(10);
21515        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21516        subscriber.interests = subscriber.base_interests.clone();
21517
21518        assert!(
21519            subscriber
21520                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21521                .await
21522                .expect("one failed receipt response remains retryable")
21523                .is_none()
21524        );
21525        assert!(subscriber.preconfirmed_receipted_transactions.is_empty());
21526        assert!(matches!(
21527            subscriber
21528                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21529                .await
21530                .expect("the complete batch is retried transactionally"),
21531            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
21532        ));
21533        assert_eq!(subscriber.preconfirmed_receipted_transactions.len(), 2);
21534        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 1);
21535        assert_eq!(
21536            subscriber
21537                .flashblocks_rpc_metrics()
21538                .pending_receipt_requests(),
21539            4
21540        );
21541        assert!(asserter.read_q().is_empty());
21542    }
21543
21544    #[tokio::test]
21545    async fn optimism_sampler_rejects_a_receipt_for_a_different_transaction() {
21546        let asserter = Asserter::new();
21547        let sampled_transaction = B256::repeat_byte(0x41);
21548        let advanced_transaction = B256::repeat_byte(0x42);
21549        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
21550            alloy_network::primitives::BlockTransactions::Hashes(vec![sampled_transaction]),
21551        );
21552        let mut log = rpc_log(false);
21553        log.block_number = Some(101);
21554        log.transaction_hash = Some(advanced_transaction);
21555        queue_op_pending(&asserter, pending);
21556        asserter.push_success(&Vec::<Log>::new());
21557        asserter.push_success(&serde_json::json!({
21558            "transactionHash": advanced_transaction,
21559            "logs": [log]
21560        }));
21561        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21562        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21563            provider,
21564            SubscriberMode::PubSub,
21565            SubscriberConfig {
21566                preconfirmations: PreconfirmationMode::Required,
21567                ..SubscriberConfig::default()
21568            },
21569        )
21570        .with_provider_ref(ProviderRef::new("op-paid", 12));
21571        subscriber.chain_id = Some(10);
21572        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21573        subscriber.interests = subscriber.base_interests.clone();
21574
21575        let error = match subscriber
21576            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21577            .await
21578        {
21579            Err(error) => error,
21580            Ok(_) => panic!("a receipt for another transaction must fail closed"),
21581        };
21582        assert!(
21583            error
21584                .to_string()
21585                .contains("hash disagrees with its request")
21586        );
21587        assert!(asserter.read_q().is_empty());
21588    }
21589
21590    #[tokio::test]
21591    async fn optimism_sampler_revokes_then_recovers_from_a_regressive_pending_view() {
21592        let asserter = Asserter::new();
21593        let transaction_a = B256::repeat_byte(0x41);
21594        let transaction_b = B256::repeat_byte(0x42);
21595        let first = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
21596            alloy_network::primitives::BlockTransactions::Hashes(vec![
21597                transaction_a,
21598                transaction_b,
21599            ]),
21600        );
21601        let regressive = rpc_block(101, B256::repeat_byte(0xa2)).with_transactions(
21602            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction_a]),
21603        );
21604        queue_op_pending(&asserter, first);
21605        asserter.push_success(&Vec::<Log>::new());
21606        asserter.push_success(&serde_json::Value::Null);
21607        asserter.push_success(&serde_json::Value::Null);
21608        queue_op_pending(&asserter, regressive.clone());
21609        queue_op_pending(&asserter, regressive);
21610        asserter.push_success(&Vec::<Log>::new());
21611        asserter.push_success(&serde_json::Value::Null);
21612        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21613        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21614            provider,
21615            SubscriberMode::PubSub,
21616            SubscriberConfig {
21617                preconfirmations: PreconfirmationMode::Required,
21618                ..SubscriberConfig::default()
21619            },
21620        )
21621        .with_provider_ref(ProviderRef::new("op-paid", 12));
21622        subscriber.chain_id = Some(10);
21623        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21624        subscriber.interests = subscriber.base_interests.clone();
21625
21626        assert!(
21627            subscriber
21628                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21629                .await
21630                .expect("first pending view is coherent")
21631                .is_some()
21632        );
21633        assert!(matches!(
21634            subscriber
21635                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21636                .await
21637                .expect("regression revokes instead of terminating the stream"),
21638            Some(SubscriberEvent::FlashblockInvalidated)
21639        ));
21640        assert!(subscriber.latest_preconfirmation.is_none());
21641        assert!(subscriber.pending_preconfirmation_invalidation);
21642        assert!(
21643            subscriber
21644                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21645                .await
21646                .expect("a later coherent view establishes a fresh snapshot")
21647                .is_some()
21648        );
21649        assert!(subscriber.latest_preconfirmation.is_some());
21650        assert_eq!(subscriber.provider_ref.as_ref().unwrap().generation, 12);
21651        assert!(asserter.read_q().is_empty());
21652    }
21653
21654    #[tokio::test]
21655    async fn optimism_new_quiet_payload_revokes_the_previous_snapshot() {
21656        let asserter = Asserter::new();
21657        let first = rpc_block(101, B256::repeat_byte(0xa1));
21658        let second = rpc_block(102, B256::repeat_byte(0xa2));
21659        queue_op_pending(&asserter, first);
21660        asserter.push_success(&Vec::<Log>::new());
21661        queue_op_pending(&asserter, second);
21662        asserter.push_success(&Vec::<Log>::new());
21663        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21664        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21665            provider,
21666            SubscriberMode::PubSub,
21667            SubscriberConfig {
21668                preconfirmations: PreconfirmationMode::Required,
21669                ..SubscriberConfig::default()
21670            },
21671        )
21672        .with_provider_ref(ProviderRef::new("op-paid", 12));
21673        subscriber.chain_id = Some(10);
21674        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21675        subscriber.interests = subscriber.base_interests.clone();
21676
21677        subscriber
21678            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21679            .await
21680            .expect("first quiet payload is observed");
21681        assert!(!subscriber.pending_preconfirmation_invalidation);
21682        subscriber
21683            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21684            .await
21685            .expect("replacement quiet payload is observed");
21686        assert!(subscriber.pending_preconfirmation_invalidation);
21687        assert_eq!(
21688            subscriber
21689                .latest_preconfirmation
21690                .as_ref()
21691                .map(|flashblock| flashblock.block_number),
21692            Some(102)
21693        );
21694        assert!(asserter.read_q().is_empty());
21695    }
21696
21697    #[tokio::test]
21698    async fn optimism_sampler_rejects_malformed_pending_receipts() {
21699        let asserter = Asserter::new();
21700        let transaction = B256::repeat_byte(0x42);
21701        let pending = rpc_block(101, B256::ZERO).with_transactions(
21702            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction]),
21703        );
21704        queue_op_pending(&asserter, pending);
21705        asserter.push_success(&Vec::<Log>::new());
21706        asserter.push_success(&serde_json::json!({"transactionHash": transaction}));
21707        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21708        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21709            provider,
21710            SubscriberMode::PubSub,
21711            SubscriberConfig {
21712                preconfirmations: PreconfirmationMode::Required,
21713                ..SubscriberConfig::default()
21714            },
21715        )
21716        .with_provider_ref(ProviderRef::new("op-paid", 12));
21717        subscriber.chain_id = Some(10);
21718        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21719        subscriber.interests = subscriber.base_interests.clone();
21720
21721        let error = match subscriber
21722            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21723            .await
21724        {
21725            Err(error) => error,
21726            Ok(_) => panic!("malformed receipt content must fail closed"),
21727        };
21728        assert!(error.to_string().contains("missing its log array"));
21729        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 0);
21730        assert!(asserter.read_q().is_empty());
21731    }
21732
21733    #[tokio::test]
21734    async fn optimism_sampler_retries_when_logs_advance_past_the_sampled_block() {
21735        let asserter = Asserter::new();
21736        let transaction_a = B256::repeat_byte(0x41);
21737        let transaction_b = B256::repeat_byte(0x42);
21738        let first = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
21739            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction_a]),
21740        );
21741        let second = rpc_block(101, B256::repeat_byte(0xa2)).with_transactions(
21742            alloy_network::primitives::BlockTransactions::Hashes(vec![
21743                transaction_a,
21744                transaction_b,
21745            ]),
21746        );
21747        let mut log = rpc_log(false);
21748        log.block_number = Some(101);
21749        log.block_hash = Some(B256::repeat_byte(0xa2));
21750        log.transaction_hash = Some(transaction_b);
21751        log.transaction_index = Some(1);
21752        log.log_index = Some(0);
21753        queue_op_pending(&asserter, first);
21754        asserter.push_success(&vec![log.clone()]);
21755        asserter.push_success(&serde_json::Value::Null);
21756        queue_op_pending(&asserter, second);
21757        asserter.push_success(&vec![log.clone()]);
21758        asserter.push_success(&serde_json::Value::Null);
21759        asserter.push_success(&serde_json::json!({
21760            "transactionHash": transaction_b,
21761            "logs": [log]
21762        }));
21763        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21764        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21765            provider,
21766            SubscriberMode::PubSub,
21767            SubscriberConfig {
21768                preconfirmations: PreconfirmationMode::Required,
21769                ..SubscriberConfig::default()
21770            },
21771        )
21772        .with_provider_ref(ProviderRef::new("op-paid", 12));
21773        subscriber.chain_id = Some(10);
21774        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21775        subscriber.interests = subscriber.base_interests.clone();
21776
21777        assert!(
21778            subscriber
21779                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21780                .await
21781                .expect("a cross-request race remains retryable")
21782                .is_none()
21783        );
21784        assert!(
21785            subscriber
21786                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21787                .await
21788                .expect("the next coherent cumulative view is delivered")
21789                .is_some()
21790        );
21791        assert_eq!(subscriber.flashblocks_rpc_metrics().raced_samples(), 1);
21792        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 0);
21793        assert!(asserter.read_q().is_empty());
21794    }
21795
21796    #[tokio::test]
21797    async fn optimism_sampler_uses_the_paired_pending_state_provider() {
21798        let stream_asserter = Asserter::new();
21799        let stream_provider = ProviderBuilder::new().connect_mocked_client(stream_asserter.clone());
21800        let state_asserter = Asserter::new();
21801        queue_op_pending(&state_asserter, rpc_block(101, B256::ZERO));
21802        state_asserter.push_success(&Vec::<Log>::new());
21803        let state_provider = ProviderBuilder::new().connect_mocked_client(state_asserter.clone());
21804        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21805            stream_provider,
21806            SubscriberMode::PubSub,
21807            SubscriberConfig {
21808                preconfirmations: PreconfirmationMode::Required,
21809                ..SubscriberConfig::default()
21810            },
21811        )
21812        .with_provider_ref(ProviderRef::new("op-paid", 12))
21813        .with_flashblocks_state_provider(state_provider);
21814        subscriber.chain_id = Some(10);
21815        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21816        subscriber.interests = subscriber.base_interests.clone();
21817
21818        assert!(
21819            subscriber
21820                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21821                .await
21822                .expect("paired pending-state reads succeed")
21823                .is_some()
21824        );
21825        assert!(state_asserter.read_q().is_empty());
21826        assert!(stream_asserter.read_q().is_empty());
21827    }
21828
21829    #[tokio::test]
21830    async fn optimism_sampler_retries_an_isolated_provider_request_failure() {
21831        let asserter = Asserter::new();
21832        let pending = rpc_block(101, B256::ZERO);
21833        queue_op_pending(&asserter, pending.clone());
21834        asserter.push_failure_msg("temporarily unavailable");
21835        queue_op_pending(&asserter, pending);
21836        asserter.push_success(&Vec::<Log>::new());
21837        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21838        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21839            provider,
21840            SubscriberMode::PubSub,
21841            SubscriberConfig {
21842                preconfirmations: PreconfirmationMode::Required,
21843                max_consecutive_flashblock_poll_failures: 2,
21844                ..SubscriberConfig::default()
21845            },
21846        )
21847        .with_provider_ref(ProviderRef::new("op-paid", 12));
21848        subscriber.chain_id = Some(10);
21849        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21850        subscriber.interests = subscriber.base_interests.clone();
21851
21852        assert!(
21853            subscriber
21854                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21855                .await
21856                .expect("one request failure stays retryable")
21857                .is_none()
21858        );
21859        assert!(
21860            subscriber
21861                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21862                .await
21863                .expect("the next cumulative view retries the missing logs")
21864                .is_some()
21865        );
21866        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 1);
21867        assert!(asserter.read_q().is_empty());
21868    }
21869
21870    #[tokio::test]
21871    async fn optimism_sampler_surfaces_sustained_provider_request_failures() {
21872        let asserter = Asserter::new();
21873        asserter.push_failure_msg("temporarily unavailable");
21874        asserter.push_failure_msg("still unavailable");
21875        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21876        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21877            provider,
21878            SubscriberMode::PubSub,
21879            SubscriberConfig {
21880                preconfirmations: PreconfirmationMode::Required,
21881                max_consecutive_flashblock_poll_failures: 2,
21882                ..SubscriberConfig::default()
21883            },
21884        )
21885        .with_provider_ref(ProviderRef::new("op-paid", 12));
21886        subscriber.chain_id = Some(10);
21887
21888        assert!(
21889            subscriber
21890                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21891                .await
21892                .expect("the first request failure stays retryable")
21893                .is_none()
21894        );
21895        let error = match subscriber
21896            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21897            .await
21898        {
21899            Err(error) => error,
21900            Ok(_) => panic!("the configured consecutive-failure limit must fail closed"),
21901        };
21902        assert!(error.to_string().contains("still unavailable"));
21903        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 2);
21904        assert!(asserter.read_q().is_empty());
21905    }
21906
21907    #[tokio::test]
21908    async fn flashblocks_preflight_rejects_a_mismatched_chain_before_subscribing() {
21909        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21910        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21911            provider,
21912            SubscriberMode::PubSub,
21913            SubscriberConfig {
21914                preconfirmations: PreconfirmationMode::Required,
21915                ..SubscriberConfig::default()
21916            },
21917        )
21918        .with_provider_ref(ProviderRef::new("wrong-chain", 1));
21919        subscriber.chain_id = Some(10);
21920        subscriber.interests = vec![log_interest_matching_rpc_log()];
21921
21922        assert!(matches!(
21923            subscriber.establish_flashblocks_preflight(8_453).await,
21924            Err(SubscriberError::ChainMismatch {
21925                expected: 8_453,
21926                actual: 10
21927            })
21928        ));
21929    }
21930
21931    #[tokio::test]
21932    async fn optimism_preflight_rejects_a_mismatched_paired_provider() {
21933        let stream_asserter = Asserter::new();
21934        let stream_provider = ProviderBuilder::new().connect_mocked_client(stream_asserter.clone());
21935        let state_asserter = Asserter::new();
21936        state_asserter.push_success(&serde_json::json!(["flashblocksv1"]));
21937        state_asserter.push_success(&8_453_u64);
21938        let state_provider = ProviderBuilder::new().connect_mocked_client(state_asserter.clone());
21939        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21940            stream_provider,
21941            SubscriberMode::PubSub,
21942            SubscriberConfig {
21943                preconfirmations: PreconfirmationMode::Required,
21944                ..SubscriberConfig::default()
21945            },
21946        )
21947        .with_provider_ref(ProviderRef::new("op-paid", 12))
21948        .with_flashblocks_state_provider(state_provider);
21949        subscriber.chain_id = Some(10);
21950        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21951        subscriber.interests = subscriber.base_interests.clone();
21952        let desired = subscriber.pubsub_stream_sources();
21953        let mut streams = SubscriberStreams::new();
21954        for source in desired {
21955            streams.push(source, stream::pending().boxed());
21956        }
21957        subscriber.state = AlloySubscriberState::Active(streams);
21958        subscriber.sources_dirty = false;
21959        assert!(matches!(
21960            subscriber.establish_flashblocks_preflight(10).await,
21961            Err(SubscriberError::ChainMismatch {
21962                expected: 10,
21963                actual: 8_453
21964            })
21965        ));
21966        assert!(state_asserter.read_q().is_empty());
21967        assert!(stream_asserter.read_q().is_empty());
21968    }
21969
21970    #[test]
21971    fn unproven_parent_replacement_rewind_discards_every_unauthenticated_identity() {
21972        let parent = BlockRef {
21973            number: 79,
21974            hash: B256::repeat_byte(0x79),
21975            parent_hash: Some(B256::repeat_byte(0x78)),
21976            timestamp: Some(1_700_000_079),
21977        };
21978        let old_tip = BlockRef {
21979            number: 80,
21980            hash: B256::repeat_byte(0x80),
21981            parent_hash: Some(parent.hash),
21982            timestamp: Some(1_700_000_080),
21983        };
21984        let replacement = BlockRef {
21985            hash: B256::repeat_byte(0xe0),
21986            parent_hash: Some(B256::repeat_byte(0xdf)),
21987            ..old_tip
21988        };
21989        let mut state =
21990            CanonicalSequenceState::new(vec![parent, old_tip], Some(old_tip), Some(parent), None);
21991
21992        let rewind = apply_sequence_canonical_block(&mut state, &replacement, false)
21993            .expect("replacement metadata is structurally valid")
21994            .expect("unknown parent is an observable rewind");
21995
21996        assert_eq!(rewind.common_ancestor, None);
21997        assert_eq!(rewind.dropped, vec![parent, old_tip]);
21998        assert_eq!(state.retained_canonical_history(), &[replacement]);
21999        assert_eq!(state.coverage_head(), Some(&replacement));
22000        assert_eq!(state.safe_head(), None);
22001        assert_eq!(state.finalized_head(), None);
22002    }
22003
22004    #[test]
22005    fn handler_ids_are_non_empty_across_construction_and_deserialization() {
22006        assert_eq!(HandlerId::try_new("").unwrap_err(), HandlerIdError);
22007        let valid = HandlerId::try_new("owner-1").expect("non-empty id");
22008        let encoded = serde_json::to_string(&valid).expect("serialize id");
22009        assert_eq!(
22010            serde_json::from_str::<HandlerId>(&encoded).expect("deserialize valid id"),
22011            valid
22012        );
22013        assert!(serde_json::from_str::<HandlerId>(r#"""#).is_err());
22014    }
22015
22016    fn rpc_log(removed: bool) -> Log {
22017        Log {
22018            inner: alloy_primitives::Log::new_unchecked(
22019                Address::repeat_byte(0x42),
22020                vec![B256::repeat_byte(0x01)],
22021                Bytes::new(),
22022            ),
22023            block_hash: Some(B256::repeat_byte(0x02)),
22024            block_number: Some(7),
22025            block_timestamp: Some(1_700_000_000),
22026            transaction_hash: Some(B256::repeat_byte(0x03)),
22027            transaction_index: Some(4),
22028            log_index: Some(5),
22029            removed,
22030        }
22031    }
22032
22033    fn rpc_transaction(chain_id: Option<u64>) -> alloy_rpc_types_eth::Transaction {
22034        use alloy_consensus::SignableTransaction as _;
22035
22036        let envelope: alloy_consensus::TxEnvelope = alloy_consensus::TxLegacy {
22037            chain_id,
22038            ..Default::default()
22039        }
22040        .into_signed(alloy_primitives::Signature::test_signature())
22041        .into();
22042        alloy_rpc_types_eth::Transaction {
22043            inner: alloy_consensus::transaction::Recovered::new_unchecked(envelope, Address::ZERO),
22044            block_hash: None,
22045            block_number: None,
22046            transaction_index: None,
22047            effective_gas_price: None,
22048        }
22049    }
22050
22051    #[cfg(feature = "reactive-ws")]
22052    fn rpc_log_at(block_number: u64, transaction_index: u64, log_index: u64) -> Log {
22053        Log {
22054            inner: alloy_primitives::Log::new_unchecked(
22055                Address::repeat_byte(0x42),
22056                vec![B256::repeat_byte(0x01)],
22057                Bytes::new(),
22058            ),
22059            block_hash: Some(B256::repeat_byte(block_number as u8)),
22060            block_number: Some(block_number),
22061            block_timestamp: Some(1_700_000_000 + block_number),
22062            transaction_hash: Some(B256::repeat_byte(0x20 + transaction_index as u8)),
22063            transaction_index: Some(transaction_index),
22064            log_index: Some(log_index),
22065            removed: false,
22066        }
22067    }
22068
22069    #[cfg(any(
22070        feature = "raw-flashblocks-json",
22071        feature = "reactive-polling",
22072        feature = "reactive-ws"
22073    ))]
22074    fn rpc_block(number: u64, hash: B256) -> alloy_rpc_types_eth::Block {
22075        alloy_rpc_types_eth::Block::empty(alloy_rpc_types_eth::Header {
22076            hash,
22077            inner: alloy_consensus::Header {
22078                number,
22079                parent_hash: B256::repeat_byte(number.saturating_sub(1) as u8),
22080                timestamp: 1_700_000_000 + number,
22081                ..Default::default()
22082            },
22083            total_difficulty: None,
22084            size: None,
22085        })
22086    }
22087
22088    fn queue_op_pending(asserter: &Asserter, pending: alloy_rpc_types_eth::Block) {
22089        let parent = rpc_block(
22090            pending.header().number().saturating_sub(1),
22091            pending.header().parent_hash(),
22092        );
22093        asserter.push_success(&Some(pending));
22094        asserter.push_success(&Some(parent));
22095    }
22096
22097    #[tokio::test(flavor = "multi_thread")]
22098    #[cfg(feature = "reactive-ws")]
22099    async fn verified_log_context_fetches_and_caches_exact_parent_identity() {
22100        let stream_asserter = Asserter::new();
22101        let provider = ProviderBuilder::new().connect_mocked_client(stream_asserter.clone());
22102        let verification_asserter = Asserter::new();
22103        verification_asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(7))));
22104        let verification_provider =
22105            ProviderBuilder::new().connect_mocked_client(verification_asserter.clone());
22106        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22107            provider,
22108            SubscriberMode::PubSub,
22109            SubscriberConfig {
22110                verify_log_block_context: true,
22111                ..SubscriberConfig::default()
22112            },
22113        )
22114        .with_log_verification_provider(verification_provider);
22115        let log = rpc_log_at(7, 0, 0);
22116
22117        subscriber
22118            .verify_log_block_context(&log)
22119            .await
22120            .expect("verify live log block");
22121        subscriber
22122            .verify_log_block_context(&log)
22123            .await
22124            .expect("reuse verified block cache");
22125        let record = subscriber.with_chain_id(log_input_record(log, InputSource::Subscription));
22126
22127        assert_eq!(
22128            record.context.block.expect("verified block").parent_hash,
22129            Some(B256::repeat_byte(6))
22130        );
22131        assert!(
22132            verification_asserter.read_q().is_empty(),
22133            "one provider lookup should verify every log in the same block"
22134        );
22135        assert!(
22136            stream_asserter.read_q().is_empty(),
22137            "verification must not use the high-volume stream provider"
22138        );
22139    }
22140
22141    #[tokio::test(flavor = "multi_thread")]
22142    async fn stream_with_termination_yields_terminal_source_marker() {
22143        let mut stream = stream_with_termination::<Ethereum, _>(
22144            stream::iter([SubscriberEvent::<Ethereum>::PendingHash(B256::repeat_byte(
22145                0xaa,
22146            ))]),
22147            SubscriberStreamSource::PubSubPendingHashes,
22148        );
22149
22150        assert!(matches!(
22151            stream.next().await,
22152            Some(SubscriberEvent::PendingHash(hash)) if hash == B256::repeat_byte(0xaa)
22153        ));
22154        assert!(matches!(
22155            stream.next().await,
22156            Some(SubscriberEvent::StreamTerminated(source)) if source.is_pubsub()
22157        ));
22158        assert!(stream.next().await.is_none());
22159    }
22160
22161    #[test]
22162    fn reconnect_delay_doubles_until_capped() {
22163        assert_eq!(
22164            next_reconnect_delay(Duration::from_millis(250), Duration::from_secs(1)),
22165            Duration::from_millis(500)
22166        );
22167        assert_eq!(
22168            next_reconnect_delay(Duration::from_millis(750), Duration::from_secs(1)),
22169            Duration::from_secs(1)
22170        );
22171        assert_eq!(
22172            next_reconnect_delay(Duration::ZERO, Duration::from_secs(1)),
22173            Duration::ZERO
22174        );
22175    }
22176
22177    #[test]
22178    fn canonical_logs_are_deduped_but_removed_logs_are_not() {
22179        let included = log_input_record::<Ethereum>(rpc_log(false), InputSource::Subscription);
22180        let removed = log_input_record::<Ethereum>(rpc_log(true), InputSource::Subscription);
22181
22182        assert!(should_dedupe_record(&included));
22183        assert!(!should_dedupe_record(&removed));
22184    }
22185
22186    #[test]
22187    fn owner_reconcile_dedupe_rejects_conflicts_and_preserves_compatible_enrichment() {
22188        let set_context_timestamp = |record: &mut ReactiveInputRecord<Ethereum>,
22189                                     timestamp: Option<u64>| {
22190            record.context.block.as_mut().expect("block").timestamp = timestamp;
22191            match &mut record.context.chain_status {
22192                ChainStatus::Included { block, .. }
22193                | ChainStatus::Safe { block }
22194                | ChainStatus::Finalized { block }
22195                | ChainStatus::Reorged {
22196                    dropped_from: block,
22197                } => block.timestamp = timestamp,
22198                ChainStatus::Pending | ChainStatus::Preconfirmed { .. } => {
22199                    panic!("log record is canonical")
22200                }
22201            }
22202        };
22203
22204        let mut payload_only = log_input_record::<Ethereum>(rpc_log(false), InputSource::Backfill);
22205        let payload_timestamp = match &payload_only.input {
22206            ReactiveInput::Log(log) => log.block_timestamp.expect("timestamp"),
22207            _ => unreachable!(),
22208        };
22209        set_context_timestamp(&mut payload_only, None);
22210        let mut context_only = payload_only.clone();
22211        if let ReactiveInput::Log(log) = &mut context_only.input {
22212            log.block_timestamp = None;
22213        }
22214        set_context_timestamp(&mut context_only, Some(payload_timestamp + 1));
22215        assert!(matches!(
22216            dedupe_records(vec![payload_only, context_only]),
22217            Err(ReactiveError::InvalidInputRecord { .. })
22218        ));
22219
22220        let mut partial = log_input_record::<Ethereum>(rpc_log(false), InputSource::Backfill);
22221        if let ReactiveInput::Log(log) = &mut partial.input {
22222            log.block_timestamp = None;
22223        }
22224        set_context_timestamp(&mut partial, None);
22225        let complete = log_input_record::<Ethereum>(rpc_log(false), InputSource::Subscription);
22226        let deduped =
22227            dedupe_records(vec![partial, complete]).expect("compatible metadata enriches");
22228        assert_eq!(deduped.len(), 1);
22229        deduped[0]
22230            .validated_identity()
22231            .expect("merged record remains coherent");
22232        let resolved = resolve_record_block_payload_metadata(
22233            &deduped[0],
22234            *canonical_record_block(&deduped[0]).expect("canonical"),
22235        )
22236        .expect("effective block");
22237        assert_eq!(resolved.timestamp, Some(payload_timestamp));
22238    }
22239
22240    #[test]
22241    fn full_block_bodies_are_never_suppressed_from_header_hash_alone() {
22242        use alloy_rpc_types_eth::{Block, Header};
22243
22244        let block_ref = BlockRef {
22245            number: 7,
22246            hash: B256::repeat_byte(0x77),
22247            parent_hash: Some(B256::repeat_byte(0x66)),
22248            timestamp: Some(1_700_000_007),
22249        };
22250        let block = Block::empty(Header {
22251            hash: block_ref.hash,
22252            inner: alloy_consensus::Header {
22253                number: block_ref.number,
22254                parent_hash: block_ref.parent_hash.expect("parent"),
22255                timestamp: block_ref.timestamp.expect("timestamp"),
22256                ..Default::default()
22257            },
22258            total_difficulty: None,
22259            size: None,
22260        });
22261        let record = ReactiveInputRecord::<Ethereum>::new(
22262            ReactiveInput::FullBlock(block),
22263            ReactiveContext {
22264                chain_id: Some(1),
22265                source: InputSource::Subscription,
22266                chain_status: ChainStatus::Included {
22267                    block: block_ref,
22268                    confirmations: 0,
22269                },
22270                block: Some(block_ref),
22271                transaction_index: None,
22272                log_index: None,
22273            },
22274        );
22275
22276        assert!(!record.is_payload_deduplicable());
22277        assert!(!record.same_deduplicable_payload(&record));
22278        let retained = dedupe_scoped_records(vec![
22279            (
22280                record.clone(),
22281                DeliveryAudience::All,
22282                DeliveryScope::Canonical,
22283            ),
22284            (record, DeliveryAudience::All, DeliveryScope::Canonical),
22285        ])
22286        .expect("non-deduplicable bodies are preserved, not treated as conflicts");
22287        assert_eq!(retained.len(), 2);
22288    }
22289
22290    #[test]
22291    fn hydrated_transaction_wrappers_reject_inclusion_and_chain_identity_conflicts() {
22292        let pending_context = ReactiveContext {
22293            chain_id: Some(1),
22294            source: InputSource::Batch,
22295            chain_status: ChainStatus::Pending,
22296            block: None,
22297            transaction_index: None,
22298            log_index: None,
22299        };
22300        let mut included_pending = rpc_transaction(Some(1));
22301        included_pending.block_hash = Some(B256::repeat_byte(0xaa));
22302        assert!(matches!(
22303            ReactiveInputRecord::<Ethereum>::new(
22304                ReactiveInput::PendingTx(included_pending),
22305                pending_context.clone(),
22306            )
22307            .validated_identity(),
22308            Err(ReactiveError::InvalidInputRecord { .. })
22309        ));
22310        assert!(matches!(
22311            ReactiveInputRecord::<Ethereum>::new(
22312                ReactiveInput::PendingTx(rpc_transaction(Some(2))),
22313                pending_context,
22314            )
22315            .validated_identity(),
22316            Err(ReactiveError::InvalidInputRecord { .. })
22317        ));
22318
22319        let block_ref = BlockRef {
22320            number: 8,
22321            hash: B256::repeat_byte(0x88),
22322            parent_hash: Some(B256::repeat_byte(0x77)),
22323            timestamp: Some(1_700_000_008),
22324        };
22325        let header = alloy_rpc_types_eth::Header {
22326            hash: block_ref.hash,
22327            inner: alloy_consensus::Header {
22328                number: block_ref.number,
22329                parent_hash: block_ref.parent_hash.expect("parent"),
22330                timestamp: block_ref.timestamp.expect("timestamp"),
22331                ..Default::default()
22332            },
22333            total_difficulty: None,
22334            size: None,
22335        };
22336        let context = ReactiveContext {
22337            chain_id: Some(1),
22338            source: InputSource::Batch,
22339            chain_status: ChainStatus::Included {
22340                block: block_ref,
22341                confirmations: 0,
22342            },
22343            block: Some(block_ref),
22344            transaction_index: None,
22345            log_index: None,
22346        };
22347        for transaction in [
22348            alloy_rpc_types_eth::Transaction {
22349                block_hash: Some(B256::repeat_byte(0xff)),
22350                ..rpc_transaction(Some(1))
22351            },
22352            alloy_rpc_types_eth::Transaction {
22353                block_hash: Some(block_ref.hash),
22354                block_number: Some(block_ref.number),
22355                transaction_index: Some(1),
22356                ..rpc_transaction(Some(1))
22357            },
22358            rpc_transaction(Some(2)),
22359        ] {
22360            let block = alloy_rpc_types_eth::Block::new(
22361                header.clone(),
22362                alloy_network::primitives::BlockTransactions::Full(vec![transaction]),
22363            );
22364            assert!(matches!(
22365                ReactiveInputRecord::<Ethereum>::new(
22366                    ReactiveInput::FullBlock(block),
22367                    context.clone(),
22368                )
22369                .validated_identity(),
22370                Err(ReactiveError::InvalidInputRecord { .. })
22371            ));
22372        }
22373    }
22374
22375    #[test]
22376    #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))]
22377    fn compatibility_owner_backfill_and_live_overlap_split_exact_audiences() {
22378        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22379        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22380            provider,
22381            SubscriberMode::Auto,
22382            SubscriberConfig::default(),
22383        );
22384        let owner = HandlerId::new("compat-owner");
22385        subscriber
22386            .add_interest_owner(
22387                owner.clone(),
22388                &[ReactiveInterest::Logs(LogInterest {
22389                    provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
22390                    local_matcher: None,
22391                    route_key: None,
22392                })],
22393            )
22394            .unwrap();
22395        let log = rpc_log(false);
22396
22397        subscriber.enqueue_compat_owner_record(
22398            log_input_record(log.clone(), InputSource::Backfill),
22399            owner.clone(),
22400        );
22401        subscriber.enqueue_event(SubscriberEvent::Log { source_id: 0, log });
22402
22403        let batch = subscriber
22404            .drain_next_scoped_batch()
22405            .expect("owner catch-up and residual live copies");
22406        assert_eq!(batch.records.len(), 2);
22407        assert_eq!(
22408            batch.records[0].scope,
22409            SubscriberInputScope::OwnerOnlyHandlers {
22410                owners: vec![owner.clone()]
22411            }
22412        );
22413        assert_eq!(
22414            batch.records[1].scope,
22415            SubscriberInputScope::CanonicalResidual {
22416                owners: Vec::new(),
22417                excluded: vec![owner.clone()]
22418            }
22419        );
22420
22421        let reactive = batch.into_reactive_batch();
22422        assert_eq!(
22423            reactive.record_audience(0),
22424            Some(&DeliveryAudience::Owners(vec![owner.clone()]))
22425        );
22426        assert_eq!(
22427            reactive.record_delivery_scope(0),
22428            Some(DeliveryScope::OwnerCatchup)
22429        );
22430        assert_eq!(
22431            reactive.record_audience(1),
22432            Some(&DeliveryAudience::AllExcept(vec![owner]))
22433        );
22434        assert_eq!(
22435            reactive.record_delivery_scope(1),
22436            Some(DeliveryScope::Canonical)
22437        );
22438    }
22439
22440    #[test]
22441    #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))]
22442    fn active_owner_replacement_commits_atomically_to_one_new_epoch() {
22443        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22444        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22445            provider,
22446            SubscriberMode::Auto,
22447            SubscriberConfig::default(),
22448        );
22449        let owner = HandlerId::new("replace-owner");
22450        let original = ReactiveInterest::Logs(LogInterest {
22451            provider_filter: Filter::new().address(Address::repeat_byte(0x41)),
22452            local_matcher: None,
22453            route_key: None,
22454        });
22455        let replacement_interest = ReactiveInterest::Logs(LogInterest {
22456            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
22457            local_matcher: None,
22458            route_key: None,
22459        });
22460        let active = subscriber
22461            .stage_interest_owner(owner.clone(), &[original], SubscriberOwnerStart::Live)
22462            .unwrap();
22463        assert!(subscriber.activate_interest_owner(&active));
22464        let replacement = subscriber
22465            .stage_interest_owner_replacement(
22466                owner,
22467                &[replacement_interest],
22468                SubscriberOwnerStart::Live,
22469            )
22470            .unwrap();
22471
22472        assert!(subscriber.commit_interest_owner_replacement(&active, &replacement));
22473        assert_eq!(subscriber.interest_owner_state(&active), None);
22474        assert_eq!(
22475            subscriber.interest_owner_state(&replacement),
22476            Some(SubscriberOwnerState::Active)
22477        );
22478        assert_eq!(subscriber.registered_interests().len(), 1);
22479    }
22480
22481    #[test]
22482    #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))]
22483    fn compatibility_and_epoch_owner_lifecycles_cannot_mix() {
22484        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22485        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22486            provider,
22487            SubscriberMode::Auto,
22488            SubscriberConfig::default(),
22489        );
22490        let owner = HandlerId::new("one-lifecycle");
22491        let interest = ReactiveInterest::Logs(LogInterest {
22492            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
22493            local_matcher: None,
22494            route_key: None,
22495        });
22496        let epoch = subscriber
22497            .stage_interest_owner(
22498                owner.clone(),
22499                std::slice::from_ref(&interest),
22500                SubscriberOwnerStart::Live,
22501            )
22502            .expect("stage epoch owner");
22503
22504        assert!(matches!(
22505            subscriber.add_interest_owner(owner.clone(), std::slice::from_ref(&interest)),
22506            Err(SubscriberError::InvalidConfig(_))
22507        ));
22508        assert_eq!(
22509            subscriber.interest_owner_state(&epoch),
22510            Some(SubscriberOwnerState::Staged)
22511        );
22512        assert!(subscriber.abort_interest_owner(&epoch));
22513        subscriber
22514            .add_interest_owner(owner.clone(), std::slice::from_ref(&interest))
22515            .expect("compatibility owner after epoch abort");
22516        assert!(matches!(
22517            subscriber.stage_interest_owner_replacement(
22518                owner,
22519                std::slice::from_ref(&interest),
22520                SubscriberOwnerStart::Live,
22521            ),
22522            Err(SubscriberOwnerError::AlreadyRegistered(_))
22523        ));
22524    }
22525
22526    #[test]
22527    fn pending_record_overflow_is_sticky_and_fail_closed() {
22528        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22529        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22530            provider,
22531            SubscriberMode::Polling,
22532            SubscriberConfig {
22533                max_pending_records: 1,
22534                ..SubscriberConfig::default()
22535            },
22536        );
22537        subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
22538            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
22539            local_matcher: None,
22540            route_key: None,
22541        })];
22542        subscriber.enqueue_event(SubscriberEvent::Log {
22543            source_id: 0,
22544            log: rpc_log(false),
22545        });
22546        let mut second = rpc_log(false);
22547        second.log_index = Some(6);
22548        second.transaction_hash = Some(B256::repeat_byte(0x04));
22549        subscriber.enqueue_event(SubscriberEvent::Log {
22550            source_id: 0,
22551            log: second,
22552        });
22553
22554        assert_eq!(subscriber.pending_records.len(), 1);
22555        assert!(matches!(
22556            subscriber.check_resource_error(),
22557            Err(SubscriberError::ResourceExhausted(_))
22558        ));
22559        subscriber.reset_delivery_state();
22560        assert!(subscriber.check_resource_error().is_ok());
22561    }
22562
22563    #[test]
22564    fn historical_log_payload_bytes_are_bounded_independently_of_log_count() {
22565        let baseline = rpc_log(false);
22566        let fixed_bytes =
22567            validate_backfill_resource_limits(std::slice::from_ref(&baseline), 1, usize::MAX)
22568                .expect("measure fixed log accounting");
22569        let mut large = baseline;
22570        large.inner = alloy_primitives::Log::new_unchecked(
22571            Address::repeat_byte(0x42),
22572            vec![B256::repeat_byte(0x01)],
22573            Bytes::from(vec![0u8; 256]),
22574        );
22575
22576        assert!(matches!(
22577            validate_backfill_resource_limits(&[large], 1, fixed_bytes + 255),
22578            Err(SubscriberError::ResourceExhausted(_))
22579        ));
22580    }
22581
22582    #[tokio::test(flavor = "multi_thread")]
22583    #[cfg(feature = "reactive-polling")]
22584    async fn reconcile_capacity_failure_does_not_publish_progress_or_partial_history() {
22585        use alloy_rpc_types_eth::{Block, Header};
22586
22587        let asserter = Asserter::new();
22588        let baseline = BlockRef {
22589            number: 6,
22590            hash: B256::repeat_byte(6),
22591            parent_hash: Some(B256::repeat_byte(5)),
22592            timestamp: Some(1_700_000_006),
22593        };
22594        let through = BlockRef {
22595            number: 7,
22596            hash: B256::repeat_byte(7),
22597            parent_hash: Some(baseline.hash),
22598            timestamp: Some(1_700_000_007),
22599        };
22600        let rpc_block = || -> Block {
22601            Block::empty(Header {
22602                hash: through.hash,
22603                inner: alloy_consensus::Header {
22604                    number: through.number,
22605                    parent_hash: through.parent_hash.expect("parent"),
22606                    timestamp: through.timestamp.expect("timestamp"),
22607                    ..Default::default()
22608                },
22609                total_difficulty: None,
22610                size: None,
22611            })
22612        };
22613        let mut historical = rpc_log(false);
22614        historical.block_hash = Some(through.hash);
22615        historical.block_timestamp = through.timestamp;
22616        asserter.push_success(&Some(rpc_block()));
22617        asserter.push_success(&vec![historical]);
22618        asserter.push_success(&Some(rpc_block()));
22619        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
22620        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22621            provider,
22622            SubscriberMode::Polling,
22623            SubscriberConfig {
22624                max_pending_records: 1,
22625                ..SubscriberConfig::default()
22626            },
22627        );
22628        subscriber.chain_id = Some(1);
22629        let interest = ReactiveInterest::Logs(LogInterest {
22630            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
22631            local_matcher: None,
22632            route_key: None,
22633        });
22634        let epoch = subscriber
22635            .stage_interest_owner(
22636                HandlerId::new("capacity-owner"),
22637                std::slice::from_ref(&interest),
22638                SubscriberOwnerStart::PostBlock(baseline),
22639            )
22640            .expect("stage owner");
22641        // Isolate the commit-side capacity edge: the live queue acquired one
22642        // canonical record while the historical request was in flight.
22643        subscriber.sources_dirty = false;
22644        subscriber.state = AlloySubscriberState::Empty;
22645        subscriber.push_pending_record(SubscriberInputRecord {
22646            record: log_input_record(rpc_log(false), InputSource::Poll),
22647            scope: SubscriberInputScope::Canonical { owners: Vec::new() },
22648            preconfirmation_timing: None,
22649        });
22650
22651        let error = subscriber
22652            .reconcile_interest_owner(&epoch, through)
22653            .await
22654            .expect_err("historical delivery cannot displace the queued live record");
22655        assert!(matches!(
22656            error,
22657            SubscriberOwnerError::Subscriber(SubscriberError::ResourceExhausted(_))
22658        ));
22659        assert!(subscriber.interest_owner_progress(&epoch).is_none());
22660        assert_eq!(subscriber.pending_records.len(), 1);
22661        assert!(matches!(
22662            subscriber.pending_records[0].scope,
22663            SubscriberInputScope::Canonical { .. }
22664        ));
22665    }
22666
22667    #[test]
22668    #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))]
22669    fn lazy_backfill_queue_capacity_failure_is_atomic() {
22670        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22671        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22672            provider,
22673            SubscriberMode::Auto,
22674            SubscriberConfig {
22675                max_pending_backfills: 1,
22676                ..SubscriberConfig::default()
22677            },
22678        );
22679        let interest = |address| {
22680            ReactiveInterest::Logs(LogInterest {
22681                provider_filter: Filter::new().address(address),
22682                local_matcher: None,
22683                route_key: None,
22684            })
22685        };
22686        subscriber
22687            .add_interest_owner_with_backfill(
22688                HandlerId::new("owner-a"),
22689                &[interest(Address::repeat_byte(0x41))],
22690                SubscriberBackfill::from_block(10),
22691            )
22692            .expect("first queued backfill");
22693
22694        let error = subscriber
22695            .add_interest_owner_with_backfill(
22696                HandlerId::new("owner-b"),
22697                &[interest(Address::repeat_byte(0x42))],
22698                SubscriberBackfill::from_block(10),
22699            )
22700            .expect_err("second backfill must exceed capacity");
22701
22702        assert!(matches!(error, SubscriberError::ResourceExhausted(_)));
22703        assert!(
22704            subscriber
22705                .owner_interests(&HandlerId::new("owner-b"))
22706                .is_none()
22707        );
22708        assert_eq!(subscriber.pending_backfills.len(), 1);
22709    }
22710
22711    #[test]
22712    #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))]
22713    fn exact_owner_replacement_is_atomic_and_removes_crash_stale_owners() {
22714        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22715        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22716            provider,
22717            SubscriberMode::Auto,
22718            SubscriberConfig {
22719                max_pending_backfills: 1,
22720                ..SubscriberConfig::default()
22721            },
22722        );
22723        let interest = |address| {
22724            ReactiveInterest::Logs(LogInterest {
22725                provider_filter: Filter::new().address(address),
22726                local_matcher: None,
22727                route_key: None,
22728            })
22729        };
22730        subscriber
22731            .add_interest_owner(
22732                HandlerId::new("crash-stale"),
22733                &[interest(Address::repeat_byte(0xee))],
22734            )
22735            .expect("seed stale owner");
22736        subscriber.base_interests = vec![interest(Address::repeat_byte(0xdd))];
22737        subscriber.rebuild_registered_interests();
22738        subscriber.push_pending_record(SubscriberInputRecord {
22739            record: log_input_record(rpc_log(false), InputSource::Poll),
22740            scope: SubscriberInputScope::Canonical { owners: Vec::new() },
22741            preconfirmation_timing: None,
22742        });
22743        let baseline = BlockRef {
22744            number: 100,
22745            hash: B256::repeat_byte(100),
22746            parent_hash: Some(B256::repeat_byte(99)),
22747            timestamp: Some(1_700_000_100),
22748        };
22749        let backfill = SubscriberBackfill::after_canonical_block(baseline).expect("C + 1");
22750
22751        let error = subscriber
22752            .replace_interest_owners_with_global_backfill(
22753                vec![
22754                    (
22755                        HandlerId::new("pool-a"),
22756                        vec![interest(Address::repeat_byte(0xa1))],
22757                    ),
22758                    (
22759                        HandlerId::new("pool-b"),
22760                        vec![ReactiveInterest::Logs(LogInterest {
22761                            // A distinct block option prevents provider-filter
22762                            // fan-in, exercising the two-unit capacity edge.
22763                            provider_filter: Filter::new()
22764                                .address(Address::repeat_byte(0xb2))
22765                                .from_block(7),
22766                            local_matcher: None,
22767                            route_key: None,
22768                        })],
22769                    ),
22770                ],
22771                backfill,
22772            )
22773            .expect_err("two backfills exceed atomic capacity");
22774        assert!(matches!(error, SubscriberError::ResourceExhausted(_)));
22775        assert!(
22776            subscriber
22777                .owner_interests(&HandlerId::new("crash-stale"))
22778                .is_some(),
22779            "failed replacement must preserve the prior topology"
22780        );
22781        assert!(
22782            subscriber
22783                .owner_interests(&HandlerId::new("pool-a"))
22784                .is_none()
22785        );
22786        assert_eq!(subscriber.base_interests.len(), 1);
22787        assert_eq!(subscriber.pending_records.len(), 1);
22788
22789        subscriber
22790            .replace_interest_owners_with_global_backfill(
22791                vec![(
22792                    HandlerId::new("pool-a"),
22793                    vec![interest(Address::repeat_byte(0xa1))],
22794                )],
22795                backfill,
22796            )
22797            .expect("replacement within capacity");
22798        assert!(
22799            subscriber
22800                .owner_interests(&HandlerId::new("crash-stale"))
22801                .is_none(),
22802            "successful exact replacement removes stale owners"
22803        );
22804        assert!(
22805            subscriber.base_interests.is_empty(),
22806            "successful exact replacement removes stale unowned interests"
22807        );
22808        assert!(
22809            subscriber.drain_next_scoped_batch().is_none(),
22810            "stale canonical delivery must not escape before C + 1 recovery"
22811        );
22812        assert!(
22813            subscriber
22814                .owner_interests(&HandlerId::new("pool-a"))
22815                .is_some()
22816        );
22817        assert_eq!(subscriber.pending_backfills.len(), 1);
22818        assert_eq!(subscriber.pending_backfills[0].backfill, backfill);
22819        assert!(
22820            subscriber.pending_backfills[0].owner.is_none(),
22821            "startup history must be global canonical catch-up, not owner-only"
22822        );
22823    }
22824
22825    #[test]
22826    fn exclusive_canonical_backfill_rejects_block_number_overflow() {
22827        let baseline = BlockRef {
22828            number: u64::MAX,
22829            hash: B256::repeat_byte(0xff),
22830            parent_hash: None,
22831            timestamp: None,
22832        };
22833        assert!(matches!(
22834            SubscriberBackfill::after_canonical_block(baseline),
22835            Err(SubscriberError::InvalidConfig(_))
22836        ));
22837    }
22838
22839    #[tokio::test(flavor = "multi_thread")]
22840    #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))]
22841    async fn exclusive_canonical_backfill_validates_the_retained_baseline_hash() {
22842        let asserter = Asserter::new();
22843        asserter.push_success(&101u64);
22844        asserter.push_success(&Some(rpc_block(101, B256::repeat_byte(101))));
22845        asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(0xee))));
22846        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
22847        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22848            provider,
22849            SubscriberMode::Auto,
22850            SubscriberConfig::default(),
22851        );
22852        let baseline = BlockRef {
22853            number: 100,
22854            hash: B256::repeat_byte(0xaa),
22855            parent_hash: None,
22856            timestamp: None,
22857        };
22858        let backfill = SubscriberBackfill::after_canonical_block(baseline).expect("C + 1");
22859        subscriber
22860            .add_interest_owner_with_backfill(
22861                HandlerId::new("pool"),
22862                &[ReactiveInterest::Logs(LogInterest {
22863                    provider_filter: Filter::new().address(Address::repeat_byte(0xa1)),
22864                    local_matcher: None,
22865                    route_key: None,
22866                })],
22867                backfill,
22868            )
22869            .expect("queue post-baseline backfill");
22870
22871        let error = subscriber
22872            .drain_pending_backfills()
22873            .await
22874            .expect_err("provider branch differs at retained baseline");
22875        assert!(matches!(error, SubscriberError::InvalidBackfill(_)));
22876        assert_eq!(subscriber.pending_backfills.len(), 1);
22877        assert_eq!(subscriber.pending_backfills[0].backfill.start_block(), 101);
22878        assert!(subscriber.pending_records.is_empty());
22879    }
22880
22881    #[tokio::test(flavor = "multi_thread")]
22882    #[cfg(feature = "reactive-ws")]
22883    async fn coordinated_multifilter_windows_are_globally_sorted_for_owner_and_canonical_delivery()
22884    {
22885        let asserter = Asserter::new();
22886        let retained = BlockRef {
22887            number: 10,
22888            hash: B256::repeat_byte(10),
22889            parent_hash: Some(B256::repeat_byte(9)),
22890            timestamp: Some(1_700_000_010),
22891        };
22892        let activation = BlockRef {
22893            number: 12,
22894            hash: B256::repeat_byte(12),
22895            parent_hash: Some(B256::repeat_byte(11)),
22896            timestamp: Some(1_700_000_012),
22897        };
22898
22899        // 257 distinct logical block options cross the 256-filter request
22900        // chunk boundary. Each window therefore makes two concurrent log
22901        // requests whose responses deliberately arrive in reverse order.
22902        asserter.push_success(&Some(rpc_block(retained.number, retained.hash)));
22903        asserter.push_success(&vec![rpc_log_at(10, 2, 2)]);
22904        asserter.push_success(&vec![rpc_log_at(10, 1, 1)]);
22905        asserter.push_success(&Some(rpc_block(retained.number, retained.hash)));
22906        asserter.push_success(&activation.number);
22907        asserter.push_success(&Some(rpc_block(activation.number, activation.hash)));
22908        asserter.push_success(&Some(rpc_block(retained.number, retained.hash)));
22909        asserter.push_success(&vec![rpc_log_at(12, 2, 2)]);
22910        asserter.push_success(&vec![rpc_log_at(11, 1, 1)]);
22911        asserter.push_success(&Some(rpc_block(activation.number, activation.hash)));
22912        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
22913        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22914            provider,
22915            SubscriberMode::Auto,
22916            SubscriberConfig::default(),
22917        );
22918        let interests = (0..257)
22919            .map(|start| {
22920                ReactiveInterest::Logs(LogInterest {
22921                    provider_filter: Filter::new()
22922                        .address(Address::repeat_byte(0x42))
22923                        .event_signature(B256::repeat_byte(0x01))
22924                        .from_block(start),
22925                    local_matcher: None,
22926                    route_key: None,
22927                })
22928            })
22929            .collect::<Vec<_>>();
22930        subscriber
22931            .add_interest_owner_with_canonical_catchup(
22932                HandlerId::new("many-filters"),
22933                &interests,
22934                retained,
22935            )
22936            .expect("queue coordinated windows");
22937        assert_eq!(subscriber.pending_backfills.len(), 2);
22938        assert_eq!(subscriber.pending_backfills[0].filters.len(), 257);
22939        assert_eq!(subscriber.pending_backfills[1].filters.len(), 257);
22940
22941        subscriber
22942            .drain_pending_backfills()
22943            .await
22944            .expect("owner filter group");
22945        let owner = subscriber
22946            .drain_next_scoped_batch()
22947            .expect("owner ordered batch");
22948        assert_eq!(owner.records.len(), 2);
22949        assert_eq!(owner.records[0].record.context.transaction_index, Some(1));
22950        assert_eq!(owner.records[1].record.context.transaction_index, Some(2));
22951        assert!(
22952            owner.records.iter().all(|record| matches!(
22953                record.scope,
22954                SubscriberInputScope::OwnerOnlyHandlers { .. }
22955            ))
22956        );
22957
22958        subscriber
22959            .drain_pending_backfills()
22960            .await
22961            .expect("global filter group");
22962        let global = subscriber
22963            .drain_next_scoped_batch()
22964            .expect("global ordered batch");
22965        assert_eq!(global.records.len(), 2);
22966        assert_eq!(
22967            global.records[0].record.context.block.map(|b| b.number),
22968            Some(11)
22969        );
22970        assert_eq!(
22971            global.records[1].record.context.block.map(|b| b.number),
22972            Some(12)
22973        );
22974        assert!(
22975            global
22976                .records
22977                .iter()
22978                .all(|record| record.scope.is_canonical())
22979        );
22980        assert!(matches!(
22981            global.chain_controls.as_slice(),
22982            [ChainControl::Barrier {
22983                block: Some(block),
22984                ..
22985            }] if block == &activation
22986        ));
22987    }
22988
22989    #[tokio::test(flavor = "multi_thread")]
22990    #[cfg(feature = "reactive-ws")]
22991    async fn aborting_staged_epoch_purges_only_its_buffered_delivery() {
22992        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22993        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22994            provider,
22995            SubscriberMode::PubSub,
22996            SubscriberConfig::default(),
22997        );
22998        subscriber.chain_id = Some(1);
22999        let interest = ReactiveInterest::Logs(LogInterest {
23000            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
23001            local_matcher: None,
23002            route_key: None,
23003        });
23004        let owner_a = subscriber
23005            .stage_interest_owner(
23006                HandlerId::new("owner-a"),
23007                std::slice::from_ref(&interest),
23008                SubscriberOwnerStart::Live,
23009            )
23010            .unwrap();
23011        let owner_b = subscriber
23012            .stage_interest_owner(
23013                HandlerId::new("owner-b"),
23014                &[interest],
23015                SubscriberOwnerStart::Live,
23016            )
23017            .unwrap();
23018
23019        subscriber.enqueue_event(SubscriberEvent::Log {
23020            source_id: 0,
23021            log: rpc_log(false),
23022        });
23023        assert!(subscriber.abort_interest_owner(&owner_a));
23024
23025        let batch = subscriber
23026            .next_scoped_batch()
23027            .await
23028            .unwrap()
23029            .expect("shared canonical delivery remains queued");
23030        assert_eq!(batch.records.len(), 1);
23031        assert_eq!(
23032            batch.records[0].scope,
23033            SubscriberInputScope::Canonical {
23034                owners: vec![owner_b]
23035            }
23036        );
23037    }
23038
23039    #[tokio::test(flavor = "multi_thread")]
23040    #[cfg(feature = "reactive-ws")]
23041    async fn owner_backfill_dedupe_never_suppresses_canonical_delivery() {
23042        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23043        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23044            provider,
23045            SubscriberMode::PubSub,
23046            SubscriberConfig::default(),
23047        );
23048        subscriber.chain_id = Some(1);
23049        let epoch = subscriber
23050            .stage_interest_owner(
23051                HandlerId::new("owner"),
23052                &[ReactiveInterest::Logs(LogInterest {
23053                    provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
23054                    local_matcher: None,
23055                    route_key: None,
23056                })],
23057                SubscriberOwnerStart::Live,
23058            )
23059            .unwrap();
23060        let log = rpc_log(false);
23061
23062        subscriber.enqueue_owner_record(
23063            log_input_record(log.clone(), InputSource::Backfill),
23064            epoch.clone(),
23065        );
23066        subscriber.enqueue_event(SubscriberEvent::Log { source_id: 0, log });
23067
23068        let batch = subscriber
23069            .next_scoped_batch()
23070            .await
23071            .unwrap()
23072            .expect("owner backfill and canonical live delivery");
23073        assert_eq!(batch.records.len(), 2);
23074        assert_eq!(
23075            batch.records[0].scope,
23076            SubscriberInputScope::OwnerOnly {
23077                owners: vec![epoch]
23078            }
23079        );
23080        assert_eq!(
23081            batch.records[1].scope,
23082            SubscriberInputScope::Canonical { owners: Vec::new() },
23083            "owner replay dedupe must not suppress the global live record"
23084        );
23085    }
23086
23087    #[tokio::test(flavor = "multi_thread")]
23088    #[cfg(feature = "reactive-polling")]
23089    async fn reconcile_fetch_drains_live_burst_beyond_output_batch_capacity() {
23090        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23091        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23092            provider,
23093            SubscriberMode::Polling,
23094            SubscriberConfig {
23095                max_batch_size: 2,
23096                ..SubscriberConfig::default()
23097            },
23098        );
23099        let interest = ReactiveInterest::Logs(LogInterest {
23100            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
23101            local_matcher: None,
23102            route_key: None,
23103        });
23104        let epoch = subscriber
23105            .stage_interest_owner(
23106                HandlerId::new("owner"),
23107                std::slice::from_ref(&interest),
23108                SubscriberOwnerStart::Live,
23109            )
23110            .unwrap();
23111        subscriber.sources_dirty = false;
23112
23113        let mut duplicate = rpc_log(false);
23114        duplicate.transaction_hash = Some(B256::repeat_byte(1));
23115        duplicate.log_index = Some(0);
23116        let events = (0u8..10).map(|index| {
23117            let mut log = rpc_log(false);
23118            log.transaction_hash = Some(B256::repeat_byte(index.saturating_add(1)));
23119            log.log_index = Some(index as u64);
23120            SubscriberEvent::Log { source_id: 0, log }
23121        });
23122        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
23123        let mut streams = SubscriberStreams::new();
23124        streams.push(
23125            SubscriberStreamSource::PollingLog { filter },
23126            stream::iter(events).boxed(),
23127        );
23128        subscriber.state = AlloySubscriberState::Active(streams);
23129
23130        let mut polls = 0usize;
23131        let fetched_duplicate = duplicate.clone();
23132        let fetch = poll_fn(move |cx| {
23133            polls += 1;
23134            if polls > 10 {
23135                std::task::Poll::Ready(Ok::<_, SubscriberOwnerError>(fetched_duplicate.clone()))
23136            } else {
23137                cx.waker().wake_by_ref();
23138                std::task::Poll::Pending
23139            }
23140        });
23141        let target_epochs = HashSet::from([epoch.clone()]);
23142        let fetched_duplicate = subscriber
23143            .drive_reconcile_fetch(fetch, &target_epochs)
23144            .await
23145            .unwrap();
23146        subscriber.enqueue_owner_record_for_owners_unmerged(
23147            log_input_record(fetched_duplicate, InputSource::Backfill),
23148            vec![epoch.clone()],
23149        );
23150        subscriber.promote_reconcile_owner_records(&target_epochs);
23151
23152        assert_eq!(subscriber.pending_records.len(), 20);
23153        assert!(subscriber.pending_records.iter().take(10).all(|record| {
23154            record.scope == SubscriberInputScope::Canonical { owners: Vec::new() }
23155        }));
23156        assert!(subscriber.pending_records.iter().skip(10).all(|record| {
23157            record.scope
23158                == SubscriberInputScope::OwnerOnly {
23159                    owners: vec![epoch.clone()],
23160                }
23161        }));
23162    }
23163
23164    #[tokio::test(flavor = "multi_thread")]
23165    #[cfg(feature = "reactive-polling")]
23166    async fn reconcile_fetch_waits_for_provider_when_live_topology_is_empty() {
23167        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23168        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23169            provider,
23170            SubscriberMode::Polling,
23171            SubscriberConfig::default(),
23172        );
23173        subscriber.chain_id = Some(1);
23174        subscriber.sources_dirty = false;
23175        let mut first_poll = true;
23176        let fetch = poll_fn(move |cx| {
23177            if first_poll {
23178                first_poll = false;
23179                cx.waker().wake_by_ref();
23180                std::task::Poll::Pending
23181            } else {
23182                std::task::Poll::Ready(Ok::<_, SubscriberOwnerError>("certified"))
23183            }
23184        });
23185
23186        let result = subscriber
23187            .drive_reconcile_fetch(fetch, &HashSet::new())
23188            .await
23189            .expect("an empty live topology must not be mistaken for termination");
23190        assert_eq!(result, "certified");
23191    }
23192
23193    #[tokio::test(flavor = "multi_thread")]
23194    #[cfg(all(feature = "reactive-polling", feature = "reactive-ws"))]
23195    async fn successful_owner_reconcile_seeds_its_live_filter_reconnect_anchor() {
23196        use alloy_rpc_types_eth::{Block, Header};
23197
23198        let asserter = Asserter::new();
23199        let baseline = BlockRef {
23200            number: 100,
23201            hash: B256::repeat_byte(0x64),
23202            parent_hash: Some(B256::repeat_byte(0x63)),
23203            timestamp: Some(1_700_000_100),
23204        };
23205        let through = BlockRef {
23206            number: 101,
23207            hash: B256::repeat_byte(0x65),
23208            parent_hash: Some(baseline.hash),
23209            timestamp: Some(1_700_000_101),
23210        };
23211        let rpc_block = || -> Block {
23212            Block::empty(Header {
23213                hash: through.hash,
23214                inner: alloy_consensus::Header {
23215                    number: through.number,
23216                    parent_hash: through.parent_hash.unwrap(),
23217                    timestamp: through.timestamp.unwrap(),
23218                    ..Default::default()
23219                },
23220                total_difficulty: None,
23221                size: None,
23222            })
23223        };
23224        asserter.push_success(&Some(rpc_block()));
23225        asserter.push_success(&Vec::<Log>::new());
23226        asserter.push_success(&Some(rpc_block()));
23227        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
23228        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23229            provider,
23230            SubscriberMode::PubSub,
23231            SubscriberConfig::default(),
23232        );
23233        subscriber.chain_id = Some(1);
23234        let interest = ReactiveInterest::Logs(LogInterest {
23235            provider_filter: Filter::new().address(Address::repeat_byte(0xac)),
23236            local_matcher: None,
23237            route_key: None,
23238        });
23239        let epoch = subscriber
23240            .stage_interest_owner(
23241                HandlerId::new("reconnect-anchor"),
23242                std::slice::from_ref(&interest),
23243                SubscriberOwnerStart::PostBlock(baseline),
23244            )
23245            .unwrap();
23246        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
23247        let source = SubscriberStreamSource::PubSubLog {
23248            id: subscriber.log_source_id(&filter),
23249            filter: filter.clone(),
23250        };
23251        let mut streams = SubscriberStreams::new();
23252        streams.push(source, stream::pending().boxed());
23253        subscriber.state = AlloySubscriberState::Active(streams);
23254        subscriber.sources_dirty = false;
23255
23256        subscriber
23257            .reconcile_interest_owner(&epoch, through)
23258            .await
23259            .unwrap();
23260        assert_eq!(subscriber.log_anchor(&filter), Some(through.number));
23261        assert!(asserter.read_q().is_empty());
23262    }
23263
23264    #[tokio::test(flavor = "multi_thread")]
23265    #[cfg(feature = "reactive-polling")]
23266    async fn cancelled_reconcile_retains_hidden_owner_live_delivery_for_retry() {
23267        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23268        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23269            provider,
23270            SubscriberMode::Polling,
23271            SubscriberConfig::default(),
23272        );
23273        let interest = ReactiveInterest::Logs(LogInterest {
23274            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
23275            local_matcher: None,
23276            route_key: None,
23277        });
23278        let epoch = subscriber
23279            .stage_interest_owner(
23280                HandlerId::new("owner"),
23281                std::slice::from_ref(&interest),
23282                SubscriberOwnerStart::PostBlock(BlockRef {
23283                    number: 100,
23284                    hash: B256::repeat_byte(0x64),
23285                    parent_hash: None,
23286                    timestamp: None,
23287                }),
23288            )
23289            .unwrap();
23290        subscriber.sources_dirty = false;
23291
23292        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
23293        let event = SubscriberEvent::Log {
23294            source_id: 0,
23295            log: rpc_log(false),
23296        };
23297        let mut streams = SubscriberStreams::new();
23298        streams.push(
23299            SubscriberStreamSource::PollingLog { filter },
23300            stream::once(async move { event })
23301                .chain(stream::pending())
23302                .boxed(),
23303        );
23304        subscriber.state = AlloySubscriberState::Active(streams);
23305
23306        let targets = HashSet::from([epoch.clone()]);
23307        {
23308            let fetch = futures::future::pending::<Result<(), SubscriberOwnerError>>();
23309            let drive = subscriber.drive_reconcile_fetch(fetch, &targets);
23310            futures::pin_mut!(drive);
23311            poll_fn(|cx| {
23312                assert!(drive.as_mut().poll(cx).is_pending());
23313                std::task::Poll::Ready(())
23314            })
23315            .await;
23316        }
23317
23318        assert_eq!(subscriber.pending_records.len(), 1);
23319        assert_eq!(
23320            subscriber.pending_records[0].scope,
23321            SubscriberInputScope::Canonical { owners: Vec::new() },
23322            "canonical delivery commits immediately at a cancellation-safe boundary"
23323        );
23324        assert_eq!(subscriber.pending_reconcile_owner_records.len(), 1);
23325
23326        subscriber
23327            .drive_reconcile_fetch(futures::future::ready(Ok(())), &targets)
23328            .await
23329            .unwrap();
23330        subscriber.promote_reconcile_owner_records(&targets);
23331        assert!(subscriber.pending_reconcile_owner_records.is_empty());
23332        assert_eq!(subscriber.pending_records.len(), 2);
23333        assert_eq!(
23334            subscriber.pending_records[0].scope,
23335            SubscriberInputScope::Canonical { owners: Vec::new() },
23336            "canonical delivery remains target-excluded"
23337        );
23338        assert_eq!(
23339            subscriber.pending_records[1].scope,
23340            SubscriberInputScope::OwnerOnly {
23341                owners: vec![epoch]
23342            },
23343            "retry commit appends hidden owner delivery after historical catch-up"
23344        );
23345    }
23346
23347    #[tokio::test(flavor = "multi_thread")]
23348    #[cfg(feature = "reactive-ws")]
23349    async fn control_cancellation_preserves_terminated_source_reconcile_intent() {
23350        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23351        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23352            provider,
23353            SubscriberMode::PubSub,
23354            SubscriberConfig {
23355                reconnect: SubscriberReconnectConfig {
23356                    initial_delay: Duration::from_secs(60),
23357                    ..SubscriberReconnectConfig::default()
23358                },
23359                ..SubscriberConfig::default()
23360            },
23361        );
23362        subscriber.chain_id = Some(1);
23363        let interest = ReactiveInterest::Logs(LogInterest {
23364            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
23365            local_matcher: None,
23366            route_key: None,
23367        });
23368        let epoch = subscriber
23369            .stage_interest_owner(
23370                HandlerId::new("owner"),
23371                std::slice::from_ref(&interest),
23372                SubscriberOwnerStart::PostBlock(BlockRef {
23373                    number: 7,
23374                    hash: B256::repeat_byte(0x07),
23375                    parent_hash: Some(B256::repeat_byte(0x06)),
23376                    timestamp: Some(1_700_000_007),
23377                }),
23378            )
23379            .unwrap();
23380        subscriber.sources_dirty = false;
23381        subscriber.stream_revision = 1;
23382        let entry = subscriber
23383            .owned_interests
23384            .iter_mut()
23385            .find(|entry| entry.epoch.as_ref() == Some(&epoch))
23386            .unwrap();
23387        entry.progress = Some(SubscriberOwnerProgress {
23388            owner: epoch.clone(),
23389            through: entry.baseline.unwrap(),
23390        });
23391        entry.progress_stream_revision = Some(1);
23392
23393        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
23394        let source = SubscriberStreamSource::PubSubLog {
23395            id: subscriber.log_source_id(&filter),
23396            filter,
23397        };
23398        let mut streams = SubscriberStreams::new();
23399        streams.push(
23400            source.clone(),
23401            stream::iter([SubscriberEvent::StreamTerminated(source)]).boxed(),
23402        );
23403        subscriber.state = AlloySubscriberState::Active(streams);
23404        let prior_revision = subscriber.stream_revision;
23405
23406        let mut first_poll = true;
23407        let control = poll_fn(move |cx| {
23408            if first_poll {
23409                first_poll = false;
23410                cx.waker().wake_by_ref();
23411                std::task::Poll::Pending
23412            } else {
23413                std::task::Poll::Ready("stop")
23414            }
23415        });
23416        futures::pin_mut!(control);
23417        let outcome = subscriber
23418            .next_scoped_batch_or(control.as_mut())
23419            .await
23420            .unwrap();
23421
23422        assert!(matches!(outcome, SubscriberDriverPoll::Control("stop")));
23423        assert!(subscriber.sources_dirty);
23424        assert!(subscriber.stream_revision > prior_revision);
23425        assert!(
23426            !subscriber.activate_interest_owner(&epoch),
23427            "progress certified against the terminated stream revision is stale"
23428        );
23429    }
23430
23431    #[tokio::test]
23432    #[cfg(feature = "reactive-ws")]
23433    async fn pubsub_sources_assign_stable_log_ids_before_shared_streams() {
23434        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23435        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23436            provider,
23437            SubscriberMode::PubSub,
23438            SubscriberConfig::default(),
23439        );
23440        subscriber.chain_id = Some(1);
23441        subscriber
23442            .register_interests(&[
23443                ReactiveInterest::Logs(LogInterest {
23444                    provider_filter: Filter::new().address(Address::repeat_byte(0x01)),
23445                    local_matcher: None,
23446                    route_key: None,
23447                }),
23448                ReactiveInterest::Logs(LogInterest {
23449                    provider_filter: Filter::new().address(Address::repeat_byte(0x02)),
23450                    local_matcher: None,
23451                    route_key: None,
23452                }),
23453                ReactiveInterest::PendingTransactions(PendingTxInterest::default()),
23454            ])
23455            .await
23456            .expect("register base interests");
23457
23458        // The two default-block-option log filters merge into one address
23459        // superset (existing consolidation behavior), so there is one log source
23460        // — assigned id 0, before the pending-hash source.
23461        let sources = subscriber.stream_sources().expect("stream sources");
23462        assert_eq!(sources.len(), 2);
23463        assert!(matches!(
23464            &sources[0],
23465            SubscriberStreamSource::PubSubLog { id: 0, .. }
23466        ));
23467        assert!(matches!(
23468            sources[1],
23469            SubscriberStreamSource::PubSubPendingHashes
23470        ));
23471
23472        // Ids are stable across repeated source construction.
23473        let again = subscriber.stream_sources().expect("stream sources again");
23474        assert!(again[0].same_key(&sources[0]));
23475    }
23476
23477    #[tokio::test(flavor = "multi_thread")]
23478    #[cfg(feature = "reactive-ws")]
23479    async fn pubsub_stream_termination_attempts_reconnect_before_error() {
23480        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23481        let mut subscriber = AlloySubscriber::new(
23482            provider,
23483            SubscriberMode::PubSub,
23484            SubscriberConfig {
23485                reconnect: SubscriberReconnectConfig {
23486                    initial_delay: Duration::ZERO,
23487                    retry_delay: Duration::ZERO,
23488                    max_delay: Duration::ZERO,
23489                    max_attempts: Some(1),
23490                    ..SubscriberReconnectConfig::default()
23491                },
23492                ..SubscriberConfig::default()
23493            },
23494        );
23495        subscriber.chain_id = Some(1);
23496        subscriber.interests = vec![ReactiveInterest::PendingTransactions(
23497            PendingTxInterest::default(),
23498        )];
23499
23500        let mut streams = SubscriberStreams::new();
23501        let source = SubscriberStreamSource::PubSubPendingHashes;
23502        streams.push(
23503            source,
23504            stream::once(async {
23505                SubscriberEvent::<Ethereum>::StreamTerminated(
23506                    SubscriberStreamSource::PubSubPendingHashes,
23507                )
23508            })
23509            .boxed(),
23510        );
23511        subscriber.state = AlloySubscriberState::Active(streams);
23512
23513        let result = subscriber.next_batch().await;
23514        assert!(
23515            matches!(result, Err(SubscriberError::Provider(ref message)) if message.contains("reconnect failed after 1 attempt")),
23516            "terminated pubsub streams should attempt reconnect before surfacing failure: {result:?}"
23517        );
23518    }
23519
23520    #[tokio::test]
23521    #[cfg(feature = "reactive-ws")]
23522    async fn flashblock_stream_termination_invalidates_before_reconnect_io() {
23523        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23524        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23525            provider,
23526            SubscriberMode::PubSub,
23527            SubscriberConfig {
23528                preconfirmations: PreconfirmationMode::Required,
23529                ..SubscriberConfig::default()
23530            },
23531        )
23532        .with_provider_ref(ProviderRef::new("base-paid", 7));
23533        subscriber.chain_id = Some(8_453);
23534        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
23535        subscriber.interests = subscriber.base_interests.clone();
23536        subscriber.sources_dirty = false;
23537
23538        let preview: BaseFlashblockWirePayload = serde_json::from_str(
23539            r#"{
23540                "hash":"0x0000000000000000000000000000000000000000000000000000000000000000",
23541                "number":"0x65",
23542                "parentHash":"0x6464646464646464646464646464646464646464646464646464646464646464",
23543                "stateRoot":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
23544                "transactionsRoot":"0x1111111111111111111111111111111111111111111111111111111111111111",
23545                "timestamp":"0x6553f165",
23546                "transactions":["0x4141414141414141414141414141414141414141414141414141414141414141"]
23547            }"#,
23548        )
23549        .unwrap();
23550        let (preview, _) = subscriber.accept_base_flashblock(preview).unwrap();
23551        subscriber.latest_preconfirmation = Some(preview);
23552
23553        let mut streams = SubscriberStreams::new();
23554        streams.push(
23555            SubscriberStreamSource::BaseFlashblocks,
23556            stream::once(async {
23557                SubscriberEvent::<Ethereum>::StreamTerminated(
23558                    SubscriberStreamSource::BaseFlashblocks,
23559                )
23560            })
23561            .boxed(),
23562        );
23563        subscriber.state = AlloySubscriberState::Active(streams);
23564
23565        let batch = subscriber
23566            .next_scoped_batch()
23567            .await
23568            .expect("termination handling succeeds")
23569            .expect("invalidation is delivered");
23570        assert!(batch.preconfirmation_invalidated());
23571        assert!(subscriber.latest_preconfirmation.is_none());
23572        assert_eq!(subscriber.provider_ref.as_ref().unwrap().generation, 8);
23573        assert_eq!(subscriber.pending_flashblock_reconnects.len(), 2);
23574        assert!(
23575            subscriber
23576                .pending_flashblock_reconnect_sources
23577                .iter()
23578                .any(|source| matches!(source, SubscriberStreamSource::BaseFlashblocks))
23579        );
23580        assert!(
23581            subscriber
23582                .pending_flashblock_reconnect_sources
23583                .iter()
23584                .any(|source| matches!(source, SubscriberStreamSource::BasePendingLog { .. }))
23585        );
23586        let AlloySubscriberState::Active(streams) = &subscriber.state else {
23587            panic!("subscriber remains active while reconnect is pending")
23588        };
23589        assert!(
23590            streams
23591                .entries
23592                .iter()
23593                .all(|entry| !entry.source.is_flashblocks())
23594        );
23595    }
23596
23597    #[tokio::test]
23598    #[cfg(feature = "reactive-ws")]
23599    async fn preferred_initial_flashblock_rejection_retains_canonical_streams() {
23600        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23601        let filter = Filter::new().address(Address::repeat_byte(0x42));
23602        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23603            provider,
23604            SubscriberMode::PubSub,
23605            SubscriberConfig {
23606                preconfirmations: PreconfirmationMode::Preferred,
23607                reconnect: SubscriberReconnectConfig {
23608                    enabled: false,
23609                    ..SubscriberReconnectConfig::default()
23610                },
23611                ..SubscriberConfig::default()
23612            },
23613        )
23614        .with_provider_ref(ProviderRef::new("base-paid", 1));
23615        subscriber.chain_id = Some(8_453);
23616        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
23617            provider_filter: filter.clone(),
23618            local_matcher: None,
23619            route_key: None,
23620        })];
23621        subscriber.interests = subscriber.base_interests.clone();
23622        subscriber.log_source_ids.insert(filter.clone(), 0);
23623        subscriber.next_log_source_id = 1;
23624
23625        let canonical_source = SubscriberStreamSource::PubSubLog {
23626            id: 0,
23627            filter: filter.clone(),
23628        };
23629        let mut streams = SubscriberStreams::new();
23630        streams.push(
23631            canonical_source.clone(),
23632            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
23633        );
23634        subscriber.state = AlloySubscriberState::Active(streams);
23635        subscriber.sources_dirty = true;
23636
23637        subscriber
23638            .ensure_streams()
23639            .await
23640            .expect("preferred Flashblocks setup degrades to canonical-only");
23641        let AlloySubscriberState::Active(streams) = &subscriber.state else {
23642            panic!("canonical stream remains active")
23643        };
23644        assert!(streams.contains_source(&canonical_source));
23645        assert!(
23646            streams
23647                .entries
23648                .iter()
23649                .all(|entry| !entry.source.is_flashblocks())
23650        );
23651        assert!(subscriber.pending_flashblock_reconnects.is_empty());
23652        assert!(!subscriber.sources_dirty);
23653    }
23654
23655    #[tokio::test]
23656    #[cfg(feature = "reactive-ws")]
23657    async fn required_initial_flashblock_rejection_remains_fail_closed() {
23658        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23659        let filter = Filter::new().address(Address::repeat_byte(0x42));
23660        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23661            provider,
23662            SubscriberMode::PubSub,
23663            SubscriberConfig {
23664                preconfirmations: PreconfirmationMode::Required,
23665                reconnect: SubscriberReconnectConfig {
23666                    enabled: false,
23667                    ..SubscriberReconnectConfig::default()
23668                },
23669                ..SubscriberConfig::default()
23670            },
23671        )
23672        .with_provider_ref(ProviderRef::new("base-paid", 1));
23673        subscriber.chain_id = Some(8_453);
23674        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
23675            provider_filter: filter.clone(),
23676            local_matcher: None,
23677            route_key: None,
23678        })];
23679        subscriber.interests = subscriber.base_interests.clone();
23680        subscriber.log_source_ids.insert(filter.clone(), 0);
23681        subscriber.next_log_source_id = 1;
23682
23683        let canonical_source = SubscriberStreamSource::PubSubLog {
23684            id: 0,
23685            filter: filter.clone(),
23686        };
23687        let mut streams = SubscriberStreams::new();
23688        streams.push(
23689            canonical_source.clone(),
23690            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
23691        );
23692        subscriber.state = AlloySubscriberState::Active(streams);
23693        subscriber.sources_dirty = true;
23694
23695        let error = subscriber
23696            .ensure_streams()
23697            .await
23698            .expect_err("required Flashblocks setup must fail closed");
23699        assert!(matches!(error, SubscriberError::Provider(_)));
23700        let AlloySubscriberState::Active(streams) = &subscriber.state else {
23701            panic!("the already-connected canonical stream is retained")
23702        };
23703        assert!(streams.contains_source(&canonical_source));
23704    }
23705
23706    #[tokio::test]
23707    #[cfg(feature = "reactive-ws")]
23708    async fn preferred_flashblock_termination_preserves_canonical_delivery() {
23709        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23710        let filter = Filter::new().address(Address::repeat_byte(0x42));
23711        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23712            provider,
23713            SubscriberMode::PubSub,
23714            SubscriberConfig {
23715                preconfirmations: PreconfirmationMode::Preferred,
23716                reconnect: SubscriberReconnectConfig {
23717                    enabled: false,
23718                    ..SubscriberReconnectConfig::default()
23719                },
23720                ..SubscriberConfig::default()
23721            },
23722        )
23723        .with_provider_ref(ProviderRef::new("base-paid", 1));
23724        subscriber.chain_id = Some(8_453);
23725        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
23726            provider_filter: filter.clone(),
23727            local_matcher: None,
23728            route_key: None,
23729        })];
23730        subscriber.interests = subscriber.base_interests.clone();
23731        subscriber.log_source_ids.insert(filter.clone(), 0);
23732        subscriber.next_log_source_id = 1;
23733        subscriber.sources_dirty = false;
23734
23735        let mut streams = SubscriberStreams::new();
23736        streams.push(
23737            SubscriberStreamSource::BaseFlashblocks,
23738            stream::once(async {
23739                SubscriberEvent::<Ethereum>::StreamTerminated(
23740                    SubscriberStreamSource::BaseFlashblocks,
23741                )
23742            })
23743            .boxed(),
23744        );
23745        streams.push(
23746            SubscriberStreamSource::PubSubLog {
23747                id: 0,
23748                filter: filter.clone(),
23749            },
23750            stream::once(async {
23751                SubscriberEvent::<Ethereum>::Log {
23752                    source_id: 0,
23753                    log: rpc_log(false),
23754                }
23755            })
23756            .boxed(),
23757        );
23758        subscriber.state = AlloySubscriberState::Active(streams);
23759
23760        let invalidation = subscriber
23761            .next_scoped_batch()
23762            .await
23763            .expect("preferred termination does not fail")
23764            .expect("invalidation is delivered");
23765        assert!(invalidation.preconfirmation_invalidated());
23766
23767        let canonical = subscriber
23768            .next_scoped_batch()
23769            .await
23770            .expect("canonical stream remains healthy")
23771            .expect("canonical log is delivered");
23772        assert!(!canonical.preconfirmation_invalidated());
23773        assert_eq!(canonical.records().len(), 1);
23774        assert_eq!(
23775            canonical.records()[0].record.context.source,
23776            InputSource::Subscription
23777        );
23778    }
23779
23780    #[tokio::test]
23781    #[cfg(feature = "reactive-ws")]
23782    async fn preferred_flashblock_reconnect_exhaustion_preserves_canonical_delivery() {
23783        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23784        let filter = Filter::new().address(Address::repeat_byte(0x42));
23785        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23786            provider,
23787            SubscriberMode::PubSub,
23788            SubscriberConfig {
23789                preconfirmations: PreconfirmationMode::Preferred,
23790                reconnect: SubscriberReconnectConfig {
23791                    enabled: false,
23792                    ..SubscriberReconnectConfig::default()
23793                },
23794                ..SubscriberConfig::default()
23795            },
23796        )
23797        .with_provider_ref(ProviderRef::new("base-paid", 1));
23798        subscriber.chain_id = Some(8_453);
23799        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
23800            provider_filter: filter.clone(),
23801            local_matcher: None,
23802            route_key: None,
23803        })];
23804        subscriber.interests = subscriber.base_interests.clone();
23805        subscriber.log_source_ids.insert(filter.clone(), 0);
23806        subscriber.next_log_source_id = 1;
23807        subscriber.sources_dirty = false;
23808
23809        let canonical_source = SubscriberStreamSource::PubSubLog { id: 0, filter };
23810        let mut streams = SubscriberStreams::new();
23811        streams.push(
23812            canonical_source,
23813            stream::once(async {
23814                tokio::time::sleep(Duration::from_millis(1)).await;
23815                SubscriberEvent::<Ethereum>::Log {
23816                    source_id: 0,
23817                    log: rpc_log(false),
23818                }
23819            })
23820            .boxed(),
23821        );
23822        subscriber.state = AlloySubscriberState::Active(streams);
23823
23824        let source = SubscriberStreamSource::BaseFlashblocks;
23825        subscriber
23826            .pending_flashblock_reconnect_sources
23827            .push(source.clone());
23828        subscriber
23829            .pending_flashblock_reconnects
23830            .push(Box::pin(async move {
23831                (
23832                    source,
23833                    Err(SubscriberError::Provider(
23834                        "test reconnect window exhausted".to_owned(),
23835                    )),
23836                )
23837            }));
23838
23839        let canonical = subscriber
23840            .next_scoped_batch()
23841            .await
23842            .expect("preferred reconnect exhaustion does not fail")
23843            .expect("canonical log is delivered");
23844        assert_eq!(canonical.records().len(), 1);
23845        assert!(subscriber.pending_flashblock_reconnects.is_empty());
23846    }
23847
23848    #[test]
23849    fn backfilled_logs_skip_recent_subscription_duplicates() {
23850        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23851        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23852            provider,
23853            SubscriberMode::PubSub,
23854            SubscriberConfig::default(),
23855        );
23856        subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
23857            provider_filter: Filter::new()
23858                .address(Address::repeat_byte(0x42))
23859                .event_signature(B256::repeat_byte(0x01)),
23860            local_matcher: None,
23861            route_key: None,
23862        })];
23863
23864        let log = rpc_log(false);
23865        subscriber.enqueue_event(SubscriberEvent::Log {
23866            source_id: 0,
23867            log: log.clone(),
23868        });
23869        subscriber.enqueue_event(SubscriberEvent::BackfilledLogs {
23870            source_id: 0,
23871            logs: vec![log],
23872        });
23873
23874        assert_eq!(subscriber.pending_records.len(), 1);
23875        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
23876        assert_eq!(
23877            subscriber.pending_records[0].context.source,
23878            InputSource::Subscription
23879        );
23880    }
23881
23882    #[test]
23883    fn backfilled_logs_surface_with_backfill_source() {
23884        // A backfilled log with no prior subscription duplicate is delivered as
23885        // an `InputSource::Backfill` record (the positive side of the dedup test,
23886        // pinning the README's "marking recovered records as InputSource::Backfill"
23887        // claim — the only place that source is produced).
23888        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23889        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23890            provider,
23891            SubscriberMode::PubSub,
23892            SubscriberConfig::default(),
23893        );
23894        subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
23895            provider_filter: Filter::new()
23896                .address(Address::repeat_byte(0x42))
23897                .event_signature(B256::repeat_byte(0x01)),
23898            local_matcher: None,
23899            route_key: None,
23900        })];
23901
23902        subscriber.enqueue_event(SubscriberEvent::BackfilledLogs {
23903            source_id: 0,
23904            logs: vec![rpc_log(false)],
23905        });
23906
23907        assert_eq!(subscriber.pending_records.len(), 1);
23908        assert_eq!(
23909            subscriber.pending_records[0].context.source,
23910            InputSource::Backfill
23911        );
23912        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
23913    }
23914
23915    #[test]
23916    #[cfg(feature = "reactive-ws")]
23917    fn owner_removal_preserves_delivery_and_dedupe_state() {
23918        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23919        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23920            provider,
23921            SubscriberMode::PubSub,
23922            SubscriberConfig::default(),
23923        );
23924        subscriber
23925            .add_interest_owner(
23926                HandlerId::new("pool-a"),
23927                &[ReactiveInterest::Logs(LogInterest {
23928                    provider_filter: Filter::new()
23929                        .address(Address::repeat_byte(0x42))
23930                        .event_signature(B256::repeat_byte(0x01)),
23931                    local_matcher: None,
23932                    route_key: None,
23933                })],
23934            )
23935            .expect("register pool-a owner");
23936        subscriber
23937            .add_interest_owner(
23938                HandlerId::new("pool-b"),
23939                &[ReactiveInterest::Logs(LogInterest {
23940                    provider_filter: Filter::new()
23941                        .address(Address::repeat_byte(0x24))
23942                        .event_signature(B256::repeat_byte(0x02)),
23943                    local_matcher: None,
23944                    route_key: None,
23945                })],
23946            )
23947            .expect("register pool-b owner");
23948
23949        // Allocate source ids the way live stream setup would (pool-a -> id 0),
23950        // so the injected delivery anchor hangs off a referenced filter.
23951        let sources = subscriber.stream_sources().expect("stream sources");
23952        subscriber.enqueue_event(SubscriberEvent::Log {
23953            source_id: 0,
23954            log: rpc_log(false),
23955        });
23956        let mut streams = SubscriberStreams::new();
23957        streams.push(
23958            sources[0].clone(),
23959            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
23960        );
23961        subscriber.state = AlloySubscriberState::Active(streams);
23962        assert_eq!(subscriber.pending_records.len(), 1);
23963        assert_eq!(subscriber.recent_input_refs.len(), 1);
23964        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
23965
23966        let removed = subscriber
23967            .remove_interest_owner(&HandlerId::new("pool-b"))
23968            .expect("pool-b should be removed");
23969
23970        assert_eq!(removed.len(), 1);
23971        assert_eq!(subscriber.pending_records.len(), 1);
23972        assert_eq!(subscriber.recent_input_refs.len(), 1);
23973        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
23974        assert!(
23975            subscriber
23976                .owner_interests(&HandlerId::new("pool-a"))
23977                .is_some()
23978        );
23979        assert!(
23980            subscriber
23981                .owner_interests(&HandlerId::new("pool-b"))
23982                .is_none()
23983        );
23984        assert_eq!(subscriber.registered_interests().len(), 1);
23985    }
23986
23987    #[test]
23988    #[cfg(feature = "reactive-ws")]
23989    fn owner_log_sources_fan_in_across_owners() {
23990        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23991        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23992            provider,
23993            SubscriberMode::PubSub,
23994            SubscriberConfig::default(),
23995        );
23996        subscriber
23997            .add_interest_owner(
23998                HandlerId::new("pool-a"),
23999                &[ReactiveInterest::Logs(LogInterest {
24000                    provider_filter: Filter::new().address(Address::repeat_byte(0xa1)),
24001                    local_matcher: None,
24002                    route_key: None,
24003                })],
24004            )
24005            .expect("register pool-a owner");
24006
24007        let initial_sources = subscriber.stream_sources().expect("initial sources");
24008        assert_eq!(initial_sources.len(), 1);
24009        let pool_a_source = initial_sources[0].clone();
24010        assert!(matches!(
24011            &pool_a_source,
24012            SubscriberStreamSource::PubSubLog { id: 0, .. }
24013        ));
24014
24015        subscriber
24016            .add_interest_owner(
24017                HandlerId::new("pool-b"),
24018                &[ReactiveInterest::Logs(LogInterest {
24019                    provider_filter: Filter::new().address(Address::repeat_byte(0xb2)),
24020                    local_matcher: None,
24021                    route_key: None,
24022                })],
24023            )
24024            .expect("register pool-b owner");
24025
24026        let expanded_sources = subscriber.stream_sources().expect("expanded sources");
24027        assert_eq!(
24028            expanded_sources.len(),
24029            1,
24030            "compatible owner filters should share one provider subscription"
24031        );
24032        assert!(
24033            !expanded_sources[0].same_key(&pool_a_source),
24034            "the provider-facing superset changes while owner routing remains exact"
24035        );
24036
24037        subscriber
24038            .remove_interest_owner(&HandlerId::new("pool-b"))
24039            .expect("pool-b should be removed");
24040        let trimmed_sources = subscriber.stream_sources().expect("trimmed sources");
24041        assert_eq!(trimmed_sources.len(), 1);
24042        assert!(trimmed_sources[0].same_key(&pool_a_source));
24043    }
24044
24045    #[test]
24046    #[cfg(feature = "reactive-ws")]
24047    fn provider_log_fan_in_respects_address_ceiling() {
24048        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
24049        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24050            provider,
24051            SubscriberMode::PubSub,
24052            SubscriberConfig {
24053                max_log_addresses_per_subscription: 2,
24054                ..SubscriberConfig::default()
24055            },
24056        );
24057        for index in 0..5 {
24058            subscriber
24059                .add_interest_owner(
24060                    HandlerId::new(format!("pool-{index}")),
24061                    &[log_interest_for(index + 1)],
24062                )
24063                .expect("register pool owner");
24064        }
24065
24066        let sources = subscriber.stream_sources().expect("stream sources");
24067        assert_eq!(sources.len(), 3);
24068        let mut address_counts: Vec<_> = sources
24069            .iter()
24070            .map(|source| match source {
24071                SubscriberStreamSource::PubSubLog { filter, .. } => filter.address.iter().count(),
24072                _ => panic!("expected log source"),
24073            })
24074            .collect();
24075        address_counts.sort_unstable();
24076        assert_eq!(address_counts, vec![1, 2, 2]);
24077    }
24078
24079    #[tokio::test(flavor = "multi_thread")]
24080    #[cfg(feature = "reactive-ws")]
24081    async fn owner_backfill_seeds_reconnect_anchor_before_live_log() {
24082        let asserter = Asserter::new();
24083        asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
24084        asserter.push_success(&vec![rpc_log(false)]);
24085        asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
24086        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
24087        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24088            provider,
24089            SubscriberMode::PubSub,
24090            SubscriberConfig::default(),
24091        );
24092        subscriber
24093            .add_interest_owner_with_backfill(
24094                HandlerId::new("pool-a"),
24095                &[ReactiveInterest::Logs(LogInterest {
24096                    provider_filter: Filter::new()
24097                        .address(Address::repeat_byte(0x42))
24098                        .event_signature(B256::repeat_byte(0x01)),
24099                    local_matcher: None,
24100                    route_key: None,
24101                })],
24102                SubscriberBackfill::range(1, 7),
24103            )
24104            .expect("register pool-a with backfill");
24105
24106        subscriber
24107            .drain_pending_backfills()
24108            .await
24109            .expect("owner backfill should drain");
24110
24111        assert_eq!(subscriber.pending_records.len(), 1);
24112        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
24113    }
24114
24115    #[tokio::test(flavor = "multi_thread")]
24116    async fn subscriber_streams_poll_ready_sources_round_robin() {
24117        let first_hash = B256::repeat_byte(0x01);
24118        let second_hash = B256::repeat_byte(0x02);
24119        let mut streams = SubscriberStreams::new();
24120        streams.push(
24121            SubscriberStreamSource::PubSubPendingHashes,
24122            stream::iter([
24123                SubscriberEvent::<Ethereum>::PendingHash(first_hash),
24124                SubscriberEvent::<Ethereum>::PendingHash(first_hash),
24125            ])
24126            .boxed(),
24127        );
24128        streams.push(
24129            SubscriberStreamSource::PubSubBlockHeaders,
24130            stream::once(async move { SubscriberEvent::<Ethereum>::PendingHash(second_hash) })
24131                .boxed(),
24132        );
24133
24134        assert!(matches!(
24135            streams.next().await,
24136            Some(SubscriberEvent::PendingHash(hash)) if hash == first_hash
24137        ));
24138        assert!(matches!(
24139            streams.next().await,
24140            Some(SubscriberEvent::PendingHash(hash)) if hash == second_hash
24141        ));
24142    }
24143
24144    #[tokio::test(flavor = "multi_thread")]
24145    #[cfg(feature = "reactive-ws")]
24146    async fn owner_updates_ensure_streams_without_full_reset() {
24147        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
24148        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24149            provider,
24150            SubscriberMode::PubSub,
24151            SubscriberConfig::default(),
24152        );
24153        subscriber.chain_id = Some(1);
24154        subscriber
24155            .register_interests(&[ReactiveInterest::PendingTransactions(
24156                PendingTxInterest::default(),
24157            )])
24158            .await
24159            .expect("register base pending interest");
24160        subscriber
24161            .add_interest_owner(
24162                HandlerId::new("headers"),
24163                &[ReactiveInterest::Blocks(BlockInterest::default())],
24164            )
24165            .expect("register header owner");
24166
24167        let mut streams = SubscriberStreams::new();
24168        streams.push(
24169            SubscriberStreamSource::PubSubPendingHashes,
24170            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
24171        );
24172        streams.push(
24173            SubscriberStreamSource::PubSubBlockHeaders,
24174            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
24175        );
24176        subscriber.state = AlloySubscriberState::Active(streams);
24177
24178        subscriber
24179            .remove_interest_owner(&HandlerId::new("headers"))
24180            .expect("header owner should be removed");
24181        assert!(matches!(
24182            &subscriber.state,
24183            AlloySubscriberState::Active(streams) if streams.len() == 2
24184        ));
24185
24186        subscriber
24187            .ensure_streams()
24188            .await
24189            .expect("pure removal reconciliation should not touch provider");
24190
24191        assert!(matches!(
24192            &subscriber.state,
24193            AlloySubscriberState::Active(streams)
24194                if streams.len() == 1
24195                    && streams.contains_source(&SubscriberStreamSource::PubSubPendingHashes)
24196                    && !streams.contains_source(&SubscriberStreamSource::PubSubBlockHeaders)
24197        ));
24198
24199        subscriber
24200            .add_interest_owner(
24201                HandlerId::new("headers"),
24202                &[ReactiveInterest::Blocks(BlockInterest::default())],
24203            )
24204            .expect("re-add header owner");
24205        assert!(matches!(
24206            &subscriber.state,
24207            AlloySubscriberState::Active(streams) if streams.len() == 1
24208        ));
24209    }
24210
24211    #[tokio::test(flavor = "multi_thread")]
24212    #[cfg(feature = "reactive-polling")]
24213    async fn ensure_streams_retains_each_successful_connection_across_later_failure() {
24214        let asserter = Asserter::new();
24215        asserter.push_success(&U256::from(1));
24216        asserter.push_failure_msg("second filter connection failed");
24217        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
24218        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24219            provider,
24220            SubscriberMode::Polling,
24221            SubscriberConfig {
24222                max_log_addresses_per_subscription: 1,
24223                ..SubscriberConfig::default()
24224            },
24225        );
24226        subscriber.chain_id = Some(1);
24227        subscriber
24228            .register_interests(&[log_interest_for(0x41), log_interest_for(0x42)])
24229            .await
24230            .expect("register two independently connected filters");
24231
24232        let error = subscriber
24233            .ensure_streams()
24234            .await
24235            .expect_err("second provider connection is forced to fail");
24236        assert!(matches!(error, SubscriberError::Provider(_)));
24237        assert!(subscriber.sources_dirty);
24238        let retained_streams = match &subscriber.state {
24239            AlloySubscriberState::Active(streams) => Some(streams.len()),
24240            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => None,
24241        };
24242        assert_eq!(
24243            retained_streams,
24244            Some(1),
24245            "first connection must survive later error {error:?}; revision {}",
24246            subscriber.stream_revision
24247        );
24248
24249        asserter.push_success(&U256::from(2));
24250        subscriber
24251            .ensure_streams()
24252            .await
24253            .expect("retry connects only the missing source");
24254        assert!(!subscriber.sources_dirty);
24255        assert!(matches!(
24256            &subscriber.state,
24257            AlloySubscriberState::Active(streams) if streams.len() == 2
24258        ));
24259        assert!(asserter.read_q().is_empty());
24260    }
24261
24262    #[tokio::test(flavor = "multi_thread")]
24263    #[cfg(feature = "reactive-ws")]
24264    async fn cancelled_post_install_backfill_is_retried_without_reconnecting() {
24265        let asserter = Asserter::new();
24266        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
24267        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24268            provider,
24269            SubscriberMode::PubSub,
24270            SubscriberConfig::default(),
24271        );
24272        subscriber.chain_id = Some(1);
24273        subscriber
24274            .register_interests(&[log_interest_for(0x43)])
24275            .await
24276            .expect("register log source");
24277        let source = subscriber
24278            .stream_sources()
24279            .expect("one desired source")
24280            .pop()
24281            .expect("log source");
24282        let SubscriberStreamSource::PubSubLog { id, .. } = source else {
24283            panic!("expected pubsub log source")
24284        };
24285        subscriber.last_seen_log_blocks.insert(id, 6);
24286
24287        {
24288            let source = SubscriberStreamSource::PubSubLog {
24289                id,
24290                filter: subscriber
24291                    .log_stream_filters()
24292                    .pop()
24293                    .expect("provider filter"),
24294            };
24295            let interrupted = async {
24296                subscriber.install_source_stream(
24297                    source.clone(),
24298                    stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
24299                );
24300                subscriber.queue_source_backfill(source);
24301                subscriber.sources_dirty = true;
24302                futures::future::pending::<()>().await;
24303            };
24304            futures::pin_mut!(interrupted);
24305            poll_fn(|cx| {
24306                assert!(interrupted.as_mut().poll(cx).is_pending());
24307                std::task::Poll::Ready(())
24308            })
24309            .await;
24310        }
24311
24312        assert_eq!(subscriber.pending_source_backfills.len(), 1);
24313        assert!(matches!(
24314            &subscriber.state,
24315            AlloySubscriberState::Active(streams) if streams.len() == 1
24316        ));
24317
24318        asserter.push_success(&7u64);
24319        asserter.push_success(&Vec::<Log>::new());
24320        subscriber
24321            .ensure_streams()
24322            .await
24323            .expect("retry completes only the pending historical window");
24324
24325        assert!(subscriber.pending_source_backfills.is_empty());
24326        assert!(!subscriber.sources_dirty);
24327        assert!(matches!(
24328            &subscriber.state,
24329            AlloySubscriberState::Active(streams) if streams.len() == 1
24330        ));
24331        assert!(asserter.read_q().is_empty());
24332    }
24333
24334    // A log interest matching `rpc_log` (address 0x42, topic0 0x01).
24335    #[cfg(any(
24336        feature = "raw-flashblocks-json",
24337        feature = "reactive-polling",
24338        feature = "reactive-ws"
24339    ))]
24340    fn log_interest_matching_rpc_log() -> ReactiveInterest<Ethereum> {
24341        ReactiveInterest::Logs(LogInterest {
24342            provider_filter: Filter::new()
24343                .address(Address::repeat_byte(0x42))
24344                .event_signature(B256::repeat_byte(0x01)),
24345            local_matcher: None,
24346            route_key: None,
24347        })
24348    }
24349
24350    #[cfg(any(feature = "reactive-ws", feature = "reactive-polling"))]
24351    fn log_interest_for(address: u8) -> ReactiveInterest<Ethereum> {
24352        ReactiveInterest::Logs(LogInterest {
24353            provider_filter: Filter::new().address(Address::repeat_byte(address)),
24354            local_matcher: None,
24355            route_key: None,
24356        })
24357    }
24358
24359    // B1: a transient provider error must not consume the queued backfill — the
24360    // missed window has to survive for the next poll to retry.
24361    #[tokio::test(flavor = "multi_thread")]
24362    #[cfg(feature = "reactive-ws")]
24363    async fn drain_backfill_retains_queue_entry_on_provider_error() {
24364        let asserter = Asserter::new();
24365        asserter.push_failure_msg("rate limited");
24366        asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
24367        asserter.push_success(&vec![rpc_log(false)]);
24368        asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
24369        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
24370        let mut subscriber = AlloySubscriber::new(
24371            provider,
24372            SubscriberMode::PubSub,
24373            SubscriberConfig::default(),
24374        );
24375        subscriber
24376            .add_interest_owner_with_backfill(
24377                HandlerId::new("pool"),
24378                &[log_interest_matching_rpc_log()],
24379                SubscriberBackfill::range(1, 7),
24380            )
24381            .expect("register owner with backfill");
24382        assert_eq!(subscriber.pending_backfills.len(), 1);
24383
24384        let first = subscriber.drain_pending_backfills().await;
24385        assert!(first.is_err(), "provider failure should surface");
24386        assert_eq!(
24387            subscriber.pending_backfills.len(),
24388            1,
24389            "failed fetch must leave the backfill queued for retry"
24390        );
24391        assert!(subscriber.pending_records.is_empty());
24392
24393        subscriber
24394            .drain_pending_backfills()
24395            .await
24396            .expect("retry should succeed");
24397        assert!(subscriber.pending_backfills.is_empty());
24398        assert_eq!(subscriber.pending_records.len(), 1);
24399    }
24400
24401    // B3: a zero-log backfill window still advances the delivery anchor to its
24402    // upper bound, so a later reconnect catches up from the right block.
24403    #[tokio::test(flavor = "multi_thread")]
24404    #[cfg(feature = "reactive-ws")]
24405    async fn drain_backfill_seeds_anchor_on_empty_window() {
24406        let asserter = Asserter::new();
24407        asserter.push_success(&Some(rpc_block(42, B256::repeat_byte(42))));
24408        asserter.push_success(&Vec::<Log>::new());
24409        asserter.push_success(&Some(rpc_block(42, B256::repeat_byte(42))));
24410        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
24411        let mut subscriber = AlloySubscriber::new(
24412            provider,
24413            SubscriberMode::PubSub,
24414            SubscriberConfig::default(),
24415        );
24416        subscriber
24417            .add_interest_owner_with_backfill(
24418                HandlerId::new("pool"),
24419                &[log_interest_matching_rpc_log()],
24420                SubscriberBackfill::range(1, 42),
24421            )
24422            .expect("register owner with backfill");
24423
24424        subscriber
24425            .drain_pending_backfills()
24426            .await
24427            .expect("empty backfill should drain");
24428
24429        assert!(subscriber.pending_records.is_empty());
24430        let filter = log_filters(subscriber.owner_interests(&HandlerId::new("pool")).unwrap())
24431            .pop()
24432            .unwrap();
24433        assert_eq!(
24434            subscriber.log_anchor(&filter),
24435            Some(42),
24436            "empty window must still seed the anchor at its upper bound"
24437        );
24438    }
24439
24440    // B3 (open-ended): a `from_block`-only backfill resolves its upper bound to
24441    // the provider head and seeds the anchor there.
24442    #[tokio::test(flavor = "multi_thread")]
24443    #[cfg(feature = "reactive-ws")]
24444    async fn drain_backfill_open_ended_resolves_head_and_seeds_anchor() {
24445        let asserter = Asserter::new();
24446        asserter.push_success(&100u64); // get_block_number
24447        asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(100))));
24448        asserter.push_success(&Vec::<Log>::new()); // get_logs
24449        asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(100))));
24450        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
24451        let mut subscriber = AlloySubscriber::new(
24452            provider,
24453            SubscriberMode::PubSub,
24454            SubscriberConfig::default(),
24455        );
24456        subscriber
24457            .add_interest_owner_with_backfill(
24458                HandlerId::new("pool"),
24459                &[log_interest_matching_rpc_log()],
24460                SubscriberBackfill::from_block(10),
24461            )
24462            .expect("register owner with open-ended backfill");
24463
24464        subscriber
24465            .drain_pending_backfills()
24466            .await
24467            .expect("open-ended backfill should drain");
24468
24469        let filter = log_filters(subscriber.owner_interests(&HandlerId::new("pool")).unwrap())
24470            .pop()
24471            .unwrap();
24472        assert_eq!(subscriber.log_anchor(&filter), Some(100));
24473    }
24474
24475    // B2: two owners requesting the same filter shape share exactly one live
24476    // source (and thus one anchor), rather than double-subscribing.
24477    #[test]
24478    #[cfg(feature = "reactive-ws")]
24479    fn duplicate_filters_across_owners_map_to_single_source() {
24480        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
24481        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24482            provider,
24483            SubscriberMode::PubSub,
24484            SubscriberConfig::default(),
24485        );
24486        subscriber
24487            .add_interest_owner(HandlerId::new("pool-a"), &[log_interest_for(0xaa)])
24488            .expect("register pool-a");
24489        subscriber
24490            .add_interest_owner(HandlerId::new("pool-b"), &[log_interest_for(0xaa)])
24491            .expect("register pool-b with identical filter");
24492
24493        assert_eq!(
24494            subscriber.log_stream_filters().len(),
24495            1,
24496            "identical filters across owners must collapse to one"
24497        );
24498        let sources = subscriber.stream_sources().expect("stream sources");
24499        assert_eq!(sources.len(), 1);
24500    }
24501
24502    // B4: removing an owner retires the source-id and anchor bookkeeping for
24503    // filters no other owner references, so long-lived churn cannot leak.
24504    #[test]
24505    #[cfg(feature = "reactive-ws")]
24506    fn owner_removal_prunes_source_ids_and_anchors() {
24507        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
24508        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24509            provider,
24510            SubscriberMode::PubSub,
24511            SubscriberConfig::default(),
24512        );
24513        subscriber
24514            .add_interest_owner(HandlerId::new("pool-a"), &[log_interest_for(0xaa)])
24515            .expect("register pool-a");
24516        subscriber
24517            .add_interest_owner(HandlerId::new("pool-b"), &[log_interest_for(0xbb)])
24518            .expect("register pool-b");
24519
24520        // Allocate ids and simulate delivery anchors on both.
24521        let _ = subscriber.stream_sources().expect("stream sources");
24522        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
24523        let filter_b = log_filters(&[log_interest_for(0xbb)]).pop().unwrap();
24524        let id_a = subscriber.log_source_id(&filter_a);
24525        let id_b = subscriber.log_source_id(&filter_b);
24526        subscriber.last_seen_log_blocks.insert(id_a, 10);
24527        subscriber.last_seen_log_blocks.insert(id_b, 20);
24528        assert_eq!(
24529            subscriber.log_source_ids.len(),
24530            3,
24531            "one provider fan-in id plus two explicitly seeded logical ids"
24532        );
24533
24534        subscriber
24535            .remove_interest_owner(&HandlerId::new("pool-b"))
24536            .expect("remove pool-b");
24537
24538        assert_eq!(
24539            subscriber.log_source_ids.len(),
24540            1,
24541            "pool-b's filter id should be retired"
24542        );
24543        assert!(subscriber.log_source_ids.contains_key(&filter_a));
24544        assert_eq!(subscriber.last_seen_log_blocks.get(&id_a), Some(&10));
24545        assert_eq!(
24546            subscriber.last_seen_log_blocks.get(&id_b),
24547            None,
24548            "pool-b's anchor should be pruned"
24549        );
24550    }
24551
24552    // D1: growing an owner's filter set (a new pool on an existing adapter)
24553    // changes the merged filter shape; the new shape must inherit the old
24554    // anchor via an automatic continuity backfill, or logs between the last
24555    // delivery and the new subscription are silently lost.
24556    #[test]
24557    #[cfg(feature = "reactive-ws")]
24558    fn owner_filter_growth_queues_continuity_backfill_from_prior_anchor() {
24559        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
24560        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24561            provider,
24562            SubscriberMode::PubSub,
24563            SubscriberConfig::default(),
24564        );
24565        subscriber
24566            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
24567            .expect("register amm with pool A");
24568
24569        // Simulate the owner's single merged filter having delivered up to
24570        // block 50.
24571        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
24572        let id_a = subscriber.log_source_id(&filter_a);
24573        subscriber.last_seen_log_blocks.insert(id_a, 50);
24574
24575        // Grow the owner to also watch pool B (same block option -> merges into
24576        // one {A,B} filter, a new shape).
24577        subscriber
24578            .add_interest_owner(
24579                HandlerId::new("amm"),
24580                &[log_interest_for(0xaa), log_interest_for(0xbb)],
24581            )
24582            .expect("grow amm to pools A+B");
24583
24584        assert_eq!(
24585            subscriber.pending_backfills.len(),
24586            1,
24587            "the changed merged filter should queue exactly one continuity backfill"
24588        );
24589        let queued = &subscriber.pending_backfills[0];
24590        assert_eq!(queued.owner, Some(HandlerId::new("amm")));
24591        assert_eq!(queued.backfill.start_block(), 50);
24592        assert_eq!(
24593            queued.backfill.end_block(),
24594            None,
24595            "continuity backfill runs open-ended to the current head"
24596        );
24597    }
24598
24599    // D1 negative: replacing an owner's interests with the identical shape must
24600    // NOT re-fetch — the filter kept its anchor and its live stream.
24601    #[test]
24602    #[cfg(feature = "reactive-ws")]
24603    fn unchanged_owner_filter_does_not_queue_continuity_backfill() {
24604        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
24605        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24606            provider,
24607            SubscriberMode::PubSub,
24608            SubscriberConfig::default(),
24609        );
24610        subscriber
24611            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
24612            .expect("register amm");
24613        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
24614        let id_a = subscriber.log_source_id(&filter_a);
24615        subscriber.last_seen_log_blocks.insert(id_a, 50);
24616
24617        subscriber
24618            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
24619            .expect("re-register identical interests");
24620
24621        assert!(
24622            subscriber.pending_backfills.is_empty(),
24623            "an unchanged filter shape must not queue continuity backfill"
24624        );
24625    }
24626
24627    // D5 interaction: an explicit open-ended backfill starting at or below the
24628    // owner's prior anchor already covers the continuity window, so no extra
24629    // continuity backfill is queued (no redundant double fetch).
24630    #[test]
24631    #[cfg(feature = "reactive-ws")]
24632    fn explicit_open_ended_backfill_below_anchor_suppresses_continuity() {
24633        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
24634        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24635            provider,
24636            SubscriberMode::PubSub,
24637            SubscriberConfig::default(),
24638        );
24639        subscriber
24640            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
24641            .expect("register amm");
24642        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
24643        let id_a = subscriber.log_source_id(&filter_a);
24644        subscriber.last_seen_log_blocks.insert(id_a, 50);
24645
24646        // Grow with an explicit deep backfill from block 10 (< anchor 50).
24647        subscriber
24648            .add_interest_owner_with_backfill(
24649                HandlerId::new("amm"),
24650                &[log_interest_for(0xaa), log_interest_for(0xbb)],
24651                SubscriberBackfill::from_block(10),
24652            )
24653            .expect("grow amm with explicit deep backfill");
24654
24655        assert_eq!(
24656            subscriber.pending_backfills.len(),
24657            1,
24658            "only the explicit backfill should be queued; continuity is subsumed"
24659        );
24660        assert_eq!(subscriber.pending_backfills[0].backfill.start_block(), 10);
24661    }
24662
24663    // The dirty flag gates reconciliation: when nothing changed since the last
24664    // reconcile, `ensure_streams` must not touch the provider or the state.
24665    #[tokio::test(flavor = "multi_thread")]
24666    #[cfg(feature = "reactive-ws")]
24667    async fn ensure_streams_is_noop_when_not_dirty() {
24668        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
24669        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24670            provider,
24671            SubscriberMode::PubSub,
24672            SubscriberConfig::default(),
24673        );
24674        // An interest that WOULD require a new block-header source...
24675        subscriber
24676            .add_interest_owner(
24677                HandlerId::new("headers"),
24678                &[ReactiveInterest::Blocks(BlockInterest::default())],
24679            )
24680            .expect("register header owner");
24681        // ...but we mark bookkeeping clean and start from Empty.
24682        subscriber.state = AlloySubscriberState::Empty;
24683        subscriber.sources_dirty = false;
24684
24685        subscriber
24686            .ensure_streams()
24687            .await
24688            .expect("clean reconcile must be a no-op");
24689
24690        assert!(
24691            matches!(subscriber.state, AlloySubscriberState::Empty),
24692            "not-dirty ensure_streams must not connect new sources"
24693        );
24694    }
24695}
24696
24697fn resolve_subscriber_transport(
24698    mode: SubscriberMode,
24699) -> Result<SubscriberTransport, SubscriberError> {
24700    match mode {
24701        SubscriberMode::PubSub => {
24702            #[cfg(feature = "reactive-ws")]
24703            {
24704                Ok(SubscriberTransport::PubSub)
24705            }
24706            #[cfg(not(feature = "reactive-ws"))]
24707            {
24708                Err(SubscriberError::Unsupported(
24709                    "AlloySubscriber pubsub mode requires the reactive-ws feature",
24710                ))
24711            }
24712        }
24713        SubscriberMode::Polling => {
24714            #[cfg(feature = "reactive-polling")]
24715            {
24716                Ok(SubscriberTransport::Polling)
24717            }
24718            #[cfg(not(feature = "reactive-polling"))]
24719            {
24720                Err(SubscriberError::Unsupported(
24721                    "AlloySubscriber polling mode requires the reactive-polling feature",
24722                ))
24723            }
24724        }
24725        SubscriberMode::Auto => resolve_auto_subscriber_transport(),
24726    }
24727}
24728
24729fn resolve_auto_subscriber_transport() -> Result<SubscriberTransport, SubscriberError> {
24730    #[cfg(feature = "reactive-ws")]
24731    {
24732        Ok(SubscriberTransport::PubSub)
24733    }
24734
24735    #[cfg(all(not(feature = "reactive-ws"), feature = "reactive-polling"))]
24736    {
24737        Ok(SubscriberTransport::Polling)
24738    }
24739
24740    #[cfg(not(any(feature = "reactive-ws", feature = "reactive-polling")))]
24741    {
24742        Err(SubscriberError::Unsupported(
24743            "AlloySubscriber requires either reactive-ws or reactive-polling",
24744        ))
24745    }
24746}
24747
24748fn validate_subscriber_config(config: &SubscriberConfig) -> Result<(), SubscriberError> {
24749    if config.preconfirmations != PreconfirmationMode::Disabled
24750        && config.canonical_head_poll_interval.is_zero()
24751    {
24752        return Err(SubscriberError::InvalidConfig(
24753            "SubscriberConfig::canonical_head_poll_interval must be greater than zero",
24754        ));
24755    }
24756    if config.preconfirmations != PreconfirmationMode::Disabled
24757        && config.canonical_head_request_timeout.is_zero()
24758    {
24759        return Err(SubscriberError::InvalidConfig(
24760            "SubscriberConfig::canonical_head_request_timeout must be greater than zero",
24761        ));
24762    }
24763    if config.preconfirmations != PreconfirmationMode::Disabled
24764        && config.flashblock_poll_interval.is_zero()
24765    {
24766        return Err(SubscriberError::InvalidConfig(
24767            "SubscriberConfig::flashblock_poll_interval must be greater than zero",
24768        ));
24769    }
24770    if config.preconfirmations != PreconfirmationMode::Disabled
24771        && config.max_consecutive_flashblock_poll_failures == 0
24772    {
24773        return Err(SubscriberError::InvalidConfig(
24774            "SubscriberConfig::max_consecutive_flashblock_poll_failures must be greater than zero",
24775        ));
24776    }
24777    if config.preconfirmations != PreconfirmationMode::Disabled
24778        && config.max_pending_transaction_receipts_per_tick == 0
24779    {
24780        return Err(SubscriberError::InvalidConfig(
24781            "SubscriberConfig::max_pending_transaction_receipts_per_tick must be greater than zero",
24782        ));
24783    }
24784    if config.preconfirmations != PreconfirmationMode::Disabled
24785        && config.max_flashblock_rpc_requests_per_second == 0
24786    {
24787        return Err(SubscriberError::InvalidConfig(
24788            "SubscriberConfig::max_flashblock_rpc_requests_per_second must be greater than zero",
24789        ));
24790    }
24791    if config.max_batch_size == 0 {
24792        return Err(SubscriberError::InvalidConfig(
24793            "SubscriberConfig::max_batch_size must be greater than zero",
24794        ));
24795    }
24796    if config.max_log_addresses_per_subscription == 0 {
24797        return Err(SubscriberError::InvalidConfig(
24798            "SubscriberConfig::max_log_addresses_per_subscription must be greater than zero",
24799        ));
24800    }
24801    if config.max_pending_records == 0 {
24802        return Err(SubscriberError::InvalidConfig(
24803            "SubscriberConfig::max_pending_records must be greater than zero",
24804        ));
24805    }
24806    if config.max_pending_backfills == 0 {
24807        return Err(SubscriberError::InvalidConfig(
24808            "SubscriberConfig::max_pending_backfills must be greater than zero",
24809        ));
24810    }
24811    if config.max_backfill_log_bytes == 0 {
24812        return Err(SubscriberError::InvalidConfig(
24813            "SubscriberConfig::max_backfill_log_bytes must be greater than zero",
24814        ));
24815    }
24816    if config.max_reconcile_requests_in_flight == 0 {
24817        return Err(SubscriberError::InvalidConfig(
24818            "SubscriberConfig::max_reconcile_requests_in_flight must be greater than zero",
24819        ));
24820    }
24821    if config.reconnect.enabled {
24822        if config.reconnect.retry_delay > config.reconnect.max_delay {
24823            return Err(SubscriberError::InvalidConfig(
24824                "SubscriberReconnectConfig::retry_delay must be less than or equal to max_delay",
24825            ));
24826        }
24827        if matches!(config.reconnect.max_attempts, Some(0)) {
24828            return Err(SubscriberError::InvalidConfig(
24829                "SubscriberReconnectConfig::max_attempts must be greater than zero when set",
24830            ));
24831        }
24832    }
24833    Ok(())
24834}
24835
24836fn validate_supported_interests<N: Network>(
24837    mode: SubscriberMode,
24838    config: &SubscriberConfig,
24839    interests: &[ReactiveInterest<N>],
24840) -> Result<(), SubscriberError> {
24841    let transport = resolve_subscriber_transport(mode)?;
24842
24843    for interest in interests {
24844        match interest {
24845            ReactiveInterest::Logs(_) => {}
24846            ReactiveInterest::PendingTransactions(interest)
24847                if !config.hydrate_pending_transactions && interest.matches_hash_only() => {}
24848            ReactiveInterest::PendingTransactions(_) => {
24849                return Err(SubscriberError::Unsupported(
24850                    "AlloySubscriber currently supports pending transaction hash interests only (full pending-tx hydration is unimplemented)",
24851                ));
24852            }
24853            ReactiveInterest::Blocks(interest) => match (transport, interest.mode) {
24854                (SubscriberTransport::PubSub, BlockInterestMode::Header) => {}
24855                (_, BlockInterestMode::FullBlock) => {
24856                    return Err(SubscriberError::Unsupported(
24857                        "AlloySubscriber full block streams are not implemented in this transport slice",
24858                    ));
24859                }
24860                (SubscriberTransport::Polling, BlockInterestMode::Header) => {
24861                    return Err(SubscriberError::Unsupported(
24862                        "AlloySubscriber polling block streams are not implemented in this transport slice",
24863                    ));
24864                }
24865            },
24866        }
24867    }
24868
24869    Ok(())
24870}
24871
24872fn log_filters<N: Network>(interests: &[ReactiveInterest<N>]) -> Vec<Filter> {
24873    let mut filters = Vec::new();
24874    for interest in interests {
24875        if let ReactiveInterest::Logs(interest) = interest {
24876            merge_log_subscription_filter(&mut filters, &interest.provider_filter);
24877        }
24878    }
24879    filters
24880}
24881
24882fn needs_header_block_stream<N: Network>(interests: &[ReactiveInterest<N>]) -> bool {
24883    interests.iter().any(|interest| {
24884        matches!(
24885            interest,
24886            ReactiveInterest::Blocks(BlockInterest {
24887                mode: BlockInterestMode::Header,
24888            })
24889        )
24890    })
24891}
24892
24893fn needs_pending_hash_stream<N: Network>(interests: &[ReactiveInterest<N>]) -> bool {
24894    interests.iter().any(|interest| {
24895        matches!(
24896            interest,
24897            ReactiveInterest::PendingTransactions(interest) if interest.matches_hash_only()
24898        )
24899    })
24900}
24901
24902fn log_matches_any_interest<N: Network>(log: &Log, interests: &[ReactiveInterest<N>]) -> bool {
24903    interests.iter().any(|interest| {
24904        matches!(
24905            interest,
24906            ReactiveInterest::Logs(interest) if interest.matches(log)
24907        )
24908    })
24909}
24910
24911fn validate_owner_backfill_logs(
24912    logs: &[Log],
24913    from_block: u64,
24914    through: &BlockRef,
24915) -> Result<(), SubscriberOwnerError> {
24916    for log in logs {
24917        if log.removed {
24918            return Err(SubscriberOwnerError::InvalidBackfillLog(
24919                "removed log in canonical catch-up",
24920            ));
24921        }
24922        let number = log
24923            .block_number
24924            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
24925                "log missing block number",
24926            ))?;
24927        let hash = log
24928            .block_hash
24929            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
24930                "log missing block hash",
24931            ))?;
24932        log.transaction_hash
24933            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
24934                "log missing transaction hash",
24935            ))?;
24936        log.transaction_index
24937            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
24938                "log missing transaction index",
24939            ))?;
24940        log.log_index
24941            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
24942                "log missing log index",
24943            ))?;
24944        if number < from_block || number > through.number {
24945            return Err(SubscriberOwnerError::InvalidBackfillLog(
24946                "log outside requested block range",
24947            ));
24948        }
24949        if number == through.number && hash != through.hash {
24950            return Err(SubscriberOwnerError::InvalidBackfillLog(
24951                "target-block log hash mismatch",
24952            ));
24953        }
24954    }
24955    Ok(())
24956}
24957
24958fn validate_backfill_resource_limits(
24959    logs: &[Log],
24960    max_logs: usize,
24961    max_log_bytes: usize,
24962) -> Result<usize, SubscriberError> {
24963    if logs.len() > max_logs {
24964        return Err(SubscriberError::ResourceExhausted(format!(
24965            "historical response returned {} logs, above the configured limit of {max_logs}",
24966            logs.len()
24967        )));
24968    }
24969    let bytes = logs.iter().fold(0usize, |total, log| {
24970        // Include fixed address/block/transaction/index fields in addition to
24971        // the variable topic and data payload. This is deliberately a stable
24972        // conservative accounting unit rather than Rust heap-layout size.
24973        let fixed = 20usize + (32 * 3) + (8 * 4) + 1;
24974        total
24975            .saturating_add(fixed)
24976            .saturating_add(log.topics().len().saturating_mul(32))
24977            .saturating_add(log.inner.data.data.len())
24978    });
24979    if bytes > max_log_bytes {
24980        return Err(SubscriberError::ResourceExhausted(format!(
24981            "historical response retained approximately {bytes} log bytes, above the configured limit of {max_log_bytes}"
24982        )));
24983    }
24984    Ok(bytes)
24985}
24986
24987async fn fetch_provider_block_ref<P, N>(
24988    provider: &P,
24989    number: u64,
24990    counters: &SubscriberRpcCounters,
24991    cause: SubscriberRpcCause,
24992) -> Result<BlockRef, SubscriberError>
24993where
24994    P: Provider<N> + Send + Sync,
24995    N: Network,
24996{
24997    counters.record(cause, SubscriberRpcMethod::EthGetBlockByNumber);
24998    let block = provider
24999        .get_block_by_number(BlockNumberOrTag::Number(number))
25000        .await
25001        .map_err(provider_error)?
25002        .ok_or_else(|| {
25003            SubscriberError::InvalidBackfill(format!(
25004                "canonical target block {number} is unavailable"
25005            ))
25006        })?;
25007    let header = block.header();
25008    Ok(BlockRef {
25009        number: header.number(),
25010        hash: header.hash(),
25011        parent_hash: Some(header.parent_hash()),
25012        timestamp: Some(header.timestamp()),
25013    })
25014}
25015
25016fn block_ref_satisfies_expected(actual: &BlockRef, expected: &BlockRef) -> bool {
25017    actual.number == expected.number
25018        && actual.hash == expected.hash
25019        && optional_metadata_compatible(actual.parent_hash.as_ref(), expected.parent_hash.as_ref())
25020        && optional_metadata_compatible(actual.timestamp.as_ref(), expected.timestamp.as_ref())
25021}
25022
25023fn validate_owner_backfill_log_set(logs: &[Log]) -> Result<(), SubscriberOwnerError> {
25024    let mut positions = HashMap::new();
25025    let mut block_hashes = HashMap::new();
25026    let mut transaction_hashes = HashMap::new();
25027    let mut transaction_positions = HashMap::new();
25028    let mut ordering = BTreeMap::<u64, Vec<(u64, u64)>>::new();
25029    for log in logs {
25030        let number = log
25031            .block_number
25032            .expect("individual owner catch-up logs are validated before set validation");
25033        let block_hash = log
25034            .block_hash
25035            .expect("individual owner catch-up logs are validated before set validation");
25036        let transaction_hash = log
25037            .transaction_hash
25038            .expect("individual owner catch-up logs are validated before set validation");
25039        let transaction_index = log
25040            .transaction_index
25041            .expect("individual owner catch-up logs are validated before set validation");
25042        let log_index = log
25043            .log_index
25044            .expect("individual owner catch-up logs are validated before set validation");
25045        if block_hashes
25046            .insert(number, block_hash)
25047            .is_some_and(|prior| prior != block_hash)
25048        {
25049            return Err(SubscriberOwnerError::InvalidBackfillLog(
25050                "conflicting block identity in canonical catch-up",
25051            ));
25052        }
25053        if let Some(previous) = positions.insert((number, log_index), log)
25054            && previous != log
25055        {
25056            return Err(SubscriberOwnerError::InvalidBackfillLog(
25057                "conflicting logs at one canonical block position",
25058            ));
25059        }
25060        let conflicting_transaction = transaction_hashes
25061            .insert((number, transaction_index), transaction_hash)
25062            .is_some_and(|prior| prior != transaction_hash)
25063            || transaction_positions
25064                .insert((number, transaction_hash), transaction_index)
25065                .is_some_and(|prior| prior != transaction_index);
25066        if conflicting_transaction {
25067            return Err(SubscriberOwnerError::InvalidBackfillLog(
25068                "conflicting transaction identity at one canonical block position",
25069            ));
25070        }
25071        ordering
25072            .entry(number)
25073            .or_default()
25074            .push((log_index, transaction_index));
25075    }
25076    for positions in ordering.values_mut() {
25077        positions.sort_unstable();
25078        if positions.windows(2).any(|pair| pair[0].1 > pair[1].1) {
25079            return Err(SubscriberOwnerError::InvalidBackfillLog(
25080                "transaction and log positions disagree on canonical order",
25081            ));
25082        }
25083    }
25084    Ok(())
25085}
25086
25087fn merged_owner_reconcile_filters<N: Network>(
25088    plans: &[SubscriberOwnerReconcilePlan<N>],
25089    through: u64,
25090) -> Vec<SubscriberOwnerReconcileFilter> {
25091    let mut by_start = BTreeMap::<u64, Vec<Filter>>::new();
25092    for plan in plans.iter().filter(|plan| plan.from_block <= through) {
25093        let filters = by_start.entry(plan.from_block).or_default();
25094        filters.extend(
25095            log_filters(&plan.interests)
25096                .into_iter()
25097                .map(|filter| filter.from_block(plan.from_block).to_block(through)),
25098        );
25099    }
25100
25101    let mut chunks = Vec::new();
25102    for (from_block, filters) in by_start {
25103        for filters in filters.chunks(OWNER_RECONCILE_FILTERS_PER_CHUNK) {
25104            let mut merged = Vec::new();
25105            for filter in filters {
25106                merge_log_subscription_filter(&mut merged, filter);
25107            }
25108            chunks.extend(
25109                merged
25110                    .into_iter()
25111                    .map(|filter| SubscriberOwnerReconcileFilter { filter, from_block }),
25112            );
25113        }
25114    }
25115    chunks
25116}
25117
25118fn merged_lazy_backfill_filters(
25119    filters: &[Filter],
25120    from_block: u64,
25121    through: u64,
25122) -> Vec<SubscriberOwnerReconcileFilter> {
25123    let mut requests = Vec::new();
25124    for filters in filters.chunks(OWNER_RECONCILE_FILTERS_PER_CHUNK) {
25125        let mut merged = Vec::new();
25126        for filter in filters {
25127            merge_log_subscription_filter(
25128                &mut merged,
25129                &filter.clone().from_block(from_block).to_block(through),
25130            );
25131        }
25132        requests.extend(
25133            merged
25134                .into_iter()
25135                .map(|filter| SubscriberOwnerReconcileFilter { filter, from_block }),
25136        );
25137    }
25138    requests
25139}
25140
25141fn lazy_backfill_error(error: SubscriberOwnerError) -> SubscriberError {
25142    match error {
25143        SubscriberOwnerError::Subscriber(error) => error,
25144        error => SubscriberError::InvalidBackfill(error.to_string()),
25145    }
25146}
25147
25148fn global_backfill_barrier(backfill: SubscriberBackfill, certified: BlockRef) -> ChainControl {
25149    let mut id = b"alloy-global-backfill-v1".to_vec();
25150    id.extend_from_slice(&backfill.start_block().to_be_bytes());
25151    id.extend_from_slice(&certified.number.to_be_bytes());
25152    id.extend_from_slice(certified.hash.as_slice());
25153    ChainControl::Barrier {
25154        id,
25155        block: Some(certified),
25156    }
25157}
25158
25159async fn fetch_owner_catchup<P, N>(
25160    provider: P,
25161    filters: Vec<SubscriberOwnerReconcileFilter>,
25162    retained: Vec<BlockRef>,
25163    through: BlockRef,
25164    options: SubscriberOwnerCatchupOptions,
25165    counters: Arc<SubscriberRpcCounters>,
25166) -> Result<SubscriberOwnerCatchup, SubscriberOwnerError>
25167where
25168    P: Provider<N> + Send + Sync,
25169    N: Network,
25170{
25171    let counters = counters.as_ref();
25172    if !options.target_preverified {
25173        let _ =
25174            verify_provider_reconcile_target::<P, N>(&provider, &through, counters, options.cause)
25175                .await?;
25176    }
25177    let mut certified_positions = HashSet::new();
25178    for position in retained {
25179        let target_certifies_position = position == through
25180            || (position.number.checked_add(1) == Some(through.number)
25181                && through.parent_hash == Some(position.hash));
25182        if !target_certifies_position && certified_positions.insert(position) {
25183            let _ = verify_provider_reconcile_target::<P, N>(
25184                &provider,
25185                &position,
25186                counters,
25187                options.cause,
25188            )
25189            .await?;
25190        }
25191    }
25192    let mut logs = Vec::new();
25193    let mut total_log_bytes = 0usize;
25194    let requests = stream::iter(filters.into_iter().map(|filter| {
25195        let provider = &provider;
25196        async move {
25197            counters.record(options.cause, SubscriberRpcMethod::EthGetLogs);
25198            let logs = provider
25199                .get_logs(&filter.filter)
25200                .await
25201                .map_err(provider_error)?;
25202            Ok::<_, SubscriberOwnerError>((filter.from_block, logs))
25203        }
25204    }))
25205    .buffer_unordered(options.max_requests_in_flight);
25206    futures::pin_mut!(requests);
25207    while let Some(result) = requests.next().await {
25208        let (from_block, fetched) = result?;
25209        let fetched_bytes =
25210            validate_backfill_resource_limits(&fetched, options.max_logs, options.max_log_bytes)?;
25211        validate_owner_backfill_logs(&fetched, from_block, &through)?;
25212        if logs.len().saturating_add(fetched.len()) > options.max_logs {
25213            return Err(SubscriberError::ResourceExhausted(format!(
25214                "bulk reconcile returned more than {} logs",
25215                options.max_logs
25216            ))
25217            .into());
25218        }
25219        total_log_bytes = total_log_bytes.saturating_add(fetched_bytes);
25220        if total_log_bytes > options.max_log_bytes {
25221            return Err(SubscriberError::ResourceExhausted(format!(
25222                "bulk reconcile retained approximately {total_log_bytes} log bytes, above the configured limit of {}",
25223                options.max_log_bytes
25224            ))
25225            .into());
25226        }
25227        logs.extend(fetched);
25228    }
25229    validate_owner_backfill_log_set(&logs)?;
25230    let certified =
25231        verify_provider_reconcile_target::<P, N>(&provider, &through, counters, options.cause)
25232            .await?;
25233    Ok(SubscriberOwnerCatchup { logs, certified })
25234}
25235
25236async fn verify_provider_reconcile_target<P, N>(
25237    provider: &P,
25238    expected: &BlockRef,
25239    counters: &SubscriberRpcCounters,
25240    cause: SubscriberRpcCause,
25241) -> Result<BlockRef, SubscriberOwnerError>
25242where
25243    P: Provider<N> + Send + Sync,
25244    N: Network,
25245{
25246    counters.record(cause, SubscriberRpcMethod::EthGetBlockByNumber);
25247    let block = provider
25248        .get_block_by_number(BlockNumberOrTag::Number(expected.number))
25249        .await
25250        .map_err(provider_error)?
25251        .ok_or(SubscriberOwnerError::BlockUnavailable(expected.number))?;
25252    let header = block.header();
25253    let actual = BlockRef {
25254        number: header.number(),
25255        hash: header.hash(),
25256        parent_hash: Some(header.parent_hash()),
25257        timestamp: Some(header.timestamp()),
25258    };
25259    let exact_parent = expected
25260        .parent_hash
25261        .is_none_or(|parent| Some(parent) == actual.parent_hash);
25262    let exact_timestamp = expected
25263        .timestamp
25264        .is_none_or(|timestamp| Some(timestamp) == actual.timestamp);
25265    if actual.number != expected.number
25266        || actual.hash != expected.hash
25267        || !exact_parent
25268        || !exact_timestamp
25269    {
25270        return Err(SubscriberOwnerError::BlockMismatch {
25271            expected_number: expected.number,
25272            expected_hash: expected.hash,
25273            actual_number: actual.number,
25274            actual_hash: actual.hash,
25275        });
25276    }
25277    Ok(actual)
25278}
25279
25280fn log_input_record<N: Network>(log: Log, source: InputSource) -> ReactiveInputRecord<N> {
25281    let context = log_reactive_context(&log);
25282    ReactiveInputRecord::new(
25283        ReactiveInput::Log(log),
25284        ReactiveContext { source, ..context },
25285    )
25286}
25287
25288fn preconfirmed_log_input_record<N: Network>(
25289    log: Log,
25290    flashblock: FlashblockRef,
25291) -> ReactiveInputRecord<N> {
25292    let block = flashblock.block_ref();
25293    let provider = flashblock.provider.clone();
25294    ReactiveInputRecord::new(
25295        ReactiveInput::Log(log.clone()),
25296        ReactiveContext {
25297            chain_id: None,
25298            source: InputSource::Flashblocks,
25299            chain_status: ChainStatus::Preconfirmed {
25300                flashblock: Arc::new(flashblock),
25301            },
25302            block: Some(block),
25303            transaction_index: log.transaction_index,
25304            log_index: log.log_index,
25305        },
25306    )
25307    .with_provider(provider)
25308}
25309
25310fn log_reactive_context(log: &Log) -> ReactiveContext {
25311    let block = match (log.block_hash, log.block_number) {
25312        (Some(hash), Some(number)) => Some(BlockRef {
25313            number,
25314            hash,
25315            parent_hash: None,
25316            timestamp: log.block_timestamp,
25317        }),
25318        _ => None,
25319    };
25320
25321    let chain_status = match (&block, log.removed) {
25322        (Some(block), true) => ChainStatus::Reorged {
25323            dropped_from: *block,
25324        },
25325        (Some(block), false) => ChainStatus::Included {
25326            block: *block,
25327            confirmations: 0,
25328        },
25329        (None, _) => ChainStatus::Pending,
25330    };
25331
25332    ReactiveContext {
25333        chain_id: None,
25334        source: InputSource::Poll,
25335        chain_status,
25336        block,
25337        transaction_index: log.transaction_index,
25338        log_index: log.log_index,
25339    }
25340}
25341
25342fn block_header_input_record<N>(header: N::HeaderResponse) -> ReactiveInputRecord<N>
25343where
25344    N: Network,
25345{
25346    let block = BlockRef {
25347        number: header.number(),
25348        hash: HeaderResponseTrait::hash(&header),
25349        parent_hash: Some(header.parent_hash()),
25350        timestamp: Some(header.timestamp()),
25351    };
25352    ReactiveInputRecord::new(
25353        ReactiveInput::BlockHeader(header),
25354        ReactiveContext {
25355            chain_id: None,
25356            source: InputSource::Subscription,
25357            chain_status: ChainStatus::Included {
25358                block,
25359                confirmations: 0,
25360            },
25361            block: Some(block),
25362            transaction_index: None,
25363            log_index: None,
25364        },
25365    )
25366}
25367
25368fn pending_hash_input_record<N: Network>(
25369    hash: B256,
25370    source: InputSource,
25371) -> ReactiveInputRecord<N> {
25372    ReactiveInputRecord::new(
25373        ReactiveInput::PendingTxHash(hash),
25374        ReactiveContext {
25375            chain_id: None,
25376            source,
25377            chain_status: ChainStatus::Pending,
25378            block: None,
25379            transaction_index: None,
25380            log_index: None,
25381        },
25382    )
25383}
25384
25385#[cfg(feature = "reactive-ws")]
25386fn base_pending_log_filter(filter: &Filter) -> Result<serde_json::Value, SubscriberError> {
25387    let encoded = serde_json::to_value(filter)
25388        .map_err(|error| SubscriberError::Provider(error.to_string()))?;
25389    let serde_json::Value::Object(mut fields) = encoded else {
25390        return Err(SubscriberError::Provider(
25391            "Alloy log filter did not serialize as an object".into(),
25392        ));
25393    };
25394    fields.retain(|key, _| key == "address" || key == "topics");
25395    Ok(serde_json::Value::Object(fields))
25396}
25397
25398fn provider_error(error: impl fmt::Display) -> SubscriberError {
25399    SubscriberError::Provider(error.to_string())
25400}
25401
25402/// Subscriber error.
25403#[derive(Debug, thiserror::Error)]
25404#[non_exhaustive]
25405pub enum SubscriberError {
25406    /// Invalid subscriber configuration.
25407    #[error("{0}")]
25408    InvalidConfig(&'static str),
25409    /// Requested subscriber behavior is not implemented.
25410    #[error("{0}")]
25411    Unsupported(&'static str),
25412    /// The pinned provider lease reports a different chain identity.
25413    #[error("subscriber chain mismatch: expected {expected}, got {actual}")]
25414    ChainMismatch {
25415        /// Required chain id.
25416        expected: u64,
25417        /// Observed chain id.
25418        actual: u64,
25419    },
25420    /// Provider or transport error.
25421    #[error("provider error: {0}")]
25422    Provider(String),
25423    /// A provider returned malformed, out-of-range, or non-canonical lazy
25424    /// backfill data.
25425    #[error("invalid canonical backfill: {0}")]
25426    InvalidBackfill(String),
25427    /// A configured subscriber memory/concurrency boundary was exceeded.
25428    #[error("subscriber resource limit exceeded: {0}")]
25429    ResourceExhausted(String),
25430}
25431
25432/// Canonical head certification is the second unrequested request stream in the
25433/// live path: on a Flashblocks endpoint it replaces the `newHeads` subscription
25434/// with a fixed-interval poll, so it bills a request per tick regardless of
25435/// whether the head actually moved.
25436#[cfg(test)]
25437mod canonical_head_rpc_stats_tests {
25438    use super::*;
25439    use alloy_provider::ProviderBuilder;
25440    use alloy_rpc_types_eth::{Block, Header as RpcHeader};
25441    use alloy_transport::mock::Asserter;
25442
25443    fn sealed_head() -> Block {
25444        Block::empty(RpcHeader {
25445            hash: B256::repeat_byte(0x65),
25446            inner: alloy_consensus::Header {
25447                number: 101,
25448                parent_hash: B256::repeat_byte(0x64),
25449                timestamp: 1_700_000_101,
25450                ..alloy_consensus::Header::default()
25451            },
25452            total_difficulty: None,
25453            size: None,
25454        })
25455    }
25456
25457    /// Two ticks, one new head: the poll that observes no change still costs a
25458    /// request, and `rpc_stats` reports both. This is the in-process form of the
25459    /// measured Base head-poll volume — the counter tracks requests issued, not
25460    /// events produced.
25461    #[tokio::test]
25462    async fn unchanged_head_still_counts_its_certification_request() {
25463        let asserter = Asserter::new();
25464        asserter.push_success(&Some(sealed_head()));
25465        asserter.push_success(&Some(sealed_head()));
25466        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
25467        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
25468            provider,
25469            SubscriberMode::PubSub,
25470            SubscriberConfig::default(),
25471        );
25472        subscriber.chain_id = Some(8_453);
25473
25474        let first = subscriber
25475            .fetch_certified_canonical_head()
25476            .await
25477            .expect("first certification succeeds");
25478        assert!(
25479            matches!(first, Some(SubscriberEvent::BlockHeader(_))),
25480            "a newly certified head is delivered"
25481        );
25482
25483        let second = subscriber
25484            .fetch_certified_canonical_head()
25485            .await
25486            .expect("second certification succeeds");
25487        assert!(
25488            second.is_none(),
25489            "an unchanged head produces no event to deliver"
25490        );
25491
25492        let stats = subscriber.rpc_stats();
25493        assert_eq!(
25494            stats.get(
25495                SubscriberRpcCause::CanonicalHeadCertification,
25496                SubscriberRpcMethod::EthGetBlockByNumber,
25497            ),
25498            2,
25499            "both polls are billed even though only one advanced the head"
25500        );
25501        assert_eq!(stats.total(), 2, "certification is the only request issued");
25502        assert_eq!(
25503            subscriber
25504                .flashblocks_rpc_metrics()
25505                .canonical_head_requests(),
25506            2,
25507            "the attributed counter agrees with the Flashblocks-scoped one"
25508        );
25509        assert!(asserter.read_q().is_empty());
25510    }
25511}
25512
25513/// Notification loss on a live subscription must be observable and recoverable.
25514///
25515/// The premise of sourcing canonical logs from a subscription is that loss can be
25516/// detected; `alloy-pubsub`'s typed stream defeats that by treating a lagged
25517/// receiver and an undecodable payload as `continue`. These tests drive a real
25518/// broadcast channel — overflowing it for real rather than simulating the error —
25519/// and pin both the detection and the bounded recovery it triggers.
25520#[cfg(all(test, feature = "reactive-ws"))]
25521mod stream_gap_tests {
25522    use super::*;
25523    use alloy_provider::ProviderBuilder;
25524    use alloy_transport::mock::Asserter;
25525    use serde_json::value::RawValue;
25526
25527    fn raw_json(value: &serde_json::Value) -> Box<RawValue> {
25528        RawValue::from_string(value.to_string()).expect("valid JSON")
25529    }
25530
25531    fn wire_log(block_number: u64, log_index: u64) -> Box<RawValue> {
25532        raw_json(&serde_json::json!({
25533            "address": "0x0000000000000000000000000000000000000077",
25534            "topics": ["0x1111111111111111111111111111111111111111111111111111111111111111"],
25535            "data": "0x",
25536            "blockHash": format!("0x{:064x}", block_number),
25537            "blockNumber": format!("0x{block_number:x}"),
25538            "transactionHash": format!("0x{:064x}", 0x20 + log_index),
25539            "transactionIndex": format!("0x{log_index:x}"),
25540            "logIndex": format!("0x{log_index:x}"),
25541            "removed": false,
25542        }))
25543    }
25544
25545    fn log_source(id: usize) -> SubscriberStreamSource {
25546        SubscriberStreamSource::PubSubLog {
25547            id,
25548            filter: Filter::new().address(Address::repeat_byte(0x77)),
25549        }
25550    }
25551
25552    /// Build a `Subscription<Log>` over a real broadcast channel so the test can
25553    /// overflow it, feed it garbage, or close it.
25554    fn wired_subscription(
25555        capacity: usize,
25556    ) -> (
25557        tokio::sync::broadcast::Sender<Box<RawValue>>,
25558        alloy_pubsub::Subscription<Log>,
25559    ) {
25560        let (tx, rx) = tokio::sync::broadcast::channel(capacity);
25561        let raw = alloy_pubsub::RawSubscription {
25562            rx,
25563            local_id: B256::repeat_byte(0x5b),
25564        };
25565        (tx, raw.into_typed())
25566    }
25567
25568    fn gap_stream(
25569        subscription: alloy_pubsub::Subscription<Log>,
25570        source: SubscriberStreamSource,
25571        counters: Arc<SubscriberStreamGapCounters>,
25572    ) -> BoxStream<'static, SubscriberEvent<Ethereum>> {
25573        gap_observing_stream(subscription, source, counters, |log| SubscriberEvent::Log {
25574            source_id: 0,
25575            log,
25576        })
25577    }
25578
25579    /// A channel that overflows reports the loss. Under
25580    /// `Subscription::into_stream` this same sequence yields only the surviving
25581    /// notification, with the drop visible nowhere.
25582    #[tokio::test]
25583    async fn overflowing_channel_reports_the_gap_instead_of_skipping_it() {
25584        let counters = Arc::new(SubscriberStreamGapCounters::default());
25585        let (tx, subscription) = wired_subscription(2);
25586        // Publish past capacity before the stream is ever polled.
25587        for index in 0..5 {
25588            tx.send(wire_log(100 + index, index))
25589                .expect("receiver alive");
25590        }
25591        let mut stream = gap_stream(subscription, log_source(0), Arc::clone(&counters));
25592
25593        let first = stream.next().await.expect("an event is produced");
25594        let SubscriberEvent::StreamGap { source, gap } = first else {
25595            panic!("expected the dropped notifications to surface as a gap, got a delivery");
25596        };
25597        assert!(
25598            matches!(source, SubscriberStreamSource::PubSubLog { id: 0, .. }),
25599            "the gap must name the source that lost data"
25600        );
25601        assert_eq!(gap, SubscriberStreamGap::Lagged { skipped: 3 });
25602        assert_eq!(gap.skipped(), Some(3));
25603
25604        // The surviving notifications still arrive after the gap is reported.
25605        assert!(matches!(
25606            stream.next().await,
25607            Some(SubscriberEvent::Log { .. })
25608        ));
25609        assert_eq!(counters.snapshot().lagged_notifications(), 3);
25610        assert_eq!(counters.snapshot().undecodable_notifications(), 0);
25611    }
25612
25613    /// An unreadable payload is lost data, not a skippable curiosity: a filter's
25614    /// matched set cannot be called complete while one notification is opaque.
25615    #[tokio::test]
25616    async fn undecodable_notification_fails_closed_as_a_gap() {
25617        let counters = Arc::new(SubscriberStreamGapCounters::default());
25618        let (tx, subscription) = wired_subscription(8);
25619        tx.send(raw_json(&serde_json::json!({"not": "a log"})))
25620            .expect("receiver alive");
25621        tx.send(wire_log(101, 0)).expect("receiver alive");
25622        let mut stream = gap_stream(subscription, log_source(0), Arc::clone(&counters));
25623
25624        assert_eq!(
25625            stream.next().await.map(|event| matches!(
25626                event,
25627                SubscriberEvent::StreamGap {
25628                    gap: SubscriberStreamGap::Undecodable,
25629                    ..
25630                }
25631            )),
25632            Some(true),
25633        );
25634        assert!(matches!(
25635            stream.next().await,
25636            Some(SubscriberEvent::Log { .. })
25637        ));
25638        let stats = counters.snapshot();
25639        assert_eq!(stats.undecodable_notifications(), 1);
25640        assert_eq!(stats.lagged_notifications(), 0);
25641        assert_eq!(stats.total_gaps(), 1);
25642    }
25643
25644    /// A closed channel is a disconnect, not a gap: it must still end the stream
25645    /// so the existing reconnect path runs unchanged.
25646    #[tokio::test]
25647    async fn closed_channel_terminates_the_stream_for_reconnect() {
25648        let counters = Arc::new(SubscriberStreamGapCounters::default());
25649        let (tx, subscription) = wired_subscription(8);
25650        tx.send(wire_log(101, 0)).expect("receiver alive");
25651        drop(tx);
25652        let mut stream = gap_stream(subscription, log_source(0), Arc::clone(&counters));
25653
25654        assert!(matches!(
25655            stream.next().await,
25656            Some(SubscriberEvent::Log { .. })
25657        ));
25658        assert!(
25659            matches!(
25660                stream.next().await,
25661                Some(SubscriberEvent::StreamTerminated(
25662                    SubscriberStreamSource::PubSubLog { id: 0, .. }
25663                ))
25664            ),
25665            "a closed subscription must terminate, not report a gap"
25666        );
25667        assert_eq!(counters.snapshot().total_gaps(), 0);
25668    }
25669
25670    fn mocked_subscriber(
25671        asserter: Asserter,
25672    ) -> AlloySubscriber<impl alloy_provider::Provider<Ethereum> + Clone, Ethereum> {
25673        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
25674        AlloySubscriber::new(
25675            provider,
25676            SubscriberMode::PubSub,
25677            SubscriberConfig::default(),
25678        )
25679    }
25680
25681    /// A canonical log gap refetches the source's window from its delivery
25682    /// anchor to the current head, and charges the requests to `GapBackfill` so
25683    /// backpressure loss is distinguishable from reconnect churn.
25684    #[tokio::test]
25685    async fn log_gap_refetches_the_missed_window_and_attributes_it() {
25686        let asserter = Asserter::new();
25687        asserter.push_success(&U256::from(104)); // eth_blockNumber
25688        asserter.push_success(&vec![
25689            serde_json::from_str::<Log>(wire_log(103, 0).get()).expect("log"),
25690        ]);
25691        let mut subscriber = mocked_subscriber(asserter.clone());
25692        subscriber.chain_id = Some(1);
25693        // The source has delivered through block 102.
25694        subscriber.last_seen_log_blocks.insert(0, 102);
25695
25696        let event = subscriber
25697            .recover_stream_gap(&log_source(0), SubscriberStreamGap::Lagged { skipped: 2 })
25698            .await
25699            .expect("a bounded window is recoverable");
25700
25701        assert!(
25702            matches!(
25703                event,
25704                Some(SubscriberEvent::BackfilledLogs { source_id: 0, ref logs }) if logs.len() == 1
25705            ),
25706            "the missed window is delivered as backfill"
25707        );
25708        let stats = subscriber.rpc_stats();
25709        assert_eq!(
25710            stats.by_cause(SubscriberRpcCause::GapBackfill),
25711            2,
25712            "one head read plus one bounded eth_getLogs"
25713        );
25714        assert_eq!(
25715            stats.by_cause(SubscriberRpcCause::ReconnectBackfill),
25716            0,
25717            "a live-stream gap is not reconnect churn"
25718        );
25719        assert_eq!(subscriber.stream_gap_stats().log_gaps_healed(), 1);
25720        assert!(asserter.read_q().is_empty());
25721    }
25722
25723    /// Without a delivery anchor the missed range has no lower bound. Continuing
25724    /// would mean knowing logs were lost and doing nothing, so this fails closed.
25725    #[tokio::test]
25726    async fn log_gap_without_a_delivery_anchor_fails_closed() {
25727        let mut subscriber = mocked_subscriber(Asserter::new());
25728        subscriber.chain_id = Some(1);
25729
25730        let Err(error) = subscriber
25731            .recover_stream_gap(&log_source(0), SubscriberStreamGap::Lagged { skipped: 9 })
25732            .await
25733        else {
25734            panic!("an unbounded gap must not be silently ignored");
25735        };
25736
25737        assert!(
25738            matches!(&error, SubscriberError::Provider(message)
25739                if message.contains("delivery anchor") && message.contains("lagged(9)")),
25740            "the error must name the cause and the loss: {error}"
25741        );
25742        assert_eq!(subscriber.stream_gap_stats().log_gaps_healed(), 0);
25743    }
25744
25745    /// Header gaps are recovered by the consumer's parent-lineage walk, so they
25746    /// are counted rather than refetched — no provider request is spent.
25747    #[tokio::test]
25748    async fn header_gap_is_counted_without_spending_a_request() {
25749        let asserter = Asserter::new();
25750        let mut subscriber = mocked_subscriber(asserter.clone());
25751        subscriber.chain_id = Some(1);
25752
25753        let event = subscriber
25754            .recover_stream_gap(
25755                &SubscriberStreamSource::PubSubBlockHeaders,
25756                SubscriberStreamGap::Lagged { skipped: 1 },
25757            )
25758            .await
25759            .expect("a header gap is not fatal");
25760
25761        assert!(event.is_none(), "no synthetic header is fabricated");
25762        assert_eq!(subscriber.stream_gap_stats().header_gaps(), 1);
25763        assert_eq!(
25764            subscriber.rpc_stats().total(),
25765            0,
25766            "the lineage walk already covers this; refetching would duplicate it"
25767        );
25768        assert!(asserter.read_q().is_empty());
25769    }
25770
25771    /// A punctured pre-confirmation must never be published: the speculative
25772    /// snapshot is discarded and the next complete generation replaces it.
25773    #[tokio::test]
25774    async fn preconfirmation_gap_discards_the_speculative_snapshot() {
25775        let mut subscriber = mocked_subscriber(Asserter::new());
25776        subscriber.chain_id = Some(8_453);
25777
25778        let event = subscriber
25779            .recover_stream_gap(
25780                &SubscriberStreamSource::BasePendingLog {
25781                    id: 0,
25782                    filter: Filter::new(),
25783                },
25784                SubscriberStreamGap::Undecodable,
25785            )
25786            .await
25787            .expect("a preview gap is recoverable by discarding it");
25788
25789        assert!(
25790            matches!(event, Some(SubscriberEvent::FlashblockInvalidated)),
25791            "the incomplete preview must be invalidated, not delivered"
25792        );
25793        assert_eq!(subscriber.stream_gap_stats().preconfirmation_gaps(), 1);
25794        assert_eq!(subscriber.rpc_stats().total(), 0);
25795    }
25796
25797    /// Gap counters are diagnostics and must not perturb the delivery path.
25798    #[tokio::test]
25799    async fn resetting_gap_stats_opens_a_new_window() {
25800        let counters = Arc::new(SubscriberStreamGapCounters::default());
25801        counters.record_gap(SubscriberStreamGap::Lagged { skipped: 4 });
25802        counters.record_gap(SubscriberStreamGap::Undecodable);
25803        counters.record_header_gap();
25804        assert_eq!(counters.snapshot().total_gaps(), 5);
25805
25806        counters.reset();
25807
25808        assert_eq!(counters.snapshot(), SubscriberStreamGapStats::default());
25809    }
25810
25811    /// Labels are part of the diagnostic contract for a metrics export.
25812    #[test]
25813    fn gap_labels_are_stable() {
25814        assert_eq!(
25815            SubscriberStreamGap::Lagged { skipped: 7 }.as_str(),
25816            "lagged"
25817        );
25818        assert_eq!(SubscriberStreamGap::Undecodable.as_str(), "undecodable");
25819        assert_eq!(
25820            SubscriberStreamGap::Lagged { skipped: 7 }.to_string(),
25821            "lagged(7)"
25822        );
25823        assert_eq!(SubscriberRpcCause::GapBackfill.as_str(), "gap_backfill");
25824        assert_eq!(SubscriberStreamGap::Undecodable.skipped(), None);
25825    }
25826}
25827
25828/// The attestation must never outrun what the subscriber actually observed
25829/// whole. These tests drive the watermark directly, because the interesting
25830/// cases are the ones where it must *refuse* to advance.
25831#[cfg(all(test, feature = "reactive-ws"))]
25832mod log_coverage_attestation_tests {
25833    use super::*;
25834    use alloy_provider::ProviderBuilder;
25835    use alloy_rpc_types_eth::Header as RpcHeader;
25836    use alloy_transport::mock::Asserter;
25837
25838    fn header_record(number: u64) -> ReactiveInputRecord<Ethereum> {
25839        block_header_input_record::<Ethereum>(RpcHeader {
25840            hash: B256::repeat_byte(number as u8),
25841            inner: alloy_consensus::Header {
25842                number,
25843                parent_hash: B256::repeat_byte((number - 1) as u8),
25844                timestamp: 1_700_000_000 + number,
25845                ..alloy_consensus::Header::default()
25846            },
25847            total_difficulty: None,
25848            size: None,
25849        })
25850    }
25851
25852    fn subscriber(
25853        mode: SubscriberMode,
25854        with_log_interest: bool,
25855    ) -> AlloySubscriber<impl alloy_provider::Provider<Ethereum> + Clone, Ethereum> {
25856        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
25857        let mut subscriber = AlloySubscriber::new(provider, mode, SubscriberConfig::default());
25858        if with_log_interest {
25859            subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
25860                provider_filter: Filter::new().address(Address::repeat_byte(0x77)),
25861                local_matcher: None,
25862                route_key: None,
25863            })];
25864        }
25865        subscriber
25866    }
25867
25868    fn queued_coverage(
25869        subscriber: &mut AlloySubscriber<impl alloy_provider::Provider<Ethereum> + Clone, Ethereum>,
25870    ) -> Vec<u64> {
25871        subscriber.queue_log_coverage_attestation();
25872        subscriber
25873            .pending_chain_controls
25874            .drain(..)
25875            .filter_map(|control| match control {
25876                ChainControl::LogCoverage(block) => Some(block.number),
25877                _ => None,
25878            })
25879            .collect()
25880    }
25881
25882    #[test]
25883    fn attestation_advances_with_observed_canonical_headers() {
25884        let mut subscriber = subscriber(SubscriberMode::PubSub, true);
25885
25886        subscriber.note_attestable_canonical_block(&header_record(101));
25887        assert_eq!(queued_coverage(&mut subscriber), vec![101]);
25888
25889        // Re-attesting the same block says nothing new and must not be emitted.
25890        assert!(queued_coverage(&mut subscriber).is_empty());
25891
25892        subscriber.note_attestable_canonical_block(&header_record(102));
25893        assert_eq!(queued_coverage(&mut subscriber), vec![102]);
25894    }
25895
25896    /// The safety property: a gap discovered after a header was observed must
25897    /// withdraw that header's candidacy. Attesting it would tell the consumer a
25898    /// block was whole on the strength of an observation made before the loss
25899    /// was known.
25900    #[test]
25901    fn a_detected_gap_withdraws_the_pending_attestation() {
25902        let mut subscriber = subscriber(SubscriberMode::PubSub, true);
25903        subscriber.note_attestable_canonical_block(&header_record(101));
25904
25905        subscriber.reset_log_attestation();
25906
25907        assert!(
25908            queued_coverage(&mut subscriber).is_empty(),
25909            "a withdrawn candidate must not be attested"
25910        );
25911
25912        // Only a header observed after the gap re-establishes the watermark.
25913        subscriber.note_attestable_canonical_block(&header_record(102));
25914        assert_eq!(queued_coverage(&mut subscriber), vec![102]);
25915    }
25916
25917    /// A withdrawn candidate must not let a *lower* block be attested later
25918    /// either — the watermark is monotonic at the source, not just downstream.
25919    #[test]
25920    fn attestation_never_regresses_after_a_gap() {
25921        let mut subscriber = subscriber(SubscriberMode::PubSub, true);
25922        subscriber.note_attestable_canonical_block(&header_record(105));
25923        assert_eq!(queued_coverage(&mut subscriber), vec![105]);
25924
25925        subscriber.reset_log_attestation();
25926        subscriber.note_attestable_canonical_block(&header_record(103));
25927
25928        assert!(
25929            queued_coverage(&mut subscriber).is_empty(),
25930            "an older block must never be attested after a newer one"
25931        );
25932    }
25933
25934    /// The polling transport's watcher cannot observe a dropped notification, so
25935    /// it must neither claim the capability nor emit the control.
25936    #[test]
25937    #[cfg(feature = "reactive-polling")]
25938    fn polling_transport_neither_claims_nor_emits_the_attestation() {
25939        let mut subscriber = subscriber(SubscriberMode::Polling, true);
25940        assert!(!subscriber.attests_log_coverage());
25941
25942        subscriber.note_attestable_canonical_block(&header_record(101));
25943
25944        assert!(queued_coverage(&mut subscriber).is_empty());
25945        assert!(
25946            !EventSubscriber::capabilities(&subscriber)
25947                .supports(SubscriberCapability::LogCoverageAttestation)
25948        );
25949    }
25950
25951    #[test]
25952    fn pubsub_transport_claims_the_capability() {
25953        let subscriber = subscriber(SubscriberMode::PubSub, true);
25954        assert!(
25955            EventSubscriber::capabilities(&subscriber)
25956                .supports(SubscriberCapability::LogCoverageAttestation)
25957        );
25958    }
25959
25960    /// Nothing to attest about without log interests, so stay silent rather than
25961    /// emit a vacuously true watermark a consumer might lean on.
25962    #[test]
25963    fn subscriber_without_log_interests_stays_silent() {
25964        let mut subscriber = subscriber(SubscriberMode::PubSub, false);
25965        assert!(!subscriber.attests_log_coverage());
25966
25967        subscriber.note_attestable_canonical_block(&header_record(101));
25968
25969        assert!(queued_coverage(&mut subscriber).is_empty());
25970    }
25971}
25972
25973/// The canonical head poll exists because a Flashblocks endpoint's `newHeads`
25974/// may carry partial heads — but a fixed interval spends a request whether or
25975/// not anything sealed. These tests pin that a certification driven by the
25976/// flashblock stream suppresses the redundant tick, and that the timer still
25977/// works unaided when no such signal exists.
25978#[cfg(all(test, feature = "reactive-ws"))]
25979mod canonical_head_suppression_tests {
25980    use super::*;
25981    use alloy_provider::ProviderBuilder;
25982    use alloy_rpc_types_eth::{Block, Header as RpcHeader};
25983    use alloy_transport::mock::Asserter;
25984
25985    fn sealed_head(number: u64) -> Block {
25986        Block::empty(RpcHeader {
25987            hash: B256::repeat_byte(number as u8),
25988            inner: alloy_consensus::Header {
25989                number,
25990                parent_hash: B256::repeat_byte((number - 1) as u8),
25991                timestamp: 1_700_000_000 + number,
25992                ..alloy_consensus::Header::default()
25993            },
25994            total_difficulty: None,
25995            size: None,
25996        })
25997    }
25998
25999    fn subscriber(
26000        asserter: Asserter,
26001    ) -> AlloySubscriber<impl alloy_provider::Provider<Ethereum> + Clone, Ethereum> {
26002        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
26003        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
26004            provider,
26005            SubscriberMode::PubSub,
26006            SubscriberConfig::default(),
26007        );
26008        subscriber.chain_id = Some(8_453);
26009        subscriber.interests = vec![ReactiveInterest::Blocks(BlockInterest::default())];
26010        subscriber
26011    }
26012
26013    /// A tick inside the window after a certification issues no request. This is
26014    /// the whole saving: on a chain whose blocks seal faster than the interval,
26015    /// most ticks cost nothing.
26016    #[tokio::test]
26017    async fn a_tick_inside_the_window_after_a_certification_costs_nothing() {
26018        let asserter = Asserter::new();
26019        asserter.push_success(&Some(sealed_head(101)));
26020        let mut subscriber = subscriber(asserter.clone());
26021
26022        // One real certification, as the flashblock stream would drive.
26023        let certified = subscriber
26024            .certify_canonical_head_on_sealed_block()
26025            .await
26026            .expect("certification succeeds");
26027        assert!(matches!(certified, Some(SubscriberEvent::BlockHeader(_))));
26028
26029        // A tick immediately afterwards is inside the poll window.
26030        assert!(subscriber.canonical_head_certification_is_current());
26031        let suppressed = subscriber
26032            .certify_canonical_head_on_sealed_block()
26033            .await
26034            .expect("a suppressed certification is not an error");
26035
26036        assert!(suppressed.is_none());
26037        assert_eq!(
26038            subscriber.rpc_stats().get(
26039                SubscriberRpcCause::CanonicalHeadCertification,
26040                SubscriberRpcMethod::EthGetBlockByNumber,
26041            ),
26042            1,
26043            "only the first certification may spend a request"
26044        );
26045        assert!(
26046            asserter.read_q().is_empty(),
26047            "the suppressed call must not consume a queued response"
26048        );
26049    }
26050
26051    /// Once the window lapses the certification runs again, so a stalled
26052    /// flashblock stream degrades to the previous polling behaviour rather than
26053    /// to silence.
26054    #[tokio::test]
26055    async fn certification_resumes_once_the_window_lapses() {
26056        let asserter = Asserter::new();
26057        asserter.push_success(&Some(sealed_head(101)));
26058        asserter.push_success(&Some(sealed_head(102)));
26059        let mut subscriber = subscriber(asserter.clone());
26060
26061        let _ = subscriber
26062            .certify_canonical_head_on_sealed_block()
26063            .await
26064            .expect("first certification");
26065
26066        // Age the record past the poll interval.
26067        subscriber.last_canonical_head_certification = Some(
26068            Instant::now()
26069                - subscriber.config.canonical_head_poll_interval
26070                - Duration::from_millis(1),
26071        );
26072        assert!(!subscriber.canonical_head_certification_is_current());
26073
26074        let second = subscriber
26075            .certify_canonical_head_on_sealed_block()
26076            .await
26077            .expect("second certification");
26078
26079        assert!(matches!(second, Some(SubscriberEvent::BlockHeader(_))));
26080        assert_eq!(
26081            subscriber
26082                .rpc_stats()
26083                .by_cause(SubscriberRpcCause::CanonicalHeadCertification),
26084            2
26085        );
26086        assert!(asserter.read_q().is_empty());
26087    }
26088
26089    /// A subscriber with no block-header interest has no head to certify, so the
26090    /// signal must not manufacture a request.
26091    #[tokio::test]
26092    async fn without_a_header_interest_nothing_is_certified() {
26093        let asserter = Asserter::new();
26094        let mut subscriber = subscriber(asserter.clone());
26095        subscriber.interests = Vec::new();
26096
26097        let certified = subscriber
26098            .certify_canonical_head_on_sealed_block()
26099            .await
26100            .expect("no interest is not an error");
26101
26102        assert!(certified.is_none());
26103        assert_eq!(subscriber.rpc_stats().total(), 0);
26104        assert!(asserter.read_q().is_empty());
26105    }
26106}