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(all(test, not(feature = "ingress-timing")))]
68mod ingress_feature_tests;
69#[cfg(feature = "ingress-timing")]
70mod ingress_timing;
71#[cfg(feature = "ingress-timing")]
72pub use ingress_timing::{CanonicalIngressTiming, TransportReceipt, TransportReceiptLookup};
73
74#[cfg(feature = "raw-flashblocks-json")]
75mod raw_json_flashblocks;
76#[cfg(feature = "raw-flashblocks-json")]
77pub use raw_json_flashblocks::{
78    BufferedRawJsonFlashblocksAdapter, FlashblockInvalidation, FlashblockInvalidationReason,
79    FlashblockSnapshot, FlashblockUpdate, FlashblockUpdateAcknowledgement,
80    FlashblockUpdateChannelError, FlashblockUpdateSender, RawJsonFlashblocksAdapter,
81    RawJsonFlashblocksError, RawJsonFlashblocksLimits, TimedFlashblockUpdate,
82};
83
84/// Input accepted by the reactive runtime.
85#[derive(Clone, Debug, PartialEq, Eq)]
86pub enum ReactiveInput<N: Network = Ethereum> {
87    /// A canonical or removed EVM log, using Alloy's RPC log type.
88    Log(Log),
89    /// A block header response for header-oriented handlers.
90    BlockHeader(N::HeaderResponse),
91    /// A full block response for block handlers that need transaction bodies.
92    FullBlock(N::BlockResponse),
93    /// A pending transaction hash.
94    PendingTxHash(B256),
95    /// A full pending transaction body.
96    PendingTx(N::TransactionResponse),
97}
98
99/// Context supplied with each [`ReactiveInput`].
100#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
101pub struct ReactiveContext {
102    /// Chain id, when known.
103    pub chain_id: Option<u64>,
104    /// Where the input came from.
105    pub source: InputSource,
106    /// Lifecycle status of the input.
107    pub chain_status: ChainStatus,
108    /// Block metadata associated with the input, when known.
109    pub block: Option<BlockRef>,
110    /// Transaction index for log or transaction inputs.
111    pub transaction_index: Option<u64>,
112    /// Log index for log inputs.
113    pub log_index: Option<u64>,
114}
115
116/// Minimal block identity carried through reports.
117#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
118pub struct BlockRef {
119    /// Block number.
120    pub number: u64,
121    /// Block hash.
122    pub hash: B256,
123    /// Parent hash, when known.
124    pub parent_hash: Option<B256>,
125    /// Block timestamp, when known.
126    pub timestamp: Option<u64>,
127}
128
129/// Stable provider identity attached to provider-originated input.
130///
131/// `generation` changes whenever a caller replaces or reconnects the concrete
132/// provider session behind the same configured endpoint. Follow-up reads can
133/// use this value to prefer the exact source that announced speculative state
134/// without putting URLs or credentials into event payloads.
135#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
136pub struct ProviderRef {
137    /// Operator-defined endpoint identity.
138    pub endpoint: EndpointId,
139    /// Concrete connection/session generation.
140    pub generation: u64,
141}
142
143impl ProviderRef {
144    /// Construct provider provenance for one connection generation.
145    pub fn new(endpoint: impl Into<EndpointId>, generation: u64) -> Self {
146        Self {
147            endpoint: endpoint.into(),
148            generation,
149        }
150    }
151}
152
153/// Process-local monotonic time at which a Flashblock source item first
154/// entered the typed subscriber boundary.
155///
156/// This metadata never participates in Flashblock identity, ordering,
157/// canonical state, or execution authority.
158#[derive(Clone, Copy, Debug, PartialEq, Eq)]
159pub struct FlashblockIngressTiming {
160    source_ingress: Instant,
161}
162
163impl FlashblockIngressTiming {
164    /// Bind a source item to its earliest process-local typed arrival.
165    pub const fn new(source_ingress: Instant) -> Self {
166        Self { source_ingress }
167    }
168
169    /// Earliest process-local typed arrival for the source item.
170    pub const fn source_ingress(self) -> Instant {
171        self.source_ingress
172    }
173
174    /// Retain the earliest contributing source arrival.
175    pub fn earliest(self, other: Self) -> Self {
176        Self::new(self.source_ingress.min(other.source_ingress))
177    }
178}
179
180/// Identity of one cumulative pre-confirmed Flashblock snapshot.
181#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
182pub struct FlashblockRef {
183    /// Provider session that supplied this snapshot.
184    pub provider: ProviderRef,
185    /// Sequencer payload id shared by every Flashblock in the full block.
186    ///
187    /// Some provider wire shapes omit this indexed-payload identifier.
188    pub payload_id: Option<FixedBytes<8>>,
189    /// Zero-based Flashblock index, when exposed by the endpoint.
190    pub index: Option<u64>,
191    /// Pending block number represented by this cumulative snapshot.
192    pub block_number: u64,
193    /// Provider-generation-scoped commitment to this exact cumulative view.
194    ///
195    /// This is deliberately not a canonical or provider-reported block hash.
196    /// It remains non-zero even when a pending endpoint uses the zero hash
197    /// placeholder permitted by the Flashblocks specification.
198    pub content_hash: B256,
199    /// Non-placeholder partial block hash reported by the provider, when any.
200    pub partial_block_hash: Option<B256>,
201    /// Canonical parent of the pending block, when exposed.
202    pub parent_hash: Option<B256>,
203    /// State root after this cumulative snapshot, when exposed.
204    pub state_root: Option<B256>,
205    /// Transaction-trie root committed by a cumulative block-shaped preview.
206    pub transactions_root: Option<B256>,
207    /// Ordered cumulative transaction membership for this preview.
208    pub transaction_hashes: Vec<B256>,
209    /// Pending block timestamp, when exposed.
210    pub timestamp: Option<u64>,
211    /// Pending EIP-1559 base fee, when exposed.
212    pub base_fee_per_gas: Option<u64>,
213    /// Pending block beneficiary / fee recipient, when exposed.
214    pub beneficiary: Option<Address>,
215    /// Pending block randomness value, when exposed.
216    pub prevrandao: Option<B256>,
217    /// Pending block gas limit, when exposed.
218    pub gas_limit: Option<u64>,
219}
220
221impl FlashblockRef {
222    /// Convert the pre-confirmed identity into the block metadata used by
223    /// ordinary log routing. The hash is the provider-generation-scoped
224    /// [`content_hash`](Self::content_hash), never a canonical block hash, and
225    /// must not advance canonical coverage.
226    pub const fn block_ref(&self) -> BlockRef {
227        BlockRef {
228            number: self.block_number,
229            hash: self.content_hash,
230            parent_hash: self.parent_hash,
231            timestamp: self.timestamp,
232        }
233    }
234
235    /// Whether the cumulative preview contains `transaction_hash`.
236    pub fn contains_transaction(&self, transaction_hash: &B256) -> bool {
237        self.transaction_hashes.contains(transaction_hash)
238    }
239
240    fn transaction_index(&self, transaction_hash: &B256) -> Option<u64> {
241        self.transaction_hashes
242            .iter()
243            .position(|candidate| candidate == transaction_hash)
244            .and_then(|index| u64::try_from(index).ok())
245    }
246
247    fn same_payload(&self, other: &Self) -> bool {
248        self.provider == other.provider
249            && match (self.payload_id, other.payload_id) {
250                (Some(left), Some(right)) => left == right,
251                _ => {
252                    self.block_number == other.block_number && self.parent_hash == other.parent_hash
253                }
254            }
255    }
256
257    /// Whether two cumulative previews bind the same pending-block base
258    /// fields, excluding payload index, cumulative transactions, and derived
259    /// content commitments.
260    ///
261    /// This provider-free predicate lets applications retain lineage metadata
262    /// only across snapshots that cannot have crossed a pending-block
263    /// replacement boundary. It grants no canonical or execution authority.
264    #[cfg(feature = "raw-flashblocks-json")]
265    pub fn same_base_identity(&self, other: &Self) -> bool {
266        self.block_number == other.block_number
267            && self.parent_hash == other.parent_hash
268            && self.timestamp == other.timestamp
269            && self.base_fee_per_gas == other.base_fee_per_gas
270            && self.beneficiary == other.beneficiary
271            && self.prevrandao == other.prevrandao
272            && self.gas_limit == other.gas_limit
273    }
274
275    fn is_cumulative_successor_of(&self, previous: &Self) -> bool {
276        self.same_payload(previous)
277            && self.transaction_hashes.len() >= previous.transaction_hashes.len()
278            && self
279                .transaction_hashes
280                .starts_with(&previous.transaction_hashes)
281            && match (previous.index, self.index) {
282                (Some(previous), Some(current)) => current >= previous,
283                _ => true,
284            }
285    }
286}
287
288/// Whether the subscriber may use Flashblocks for speculative delivery.
289#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
290pub enum PreconfirmationMode {
291    /// Use only canonical subscription/polling behavior.
292    #[default]
293    Disabled,
294    /// Prefer Flashblocks, but retain canonical operation when the selected
295    /// chain/provider cannot establish the pre-confirmation stream.
296    Preferred,
297    /// Fail setup/reconnect closed unless Flashblocks can be established.
298    Required,
299}
300
301/// Indexed OP Stack `newFlashblocks` subscription payload.
302#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
303pub struct BaseFlashblockPayload {
304    /// Block-builder payload id shared by every incremental snapshot.
305    pub payload_id: FixedBytes<8>,
306    /// Zero-based incremental snapshot index.
307    pub index: u64,
308    /// Header fields present on index zero.
309    pub base: Option<BaseFlashblockBase>,
310    /// Cumulative state commitments for this snapshot.
311    pub diff: BaseFlashblockDiff,
312    /// Supplemental block identity retained across current Base versions.
313    #[serde(default)]
314    pub metadata: Option<BaseFlashblockMetadata>,
315}
316
317/// Stable index-zero header subset from Base's Flashblocks wire format.
318#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
319pub struct BaseFlashblockBase {
320    /// Canonical parent block hash.
321    pub parent_hash: B256,
322    /// Pending block number.
323    #[serde(deserialize_with = "deserialize_rpc_u64")]
324    pub block_number: u64,
325    /// Pending block timestamp.
326    #[serde(deserialize_with = "deserialize_rpc_u64")]
327    pub timestamp: u64,
328    /// Pending block gas limit.
329    #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
330    pub gas_limit: Option<u64>,
331    /// Pending EIP-1559 base fee.
332    #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
333    pub base_fee_per_gas: Option<u64>,
334    /// Pending block beneficiary / fee recipient.
335    #[serde(default, alias = "fee_recipient", alias = "feeRecipient")]
336    pub beneficiary: Option<Address>,
337    /// Pending block randomness value.
338    #[serde(
339        default,
340        alias = "prev_randao",
341        alias = "prevRandao",
342        alias = "mixHash"
343    )]
344    pub prevrandao: Option<B256>,
345}
346
347/// Stable commitment subset from Base's Flashblocks wire format.
348#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
349pub struct BaseFlashblockDiff {
350    /// State root after this cumulative snapshot.
351    pub state_root: B256,
352    /// Partial block hash after this cumulative snapshot.
353    pub block_hash: B256,
354    /// Transactions added by this indexed Flashblock diff.
355    #[serde(default)]
356    pub transactions: Vec<serde_json::Value>,
357    /// Transaction root when exposed by the provider.
358    #[serde(default)]
359    pub transactions_root: Option<B256>,
360}
361
362/// Stable metadata subset used when index-greater-than-zero payloads omit the
363/// Base header object.
364#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
365pub struct BaseFlashblockMetadata {
366    /// Pending block number (currently encoded as a JSON integer).
367    #[serde(deserialize_with = "deserialize_rpc_u64")]
368    pub block_number: u64,
369}
370
371/// Cumulative block-shaped `newFlashblocks` wire shape used by some OP Stack
372/// providers.
373#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
374#[serde(rename_all = "camelCase")]
375struct BaseFlashblockBlockPayload {
376    hash: B256,
377    #[serde(deserialize_with = "deserialize_rpc_u64")]
378    number: u64,
379    parent_hash: B256,
380    state_root: B256,
381    #[serde(default)]
382    transactions_root: Option<B256>,
383    #[serde(default)]
384    transactions: Vec<serde_json::Value>,
385    #[serde(deserialize_with = "deserialize_rpc_u64")]
386    timestamp: u64,
387    #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
388    base_fee_per_gas: Option<u64>,
389    #[serde(default, alias = "beneficiary", alias = "feeRecipient")]
390    miner: Option<Address>,
391    #[serde(default, alias = "prevRandao")]
392    mix_hash: Option<B256>,
393    #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
394    gas_limit: Option<u64>,
395}
396
397/// OP Stack providers expose either an indexed diff envelope or a cumulative
398/// block-shaped envelope for `newFlashblocks`. Accept both so provider rollout
399/// differences do not force callers onto separate subscriber paths.
400#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
401#[serde(untagged)]
402enum BaseFlashblockWirePayload {
403    Indexed(BaseFlashblockPayload),
404    Block(BaseFlashblockBlockPayload),
405}
406
407fn deserialize_rpc_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
408where
409    D: serde::Deserializer<'de>,
410{
411    #[derive(serde::Deserialize)]
412    #[serde(untagged)]
413    enum RpcU64 {
414        Number(u64),
415        String(String),
416    }
417
418    match <RpcU64 as serde::Deserialize>::deserialize(deserializer)? {
419        RpcU64::Number(number) => Ok(number),
420        RpcU64::String(value) => {
421            let value = value.strip_prefix("0x").unwrap_or(&value);
422            u64::from_str_radix(value, 16).map_err(serde::de::Error::custom)
423        }
424    }
425}
426
427fn deserialize_optional_rpc_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
428where
429    D: serde::Deserializer<'de>,
430{
431    #[derive(serde::Deserialize)]
432    #[serde(untagged)]
433    enum RpcU64 {
434        Number(u64),
435        String(String),
436    }
437
438    let Some(value) = <Option<RpcU64> as serde::Deserialize>::deserialize(deserializer)? else {
439        return Ok(None);
440    };
441    match value {
442        RpcU64::Number(number) => Ok(Some(number)),
443        RpcU64::String(value) => {
444            let value = value.strip_prefix("0x").unwrap_or(&value);
445            u64::from_str_radix(value, 16)
446                .map(Some)
447                .map_err(serde::de::Error::custom)
448        }
449    }
450}
451
452fn non_placeholder_hash(hash: B256) -> Option<B256> {
453    (!hash.is_zero()).then_some(hash)
454}
455
456fn flashblock_transaction_hashes(
457    transactions: &[serde_json::Value],
458) -> Result<Vec<B256>, SubscriberError> {
459    let hashes: Vec<B256> = transactions
460        .iter()
461        .map(|transaction| {
462            let value = match transaction {
463                serde_json::Value::String(value) => value.as_str(),
464                serde_json::Value::Object(object) => object
465                    .get("hash")
466                    .or_else(|| object.get("transactionHash"))
467                    .and_then(serde_json::Value::as_str)
468                    .ok_or_else(|| {
469                        SubscriberError::Provider(
470                            "Flashblock transaction object is missing its hash".into(),
471                        )
472                    })?,
473                _ => {
474                    return Err(SubscriberError::Provider(
475                        "Flashblock transaction must be a hash, raw transaction, or object".into(),
476                    ));
477                }
478            };
479            if value.len() == 66 {
480                return value.parse::<B256>().map_err(|error| {
481                    SubscriberError::Provider(format!(
482                        "Flashblock transaction hash is invalid: {error}"
483                    ))
484                });
485            }
486            let encoded = value.strip_prefix("0x").unwrap_or(value);
487            let raw = alloy_primitives::hex::decode(encoded).map_err(|error| {
488                SubscriberError::Provider(format!(
489                    "Flashblock raw transaction is invalid hex: {error}"
490                ))
491            })?;
492            Ok(alloy_primitives::keccak256(raw))
493        })
494        .collect::<Result<_, _>>()?;
495    let mut unique = HashSet::with_capacity(hashes.len());
496    if hashes.iter().any(|hash| !unique.insert(*hash)) {
497        return Err(SubscriberError::Provider(
498            "Flashblock cumulative transaction membership contains a duplicate hash".into(),
499        ));
500    }
501    Ok(hashes)
502}
503
504struct FlashblockContentCommitment<'a> {
505    provider: &'a ProviderRef,
506    payload_id: Option<FixedBytes<8>>,
507    index: Option<u64>,
508    block_number: u64,
509    partial_block_hash: Option<B256>,
510    parent_hash: Option<B256>,
511    state_root: Option<B256>,
512    transactions_root: Option<B256>,
513    transaction_hashes: &'a [B256],
514    timestamp: Option<u64>,
515    base_fee_per_gas: Option<u64>,
516    beneficiary: Option<Address>,
517    prevrandao: Option<B256>,
518    gas_limit: Option<u64>,
519}
520
521fn flashblock_content_hash(content: FlashblockContentCommitment<'_>) -> B256 {
522    let mut commitment = Keccak256::new();
523    commitment.update(b"evm-fork-cache/flashblock-content/v1");
524    let endpoint = content.provider.endpoint.as_str().as_bytes();
525    commitment.update((endpoint.len() as u64).to_be_bytes());
526    commitment.update(endpoint);
527    commitment.update(content.provider.generation.to_be_bytes());
528    commitment.update(content.block_number.to_be_bytes());
529    commit_optional_bytes(
530        &mut commitment,
531        content.payload_id.as_ref().map(FixedBytes::as_slice),
532    );
533    commit_optional_u64(&mut commitment, content.index);
534    commit_optional_bytes(
535        &mut commitment,
536        content
537            .partial_block_hash
538            .as_ref()
539            .map(FixedBytes::as_slice),
540    );
541    commit_optional_bytes(
542        &mut commitment,
543        content.parent_hash.as_ref().map(FixedBytes::as_slice),
544    );
545    commit_optional_bytes(
546        &mut commitment,
547        content.state_root.as_ref().map(FixedBytes::as_slice),
548    );
549    commit_optional_bytes(
550        &mut commitment,
551        content.transactions_root.as_ref().map(FixedBytes::as_slice),
552    );
553    commitment.update((content.transaction_hashes.len() as u64).to_be_bytes());
554    for transaction_hash in content.transaction_hashes {
555        commitment.update(transaction_hash);
556    }
557    commit_optional_u64(&mut commitment, content.timestamp);
558    commit_optional_u64(&mut commitment, content.base_fee_per_gas);
559    commit_optional_bytes(
560        &mut commitment,
561        content
562            .beneficiary
563            .as_ref()
564            .map(|address| address.as_slice()),
565    );
566    commit_optional_bytes(
567        &mut commitment,
568        content.prevrandao.as_ref().map(FixedBytes::as_slice),
569    );
570    commit_optional_u64(&mut commitment, content.gas_limit);
571    let hash = commitment.finalize();
572    if hash.is_zero() {
573        B256::with_last_byte(1)
574    } else {
575        hash
576    }
577}
578
579#[cfg(feature = "raw-flashblocks-json")]
580fn validate_standard_flashblock_snapshot(
581    snapshot: &FlashblockSnapshot,
582) -> Result<(), SubscriberError> {
583    let flashblock = &snapshot.flashblock;
584    if flashblock.payload_id.is_none() || flashblock.index.is_none() {
585        return Err(SubscriberError::Provider(
586            "external Flashblock snapshot is missing its indexed payload identity".into(),
587        ));
588    }
589    let expected_content_hash = flashblock_content_hash(FlashblockContentCommitment {
590        provider: &flashblock.provider,
591        payload_id: flashblock.payload_id,
592        index: flashblock.index,
593        block_number: flashblock.block_number,
594        partial_block_hash: flashblock.partial_block_hash,
595        parent_hash: flashblock.parent_hash,
596        state_root: flashblock.state_root,
597        transactions_root: flashblock.transactions_root,
598        transaction_hashes: &flashblock.transaction_hashes,
599        timestamp: flashblock.timestamp,
600        base_fee_per_gas: flashblock.base_fee_per_gas,
601        beneficiary: flashblock.beneficiary,
602        prevrandao: flashblock.prevrandao,
603        gas_limit: flashblock.gas_limit,
604    });
605    if flashblock.content_hash != expected_content_hash {
606        return Err(SubscriberError::Provider(
607            "external Flashblock content commitment is invalid".into(),
608        ));
609    }
610
611    let mut transactions = HashSet::with_capacity(flashblock.transaction_hashes.len());
612    if flashblock
613        .transaction_hashes
614        .iter()
615        .any(|hash| !transactions.insert(*hash))
616    {
617        return Err(SubscriberError::Provider(
618            "external Flashblock cumulative transaction membership contains a duplicate hash"
619                .into(),
620        ));
621    }
622
623    let mut log_ids = HashSet::with_capacity(snapshot.logs.len());
624    for log in &snapshot.logs {
625        if log.removed || log.block_number != Some(flashblock.block_number) {
626            return Err(SubscriberError::Provider(
627                "external pre-confirmed log disagrees with its Flashblock block identity".into(),
628            ));
629        }
630        if log.block_hash != Some(flashblock.content_hash) {
631            return Err(SubscriberError::Provider(
632                "external pre-confirmed log is not bound to its Flashblock content commitment"
633                    .into(),
634            ));
635        }
636        let transaction_hash = log.transaction_hash.ok_or_else(|| {
637            SubscriberError::Provider(
638                "external pre-confirmed log is missing its transaction hash".into(),
639            )
640        })?;
641        let expected_transaction_index = flashblock
642            .transaction_index(&transaction_hash)
643            .ok_or_else(|| {
644                SubscriberError::Provider(
645                    "external pre-confirmed log transaction is absent from the cumulative Flashblock"
646                        .into(),
647                )
648            })?;
649        if log.transaction_index != Some(expected_transaction_index) {
650            return Err(SubscriberError::Provider(
651                "external pre-confirmed log transaction index disagrees with cumulative membership"
652                    .into(),
653            ));
654        }
655        let log_index = log.log_index.ok_or_else(|| {
656            SubscriberError::Provider("external pre-confirmed log is missing its log index".into())
657        })?;
658        if !log_ids.insert((transaction_hash, log_index)) {
659            return Err(SubscriberError::Provider(
660                "external Flashblock snapshot contains a duplicate log identity".into(),
661            ));
662        }
663    }
664    Ok(())
665}
666
667fn commit_optional_bytes(commitment: &mut Keccak256, value: Option<&[u8]>) {
668    match value {
669        Some(value) => {
670            commitment.update([1]);
671            commitment.update((value.len() as u64).to_be_bytes());
672            commitment.update(value);
673        }
674        None => commitment.update([0]),
675    }
676}
677
678fn commit_optional_u64(commitment: &mut Keccak256, value: Option<u64>) {
679    match value {
680        Some(value) => {
681            commitment.update([1]);
682            commitment.update(value.to_be_bytes());
683        }
684        None => commitment.update([0]),
685    }
686}
687
688/// Exact chain/block identity of an RPC cache snapshot adopted as the starting
689/// point for reactive event continuity.
690#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
691pub struct ReactiveCanonicalBaseline {
692    /// Chain whose state the cache snapshot contains.
693    pub chain_id: u64,
694    /// Canonical block through which the snapshot already embodies state.
695    pub block: BlockRef,
696}
697
698impl ReactiveCanonicalBaseline {
699    /// Construct an exact cache snapshot baseline.
700    pub const fn new(chain_id: u64, block: BlockRef) -> Self {
701        Self { chain_id, block }
702    }
703}
704
705/// Ordered chain-lifecycle control delivered by an event subscriber.
706///
707/// Controls live inside [`ReactiveInputBatch`] so they share the same delivery
708/// token, durable checkpoint, and ordering guarantees as ordinary event data.
709/// Reorg controls are applied in declaration order before replacement records;
710/// progress, barrier, safe, and finalized controls are committed in declaration
711/// order after the records. A reorg declared after a post-record control is
712/// rejected because its ordering would otherwise be ambiguous.
713#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
714#[non_exhaustive]
715pub enum ChainControl {
716    /// Replace the old canonical branch after `common_ancestor` with `new_tip`.
717    Reorg {
718        /// Last block common to the old and new canonical branches.
719        common_ancestor: BlockRef,
720        /// Tip of the branch that ceased to be canonical.
721        old_tip: BlockRef,
722        /// Tip of the newly canonical branch known by the source.
723        new_tip: BlockRef,
724    },
725    /// Update the source's safe head.
726    Safe(BlockRef),
727    /// Update the source's finalized head.
728    Finalized(BlockRef),
729    /// Advance authoritative canonical coverage without fabricating a full header.
730    ///
731    /// Indexers that only know compact block identity should emit this control.
732    /// It never runs block handlers. The runtime exact-hash pins provider reads
733    /// and installs known `NUMBER`/timestamp values, but clears unproven
734    /// header-only environment fields such as base fee and beneficiary.
735    CanonicalProgress(BlockRef),
736    /// Attest that no notification loss has gone unhealed on any log source at
737    /// or below this block.
738    ///
739    /// This is a *negative* guarantee, and deliberately so. A source cannot
740    /// prove from its own log stream that every matching log through block `N`
741    /// arrived — a filter that matched nothing for a hundred blocks is
742    /// indistinguishable from one whose notifications were dropped. What a
743    /// source can prove is that it detected no loss it did not repair, which is
744    /// exactly the fact a consumer cannot establish for itself.
745    ///
746    /// A consumer combines this with its own ordering evidence to decide when a
747    /// block's log set is closed. That evidence must come from the log stream
748    /// itself — a delivered log for a strictly later block — or from a positive
749    /// proof of absence such as the block's `logsBloom` excluding every
750    /// interest. A header for a later block is *not* such evidence: `newHeads`
751    /// is an independent subscription, so it establishes nothing about whether
752    /// an earlier block's logs have been delivered. Neither is a timer. Sources that cannot make this promise simply
753    /// never emit it, and advertise the absence through
754    /// [`SubscriberCapability::LogCoverageAttestation`]; that distinction is why
755    /// silence here must never be read as an attestation.
756    ///
757    /// Unlike [`Self::CanonicalProgress`] this makes no claim about chain
758    /// progress and never advances the cache's pinned block.
759    LogCoverage(BlockRef),
760    /// Ordered cutover or synchronization fence.
761    Barrier {
762        /// Subscriber-defined opaque barrier identity.
763        id: Vec<u8>,
764        /// Highest canonical event block included before the fence, if known.
765        block: Option<BlockRef>,
766    },
767}
768
769/// Provider-neutral snapshot consumed by [`validate_canonical_sequence`].
770///
771/// Composite subscribers can persist this small chain-state view beside their
772/// own delivery checkpoint and validate a complete delivery envelope before it
773/// reaches a [`ReactiveRuntime`]. The retained history may be sparse (blocks
774/// without matching events need not be present), but it must contain at most
775/// one compatible identity per height. Its oldest entry is also the durable
776/// rollback horizon: an unretained explicit ancestor is accepted only when that
777/// oldest entry is at or below the ancestor. This type carries no cache data,
778/// event payloads, handler state, or transport-specific cursor.
779///
780/// The serde representation is a convenience for caller-owned persistence; it
781/// is not a versioned wire or checkpoint format. Durable protocols should wrap
782/// it in their own versioned envelope and define migrations before upgrading
783/// this pre-1.0 crate. External callers also own retention: successful
784/// validation appends canonical identities but does not silently discard the
785/// rollback proof window. Bound it with [`Self::retain_recent_history`] after
786/// committing the matching source cursor/ACK.
787#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
788pub struct CanonicalSequenceState {
789    retained_canonical_history: Vec<BlockRef>,
790    coverage_head: Option<BlockRef>,
791    safe_head: Option<BlockRef>,
792    finalized_head: Option<BlockRef>,
793    log_coverage_head: Option<BlockRef>,
794}
795
796impl CanonicalSequenceState {
797    /// Construct a validation snapshot from retained canonical metadata.
798    ///
799    /// Construction does not validate ordering, adjacency, coverage, or
800    /// finality invariants. Call [`Self::validate`] before installing decoded or
801    /// externally assembled state.
802    pub fn new(
803        retained_canonical_history: Vec<BlockRef>,
804        coverage_head: Option<BlockRef>,
805        safe_head: Option<BlockRef>,
806        finalized_head: Option<BlockRef>,
807    ) -> Self {
808        Self {
809            retained_canonical_history,
810            coverage_head,
811            safe_head,
812            finalized_head,
813            log_coverage_head: None,
814        }
815    }
816
817    /// Seed the attested log-coverage watermark.
818    ///
819    /// Kept separate from [`Self::new`] so restoring durable state that predates
820    /// log-coverage attestation stays a compile-time no-op: an absent watermark
821    /// means "no source has attested", never "attested at genesis".
822    #[must_use]
823    pub fn with_log_coverage_head(mut self, log_coverage_head: Option<BlockRef>) -> Self {
824        self.log_coverage_head = log_coverage_head;
825        self
826    }
827
828    /// Sparse retained canonical history in ascending processing order.
829    pub fn retained_canonical_history(&self) -> &[BlockRef] {
830        &self.retained_canonical_history
831    }
832
833    /// Highest canonical identity covered by this state, when known.
834    pub const fn coverage_head(&self) -> Option<&BlockRef> {
835        self.coverage_head.as_ref()
836    }
837
838    /// Latest safe head accepted by the validator, when known.
839    pub const fn safe_head(&self) -> Option<&BlockRef> {
840        self.safe_head.as_ref()
841    }
842
843    /// Latest finalized head accepted by the validator, when known.
844    pub const fn finalized_head(&self) -> Option<&BlockRef> {
845        self.finalized_head.as_ref()
846    }
847
848    /// Highest block at or below which no log-notification loss went unhealed.
849    ///
850    /// `None` means no source has attested, which is not an attestation of
851    /// anything: treat it as unknown, never as complete. See
852    /// [`ChainControl::LogCoverage`].
853    pub const fn log_coverage_head(&self) -> Option<&BlockRef> {
854        self.log_coverage_head.as_ref()
855    }
856
857    /// Retain at most the newest `max_entries` canonical history identities.
858    ///
859    /// Coverage and safe/finalized heads are unchanged. The oldest retained
860    /// identity defines how far strict validation can prove a complete
861    /// rollback, so choose a bound at least as large as the deployment's
862    /// supported reorg depth and trim only after atomically committing the
863    /// corresponding validated state and source cursor. `0` intentionally
864    /// produces a coverage-only snapshot.
865    pub fn retain_recent_history(&mut self, max_entries: usize) {
866        let remove = self
867            .retained_canonical_history
868            .len()
869            .saturating_sub(max_entries);
870        self.retained_canonical_history.drain(..remove);
871    }
872
873    /// Validate a decoded/checkpointed snapshot before installing it.
874    ///
875    /// This rejects out-of-order or conflicting retained identities,
876    /// broken adjacent parent links, retained history without coverage,
877    /// incompatible coverage/finality aliases, hash reuse across heights,
878    /// known parent hashes at non-adjacent heights, finality beyond coverage,
879    /// and a finalized head beyond or conflicting with the safe head.
880    ///
881    /// # Errors
882    ///
883    /// Returns [`ReactiveError`] when any retained identity, parent link,
884    /// coverage alias, or safe/finalized relationship violates the canonical
885    /// snapshot invariants described above.
886    pub fn validate(&self) -> Result<(), ReactiveError> {
887        validate_canonical_sequence_snapshot(self)
888    }
889}
890
891/// Cache-free canonical transition proven by [`validate_canonical_sequence`].
892#[derive(Clone, Debug, PartialEq, Eq)]
893#[non_exhaustive]
894pub enum CanonicalSequenceMutation {
895    /// Rewind the listed retained identities and continue from `common_ancestor`.
896    Rewind {
897        /// Surviving canonical anchor, when one is retained or authenticated.
898        /// `None` is a transient same-envelope state: callers must stage the
899        /// complete validation atomically and may checkpoint only the returned
900        /// `next_state`, after a later canonical mutation installs the proven
901        /// replacement.
902        common_ancestor: Option<BlockRef>,
903        /// Exact retained identities removed by the transition.
904        dropped: Vec<BlockRef>,
905    },
906    /// Accept or enrich one canonical identity.
907    Canonical(BlockRef),
908    /// Accept a safe-head update with metadata resolved against prior state.
909    Safe(BlockRef),
910    /// Accept a finalized-head update with metadata resolved against prior state.
911    Finalized(BlockRef),
912    /// Advance the attested log-coverage watermark.
913    LogCoverage(BlockRef),
914}
915
916/// Successful result of provider-neutral canonical envelope validation.
917#[derive(Clone, Debug, PartialEq, Eq)]
918pub struct CanonicalSequenceValidation {
919    pre_record_state: CanonicalSequenceState,
920    next_state: CanonicalSequenceState,
921    mutations: Vec<CanonicalSequenceMutation>,
922    normalized_chain_controls: Vec<ChainControl>,
923}
924
925impl CanonicalSequenceValidation {
926    /// State after pre-record explicit reorg controls and before event records.
927    pub const fn pre_record_state(&self) -> &CanonicalSequenceState {
928        &self.pre_record_state
929    }
930
931    /// Fully validated state after records and post-record controls.
932    pub const fn next_state(&self) -> &CanonicalSequenceState {
933        &self.next_state
934    }
935
936    /// Ordered cache-free canonical mutations proven by this envelope.
937    pub fn mutations(&self) -> &[CanonicalSequenceMutation] {
938        &self.mutations
939    }
940
941    /// Controls safe to forward after composite overlap normalization.
942    ///
943    /// Ordinary validation retains the original controls. See
944    /// [`normalize_and_validate_canonical_sequence`] for the mode that removes
945    /// compatible stale progress and converts a stale blockful barrier into the
946    /// same barrier identity without a block assertion. Equal-height controls
947    /// that add previously absent parent/timestamp metadata remain present;
948    /// older compatible enrichment is intentionally not applied because the
949    /// corresponding regressive control is not forwarded to the runtime.
950    pub fn normalized_chain_controls(&self) -> &[ChainControl] {
951        &self.normalized_chain_controls
952    }
953}
954
955/// Lifecycle status for an input.
956#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
957#[non_exhaustive]
958pub enum ChainStatus {
959    /// The input is mempool-only and must not mutate canonical cache state.
960    Pending,
961    /// The input is ordered into an ephemeral sequencer-built Flashblock.
962    ///
963    /// Handlers may update the runtime's speculative overlay for this status,
964    /// but the update never advances canonical coverage or durable journals.
965    Preconfirmed {
966        /// Shared exact cumulative pre-confirmation snapshot observed by the
967        /// source. Sharing keeps ordinary canonical records compact and makes
968        /// multi-log Flashblock delivery cheap to clone.
969        flashblock: Arc<FlashblockRef>,
970    },
971    /// The input is included in a block with a confirmation count.
972    Included {
973        /// Included block.
974        block: BlockRef,
975        /// Confirmation count.
976        confirmations: u64,
977    },
978    /// The input is in the chain's safe head.
979    Safe {
980        /// Safe block.
981        block: BlockRef,
982    },
983    /// The input is in the finalized head.
984    Finalized {
985        /// Finalized block.
986        block: BlockRef,
987    },
988    /// The input was dropped by a reorg.
989    Reorged {
990        /// Block the input was dropped from.
991        dropped_from: BlockRef,
992    },
993}
994
995/// Source of an input batch.
996#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
997#[non_exhaustive]
998pub enum InputSource {
999    /// Caller-supplied batch.
1000    Batch,
1001    /// Live subscription stream.
1002    Subscription,
1003    /// Polling subscriber.
1004    Poll,
1005    /// Historical backfill.
1006    Backfill,
1007    /// Sequencer pre-confirmation / Flashblocks surface.
1008    Flashblocks,
1009    /// Test or synthetic input.
1010    Synthetic,
1011}
1012
1013/// Stable identity used for input deduplication and reports.
1014#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1015pub enum InputRef {
1016    /// Stable log identity.
1017    Log {
1018        /// Chain id, when known.
1019        chain_id: Option<u64>,
1020        /// Block hash containing the log.
1021        block_hash: B256,
1022        /// Transaction hash that emitted the log.
1023        transaction_hash: B256,
1024        /// Log index within the block.
1025        log_index: u64,
1026    },
1027    /// Stable pending transaction identity.
1028    PendingTx {
1029        /// Chain id, when known.
1030        chain_id: Option<u64>,
1031        /// Transaction hash.
1032        hash: B256,
1033    },
1034    /// Stable block identity.
1035    Block {
1036        /// Chain id, when known.
1037        chain_id: Option<u64>,
1038        /// Block hash.
1039        hash: B256,
1040        /// Block number.
1041        number: u64,
1042    },
1043}
1044
1045/// Representation and lifecycle class retained alongside an [`InputRef`].
1046///
1047/// `InputRef` identifies the underlying chain object. This discriminator keeps
1048/// distinct handler inputs from collapsing merely because they commit to the
1049/// same object: a header and full block, a pending hash and hydrated body, and
1050/// canonical versus reorg-signalling log delivery are independently routable.
1051#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1052#[non_exhaustive]
1053pub enum ReactiveInputKind {
1054    /// Canonical log data.
1055    CanonicalLog,
1056    /// Removed or otherwise reorg-signalling log data.
1057    ReorgSignalLog,
1058    /// Header-only block representation.
1059    BlockHeader,
1060    /// Full block representation.
1061    FullBlock,
1062    /// Hash-only pending transaction representation.
1063    PendingTxHash,
1064    /// Hydrated pending transaction representation.
1065    PendingTx,
1066}
1067
1068/// Validated, representation-aware identity for one reactive input.
1069///
1070/// Composite subscribers can use this as a dedupe key without conflating
1071/// independently routable representations. When a key repeats, use
1072/// [`ReactiveInputRecord::same_deduplicable_payload`] to distinguish a true
1073/// provider overlap from a conflicting payload.
1074#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1075pub struct ReactiveInputIdentity {
1076    input_ref: InputRef,
1077    kind: ReactiveInputKind,
1078}
1079
1080impl ReactiveInputIdentity {
1081    /// Validate and construct an identity from explicit wire/codec parts.
1082    ///
1083    /// `InputRef` identifies the underlying object, while `kind` identifies its
1084    /// representation/lifecycle. Only log kinds may pair with [`InputRef::Log`],
1085    /// block representations with [`InputRef::Block`], and pending-transaction
1086    /// representations with [`InputRef::PendingTx`]. This constructor lets
1087    /// external codecs rebuild the otherwise-private invariant without serde or
1088    /// layout-dependent decoding.
1089    ///
1090    /// # Errors
1091    ///
1092    /// Returns [`ReactiveInputIdentityError`] when `input_ref` does not belong
1093    /// to the supplied representation `kind`.
1094    pub fn try_from_parts(
1095        input_ref: InputRef,
1096        kind: ReactiveInputKind,
1097    ) -> Result<Self, ReactiveInputIdentityError> {
1098        let compatible = matches!(
1099            (input_ref, kind),
1100            (
1101                InputRef::Log { .. },
1102                ReactiveInputKind::CanonicalLog | ReactiveInputKind::ReorgSignalLog
1103            ) | (
1104                InputRef::Block { .. },
1105                ReactiveInputKind::BlockHeader | ReactiveInputKind::FullBlock
1106            ) | (
1107                InputRef::PendingTx { .. },
1108                ReactiveInputKind::PendingTxHash | ReactiveInputKind::PendingTx
1109            )
1110        );
1111        if !compatible {
1112            return Err(ReactiveInputIdentityError { input_ref, kind });
1113        }
1114        Ok(Self { input_ref, kind })
1115    }
1116
1117    /// Underlying stable chain-object reference.
1118    pub const fn input_ref(&self) -> InputRef {
1119        self.input_ref
1120    }
1121
1122    /// Exact handler-input representation and lifecycle class.
1123    pub const fn kind(&self) -> ReactiveInputKind {
1124        self.kind
1125    }
1126}
1127
1128/// An explicit [`InputRef`] and [`ReactiveInputKind`] describe incompatible
1129/// object/representation classes.
1130#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
1131#[error("reactive input kind {kind:?} is incompatible with input reference {input_ref:?}")]
1132pub struct ReactiveInputIdentityError {
1133    input_ref: InputRef,
1134    kind: ReactiveInputKind,
1135}
1136
1137impl ReactiveInputIdentityError {
1138    /// Rejected stable object reference.
1139    pub const fn input_ref(&self) -> InputRef {
1140        self.input_ref
1141    }
1142
1143    /// Rejected representation/lifecycle kind.
1144    pub const fn kind(&self) -> ReactiveInputKind {
1145        self.kind
1146    }
1147}
1148
1149/// Reliability of state effects emitted by a handler.
1150#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1151pub enum StateEffectQuality {
1152    /// Effects are exact from the input alone.
1153    ExactFromInput,
1154    /// Effects were applied, but follow-up resync is pending.
1155    AppliedWithPendingResync,
1156    /// Effects came from authoritative resync.
1157    ResyncedAuthoritatively,
1158    /// State requires repair before it should be trusted.
1159    RequiresRepair,
1160    /// No canonical state effect was emitted.
1161    NoStateEffect,
1162}
1163
1164/// Identifier for a reactive handler.
1165#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize)]
1166pub struct HandlerId(String);
1167
1168impl HandlerId {
1169    /// Create a non-empty handler id.
1170    ///
1171    /// # Panics
1172    ///
1173    /// Panics when `id` is empty. Use [`try_new`](Self::try_new) for untrusted
1174    /// configuration or wire input.
1175    pub fn new(id: impl Into<String>) -> Self {
1176        Self::try_new(id).expect("handler id must not be empty")
1177    }
1178
1179    /// Validate and create a handler id from untrusted input.
1180    ///
1181    /// # Errors
1182    ///
1183    /// Returns [`HandlerIdError`] when `id` is empty. The empty identity is
1184    /// reserved for canonical/global protocol scope.
1185    pub fn try_new(id: impl Into<String>) -> Result<Self, HandlerIdError> {
1186        let id = id.into();
1187        if id.is_empty() {
1188            return Err(HandlerIdError);
1189        }
1190        Ok(Self(id))
1191    }
1192
1193    /// Return the id as a string slice.
1194    pub fn as_str(&self) -> &str {
1195        &self.0
1196    }
1197}
1198
1199impl<'de> serde::Deserialize<'de> for HandlerId {
1200    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1201    where
1202        D: serde::Deserializer<'de>,
1203    {
1204        let id = <String as serde::Deserialize>::deserialize(deserializer)?;
1205        Self::try_new(id).map_err(serde::de::Error::custom)
1206    }
1207}
1208
1209/// An empty handler identity cannot be represented portably across subscriber
1210/// protocols because the empty owner is reserved for canonical/global scope.
1211#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
1212#[error("handler id must not be empty")]
1213pub struct HandlerIdError;
1214
1215impl fmt::Display for HandlerId {
1216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1217        self.0.fmt(f)
1218    }
1219}
1220
1221/// Lightweight report label.
1222#[derive(Clone, Debug, PartialEq, Eq, Hash)]
1223pub struct ReportTag {
1224    /// Label key.
1225    pub key: String,
1226    /// Label value.
1227    pub value: String,
1228}
1229
1230impl ReportTag {
1231    /// Create a report tag.
1232    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
1233        Self {
1234            key: key.into(),
1235            value: value.into(),
1236        }
1237    }
1238}
1239
1240/// Domain-neutral hook signal emitted by a handler.
1241#[derive(Clone)]
1242pub struct HookSignal {
1243    /// Signal namespace owned by the caller.
1244    pub namespace: Cow<'static, str>,
1245    /// Signal kind within the namespace.
1246    pub kind: Cow<'static, str>,
1247    /// Additional labels for routing or observability.
1248    pub labels: Vec<ReportTag>,
1249    /// Optional in-process typed payload.
1250    pub payload: Option<Arc<dyn Any + Send + Sync>>,
1251}
1252
1253impl fmt::Debug for HookSignal {
1254    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1255        f.debug_struct("HookSignal")
1256            .field("namespace", &self.namespace)
1257            .field("kind", &self.kind)
1258            .field("labels", &self.labels)
1259            .field("payload", &self.payload.as_ref().map(|_| "<payload>"))
1260            .finish()
1261    }
1262}
1263
1264/// Effect emitted by a [`ReactiveHandler`].
1265#[derive(Clone, Debug)]
1266pub enum ReactiveEffect {
1267    /// Canonical cache mutation applied through [`EvmCache::apply_updates`].
1268    StateUpdate(StateUpdate),
1269    /// Request for authoritative state repair.
1270    Resync(ResyncRequest),
1271    /// Rich invalidation request lowered to [`StateUpdate::Purge`].
1272    Invalidate(InvalidationRequest),
1273    /// Hook signal dispatched after committed mutation phases.
1274    Hook(HookSignal),
1275    /// Speculative signal for mempool or downstream work.
1276    Speculative(SpeculativeRequest),
1277}
1278
1279/// Handler output for a single input.
1280#[derive(Clone, Debug)]
1281pub struct HandlerOutcome {
1282    /// Effects emitted by the handler.
1283    pub effects: Vec<ReactiveEffect>,
1284    /// Reliability of emitted state effects.
1285    pub quality: StateEffectQuality,
1286    /// Labels copied into reports.
1287    pub tags: Vec<ReportTag>,
1288}
1289
1290impl HandlerOutcome {
1291    /// Construct an empty outcome with the supplied quality.
1292    pub fn empty(quality: StateEffectQuality) -> Self {
1293        Self {
1294            effects: Vec::new(),
1295            quality,
1296            tags: Vec::new(),
1297        }
1298    }
1299}
1300
1301/// One input and its execution context.
1302#[derive(Clone, Debug)]
1303pub struct ReactiveInputRecord<N: Network = Ethereum> {
1304    /// Input value.
1305    pub input: ReactiveInput<N>,
1306    /// Input context.
1307    pub context: ReactiveContext,
1308    /// Provider session that originated this input, when it came from a
1309    /// concrete provider rather than a synthetic or aggregate source.
1310    pub provider: Option<ProviderRef>,
1311}
1312
1313impl<N: Network> ReactiveInputRecord<N> {
1314    /// Create an input record.
1315    pub fn new(input: ReactiveInput<N>, context: ReactiveContext) -> Self {
1316        Self {
1317            input,
1318            context,
1319            provider: None,
1320        }
1321    }
1322
1323    /// Attach provider provenance used to route follow-up reads.
1324    #[must_use]
1325    pub fn with_provider(mut self, provider: ProviderRef) -> Self {
1326        self.provider = Some(provider);
1327        self
1328    }
1329
1330    /// Compute the stable input reference used for deduplication.
1331    pub fn input_ref(&self) -> InputRef {
1332        input_ref(&self.input, &self.context)
1333    }
1334
1335    /// Validate payload/context coherence and return a representation-aware
1336    /// identity suitable for subscriber and runtime deduplication.
1337    ///
1338    /// Validation is fail-closed for canonical logs: their block, transaction,
1339    /// and log positions must be complete and agree with the context. Block and
1340    /// pending-transaction representations receive the corresponding lifecycle,
1341    /// inclusion-wrapper, and payload/context checks. This does not recompute a
1342    /// claimed header hash, transaction root, or transaction signature; exact
1343    /// subscriber payload commitments remain the transport-integrity boundary
1344    /// for those cryptographic claims.
1345    ///
1346    /// # Errors
1347    ///
1348    /// Returns [`ReactiveError::InvalidInputRecord`] when the payload,
1349    /// lifecycle, inclusion metadata, or context is incomplete or internally
1350    /// inconsistent.
1351    pub fn validated_identity(&self) -> Result<ReactiveInputIdentity, ReactiveError> {
1352        validate_input_record(self)?;
1353        let kind = match &self.input {
1354            ReactiveInput::Log(log)
1355                if log.removed
1356                    || matches!(self.context.chain_status, ChainStatus::Reorged { .. }) =>
1357            {
1358                ReactiveInputKind::ReorgSignalLog
1359            }
1360            ReactiveInput::Log(_) => ReactiveInputKind::CanonicalLog,
1361            ReactiveInput::BlockHeader(_) => ReactiveInputKind::BlockHeader,
1362            ReactiveInput::FullBlock(_) => ReactiveInputKind::FullBlock,
1363            ReactiveInput::PendingTxHash(_) => ReactiveInputKind::PendingTxHash,
1364            ReactiveInput::PendingTx(_) => ReactiveInputKind::PendingTx,
1365        };
1366        ReactiveInputIdentity::try_from_parts(self.input_ref(), kind).map_err(|error| {
1367            ReactiveError::InvalidInputRecord {
1368                message: error.to_string(),
1369            }
1370        })
1371    }
1372
1373    /// Whether two same-identity records carry the same deduplicable payload.
1374    ///
1375    /// This deliberately ignores [`ReactiveContext`]: the same provider object
1376    /// can legitimately arrive from backfill and subscription transports with
1377    /// different provenance or confirmation metadata. Callers must first
1378    /// compare [`validated_identity`](Self::validated_identity) and reconcile
1379    /// lifecycle/context authority separately. Logs are compared structurally;
1380    /// block and transaction hashes are cryptographic commitments for the
1381    /// remaining same-representation payloads. Full block responses and
1382    /// hydrated pending transaction bodies deliberately return `false`: the
1383    /// core does not currently prove a supplied body against the header's
1384    /// transaction root or compare every response field, so a composite source
1385    /// must preserve both rather than suppress one based only on its hash.
1386    pub fn same_deduplicable_payload(&self, other: &Self) -> bool {
1387        match (&self.input, &other.input) {
1388            (ReactiveInput::Log(left), ReactiveInput::Log(right)) => {
1389                left.inner == right.inner
1390                    && left.block_hash == right.block_hash
1391                    && left.block_number == right.block_number
1392                    && optional_metadata_compatible(
1393                        left.block_timestamp.as_ref(),
1394                        right.block_timestamp.as_ref(),
1395                    )
1396                    && left.transaction_hash == right.transaction_hash
1397                    && left.transaction_index == right.transaction_index
1398                    && left.log_index == right.log_index
1399                    && left.removed == right.removed
1400            }
1401            (ReactiveInput::BlockHeader(left), ReactiveInput::BlockHeader(right)) => {
1402                left.hash() == right.hash()
1403            }
1404            (ReactiveInput::FullBlock(_), ReactiveInput::FullBlock(_)) => false,
1405            (ReactiveInput::PendingTxHash(left), ReactiveInput::PendingTxHash(right)) => {
1406                left == right
1407            }
1408            (ReactiveInput::PendingTx(_), ReactiveInput::PendingTx(_)) => false,
1409            _ => false,
1410        }
1411    }
1412
1413    /// Whether this representation has a complete payload-equivalence contract
1414    /// and may participate in duplicate suppression.
1415    ///
1416    /// Full block and hydrated pending transaction bodies are intentionally
1417    /// excluded until their complete body/response integrity is validated.
1418    pub fn is_payload_deduplicable(&self) -> bool {
1419        matches!(
1420            &self.input,
1421            ReactiveInput::Log(_) | ReactiveInput::BlockHeader(_) | ReactiveInput::PendingTxHash(_)
1422        )
1423    }
1424
1425    /// Merge `other` when it is the same safely deduplicable provider object.
1426    ///
1427    /// Returns `Ok(false)` for a different identity or a representation whose
1428    /// complete payload cannot be proven equivalent. A same-identity payload or
1429    /// semantic conflict returns an error. Successful merges are deterministic:
1430    /// optional block/timestamp metadata is enriched, canonical lifecycle moves
1431    /// toward `Finalized` then `Safe` then the highest-confirmation `Included`,
1432    /// and provenance uses a stable source priority. The result is therefore
1433    /// independent of historical/live arrival order.
1434    ///
1435    /// # Errors
1436    ///
1437    /// Returns [`ReactiveError`] when either record is invalid, or when equal
1438    /// identities carry conflicting payload or semantic context.
1439    pub fn merge_compatible_duplicate(&mut self, other: &Self) -> Result<bool, ReactiveError> {
1440        let identity = self.validated_identity()?;
1441        let other_identity = other.validated_identity()?;
1442        if identity != other_identity
1443            || !self.is_payload_deduplicable()
1444            || !other.is_payload_deduplicable()
1445        {
1446            return Ok(false);
1447        }
1448        if !self.same_deduplicable_payload(other) || !self.dedupe_context_is_compatible(other) {
1449            return Err(ReactiveError::InvalidInputRecord {
1450                message: format!(
1451                    "conflicting payload or semantic context for identity {identity:?}"
1452                ),
1453            });
1454        }
1455        let mut merged = self.clone();
1456        merge_deduplicable_record(&mut merged, other);
1457        merged.validated_identity()?;
1458        *self = merged;
1459        Ok(true)
1460    }
1461
1462    /// Whether semantic context agrees for deduplication across transports.
1463    ///
1464    /// Provenance source and confirmation count may legitimately differ at a
1465    /// historical/live overlap and are ignored. Chain id, lifecycle class, and
1466    /// transaction/log positions must agree. Block number/hash are exact;
1467    /// optional parent/timestamp metadata may be enriched by one source but two
1468    /// present conflicting values are rejected.
1469    pub fn dedupe_context_is_compatible(&self, other: &Self) -> bool {
1470        let left = &self.context;
1471        let right = &other.context;
1472        left.chain_id == right.chain_id
1473            && optional_block_refs_are_compatible(left.block.as_ref(), right.block.as_ref())
1474            && left.transaction_index == right.transaction_index
1475            && left.log_index == right.log_index
1476            && chain_statuses_are_dedupe_compatible(&left.chain_status, &right.chain_status)
1477    }
1478}
1479
1480fn chain_statuses_are_dedupe_compatible(left: &ChainStatus, right: &ChainStatus) -> bool {
1481    match (left, right) {
1482        (ChainStatus::Pending, ChainStatus::Pending)
1483        | (ChainStatus::Reorged { .. }, ChainStatus::Reorged { .. }) => true,
1484        (
1485            ChainStatus::Preconfirmed { flashblock: left },
1486            ChainStatus::Preconfirmed { flashblock: right },
1487        ) => left == right,
1488        (
1489            ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. },
1490            ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. },
1491        ) => true,
1492        _ => false,
1493    }
1494}
1495
1496fn optional_metadata_compatible<T: PartialEq>(left: Option<&T>, right: Option<&T>) -> bool {
1497    left.zip(right).is_none_or(|(left, right)| left == right)
1498}
1499
1500fn optional_block_refs_are_compatible(left: Option<&BlockRef>, right: Option<&BlockRef>) -> bool {
1501    match (left, right) {
1502        (None, None) => true,
1503        (Some(left), Some(right)) => {
1504            left.number == right.number
1505                && left.hash == right.hash
1506                && optional_metadata_compatible(
1507                    left.parent_hash.as_ref(),
1508                    right.parent_hash.as_ref(),
1509                )
1510                && optional_metadata_compatible(left.timestamp.as_ref(), right.timestamp.as_ref())
1511        }
1512        _ => false,
1513    }
1514}
1515
1516fn merge_deduplicable_record<N: Network>(
1517    retained: &mut ReactiveInputRecord<N>,
1518    incoming: &ReactiveInputRecord<N>,
1519) {
1520    if let (ReactiveInput::Log(retained), ReactiveInput::Log(incoming)) =
1521        (&mut retained.input, &incoming.input)
1522        && retained.block_timestamp.is_none()
1523    {
1524        retained.block_timestamp = incoming.block_timestamp;
1525    }
1526    if let (Some(retained), Some(incoming)) =
1527        (&mut retained.context.block, incoming.context.block.as_ref())
1528    {
1529        enrich_block_ref(retained, incoming);
1530    }
1531    retained.context.chain_status = merged_chain_status(
1532        &retained.context.chain_status,
1533        &incoming.context.chain_status,
1534    );
1535    if input_source_rank(incoming.context.source) > input_source_rank(retained.context.source) {
1536        retained.context.source = incoming.context.source;
1537    }
1538    if retained.provider.is_none() {
1539        retained.provider = incoming.provider.clone();
1540    }
1541}
1542
1543fn enrich_block_ref(retained: &mut BlockRef, incoming: &BlockRef) {
1544    if retained.parent_hash.is_none() {
1545        retained.parent_hash = incoming.parent_hash;
1546    }
1547    if retained.timestamp.is_none() {
1548        retained.timestamp = incoming.timestamp;
1549    }
1550}
1551
1552fn merged_chain_status(retained: &ChainStatus, incoming: &ChainStatus) -> ChainStatus {
1553    let merged_block = |left: &BlockRef, right: &BlockRef| {
1554        let mut block = *left;
1555        enrich_block_ref(&mut block, right);
1556        block
1557    };
1558    match (retained, incoming) {
1559        (ChainStatus::Pending, ChainStatus::Pending) => ChainStatus::Pending,
1560        (
1561            ChainStatus::Preconfirmed { flashblock: left },
1562            ChainStatus::Preconfirmed { flashblock: right },
1563        ) => {
1564            debug_assert_eq!(left, right, "compatible pre-confirmed records agree");
1565            ChainStatus::Preconfirmed {
1566                flashblock: left.clone(),
1567            }
1568        }
1569        (
1570            ChainStatus::Reorged { dropped_from: left },
1571            ChainStatus::Reorged {
1572                dropped_from: right,
1573            },
1574        ) => ChainStatus::Reorged {
1575            dropped_from: merged_block(left, right),
1576        },
1577        (left, right) => {
1578            let (left_block, left_rank, left_confirmations) = canonical_status_parts(left)
1579                .expect("compatible duplicate has a canonical lifecycle");
1580            let (right_block, right_rank, right_confirmations) = canonical_status_parts(right)
1581                .expect("compatible duplicate has a canonical lifecycle");
1582            let block = merged_block(left_block, right_block);
1583            let rank = left_rank.max(right_rank);
1584            match rank {
1585                3 => ChainStatus::Finalized { block },
1586                2 => ChainStatus::Safe { block },
1587                _ => ChainStatus::Included {
1588                    block,
1589                    confirmations: left_confirmations.max(right_confirmations),
1590                },
1591            }
1592        }
1593    }
1594}
1595
1596fn canonical_status_parts(status: &ChainStatus) -> Option<(&BlockRef, u8, u64)> {
1597    match status {
1598        ChainStatus::Included {
1599            block,
1600            confirmations,
1601        } => Some((block, 1, *confirmations)),
1602        ChainStatus::Safe { block } => Some((block, 2, 0)),
1603        ChainStatus::Finalized { block } => Some((block, 3, 0)),
1604        ChainStatus::Pending | ChainStatus::Preconfirmed { .. } | ChainStatus::Reorged { .. } => {
1605            None
1606        }
1607    }
1608}
1609
1610fn input_source_rank(source: InputSource) -> u8 {
1611    match source {
1612        InputSource::Backfill => 0,
1613        InputSource::Poll => 1,
1614        InputSource::Subscription => 2,
1615        InputSource::Flashblocks => 3,
1616        InputSource::Batch => 4,
1617        InputSource::Synthetic => 5,
1618    }
1619}
1620
1621/// Opaque subscriber-owned token attached to a delivered input batch.
1622///
1623/// Subscribers that provide durable, at-least-once delivery can use this token
1624/// to identify the batch that becomes committable after runtime ingestion
1625/// succeeds. The runtime never interprets the bytes. A token must be immutable,
1626/// stable across replay, and must never identify two different batch payloads.
1627/// Subscriber implementations must preserve delivery order while one token is
1628/// awaiting acknowledgement; [`ReactiveEngine`] retries it before polling a
1629/// later batch.
1630#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1631pub struct SubscriberDeliveryToken(Vec<u8>);
1632
1633impl SubscriberDeliveryToken {
1634    /// Create an opaque delivery token from subscriber-owned bytes.
1635    pub fn new(bytes: Vec<u8>) -> Self {
1636        Self(bytes)
1637    }
1638
1639    /// Borrow the opaque token bytes.
1640    pub fn as_bytes(&self) -> &[u8] {
1641        &self.0
1642    }
1643
1644    /// Consume the token into its opaque bytes.
1645    pub fn into_bytes(self) -> Vec<u8> {
1646        self.0
1647    }
1648}
1649
1650/// Opaque source checkpoint associated with a delivered batch.
1651///
1652/// Unlike [`SubscriberDeliveryToken`], which identifies the delivery to
1653/// acknowledge, this value describes provider-specific resume state. The core
1654/// crate persists and returns the bytes without interpreting their format.
1655#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1656pub struct SubscriberCheckpoint(Vec<u8>);
1657
1658impl SubscriberCheckpoint {
1659    /// Create an opaque source checkpoint from subscriber-owned bytes.
1660    pub fn new(bytes: Vec<u8>) -> Self {
1661        Self(bytes)
1662    }
1663
1664    /// Borrow the opaque checkpoint bytes.
1665    pub fn as_bytes(&self) -> &[u8] {
1666        &self.0
1667    }
1668
1669    /// Consume the checkpoint into its opaque bytes.
1670    pub fn into_bytes(self) -> Vec<u8> {
1671        self.0
1672    }
1673}
1674
1675/// Subscriber-supplied commitment to the exact canonical wire payload of one
1676/// delivered batch.
1677///
1678/// The core includes this value in its durable replay witness. It is required
1679/// for tokened block-header, full-block, and hydrated-transaction payloads whose
1680/// network-generic Rust response types cannot be serialized completely by the
1681/// core. The source must recompute the commitment from a stable canonical
1682/// encoding on every replay; reusing a commitment for changed bytes violates the
1683/// [`EventSubscriber`] contract.
1684#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1685pub struct SubscriberPayloadCommitment(B256);
1686
1687impl SubscriberPayloadCommitment {
1688    /// Wrap a cryptographic commitment produced by the subscriber.
1689    pub const fn new(commitment: B256) -> Self {
1690        Self(commitment)
1691    }
1692
1693    /// Return the committed digest.
1694    pub const fn digest(&self) -> B256 {
1695        self.0
1696    }
1697}
1698
1699/// Durable subscriber position restored together with cache/runtime state.
1700///
1701/// The core never interprets provider checkpoint bytes. Composite and remote
1702/// subscribers use this synchronous hand-off to seed their source cursors,
1703/// replay fences, and canonical overlap journals before polling resumes.
1704#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1705#[non_exhaustive]
1706pub struct SubscriberResumePosition {
1707    /// Chain whose canonical position and provider cursor are being restored.
1708    pub chain_id: u64,
1709    /// Authoritative canonical coverage embodied by the restored cache.
1710    pub coverage_head: BlockRef,
1711    /// Ordered canonical identities still retained for in-window reconciliation.
1712    pub canonical_history: Vec<BlockRef>,
1713    /// Last delivery token whose effects are already represented by the cache.
1714    /// It may still be pending at the source when the process stopped after its
1715    /// durable save but before the source acknowledgement committed.
1716    pub delivery_token: Option<SubscriberDeliveryToken>,
1717    /// Provider-specific durable cursor committed with that delivery.
1718    pub subscriber_checkpoint: Option<SubscriberCheckpoint>,
1719}
1720
1721impl SubscriberResumePosition {
1722    /// Construct a complete restored subscriber position.
1723    pub fn new(
1724        chain_id: u64,
1725        coverage_head: BlockRef,
1726        canonical_history: Vec<BlockRef>,
1727        delivery_token: Option<SubscriberDeliveryToken>,
1728        subscriber_checkpoint: Option<SubscriberCheckpoint>,
1729    ) -> Self {
1730        Self {
1731            chain_id,
1732            coverage_head,
1733            canonical_history,
1734            delivery_token,
1735            subscriber_checkpoint,
1736        }
1737    }
1738}
1739
1740/// Runtime routing audience for one delivered subscriber batch.
1741///
1742/// Historical catch-up for a newly registered handler must not be routed
1743/// through older handlers whose filters happen to overlap. Subscribers retain
1744/// that provenance by targeting the batch at the exact logical owners that
1745/// requested it. Ordinary canonical delivery remains broadcast to every
1746/// matching handler.
1747#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1748#[non_exhaustive]
1749pub enum DeliveryAudience {
1750    /// Route each record through every matching registered handler.
1751    #[default]
1752    All,
1753    /// Route each record only through the named matching handlers.
1754    Owners(Vec<HandlerId>),
1755    /// Route through every matching handler except the named owners.
1756    ///
1757    /// Composite subscribers use this to deliver the residual audience after an
1758    /// overlapping source already committed the same input for selected owners.
1759    AllExcept(Vec<HandlerId>),
1760}
1761
1762/// How one delivered record participates in the runtime's canonical state machine.
1763///
1764/// Routing and chain authority are deliberately independent: [`DeliveryAudience`]
1765/// selects handlers, while this value decides whether a record may advance or
1766/// rewind global chain state. Historical replay for a newly added owner must use
1767/// [`OwnerCatchup`](Self::OwnerCatchup), even though its original on-chain status
1768/// is canonical.
1769#[derive(
1770    Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
1771)]
1772#[non_exhaustive]
1773pub enum DeliveryScope {
1774    /// Authoritative live canonical delivery.
1775    #[default]
1776    Canonical,
1777    /// Authoritative historical/recovery delivery that advances canonical progress.
1778    CanonicalProgress,
1779    /// Historical replay routed to selected owners without changing global chain state.
1780    OwnerCatchup,
1781    /// Ephemeral pre-confirmation delivery applied only to the speculative
1782    /// cache overlay.
1783    Preconfirmed,
1784}
1785
1786impl DeliveryScope {
1787    const fn advances_canonical_state(self) -> bool {
1788        matches!(self, Self::Canonical | Self::CanonicalProgress)
1789    }
1790}
1791
1792/// One input together with its routing and canonical-processing provenance.
1793#[derive(Clone, Debug)]
1794pub struct ReactiveInputDelivery<N: Network = Ethereum> {
1795    record: ReactiveInputRecord<N>,
1796    audience: DeliveryAudience,
1797    scope: DeliveryScope,
1798}
1799
1800impl<N: Network> ReactiveInputDelivery<N> {
1801    /// Construct one lossless delivered record.
1802    pub fn new(
1803        record: ReactiveInputRecord<N>,
1804        audience: DeliveryAudience,
1805        scope: DeliveryScope,
1806    ) -> Self {
1807        Self {
1808            record,
1809            audience,
1810            scope,
1811        }
1812    }
1813
1814    /// Borrow the runtime input record.
1815    pub const fn record(&self) -> &ReactiveInputRecord<N> {
1816        &self.record
1817    }
1818
1819    /// Borrow the exact routing audience.
1820    pub const fn audience(&self) -> &DeliveryAudience {
1821        &self.audience
1822    }
1823
1824    /// Return the record's canonical-processing scope.
1825    pub const fn scope(&self) -> DeliveryScope {
1826        self.scope
1827    }
1828
1829    /// Consume this value into its complete parts.
1830    pub fn into_parts(self) -> (ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope) {
1831        (self.record, self.audience, self.scope)
1832    }
1833}
1834
1835/// Complete contents of a consumed [`ReactiveInputBatch`].
1836///
1837/// Use this instead of [`ReactiveInputBatch::into_records`], which intentionally
1838/// discards subscriber commit and chain-lifecycle metadata.
1839#[derive(Clone, Debug)]
1840#[non_exhaustive]
1841pub struct ReactiveInputBatchParts<N: Network = Ethereum> {
1842    /// Authoritative chain identity for controls and records in this batch.
1843    pub chain_id: Option<u64>,
1844    /// Records with per-record routing and chain provenance.
1845    pub deliveries: Vec<ReactiveInputDelivery<N>>,
1846    /// Subscriber delivery token committed after ingestion.
1847    pub delivery_token: Option<SubscriberDeliveryToken>,
1848    /// Provider-specific resume cursor associated with the delivery.
1849    pub subscriber_checkpoint: Option<SubscriberCheckpoint>,
1850    /// Exact opaque wire-payload commitment supplied by the subscriber.
1851    pub payload_commitment: Option<SubscriberPayloadCommitment>,
1852    /// Ordered chain controls sharing the delivery's commit boundary.
1853    pub chain_controls: Vec<ChainControl>,
1854    /// Original typed source ingress for a preconfirmed-only batch.
1855    pub preconfirmation_timing: Option<FlashblockIngressTiming>,
1856}
1857
1858/// Batch of reactive input records.
1859#[derive(Clone, Debug)]
1860pub struct ReactiveInputBatch<N: Network = Ethereum> {
1861    records: Vec<ReactiveInputRecord<N>>,
1862    chain_id: Option<u64>,
1863    delivery_token: Option<SubscriberDeliveryToken>,
1864    subscriber_checkpoint: Option<SubscriberCheckpoint>,
1865    payload_commitment: Option<SubscriberPayloadCommitment>,
1866    audience: DeliveryAudience,
1867    record_audiences: Option<Vec<DeliveryAudience>>,
1868    delivery_scope: DeliveryScope,
1869    record_delivery_scopes: Option<Vec<DeliveryScope>>,
1870    chain_controls: Vec<ChainControl>,
1871    preconfirmation_timing: Option<FlashblockIngressTiming>,
1872}
1873
1874type RuntimeInputDelivery<N> = (ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope);
1875
1876impl<N: Network> ReactiveInputBatch<N> {
1877    /// Create a batch from records.
1878    pub fn new(records: Vec<ReactiveInputRecord<N>>) -> Self {
1879        let chain_id = common_record_chain_id(&records);
1880        Self {
1881            records,
1882            chain_id,
1883            delivery_token: None,
1884            subscriber_checkpoint: None,
1885            payload_commitment: None,
1886            audience: DeliveryAudience::All,
1887            record_audiences: None,
1888            delivery_scope: DeliveryScope::Canonical,
1889            record_delivery_scopes: None,
1890            chain_controls: Vec::new(),
1891            preconfirmation_timing: None,
1892        }
1893    }
1894
1895    /// Bind the complete batch, including control-only progress/finality, to a
1896    /// chain. Runtime ingestion rejects a different cache chain.
1897    pub fn with_chain_id(mut self, chain_id: u64) -> Self {
1898        self.chain_id = Some(chain_id);
1899        self
1900    }
1901
1902    /// Authoritative batch chain identity, when supplied or unambiguously
1903    /// derived from its records.
1904    pub const fn chain_id(&self) -> Option<u64> {
1905        self.chain_id
1906    }
1907
1908    /// Attach the subscriber-owned token committed after successful ingestion.
1909    pub fn with_delivery_token(mut self, token: SubscriberDeliveryToken) -> Self {
1910        self.delivery_token = Some(token);
1911        self
1912    }
1913
1914    /// Borrow the subscriber-owned delivery token, when present.
1915    pub fn delivery_token(&self) -> Option<&SubscriberDeliveryToken> {
1916        self.delivery_token.as_ref()
1917    }
1918
1919    /// Attach provider-specific resume state included by this delivery.
1920    pub fn with_subscriber_checkpoint(mut self, checkpoint: SubscriberCheckpoint) -> Self {
1921        self.subscriber_checkpoint = Some(checkpoint);
1922        self
1923    }
1924
1925    /// Borrow provider-specific resume state, when present.
1926    pub fn subscriber_checkpoint(&self) -> Option<&SubscriberCheckpoint> {
1927        self.subscriber_checkpoint.as_ref()
1928    }
1929
1930    /// Attach a commitment to the exact canonical wire payload represented by
1931    /// this batch.
1932    pub fn with_payload_commitment(mut self, commitment: SubscriberPayloadCommitment) -> Self {
1933        self.payload_commitment = Some(commitment);
1934        self
1935    }
1936
1937    /// Borrow the subscriber-supplied exact payload commitment, when present.
1938    pub const fn payload_commitment(&self) -> Option<&SubscriberPayloadCommitment> {
1939        self.payload_commitment.as_ref()
1940    }
1941
1942    /// Restrict runtime routing to exact logical interest owners.
1943    pub fn with_audience(mut self, audience: DeliveryAudience) -> Self {
1944        self.audience = audience;
1945        self.record_audiences = None;
1946        self
1947    }
1948
1949    /// Delivery audience captured by the subscriber.
1950    pub const fn audience(&self) -> &DeliveryAudience {
1951        &self.audience
1952    }
1953
1954    /// Create a batch whose records retain independent delivery audiences.
1955    pub fn from_scoped_records(
1956        records: impl IntoIterator<Item = (ReactiveInputRecord<N>, DeliveryAudience)>,
1957    ) -> Self {
1958        let (records, record_audiences): (Vec<_>, Vec<_>) = records.into_iter().unzip();
1959        let chain_id = common_record_chain_id(&records);
1960        Self {
1961            records,
1962            chain_id,
1963            delivery_token: None,
1964            subscriber_checkpoint: None,
1965            payload_commitment: None,
1966            audience: DeliveryAudience::All,
1967            record_audiences: Some(record_audiences),
1968            delivery_scope: DeliveryScope::Canonical,
1969            record_delivery_scopes: None,
1970            chain_controls: Vec::new(),
1971            preconfirmation_timing: None,
1972        }
1973    }
1974
1975    /// Create a batch with independent routing and canonical provenance per record.
1976    pub fn from_deliveries(deliveries: impl IntoIterator<Item = ReactiveInputDelivery<N>>) -> Self {
1977        Self::from_scoped_records_with_delivery_scope(
1978            deliveries
1979                .into_iter()
1980                .map(ReactiveInputDelivery::into_parts),
1981        )
1982    }
1983
1984    /// Audience for the record at `index`.
1985    pub fn record_audience(&self, index: usize) -> Option<&DeliveryAudience> {
1986        if index >= self.records.len() {
1987            return None;
1988        }
1989        Some(
1990            self.record_audiences
1991                .as_ref()
1992                .and_then(|audiences| audiences.get(index))
1993                .unwrap_or(&self.audience),
1994        )
1995    }
1996
1997    /// Set how every record in this batch participates in canonical state.
1998    pub fn with_delivery_scope(mut self, scope: DeliveryScope) -> Self {
1999        self.delivery_scope = scope;
2000        self.record_delivery_scopes = None;
2001        self
2002    }
2003
2004    /// Canonical-processing scope for the record at `index`.
2005    pub fn record_delivery_scope(&self, index: usize) -> Option<DeliveryScope> {
2006        if index >= self.records.len() {
2007            return None;
2008        }
2009        Some(
2010            self.record_delivery_scopes
2011                .as_ref()
2012                .and_then(|scopes| scopes.get(index))
2013                .copied()
2014                .unwrap_or(self.delivery_scope),
2015        )
2016    }
2017
2018    fn from_scoped_records_with_delivery_scope(
2019        records: impl IntoIterator<Item = (ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>,
2020    ) -> Self {
2021        let mut input_records = Vec::new();
2022        let mut audiences = Vec::new();
2023        let mut scopes = Vec::new();
2024        for (record, audience, scope) in records {
2025            input_records.push(record);
2026            audiences.push(audience);
2027            scopes.push(scope);
2028        }
2029        let chain_id = common_record_chain_id(&input_records);
2030        Self {
2031            records: input_records,
2032            chain_id,
2033            delivery_token: None,
2034            subscriber_checkpoint: None,
2035            payload_commitment: None,
2036            audience: DeliveryAudience::All,
2037            record_audiences: Some(audiences),
2038            delivery_scope: DeliveryScope::Canonical,
2039            record_delivery_scopes: Some(scopes),
2040            chain_controls: Vec::new(),
2041            preconfirmation_timing: None,
2042        }
2043    }
2044
2045    /// Attach original typed source ingress to a preconfirmed-only batch.
2046    pub fn with_preconfirmation_timing(mut self, timing: FlashblockIngressTiming) -> Self {
2047        self.preconfirmation_timing = Some(timing);
2048        self
2049    }
2050
2051    /// Original typed source ingress for a preconfirmed-only batch.
2052    pub const fn preconfirmation_timing(&self) -> Option<FlashblockIngressTiming> {
2053        self.preconfirmation_timing
2054    }
2055
2056    /// Attach ordered chain-lifecycle controls to this delivery.
2057    ///
2058    /// A control-only batch must also call [`with_chain_id`](Self::with_chain_id).
2059    /// When records are present, their unanimous chain id is derived by the
2060    /// constructor; a missing or cache-mismatched authoritative batch identity
2061    /// is rejected before any control mutates runtime state.
2062    pub fn with_chain_controls(mut self, controls: impl IntoIterator<Item = ChainControl>) -> Self {
2063        self.chain_controls = controls.into_iter().collect();
2064        self
2065    }
2066
2067    /// Ordered chain-lifecycle controls in this delivery.
2068    pub fn chain_controls(&self) -> &[ChainControl] {
2069        &self.chain_controls
2070    }
2071
2072    /// Borrow the records in this batch.
2073    pub fn records(&self) -> &[ReactiveInputRecord<N>] {
2074        &self.records
2075    }
2076
2077    /// Consume the batch into only its input records.
2078    ///
2079    /// This is intentionally lossy: it discards the authoritative batch chain
2080    /// identity, routing audiences, delivery scopes, ordered chain controls,
2081    /// acknowledgement tokens, and provider checkpoints. Adapters should use
2082    /// [`into_parts`](Self::into_parts) instead.
2083    pub fn into_records(self) -> Vec<ReactiveInputRecord<N>> {
2084        self.records
2085    }
2086
2087    /// Consume the batch without losing subscriber or chain-lifecycle metadata.
2088    pub fn into_parts(self) -> ReactiveInputBatchParts<N> {
2089        let chain_id = self.chain_id;
2090        let delivery_token = self.delivery_token;
2091        let subscriber_checkpoint = self.subscriber_checkpoint;
2092        let payload_commitment = self.payload_commitment;
2093        let chain_controls = self.chain_controls;
2094        let preconfirmation_timing = self.preconfirmation_timing;
2095        let audiences = self
2096            .record_audiences
2097            .unwrap_or_else(|| vec![self.audience; self.records.len()]);
2098        let scopes = self
2099            .record_delivery_scopes
2100            .unwrap_or_else(|| vec![self.delivery_scope; self.records.len()]);
2101        let deliveries = self
2102            .records
2103            .into_iter()
2104            .zip(audiences)
2105            .zip(scopes)
2106            .map(|((record, audience), scope)| ReactiveInputDelivery::new(record, audience, scope))
2107            .collect();
2108        ReactiveInputBatchParts {
2109            chain_id,
2110            deliveries,
2111            delivery_token,
2112            subscriber_checkpoint,
2113            payload_commitment,
2114            chain_controls,
2115            preconfirmation_timing,
2116        }
2117    }
2118
2119    fn into_runtime_parts(self) -> (Vec<RuntimeInputDelivery<N>>, Vec<ChainControl>, Option<u64>) {
2120        let audiences = self
2121            .record_audiences
2122            .unwrap_or_else(|| vec![self.audience; self.records.len()]);
2123        let scopes = self
2124            .record_delivery_scopes
2125            .unwrap_or_else(|| vec![self.delivery_scope; self.records.len()]);
2126        let records = self
2127            .records
2128            .into_iter()
2129            .zip(audiences)
2130            .zip(scopes)
2131            .map(|((record, audience), scope)| (record, audience, scope))
2132            .collect();
2133        (records, self.chain_controls, self.chain_id)
2134    }
2135
2136    fn take_delivery_token(&mut self) -> Option<SubscriberDeliveryToken> {
2137        self.delivery_token.take()
2138    }
2139
2140    fn take_subscriber_checkpoint(&mut self) -> Option<SubscriberCheckpoint> {
2141        self.subscriber_checkpoint.take()
2142    }
2143}
2144
2145fn common_record_chain_id<N: Network>(records: &[ReactiveInputRecord<N>]) -> Option<u64> {
2146    let chain_id = records.first()?.context.chain_id?;
2147    records
2148        .iter()
2149        .all(|record| record.context.chain_id == Some(chain_id))
2150        .then_some(chain_id)
2151}
2152
2153/// Pure synchronous handler for reactive inputs.
2154pub trait ReactiveHandler<N: Network = Ethereum>: Send + Sync {
2155    /// Stable handler id.
2156    fn id(&self) -> HandlerId;
2157
2158    /// Interests used by subscribers and the local router.
2159    fn interests(&self) -> Vec<ReactiveInterest<N>>;
2160
2161    /// Exhaustive exact keys for log inputs this handler can accept.
2162    ///
2163    /// Returning `None` keeps the handler on the compatibility fallback path.
2164    /// Returning an index promises that every matching log has at least one of
2165    /// its keys; the registry still re-checks the handler's original
2166    /// [`LogInterest`]s and local matchers before dispatch.
2167    fn log_route_index(&self) -> Option<LogRouteIndex> {
2168        None
2169    }
2170
2171    /// Handle one input against a read-only cache view.
2172    fn handle(
2173        &self,
2174        ctx: &ReactiveContext,
2175        input: &ReactiveInput<N>,
2176        state: &dyn StateView,
2177    ) -> Result<HandlerOutcome, HandlerError>;
2178}
2179
2180/// Hook invoked after reports are built and cache mutation phases have ended.
2181///
2182/// Hooks are synchronous in-process observers, not a durable transactional
2183/// outbox. The runtime never dispatches reports for a batch it rejects or rolls
2184/// back during checkpoint staging, and it dispatches a successfully staged
2185/// batch at most once per live engine. A process crash can still occur between
2186/// hook dispatch and durable checkpoint or transport acknowledgement. External
2187/// side effects therefore need their own idempotency key (normally an
2188/// [`InputRef`] or [`SubscriberDeliveryToken`]) and durable delivery mechanism.
2189pub trait ReactiveHook<N: Network = Ethereum>: Send + Sync {
2190    /// Observe a runtime report.
2191    fn on_report(&self, report: Arc<ReactiveReport<N>>);
2192}
2193
2194/// Reactive subscription interest.
2195#[allow(clippy::large_enum_variant)]
2196#[derive(Clone)]
2197pub enum ReactiveInterest<N: Network = Ethereum> {
2198    /// Log interest.
2199    Logs(LogInterest),
2200    /// Block interest.
2201    Blocks(BlockInterest),
2202    /// Pending transaction interest.
2203    PendingTransactions(PendingTxInterest<N>),
2204}
2205
2206impl<N: Network> fmt::Debug for ReactiveInterest<N> {
2207    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2208        match self {
2209            Self::Logs(interest) => f.debug_tuple("Logs").field(interest).finish(),
2210            Self::Blocks(interest) => f.debug_tuple("Blocks").field(interest).finish(),
2211            Self::PendingTransactions(interest) => f
2212                .debug_tuple("PendingTransactions")
2213                .field(interest)
2214                .finish(),
2215        }
2216    }
2217}
2218
2219/// Interest in logs.
2220#[derive(Clone)]
2221pub struct LogInterest {
2222    /// Provider-side filter.
2223    pub provider_filter: Filter,
2224    /// Optional local matcher for predicates providers cannot express.
2225    pub local_matcher: Option<Arc<dyn LogMatcher>>,
2226    /// Optional route-key extraction strategy.
2227    pub route_key: Option<RouteKeySpec>,
2228}
2229
2230impl LogInterest {
2231    /// Return true if the log matches both the provider filter and local matcher.
2232    pub fn matches(&self, log: &Log) -> bool {
2233        self.provider_filter.rpc_matches(log)
2234            && self
2235                .local_matcher
2236                .as_ref()
2237                .is_none_or(|matcher| matcher.matches(log))
2238    }
2239
2240    /// Extract the route key for a matching log, if configured.
2241    pub fn route_key(&self, log: &Log) -> Option<RouteKey> {
2242        self.route_key.as_ref().and_then(|spec| spec.extract(log))
2243    }
2244}
2245
2246impl fmt::Debug for LogInterest {
2247    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2248        f.debug_struct("LogInterest")
2249            .field("provider_filter", &self.provider_filter)
2250            .field(
2251                "local_matcher",
2252                &self.local_matcher.as_ref().map(|_| "<matcher>"),
2253            )
2254            .field("route_key", &self.route_key)
2255            .finish()
2256    }
2257}
2258
2259/// Local log predicate.
2260pub trait LogMatcher: Send + Sync {
2261    /// Return true when the log should be routed to the handler.
2262    fn matches(&self, log: &Log) -> bool;
2263}
2264
2265/// Route-key extraction strategy for logs.
2266#[derive(Clone)]
2267pub enum RouteKeySpec {
2268    /// Route by emitting address.
2269    EmitterAddress,
2270    /// Route by indexed topic.
2271    Topic {
2272        /// Topic index.
2273        index: usize,
2274    },
2275    /// Route by a byte slice in log data.
2276    DataSlice {
2277        /// Byte offset in the data payload.
2278        offset: usize,
2279        /// Number of bytes to copy.
2280        len: usize,
2281    },
2282    /// Custom extractor.
2283    Custom(Arc<dyn RouteKeyExtractor>),
2284}
2285
2286impl RouteKeySpec {
2287    /// Extract a route key from a log.
2288    pub fn extract(&self, log: &Log) -> Option<RouteKey> {
2289        match self {
2290            Self::EmitterAddress => Some(RouteKey::Address(log.address())),
2291            Self::Topic { index } => log.topics().get(*index).copied().map(RouteKey::Bytes32),
2292            Self::DataSlice { offset, len } => {
2293                let data = log.inner.data.data.as_ref();
2294                let end = offset.checked_add(*len)?;
2295                data.get(*offset..end)
2296                    .map(|bytes| RouteKey::Bytes(bytes.to_vec()))
2297            }
2298            Self::Custom(extractor) => extractor.extract(log),
2299        }
2300    }
2301}
2302
2303impl fmt::Debug for RouteKeySpec {
2304    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2305        match self {
2306            Self::EmitterAddress => f.write_str("EmitterAddress"),
2307            Self::Topic { index } => f.debug_struct("Topic").field("index", index).finish(),
2308            Self::DataSlice { offset, len } => f
2309                .debug_struct("DataSlice")
2310                .field("offset", offset)
2311                .field("len", len)
2312                .finish(),
2313            Self::Custom(_) => f.write_str("Custom(<extractor>)"),
2314        }
2315    }
2316}
2317
2318/// Extracts custom route keys from logs.
2319pub trait RouteKeyExtractor: Send + Sync {
2320    /// Extract a route key.
2321    fn extract(&self, log: &Log) -> Option<RouteKey>;
2322}
2323
2324/// Extracted route key.
2325#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2326pub enum RouteKey {
2327    /// Address key.
2328    Address(Address),
2329    /// 32-byte key.
2330    Bytes32(B256),
2331    /// Arbitrary bytes key.
2332    Bytes(Vec<u8>),
2333}
2334
2335/// Exact protocol-neutral key used to select candidate log handlers.
2336#[non_exhaustive]
2337#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2338pub enum LogRouteKey {
2339    /// Emitting contract address.
2340    Emitter(Address),
2341    /// Exact indexed topic.
2342    Topic {
2343        /// Topic position in the log.
2344        index: usize,
2345        /// Expected topic value.
2346        value: B256,
2347    },
2348    /// Exact byte slice in the log data.
2349    DataSlice {
2350        /// Byte offset in the data payload.
2351        offset: usize,
2352        /// Expected bytes.
2353        value: Vec<u8>,
2354    },
2355}
2356
2357/// Non-empty exhaustive OR-set of exact log route keys.
2358#[derive(Clone, Debug, PartialEq, Eq)]
2359pub struct LogRouteIndex {
2360    keys: Vec<LogRouteKey>,
2361}
2362
2363impl LogRouteIndex {
2364    /// Construct an index from one required key and optional additional keys.
2365    pub fn new(primary: LogRouteKey, additional: impl IntoIterator<Item = LogRouteKey>) -> Self {
2366        let mut keys = vec![primary];
2367        for key in additional {
2368            if !keys.contains(&key) {
2369                keys.push(key);
2370            }
2371        }
2372        Self { keys }
2373    }
2374
2375    /// Construct a single-key index.
2376    pub fn single(key: LogRouteKey) -> Self {
2377        Self { keys: vec![key] }
2378    }
2379
2380    /// Exact keys in declaration order.
2381    pub fn keys(&self) -> &[LogRouteKey] {
2382        &self.keys
2383    }
2384}
2385
2386/// Exact log route selected by [`ReactiveRegistry::route_log`].
2387#[derive(Clone, Debug, PartialEq, Eq)]
2388pub struct ReactiveLogRoute {
2389    /// Handler whose log interest matched.
2390    pub handler_id: HandlerId,
2391    /// Optional route key extracted from the matching log interest.
2392    pub route_key: Option<RouteKey>,
2393}
2394
2395/// Interest in block inputs.
2396#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2397pub struct BlockInterest {
2398    /// Block input mode.
2399    pub mode: BlockInterestMode,
2400}
2401
2402impl Default for BlockInterest {
2403    fn default() -> Self {
2404        Self {
2405            mode: BlockInterestMode::Header,
2406        }
2407    }
2408}
2409
2410/// Block subscription mode.
2411#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
2412pub enum BlockInterestMode {
2413    /// Header-only block input.
2414    Header,
2415    /// Full block input.
2416    FullBlock,
2417}
2418
2419/// Interest in pending transaction inputs.
2420#[derive(Clone)]
2421pub struct PendingTxInterest<N: Network = Ethereum> {
2422    /// Whether the handler requires full transaction bodies.
2423    pub full_transactions: bool,
2424    /// Sender matcher.
2425    pub from: AddressMatcher,
2426    /// Recipient matcher.
2427    pub to: AddressMatcher,
2428    /// Calldata selector matcher.
2429    pub selectors: SelectorMatcher,
2430    /// Optional local transaction matcher.
2431    pub local_matcher: Option<Arc<dyn PendingTxMatcher<N>>>,
2432}
2433
2434impl<N: Network> Default for PendingTxInterest<N> {
2435    fn default() -> Self {
2436        Self {
2437            full_transactions: false,
2438            from: AddressMatcher::Any,
2439            to: AddressMatcher::Any,
2440            selectors: SelectorMatcher::Any,
2441            local_matcher: None,
2442        }
2443    }
2444}
2445
2446impl<N: Network> PendingTxInterest<N> {
2447    fn matches_hash_only(&self) -> bool {
2448        !self.full_transactions
2449            && self.from.is_any()
2450            && self.to.is_any()
2451            && self.selectors.is_any()
2452            && self.local_matcher.is_none()
2453    }
2454
2455    fn matches_tx(&self, tx: &N::TransactionResponse) -> bool {
2456        self.from.matches(tx.from())
2457            && self.to.matches_option(tx.to())
2458            && self.selectors.matches(tx.input())
2459            && self
2460                .local_matcher
2461                .as_ref()
2462                .is_none_or(|matcher| matcher.matches(tx))
2463    }
2464}
2465
2466impl<N: Network> fmt::Debug for PendingTxInterest<N> {
2467    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2468        f.debug_struct("PendingTxInterest")
2469            .field("full_transactions", &self.full_transactions)
2470            .field("from", &self.from)
2471            .field("to", &self.to)
2472            .field("selectors", &self.selectors)
2473            .field(
2474                "local_matcher",
2475                &self.local_matcher.as_ref().map(|_| "<matcher>"),
2476            )
2477            .finish()
2478    }
2479}
2480
2481/// Address matching helper for pending transaction interests.
2482#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2483pub enum AddressMatcher {
2484    /// Match every address.
2485    Any,
2486    /// Match one address.
2487    Exact(Address),
2488    /// Match any address in the list.
2489    AnyOf(Vec<Address>),
2490}
2491
2492impl AddressMatcher {
2493    /// Return true when the matcher is unconstrained.
2494    pub fn is_any(&self) -> bool {
2495        matches!(self, Self::Any)
2496    }
2497
2498    /// Match a present address.
2499    pub fn matches(&self, address: Address) -> bool {
2500        match self {
2501            Self::Any => true,
2502            Self::Exact(expected) => *expected == address,
2503            Self::AnyOf(addresses) => addresses.contains(&address),
2504        }
2505    }
2506
2507    /// Match an optional address.
2508    pub fn matches_option(&self, address: Option<Address>) -> bool {
2509        match (self, address) {
2510            (Self::Any, _) => true,
2511            (_, Some(address)) => self.matches(address),
2512            _ => false,
2513        }
2514    }
2515}
2516
2517/// Calldata selector matching helper.
2518#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2519pub enum SelectorMatcher {
2520    /// Match every selector.
2521    Any,
2522    /// Match any selector in the list.
2523    AnyOf(Vec<[u8; 4]>),
2524}
2525
2526impl SelectorMatcher {
2527    /// Return true when the matcher is unconstrained.
2528    pub fn is_any(&self) -> bool {
2529        matches!(self, Self::Any)
2530    }
2531
2532    /// Match calldata bytes.
2533    pub fn matches(&self, input: &Bytes) -> bool {
2534        match self {
2535            Self::Any => true,
2536            Self::AnyOf(selectors) => input
2537                .get(..4)
2538                .and_then(|bytes| bytes.try_into().ok())
2539                .is_some_and(|selector| selectors.contains(&selector)),
2540        }
2541    }
2542}
2543
2544/// Local predicate over a full pending transaction.
2545pub trait PendingTxMatcher<N: Network = Ethereum>: Send + Sync {
2546    /// Return true when the transaction should be routed to the handler.
2547    fn matches(&self, tx: &N::TransactionResponse) -> bool;
2548}
2549
2550/// How a tracked account is kept live by the per-block root gate (Phase-8 step 4).
2551///
2552/// The `storageHash` root gate behaves *oppositely* for two contract shapes, so
2553/// liveness strategy is per-contract:
2554///
2555/// - A sparse-interest contract (a few balance slots, e.g. WETH) has its root
2556///   churn on nearly every block, so the root is a noisy gate — [`Slots`] opts
2557///   out. Its enumerated slots stay fresh via decoders + cadence reconcile.
2558/// - A whole-economic-state contract (e.g. a Uniswap-V2 pool) has
2559///   `root_moved ≈ my_state_changed`, so [`WholeAccount`] opts in: probe the root
2560///   each canonical block; a move a decoder did not cover is a coverage gap.
2561///
2562/// A false-positive resync is never *incorrect* — it costs one batched read — so
2563/// the policy is a **pure cost knob**, not a correctness lever.
2564///
2565/// [`Slots`]: TrackingPolicy::Slots
2566/// [`WholeAccount`]: TrackingPolicy::WholeAccount
2567#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
2568#[non_exhaustive]
2569pub enum TrackingPolicy {
2570    /// Sparse interest (e.g. WETH: a few balance slots). The root churns on
2571    /// nearly every block, so it is a noisy gate — this policy is **never**
2572    /// root-gated (spec Decision 3). Keep the enumerated slots fresh via decoders
2573    /// and cadence reconcile.
2574    Slots {
2575        /// The enumerated storage slots of interest.
2576        slots: Vec<U256>,
2577    },
2578    /// Whole economic state (e.g. a V2 pool). `root_moved ≈ my_state_changed`, so
2579    /// the root is a tight, cheap gate: probe each canonical block; on a move no
2580    /// decoder covered, emit a [`ReactiveReport::CoverageGap`] and schedule a
2581    /// [`ResyncReason::RootMoved`] repair.
2582    WholeAccount,
2583    /// Balance / nonce / code-hash only — resolved from the same `get_proof`
2584    /// response's account fields; no storage interest. Native balance/nonce
2585    /// changes do **not** move the storage root, so this policy compares the
2586    /// account fields directly across blocks rather than root-gating.
2587    Scalars,
2588}
2589
2590/// How often the reactive root gate probes tracked accounts
2591/// ([`TrackingPolicy::WholeAccount`] / [`TrackingPolicy::Scalars`]; the
2592/// `Scalars` account-fields comparison rides the same firing).
2593///
2594/// `eth_getProof` is the slowest read this crate issues, so per-block probing
2595/// is never the default. Skipping blocks is safe by construction: the gate
2596/// diffs `root_now` against its **persisted baseline**, never
2597/// block-over-block, so a move in any skipped block is still visible at the
2598/// next firing — cadence trades detection lag (at most `n − 1` blocks) for
2599/// cost, never eventual detection. The decoder-touched set accumulates across
2600/// skipped blocks and drains per firing, so a covered write in a skipped
2601/// block never false-positives as a [`ReactiveReport::CoverageGap`].
2602#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2603pub enum RootGateCadence {
2604    /// Probe at most once every `n` canonical blocks (the first canonical
2605    /// block ever seen always fires, so baseline adoption does not wait a
2606    /// full window). `EveryNBlocks(1)` is per-block probing.
2607    EveryNBlocks(NonZeroU64),
2608    /// Root gate off: coverage gaps surface only via decoders + freshness.
2609    Disabled,
2610}
2611
2612impl RootGateCadence {
2613    /// Probe at most once every `n` canonical blocks, clamping `0` to `1`.
2614    ///
2615    /// # Cost
2616    ///
2617    /// Each firing issues one `eth_getProof` per tracked account — the most
2618    /// expensive read this crate makes — so the request rate is
2619    /// `tracked accounts / n` per canonical block. A few hundred tracked
2620    /// accounts on a fast chain is a substantial standing budget. The gate is
2621    /// inert until [`ReactiveRuntime::track_account`] is called, so a runtime
2622    /// that never tracks accounts never pays it.
2623    #[must_use]
2624    pub fn every_n_blocks(n: u64) -> Self {
2625        Self::EveryNBlocks(NonZeroU64::new(n.max(1)).expect("clamped to at least 1"))
2626    }
2627}
2628
2629impl Default for RootGateCadence {
2630    /// Every 16 canonical blocks — ~3.2 min worst-case detection lag on
2631    /// mainnet for a 16× probe-cost cut. Fast-block chains should *raise*
2632    /// `n`, not lower it.
2633    fn default() -> Self {
2634        Self::every_n_blocks(16)
2635    }
2636}
2637
2638/// Per-account baseline held by the root gate: the last observed on-chain root
2639/// and account fields, plus the block they were observed at.
2640///
2641/// The gate diffs the on-chain root **across time** (never local-vs-chain, per
2642/// spec §6): it persists the *observed* root as a baseline and compares
2643/// `root_now` to it. This is a currency gate, not a completeness gate.
2644#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
2645struct TrackedRoot {
2646    last_root: B256,
2647    last_block: u64,
2648    balance: U256,
2649    nonce: u64,
2650    code_hash: B256,
2651}
2652
2653/// Request for authoritative state repair.
2654#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2655pub struct ResyncRequest {
2656    /// Resync id.
2657    pub id: ResyncId,
2658    /// Reason for the request.
2659    pub reason: ResyncReason,
2660    /// Block selection for the read.
2661    pub block: ResyncBlock,
2662    /// Targets to resync.
2663    pub targets: Vec<ResyncTarget>,
2664    /// Scheduling priority.
2665    pub priority: ResyncPriority,
2666}
2667
2668/// Resync id.
2669#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2670pub struct ResyncId(String);
2671
2672impl ResyncId {
2673    /// Create a resync id.
2674    pub fn new(id: impl Into<String>) -> Self {
2675        Self(id.into())
2676    }
2677}
2678
2679/// Reason for a resync request.
2680#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2681#[non_exhaustive]
2682pub enum ResyncReason {
2683    /// Handler requested repair.
2684    HandlerRequested,
2685    /// State effect could not be applied completely.
2686    SkippedStateEffect,
2687    /// A missed block range was detected; caller-scheduled repair.
2688    ///
2689    /// The runtime does not fabricate a targetless [`ResyncRequest`] for a missed
2690    /// range (there are no known targets to resync). This reason is provided so a
2691    /// caller building its own repair in response to a
2692    /// [`ReactiveReport::MissedBlockRange`] can attribute it.
2693    MissedBlockRange,
2694    /// A tracked account's storage root moved with no covering decoder.
2695    ///
2696    /// Emitted by the per-block root gate (Phase-8 step 4). A
2697    /// [`WholeAccount`](TrackingPolicy::WholeAccount)-tracked account's
2698    /// `storageHash` moved between the adopted baseline and the current canonical
2699    /// block, yet no decoder wrote that account during the block — a coverage gap.
2700    /// The gate schedules a resync with this reason to re-read the account
2701    /// authoritatively and self-heal the blind spot. Also used for the
2702    /// [`Scalars`](TrackingPolicy::Scalars) account-field freshness path.
2703    RootMoved,
2704    /// Caller-defined reason.
2705    Custom(String),
2706}
2707
2708/// Block target for a resync.
2709#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2710pub enum ResyncBlock {
2711    /// Latest block.
2712    Latest,
2713    /// Current provider pre-confirmation state.
2714    Pending,
2715    /// Safe head.
2716    Safe,
2717    /// Finalized head.
2718    Finalized,
2719    /// Block number.
2720    Number(u64),
2721    /// Block hash and number.
2722    Hash {
2723        /// Block number.
2724        number: u64,
2725        /// Block hash.
2726        hash: B256,
2727        /// Require the hash to still be canonical.
2728        require_canonical: bool,
2729    },
2730}
2731
2732/// State target for a resync.
2733#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2734pub enum ResyncTarget {
2735    /// One storage slot.
2736    StorageSlot {
2737        /// Contract address.
2738        address: Address,
2739        /// Storage slot.
2740        slot: U256,
2741    },
2742    /// Multiple storage slots on one contract.
2743    StorageSlots {
2744        /// Contract address.
2745        address: Address,
2746        /// Storage slots.
2747        slots: Vec<U256>,
2748    },
2749    /// Account fields.
2750    Account {
2751        /// Account address.
2752        address: Address,
2753        /// Fields to resync.
2754        fields: AccountFieldMask,
2755    },
2756}
2757
2758/// Account fields requested by a resync.
2759#[derive(
2760    Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
2761)]
2762pub struct AccountFieldMask {
2763    /// Balance field.
2764    pub balance: bool,
2765    /// Nonce field.
2766    pub nonce: bool,
2767    /// Code field.
2768    pub code: bool,
2769}
2770
2771/// Resync priority.
2772#[derive(
2773    Clone,
2774    Copy,
2775    Debug,
2776    Default,
2777    PartialEq,
2778    Eq,
2779    Hash,
2780    PartialOrd,
2781    Ord,
2782    serde::Serialize,
2783    serde::Deserialize,
2784)]
2785pub enum ResyncPriority {
2786    /// Low priority.
2787    Low,
2788    /// Normal priority.
2789    #[default]
2790    Normal,
2791    /// High priority.
2792    High,
2793}
2794
2795/// Rich invalidation request lowered to [`StateUpdate::Purge`].
2796#[derive(Clone, Debug, PartialEq, Eq)]
2797pub struct InvalidationRequest {
2798    /// Purge scope.
2799    pub scope: PurgeScope,
2800    /// Address to purge.
2801    pub address: Address,
2802    /// Reason for reporting.
2803    pub reason: InvalidationReason,
2804}
2805
2806/// Invalidation reason.
2807#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2808pub enum InvalidationReason {
2809    /// Handler requested invalidation.
2810    HandlerRequested,
2811    /// Reorg invalidation.
2812    Reorg,
2813    /// Caller-defined reason.
2814    Custom(String),
2815}
2816
2817/// Speculative signal emitted by handlers.
2818#[derive(Clone, Debug, PartialEq, Eq)]
2819pub struct SpeculativeRequest {
2820    /// Speculative request id.
2821    pub id: SpeculativeId,
2822    /// Input that triggered the request.
2823    pub input_ref: InputRef,
2824    /// Labels for downstream routing.
2825    pub labels: Vec<ReportTag>,
2826}
2827
2828/// Speculative request id.
2829#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2830pub struct SpeculativeId(String);
2831
2832impl SpeculativeId {
2833    /// Create a speculative id.
2834    pub fn new(id: impl Into<String>) -> Self {
2835        Self(id.into())
2836    }
2837}
2838
2839/// Configuration for [`ReactiveRuntime`].
2840#[derive(Clone, Debug, PartialEq, Eq)]
2841pub struct ReactiveConfig {
2842    /// Hook backpressure policy. **Reserved — currently has no effect.** Hook
2843    /// dispatch is synchronous today (every report is delivered to every hook in
2844    /// order), so this field is a no-op placeholder for a future async dispatcher.
2845    /// Setting it to anything other than the default does not change behavior.
2846    pub hook_backpressure: HookBackpressure,
2847    /// Reorg journal depth: the number of recent canonical blocks whose effects
2848    /// are journaled for rollback. This is **load-bearing** for reorg recovery:
2849    /// only blocks still resident in the journal can be recovered. A reorg deeper
2850    /// than `journal_depth` recovers the blocks still in the journal and leaves
2851    /// the aged-out blocks' effects in place — they are **neither rolled back nor
2852    /// purged**, so the freshness/validation loop is the only backstop for that
2853    /// span. `0` disables journaling entirely: no reorg is rolled back or purged.
2854    ///
2855    /// Set `journal_depth` to exceed the deepest reorg you intend to recover
2856    /// precisely. When a reorg references a block that is no longer in the journal,
2857    /// the runtime emits a `tracing::warn!` so the under-recovery is observable
2858    /// rather than silent. Checkpointed engine ingestion is stricter: explicit
2859    /// reorgs, implicit parent replacements, and removed/reorged records whose
2860    /// rollback proof falls outside the retained effect journal are rejected
2861    /// before mutation, durable save, or acknowledgement. Align this depth with
2862    /// the complete reorg horizon promised by the subscriber.
2863    pub journal_depth: usize,
2864}
2865
2866impl Default for ReactiveConfig {
2867    fn default() -> Self {
2868        Self {
2869            hook_backpressure: HookBackpressure::Block,
2870            journal_depth: 64,
2871        }
2872    }
2873}
2874
2875/// Hook backpressure policy.
2876#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
2877pub enum HookBackpressure {
2878    /// Block the producer until hooks are accepted.
2879    Block,
2880    /// Drop the newest report under pressure.
2881    DropNewest,
2882    /// Drop the oldest report under pressure.
2883    DropOldest,
2884    /// Return an error under pressure.
2885    Error,
2886}
2887
2888/// Queryable coarse health of the reactive cache.
2889///
2890/// The runtime starts [`Healthy`](CacheHealth::Healthy) and transitions to a
2891/// degraded or unhealthy state when it detects that its recovery guarantees no
2892/// longer hold (for example a reorg that runs deeper than the journal, so some
2893/// dropped effects are neither rolled back nor purged). Later waves report
2894/// missed-range and coverage-gap conditions into the same state machine.
2895#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2896#[non_exhaustive]
2897pub enum CacheHealth {
2898    /// All recovery guarantees hold; the cache is fully self-consistent.
2899    #[default]
2900    Healthy,
2901    /// A recoverable inconsistency was detected (for example under-recovered
2902    /// reorg effects); `since_block` records the block that triggered the
2903    /// transition.
2904    Degraded {
2905        /// Block number at which the degradation was first observed.
2906        since_block: u64,
2907    },
2908    /// A more serious inconsistency was detected; `since_block` records the
2909    /// block that triggered the transition.
2910    Unhealthy {
2911        /// Block number at which the unhealthy condition was first observed.
2912        since_block: u64,
2913    },
2914}
2915
2916/// Point-in-time copy of the reactive runtime's observability counters.
2917///
2918/// Returned by [`ReactiveRuntime::metrics`]. Each field is a monotonically
2919/// increasing count over the lifetime of the runtime. Counters wired by later
2920/// waves (missed-range detection, storage-hash coverage gaps, stale-verdict
2921/// tracking) remain zero until those waves land.
2922#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2923#[non_exhaustive]
2924pub struct CacheMetricsSnapshot {
2925    /// Reorgs that ran deeper than the journal, so aged-out effects could not be
2926    /// rolled back or purged.
2927    pub deep_reorgs: u64,
2928    /// Reorgs for which a [`ReorgReport`] recovery ran (including deep reorgs).
2929    pub reorgs_recovered: u64,
2930    /// Storage resync targets considered by the resync execution pass.
2931    pub resync_requests: u64,
2932    /// Storage resync targets that could not be fetched or applied.
2933    pub resync_failures: u64,
2934    /// Ranges of blocks the runtime detected it did not observe (reserved).
2935    pub missed_ranges: u64,
2936    /// Storage-hash coverage gaps detected (reserved).
2937    pub coverage_gaps: u64,
2938    /// Pending-source inputs that attempted a canonical cache effect.
2939    pub pending_contamination: u64,
2940    /// Verdicts served past their freshness horizon (reserved).
2941    pub stale_verdicts: u64,
2942}
2943
2944/// Internal atomic-backed counters mirrored by [`CacheMetricsSnapshot`].
2945///
2946/// Fields are [`AtomicU64`] so counters can be incremented behind a shared
2947/// reference; [`ReactiveRuntime::metrics`] loads each with [`Ordering::Relaxed`]
2948/// into a plain [`CacheMetricsSnapshot`].
2949#[derive(Debug, Default)]
2950struct CacheMetrics {
2951    deep_reorgs: AtomicU64,
2952    reorgs_recovered: AtomicU64,
2953    resync_requests: AtomicU64,
2954    resync_failures: AtomicU64,
2955    missed_ranges: AtomicU64,
2956    coverage_gaps: AtomicU64,
2957    pending_contamination: AtomicU64,
2958    stale_verdicts: AtomicU64,
2959}
2960
2961impl CacheMetrics {
2962    fn snapshot(&self) -> CacheMetricsSnapshot {
2963        CacheMetricsSnapshot {
2964            deep_reorgs: self.deep_reorgs.load(Ordering::Relaxed),
2965            reorgs_recovered: self.reorgs_recovered.load(Ordering::Relaxed),
2966            resync_requests: self.resync_requests.load(Ordering::Relaxed),
2967            resync_failures: self.resync_failures.load(Ordering::Relaxed),
2968            missed_ranges: self.missed_ranges.load(Ordering::Relaxed),
2969            coverage_gaps: self.coverage_gaps.load(Ordering::Relaxed),
2970            pending_contamination: self.pending_contamination.load(Ordering::Relaxed),
2971            stale_verdicts: self.stale_verdicts.load(Ordering::Relaxed),
2972        }
2973    }
2974
2975    fn restore(&self, snapshot: CacheMetricsSnapshot) {
2976        self.deep_reorgs
2977            .store(snapshot.deep_reorgs, Ordering::Relaxed);
2978        self.reorgs_recovered
2979            .store(snapshot.reorgs_recovered, Ordering::Relaxed);
2980        self.resync_requests
2981            .store(snapshot.resync_requests, Ordering::Relaxed);
2982        self.resync_failures
2983            .store(snapshot.resync_failures, Ordering::Relaxed);
2984        self.missed_ranges
2985            .store(snapshot.missed_ranges, Ordering::Relaxed);
2986        self.coverage_gaps
2987            .store(snapshot.coverage_gaps, Ordering::Relaxed);
2988        self.pending_contamination
2989            .store(snapshot.pending_contamination, Ordering::Relaxed);
2990        self.stale_verdicts
2991            .store(snapshot.stale_verdicts, Ordering::Relaxed);
2992    }
2993}
2994
2995/// Runtime report.
2996#[derive(Clone, Debug)]
2997#[non_exhaustive]
2998pub enum ReactiveReport<N: Network = Ethereum> {
2999    /// Input was accepted after deduplication.
3000    Input(InputReport<N>),
3001    /// Handlers produced outcomes.
3002    Decoded(DecodedReport<N>),
3003    /// Direct state effects were applied.
3004    Applied(AppliedReport<N>),
3005    /// Resync request was scheduled or completed.
3006    Resynced(ResyncReport),
3007    /// Block-level processing completed.
3008    BlockCommitted(BlockReport<N>),
3009    /// Reorg processing report.
3010    Reorg(ReorgReport<N>),
3011    /// Ordered source control accepted by the runtime.
3012    ChainControl(ChainControlReport),
3013    /// A forward gap in the canonical block sequence was detected: blocks between
3014    /// the last-seen head and an arriving block were never observed.
3015    MissedBlockRange(MissedRangeReport<N>),
3016    /// Cache health transitioned between states.
3017    Health(HealthReport<N>),
3018    /// A tracked account's storage root moved with no covering decoder — a
3019    /// coverage gap the per-block root gate detected (Phase-8 step 4).
3020    CoverageGap(CoverageGapReport<N>),
3021    /// Runtime or handler error.
3022    Error(ReactiveErrorReport<N>),
3023}
3024
3025/// Report emitted after an ordered source control is accepted.
3026#[derive(Clone, Debug, PartialEq, Eq)]
3027pub struct ChainControlReport {
3028    /// Control in its original delivery order.
3029    pub control: ChainControl,
3030}
3031
3032/// Input acceptance report.
3033#[derive(Clone, Debug)]
3034pub struct InputReport<N: Network = Ethereum> {
3035    /// Input reference.
3036    pub input_ref: InputRef,
3037    /// Input context.
3038    pub context: ReactiveContext,
3039    /// Provider session that originated the input, when known.
3040    pub provider: Option<ProviderRef>,
3041    /// Network marker.
3042    pub _network: PhantomData<N>,
3043}
3044
3045/// Decoding report.
3046#[derive(Clone, Debug)]
3047pub struct DecodedReport<N: Network = Ethereum> {
3048    /// Input reference.
3049    pub input_ref: InputRef,
3050    /// Handler ids that matched the input.
3051    pub handler_ids: Vec<HandlerId>,
3052    /// Network marker.
3053    pub _network: PhantomData<N>,
3054}
3055
3056/// Applied state report.
3057#[derive(Clone, Debug)]
3058pub struct AppliedReport<N: Network = Ethereum> {
3059    /// Input reference.
3060    pub input_ref: InputRef,
3061    /// Handler that produced the applied effects.
3062    pub handler_id: HandlerId,
3063    /// State effect quality.
3064    pub quality: StateEffectQuality,
3065    /// Labels emitted by the handler.
3066    pub tags: Vec<ReportTag>,
3067    /// Merged state diff from applied updates and invalidations.
3068    pub diff: StateDiff,
3069    /// State updates applied through the cache.
3070    pub state_updates: Vec<StateUpdate>,
3071    /// Invalidation requests lowered to purge updates.
3072    pub invalidations: Vec<InvalidationRequest>,
3073    /// Resync requests surfaced for a scheduler.
3074    pub resyncs: Vec<ResyncRequest>,
3075    /// Speculative requests surfaced for downstream users.
3076    pub speculative: Vec<SpeculativeRequest>,
3077    /// Hook signals emitted by the handler.
3078    pub hook_signals: Vec<HookSignal>,
3079    /// Network marker.
3080    pub _network: PhantomData<N>,
3081}
3082
3083/// Report of the storage resync requests executed during an ingest cycle: the
3084/// requests considered, the authoritative updates built from successful fetches
3085/// (and their applied diff), and any targets that could not be resynced.
3086#[derive(Clone, Debug, Default, PartialEq, Eq)]
3087pub struct ResyncReport {
3088    /// Requests considered by the resync execution pass.
3089    pub requested: Vec<ResyncRequest>,
3090    /// Authoritative state updates built from successful resync fetches.
3091    pub state_updates: Vec<StateUpdate>,
3092    /// Diff returned by applying [`state_updates`](Self::state_updates).
3093    pub diff: StateDiff,
3094    /// Targets that could not be resynced.
3095    pub failed: Vec<ResyncFailure>,
3096}
3097
3098/// One resync target that could not be fetched or applied.
3099#[derive(Clone, Debug, PartialEq, Eq)]
3100pub struct ResyncFailure {
3101    /// Request that produced the failed target.
3102    pub request_id: ResyncId,
3103    /// Block selection used for the failed target.
3104    pub block: ResyncBlock,
3105    /// Target that could not be resynced.
3106    pub target: ResyncTarget,
3107    /// Stable failure classification for retry policy and metrics.
3108    pub kind: ResyncFailureKind,
3109    /// Human-readable failure reason.
3110    pub message: String,
3111}
3112
3113/// Stable classification for a failed resync target.
3114#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
3115#[non_exhaustive]
3116pub enum ResyncFailureKind {
3117    /// A storage target could not be fetched because no storage batch fetcher is configured.
3118    MissingStorageFetcher,
3119    /// The storage batch fetcher returned an error for the requested slot.
3120    StorageFetchFailed,
3121    /// The storage batch fetcher did not return a result for the requested slot.
3122    StorageFetchOmitted,
3123    /// An account target could not be fetched because no account proof fetcher is configured.
3124    MissingAccountFetcher,
3125    /// The account proof fetcher returned an error for the requested address.
3126    AccountFetchFailed,
3127    /// The account proof fetcher did not return a result for the requested address.
3128    AccountFetchOmitted,
3129}
3130
3131/// Block processing report.
3132#[derive(Clone, Debug)]
3133pub struct BlockReport<N: Network = Ethereum> {
3134    /// Block reference, when known.
3135    pub block: Option<BlockRef>,
3136    /// Input references committed for the block.
3137    pub inputs: Vec<InputRef>,
3138    /// Network marker.
3139    pub _network: PhantomData<N>,
3140}
3141
3142/// Report of a detected reorg and the recovery it performed: the dropped
3143/// block(s) and inputs, the exact rollback updates applied for reversible dropped
3144/// effects, the conservative purge updates for irreversible ones, the canceled
3145/// hash-pinned resyncs, and why recovery ran.
3146///
3147/// Recovery only covers blocks still resident in the journal. If a reorg runs
3148/// deeper than [`ReactiveConfig::journal_depth`], the aged-out blocks do not
3149/// appear here and their effects are neither rolled back nor purged (the runtime
3150/// logs a `tracing::warn!` in that case); the freshness/validation loop is the
3151/// backstop for that span. Checkpointed engine ingestion rejects explicit,
3152/// implicit-parent, and removed-log recovery outside the retained journal
3153/// instead of producing and durably acknowledging a partial report.
3154/// Non-checkpointed ingestion still emits this report when no journal entry was
3155/// recoverable; in that case `dropped` identifies the signal/head when known,
3156/// while `dropped_blocks` and rollback effects are empty.
3157#[derive(Clone, Debug)]
3158pub struct ReorgReport<N: Network = Ethereum> {
3159    /// First dropped block, when known.
3160    pub dropped: Option<BlockRef>,
3161    /// Blocks dropped from the journal, in ascending journal order.
3162    pub dropped_blocks: Vec<BlockRef>,
3163    /// Input references that belonged to dropped blocks.
3164    pub dropped_inputs: Vec<InputRef>,
3165    /// Exact rollback updates applied for reversible dropped effects.
3166    pub rollback_updates: Vec<StateUpdate>,
3167    /// Diff returned by applying [`rollback_updates`](Self::rollback_updates).
3168    pub rollback_diff: StateDiff,
3169    /// Conservative purge updates applied for irreversible dropped effects.
3170    pub purge_updates: Vec<StateUpdate>,
3171    /// Diff returned by applying [`purge_updates`](Self::purge_updates).
3172    pub purge_diff: StateDiff,
3173    /// Hash-pinned pending resync requests canceled because their block was dropped.
3174    pub canceled_resyncs: Vec<ResyncRequest>,
3175    /// Reorg trigger.
3176    pub reason: ReorgReason,
3177    /// Network marker.
3178    pub _network: PhantomData<N>,
3179}
3180
3181/// Reason reorg recovery ran.
3182#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
3183pub enum ReorgReason {
3184    /// A provider emitted an Alloy removed log.
3185    RemovedLog,
3186    /// The input context explicitly marked an input as reorged.
3187    ReorgedInput,
3188    /// A canonical block did not connect to the journaled head.
3189    ParentMismatch,
3190    /// A subscriber delivered an explicit canonical branch transition.
3191    Explicit,
3192}
3193
3194/// Report of a forward gap in the canonical block sequence: an arriving block
3195/// whose number is more than one past the last-seen head, so the blocks in
3196/// between were never observed (for example during a subscription disconnect).
3197///
3198/// The arriving block is still accepted and applied — the chain extends — so this
3199/// report only makes the skipped span observable; it does not drop the block. The
3200/// span `from..=to` is inclusive of both endpoints.
3201#[derive(Clone, Debug)]
3202pub struct MissedRangeReport<N: Network = Ethereum> {
3203    /// First skipped block (`last-seen block number + 1`).
3204    pub from: u64,
3205    /// Last skipped block (`arriving block number - 1`).
3206    pub to: u64,
3207    /// The arriving block's number.
3208    pub block: u64,
3209    /// Network marker.
3210    pub _network: PhantomData<N>,
3211}
3212
3213/// Report of a [`CacheHealth`] transition, emitted into the ingest cycle that
3214/// caused it and delivered to hooks through the normal dispatch path.
3215#[derive(Clone, Debug)]
3216pub struct HealthReport<N: Network = Ethereum> {
3217    /// Health state before the transition.
3218    pub from: CacheHealth,
3219    /// Health state after the transition.
3220    pub to: CacheHealth,
3221    /// Block number associated with the transition, when known.
3222    pub block: Option<u64>,
3223    /// Network marker.
3224    pub _network: PhantomData<N>,
3225}
3226
3227/// Report that a tracked account's storage root moved on a canonical block that
3228/// no decoder covered — a coverage gap surfaced by the per-block root gate
3229/// (Phase-8 step 4).
3230///
3231/// An account's `storageHash` is a collision-resistant commitment over all of its
3232/// storage, so a moved root proves *something* under the account changed. When
3233/// that account is [`WholeAccount`](TrackingPolicy::WholeAccount)-tracked and the
3234/// batch's touched-address set does not include it, the change arrived through a
3235/// path no decoder observed. The runtime emits this report (delivered through the
3236/// normal dispatch path so [`ReactiveHook::on_report`] observers see it),
3237/// increments [`CacheMetricsSnapshot::coverage_gaps`], and schedules a
3238/// [`ResyncReason::RootMoved`] repair to re-read the account authoritatively.
3239#[derive(Clone, Debug)]
3240pub struct CoverageGapReport<N: Network = Ethereum> {
3241    /// The tracked account whose root moved with no covering decoder.
3242    pub address: Address,
3243    /// The canonical block number at which the gap was observed.
3244    pub block: u64,
3245    /// Network marker.
3246    pub _network: PhantomData<N>,
3247}
3248
3249/// Report of a non-fatal error surfaced during an ingest cycle, with the
3250/// associated input (when known) and a human-readable message.
3251#[derive(Clone, Debug)]
3252pub struct ReactiveErrorReport<N: Network = Ethereum> {
3253    /// Input associated with the error, when known.
3254    pub input_ref: Option<InputRef>,
3255    /// Error message.
3256    pub message: String,
3257    /// Network marker.
3258    pub _network: PhantomData<N>,
3259}
3260
3261/// Batch report returned by [`ReactiveRuntime::ingest_batch`] and
3262/// [`ReactiveRuntime::ingest_batch_with_resync`].
3263#[derive(Clone, Debug)]
3264pub struct ReactiveBatchReport<N: Network = Ethereum> {
3265    /// Applied reports in commit order.
3266    pub applied: Vec<AppliedReport<N>>,
3267    /// Resync requests surfaced during the batch.
3268    pub resyncs: Vec<ResyncRequest>,
3269    /// Speculative requests surfaced during the batch.
3270    pub speculative: Vec<SpeculativeRequest>,
3271    /// Hook reports dispatched after mutation phases.
3272    pub reports: Vec<Arc<ReactiveReport<N>>>,
3273}
3274
3275impl<N: Network> Default for ReactiveBatchReport<N> {
3276    fn default() -> Self {
3277        Self {
3278            applied: Vec::new(),
3279            resyncs: Vec::new(),
3280            speculative: Vec::new(),
3281            reports: Vec::new(),
3282        }
3283    }
3284}
3285
3286/// Error returned by a handler.
3287#[derive(Clone, Debug, PartialEq, Eq)]
3288pub struct HandlerError {
3289    message: String,
3290}
3291
3292impl HandlerError {
3293    /// Create a handler error from a message.
3294    pub fn new(message: impl Into<String>) -> Self {
3295        Self {
3296            message: message.into(),
3297        }
3298    }
3299}
3300
3301impl fmt::Display for HandlerError {
3302    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3303        self.message.fmt(f)
3304    }
3305}
3306
3307impl std::error::Error for HandlerError {}
3308
3309impl From<String> for HandlerError {
3310    fn from(message: String) -> Self {
3311        Self::new(message)
3312    }
3313}
3314
3315impl From<&str> for HandlerError {
3316    fn from(message: &str) -> Self {
3317        Self::new(message)
3318    }
3319}
3320
3321/// Runtime error.
3322#[derive(Debug, thiserror::Error)]
3323#[non_exhaustive]
3324pub enum ReactiveError {
3325    /// Handler returned an error.
3326    #[error("handler `{handler_id}` failed: {source}")]
3327    HandlerFailed {
3328        /// Handler id.
3329        handler_id: HandlerId,
3330        /// Handler error.
3331        source: HandlerError,
3332    },
3333    /// Multiple handlers emitted incompatible absolute writes for one input.
3334    #[error(
3335        "conflicting effects for input {input_ref:?} on target {target:?}: `{first}` vs `{second}`"
3336    )]
3337    ConflictingEffects {
3338        /// Input reference.
3339        input_ref: Box<InputRef>,
3340        /// Conflicting target.
3341        target: Box<EffectTarget>,
3342        /// First handler id.
3343        first: HandlerId,
3344        /// Second handler id.
3345        second: HandlerId,
3346    },
3347    /// Pending inputs attempted to mutate canonical cache state.
3348    #[error(
3349        "pending input {input_ref:?} emitted invalid canonical effect `{effect_kind}` from `{handler_id}`"
3350    )]
3351    InvalidPendingEffect {
3352        /// Input reference.
3353        input_ref: Box<InputRef>,
3354        /// Handler id.
3355        handler_id: HandlerId,
3356        /// Effect kind.
3357        effect_kind: &'static str,
3358    },
3359    /// A subscriber supplied payload metadata that is incomplete or
3360    /// contradicts the accompanying context.
3361    #[error("invalid reactive input record: {message}")]
3362    InvalidInputRecord {
3363        /// Human-readable invariant violation.
3364        message: String,
3365    },
3366    /// A source delivered a contradictory chain-lifecycle transition.
3367    #[error("invalid chain control: {message}")]
3368    InvalidChainControl {
3369        /// Human-readable invariant violation.
3370        message: String,
3371    },
3372    /// Owner-scoped catch-up would mutate a historical block for which the
3373    /// runtime has no rollback journal entry.
3374    #[error(
3375        "owner catch-up block {number} {hash} is outside the retained canonical rollback journal"
3376    )]
3377    OwnerCatchupOutsideJournal {
3378        /// Catch-up block number.
3379        number: u64,
3380        /// Catch-up block hash.
3381        hash: B256,
3382    },
3383    /// Registration error.
3384    #[error(transparent)]
3385    Register(#[from] RegisterError),
3386}
3387
3388/// Handler registration error.
3389#[derive(Debug, thiserror::Error)]
3390#[non_exhaustive]
3391pub enum RegisterError {
3392    /// Duplicate handler id.
3393    #[error("handler id `{0}` is already registered")]
3394    DuplicateHandler(HandlerId),
3395}
3396
3397/// Error returned when [`ReactiveEngine`] cannot register a handler on both the
3398/// runtime and subscriber sides.
3399#[derive(Debug, thiserror::Error)]
3400#[non_exhaustive]
3401pub enum ReactiveEngineRegisterError {
3402    /// Runtime registry rejected the handler.
3403    #[error(transparent)]
3404    Register(#[from] RegisterError),
3405    /// Subscriber rejected the handler's interests.
3406    #[error(transparent)]
3407    Subscriber(#[from] SubscriberError),
3408    /// Owner-only history was not constrained to one hash-certified block that
3409    /// remains in the runtime rollback journal.
3410    #[error(
3411        "owner backfill {start_block}..={end_block:?} must target exactly one hash-certified block in the retained rollback journal"
3412    )]
3413    BackfillOutsideJournal {
3414        /// First requested block.
3415        start_block: u64,
3416        /// Inclusive requested upper bound, if bounded.
3417        end_block: Option<u64>,
3418        /// Hash-certified anchor supplied by the caller, if any.
3419        retained_anchor: Option<BlockRef>,
3420    },
3421}
3422
3423/// Error adopting an RPC snapshot as a runtime's canonical continuity
3424/// baseline.
3425#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
3426#[non_exhaustive]
3427pub enum ReactiveBaselineError {
3428    /// Runtime or engine delivery state already contains lifecycle work.
3429    #[error("cannot adopt a canonical baseline after reactive processing has started")]
3430    ActiveRuntime,
3431    /// An exact repeat is allowed, but the requested baseline conflicts with
3432    /// the previously adopted block.
3433    #[error(
3434        "canonical baseline conflicts with existing block {existing_number} {existing_hash} (requested {requested_number} {requested_hash})"
3435    )]
3436    ConflictingBaseline {
3437        /// Existing baseline number.
3438        existing_number: u64,
3439        /// Existing baseline hash.
3440        existing_hash: B256,
3441        /// Requested baseline number.
3442        requested_number: u64,
3443        /// Requested baseline hash.
3444        requested_hash: B256,
3445    },
3446    /// Typed baseline and cache identify different chains.
3447    #[error("baseline chain id {baseline_chain_id} does not match cache chain id {cache_chain_id}")]
3448    CacheChainMismatch {
3449        /// Chain declared by the baseline.
3450        baseline_chain_id: u64,
3451        /// Chain configured on the cache.
3452        cache_chain_id: u64,
3453    },
3454    /// The cache is not hash-pinned to the exact adopted canonical block.
3455    #[error("cache block selector is not canonically hash-pinned to baseline {number} {hash}")]
3456    CacheBlockMismatch {
3457        /// Expected baseline number.
3458        number: u64,
3459        /// Expected baseline hash.
3460        hash: B256,
3461    },
3462}
3463
3464/// Error returned by [`ReactiveEngine`] helpers that combine subscriber polling
3465/// and runtime ingestion.
3466#[derive(Debug, thiserror::Error)]
3467#[non_exhaustive]
3468pub enum ReactiveEngineError {
3469    /// Subscriber polling failed.
3470    #[error(transparent)]
3471    Subscriber(#[from] SubscriberError),
3472    /// Runtime ingestion failed.
3473    #[error(transparent)]
3474    Runtime(ReactiveError),
3475    /// Canonical cold-start baseline adoption failed.
3476    #[error(transparent)]
3477    Baseline(#[from] ReactiveBaselineError),
3478    /// Runtime ingestion succeeded, but its durable delivery acknowledgement
3479    /// did not commit. The subscriber may replay the batch.
3480    #[error("runtime ingestion succeeded but subscriber acknowledgement failed: {0}")]
3481    Acknowledgement(#[source] SubscriberError),
3482    /// Runtime ingestion succeeded, but the resulting cache state could not be
3483    /// durably checkpointed. The engine retains the commit in memory and must
3484    /// retry it before polling another batch.
3485    #[error("runtime ingestion succeeded but durable checkpoint commit failed: {0}")]
3486    Checkpoint(#[source] DurableCheckpointError),
3487    /// A checkpointed ingest had no canonical block to bind the state to.
3488    #[error("cannot durably checkpoint reactive state before observing a canonical block")]
3489    MissingCheckpointBlock,
3490    /// Speculative pre-confirmation state is intentionally excluded from
3491    /// canonical durable checkpoints.
3492    #[error("pre-confirmed Flashblock batches cannot be durably checkpointed")]
3493    PreconfirmationNotCheckpointable,
3494    /// Runtime rollback/finality state could not be encoded for the checkpoint.
3495    #[error("failed to encode durable reactive runtime state: {0}")]
3496    RuntimeCheckpoint(String),
3497    /// A crash-safe checkpoint commit is pending, so the engine cannot switch
3498    /// to ordinary acknowledgement ordering without first completing it.
3499    #[error("cannot use ordinary ingestion while a durable checkpoint commit is pending")]
3500    PendingCheckpointCommit,
3501    /// An ordinary delivery acknowledgement is pending, so the engine cannot
3502    /// switch to checkpointed ingestion and retroactively make it durable.
3503    #[error("cannot use checkpointed ingestion while an ordinary acknowledgement is pending")]
3504    PendingAcknowledgementCommit,
3505    /// A caller attempted to use a raw ingestion helper with subscriber-owned
3506    /// commit metadata. Only the combined polling helpers can preserve the
3507    /// required ingest-before-checkpoint-before-acknowledgement ordering.
3508    #[error(
3509        "raw engine ingestion cannot consume delivery tokens or subscriber checkpoints; use a combined next_ingest helper"
3510    )]
3511    UncommittedDeliveryMetadata,
3512    /// Subscriber and cache are bound to different chains.
3513    #[error(
3514        "subscriber chain id {subscriber_chain_id} does not match cache chain id {cache_chain_id}"
3515    )]
3516    SubscriberChainMismatch {
3517        /// Chain reported by the subscriber.
3518        subscriber_chain_id: u64,
3519        /// Chain configured on the cache.
3520        cache_chain_id: u64,
3521    },
3522    /// Crash-safe checkpoint APIs require durable replay/resume semantics.
3523    #[error("subscriber does not advertise durable replay support")]
3524    SubscriberNotDurable,
3525    /// A restored delivery token predates or otherwise lacks the core witness
3526    /// needed to prove that a replay carries the same delivery.
3527    #[error(
3528        "committed delivery token has no delivery witness; replay cannot be acknowledged safely"
3529    )]
3530    MissingReplayWitness,
3531    /// A source reused a committed token for different records, routing,
3532    /// controls, chain identity, or provider resume state.
3533    #[error("replayed delivery token does not match its committed delivery witness")]
3534    ReplayDeliveryMismatch,
3535    /// The stable delivery witness could not be encoded.
3536    #[error("failed to encode durable delivery witness: {0}")]
3537    DeliveryWitness(String),
3538    /// A tokened network-generic header/body cannot be witnessed completely
3539    /// without a source-supplied canonical wire commitment.
3540    #[error(
3541        "tokened block-header, full-block, or hydrated-transaction delivery requires an exact payload commitment"
3542    )]
3543    MissingPayloadCommitment,
3544    /// Cache state changed after a batch was staged for a checkpoint. Retrying
3545    /// would bind those unrelated mutations to the older delivery metadata.
3546    #[error(
3547        "cache changed while durable checkpoint commit was pending (staged generation {staged_generation}, current generation {current_generation})"
3548    )]
3549    PendingCheckpointCacheChanged {
3550        /// Generation immediately after the staged batch was ingested.
3551        staged_generation: u64,
3552        /// Generation observed when checkpoint commit was retried.
3553        current_generation: u64,
3554    },
3555    /// Checkpointed ingestion cannot durably acknowledge a reorg when the
3556    /// runtime no longer retains every potentially affected journal entry.
3557    #[error(
3558        "reorg after block {common_ancestor} exceeds the retained rollback journal (oldest retained block {oldest_journaled:?}, configured depth {journal_depth})"
3559    )]
3560    CheckpointReorgOutsideJournal {
3561        /// Last block shared by the old and replacement branches.
3562        common_ancestor: u64,
3563        /// Oldest retained effect-bearing journal block, if any.
3564        oldest_journaled: Option<u64>,
3565        /// Configured maximum journal entries.
3566        journal_depth: usize,
3567    },
3568    /// Owner-scoped catch-up would mutate a historical block for which the
3569    /// runtime has no rollback journal entry.
3570    #[error(
3571        "owner catch-up block {number} {hash} is outside the retained canonical rollback journal"
3572    )]
3573    OwnerCatchupOutsideJournal {
3574        /// Catch-up block number.
3575        number: u64,
3576        /// Catch-up block hash.
3577        hash: B256,
3578    },
3579}
3580
3581impl From<ReactiveError> for ReactiveEngineError {
3582    fn from(error: ReactiveError) -> Self {
3583        match error {
3584            ReactiveError::OwnerCatchupOutsideJournal { number, hash } => {
3585                Self::OwnerCatchupOutsideJournal { number, hash }
3586            }
3587            error => Self::Runtime(error),
3588        }
3589    }
3590}
3591
3592/// Error restoring a durable checkpoint anchor into an active runtime.
3593#[derive(Debug, thiserror::Error)]
3594#[non_exhaustive]
3595pub enum ReactiveCheckpointRestoreError {
3596    /// A runtime with canonical journal state cannot be silently rewound.
3597    #[error("cannot restore a durable checkpoint into a runtime with canonical journal state")]
3598    ActiveRuntime,
3599    /// Stored runtime recovery bytes were malformed or unsupported.
3600    #[error("invalid durable reactive runtime state: {0}")]
3601    InvalidRuntimeCheckpoint(String),
3602    /// Checkpoint identity or cache restoration failed before activation.
3603    #[error(transparent)]
3604    Checkpoint(#[from] DurableCheckpointError),
3605    /// Subscriber rejected the restored durable cursor or canonical position.
3606    #[error("subscriber rejected durable resume position: {0}")]
3607    Subscriber(#[source] SubscriberError),
3608    /// Subscriber and checkpoint identities name different chains.
3609    #[error(
3610        "subscriber chain id {subscriber_chain_id} does not match checkpoint chain id {checkpoint_chain_id}"
3611    )]
3612    SubscriberChainMismatch {
3613        /// Chain reported by the subscriber.
3614        subscriber_chain_id: u64,
3615        /// Chain committed by the checkpoint identity.
3616        checkpoint_chain_id: u64,
3617    },
3618    /// Restoring event continuity requires a durable replay-capable subscriber.
3619    #[error("subscriber does not advertise durable replay support")]
3620    SubscriberNotDurable,
3621}
3622
3623/// Result of one crash-safe subscriber ingest cycle.
3624#[derive(Clone, Debug)]
3625#[non_exhaustive]
3626pub enum CheckpointedIngest<N: Network = Ethereum> {
3627    /// A new batch was ingested, durably checkpointed, and acknowledged.
3628    Applied(ReactiveBatchReport<N>),
3629    /// The checkpoint already contained this replayed delivery token, so the
3630    /// batch was acknowledged without applying its effects twice.
3631    ReplayAcknowledged,
3632}
3633
3634/// Absolute write target used for conflict reports.
3635#[derive(Clone, Debug, PartialEq, Eq, Hash)]
3636pub enum EffectTarget {
3637    /// Storage slot target.
3638    StorageSlot {
3639        /// Contract address.
3640        address: Address,
3641        /// Storage slot.
3642        slot: U256,
3643    },
3644    /// Account balance target.
3645    AccountBalance {
3646        /// Account address.
3647        address: Address,
3648    },
3649    /// Account nonce target.
3650    AccountNonce {
3651        /// Account address.
3652        address: Address,
3653    },
3654    /// Account code target.
3655    AccountCode {
3656        /// Account address.
3657        address: Address,
3658    },
3659    /// Masked storage slot target.
3660    MaskedStorageSlot {
3661        /// Contract address.
3662        address: Address,
3663        /// Storage slot.
3664        slot: U256,
3665        /// Bit mask.
3666        mask: U256,
3667    },
3668}
3669
3670#[derive(Clone, Debug, PartialEq, Eq)]
3671enum AbsoluteValue {
3672    U256(U256),
3673    U64(u64),
3674    Bytes(Bytes),
3675}
3676
3677/// Reactive runtime.
3678pub struct ReactiveRuntime<N: Network = Ethereum> {
3679    registry: ReactiveRegistry<N>,
3680    hooks: Vec<Arc<dyn ReactiveHook<N>>>,
3681    config: ReactiveConfig,
3682    journal: VecDeque<BlockJournal<N>>,
3683    coverage_head: Option<BlockRef>,
3684    /// Highest block a source has attested carries no unhealed log-notification
3685    /// loss. Never inferred: absent until a source says so.
3686    log_coverage_head: Option<BlockRef>,
3687    pending_resyncs: Vec<ResyncRequest>,
3688    health: CacheHealth,
3689    safe_head: Option<BlockRef>,
3690    finalized_head: Option<BlockRef>,
3691    metrics: CacheMetrics,
3692    /// Opt-in freshness registry the runtime stamps for canonical event writes.
3693    ///
3694    /// `None` by default (behavior unchanged); populated by
3695    /// [`enable_freshness_stamping`](Self::enable_freshness_stamping). When
3696    /// present, applying a canonical handler storage-slot effect stamps the
3697    /// touched `(address, slot)` as [`Validity::ValidThrough`](crate::freshness::Validity::ValidThrough)`(N)`
3698    /// so event-maintained slots stop being needlessly re-verified while aging to
3699    /// volatile once the clock passes `N`.
3700    freshness: Option<FreshnessRegistry>,
3701    /// Per-account tracking registry consulted by the per-block root gate
3702    /// (Phase-8 step 4). Empty by default; populated by
3703    /// [`track_account`](Self::track_account). When empty the gate is a no-op.
3704    tracking: HashMap<Address, TrackingPolicy>,
3705    /// Per-account root/field baselines the gate diffs against across blocks.
3706    /// Adopted on first probe and re-adopted on every observed move.
3707    tracked_roots: HashMap<Address, TrackedRoot>,
3708    /// How often the root gate fires (§6.2); see [`RootGateCadence`].
3709    root_gate_cadence: RootGateCadence,
3710    /// Canonical block of the last root-gate firing. `None` until the first
3711    /// firing (which happens at the first canonical block ever seen, so
3712    /// baseline adoption never waits a full cadence window).
3713    last_gate_block: Option<u64>,
3714    /// Union of decoder-touched addresses since the last root-gate firing,
3715    /// drained when it fires. Under cadence the gap rule "root moved ∧ addr ∉
3716    /// touched" must judge against every covered write in the window, or a
3717    /// decoder-covered write in a skipped block would false-positive as a
3718    /// [`ReactiveReport::CoverageGap`].
3719    touched_since_gate: HashSet<Address>,
3720    /// Disposable pre-confirmation branch layered over the canonical cache.
3721    /// This is deliberately omitted from durable runtime checkpoints.
3722    preconfirmed_branch: Option<PreconfirmedBranch>,
3723}
3724
3725#[derive(Clone)]
3726struct PreconfirmedBranch {
3727    flashblock: FlashblockRef,
3728    canonical_cache: EvmCacheStateSnapshot,
3729}
3730
3731#[derive(Clone, Debug)]
3732struct BlockJournal<N: Network = Ethereum> {
3733    block: BlockRef,
3734    inputs: Vec<InputRef>,
3735    applied: Vec<AppliedReport<N>>,
3736    handler_ids: Vec<HandlerId>,
3737    resynced: Vec<ResyncReport>,
3738    rollback_diffs: Vec<StateDiff>,
3739}
3740
3741// 4: adds `log_coverage_head`, the attested log-completeness watermark.
3742const DURABLE_RUNTIME_CHECKPOINT_VERSION: u32 = 4;
3743
3744#[derive(serde::Serialize, serde::Deserialize)]
3745struct DurableRuntimeCheckpoint {
3746    version: u32,
3747    safe_head: Option<BlockRef>,
3748    finalized_head: Option<BlockRef>,
3749    health: CacheHealth,
3750    pending_resyncs: Vec<ResyncRequest>,
3751    coverage_head: Option<BlockRef>,
3752    log_coverage_head: Option<BlockRef>,
3753    journal: Vec<DurableBlockJournal>,
3754    freshness: Option<FreshnessRegistry>,
3755    tracking: HashMap<Address, TrackingPolicy>,
3756    tracked_roots: HashMap<Address, TrackedRoot>,
3757    root_gate_cadence: RootGateCadence,
3758    last_gate_block: Option<u64>,
3759    touched_since_gate: HashSet<Address>,
3760    metrics: CacheMetricsSnapshot,
3761}
3762
3763#[derive(serde::Serialize, serde::Deserialize)]
3764struct DurableBlockJournal {
3765    block: BlockRef,
3766    handler_ids: Vec<HandlerId>,
3767    rollback_diffs: Vec<StateDiff>,
3768}
3769
3770struct DurableRuntimeRestorePlan {
3771    checkpoint: Option<DurableRuntimeCheckpoint>,
3772    fallback_history: Vec<BlockRef>,
3773}
3774
3775impl DurableRuntimeRestorePlan {
3776    fn canonical_history(&self) -> Vec<BlockRef> {
3777        self.checkpoint.as_ref().map_or_else(
3778            || self.fallback_history.clone(),
3779            |checkpoint| checkpoint.journal.iter().map(|entry| entry.block).collect(),
3780        )
3781    }
3782}
3783
3784#[derive(Clone)]
3785struct ReactiveRuntimeState<N: Network> {
3786    journal: VecDeque<BlockJournal<N>>,
3787    coverage_head: Option<BlockRef>,
3788    log_coverage_head: Option<BlockRef>,
3789    pending_resyncs: Vec<ResyncRequest>,
3790    health: CacheHealth,
3791    safe_head: Option<BlockRef>,
3792    finalized_head: Option<BlockRef>,
3793    freshness: Option<FreshnessRegistry>,
3794    tracking: HashMap<Address, TrackingPolicy>,
3795    tracked_roots: HashMap<Address, TrackedRoot>,
3796    root_gate_cadence: RootGateCadence,
3797    last_gate_block: Option<u64>,
3798    touched_since_gate: HashSet<Address>,
3799    metrics: CacheMetricsSnapshot,
3800}
3801
3802#[derive(Clone)]
3803struct ChainControlState {
3804    journal_invalidated_from: Option<u64>,
3805    resolved_canonical_blocks: HashMap<(u64, B256), BlockRef>,
3806}
3807
3808/// Canonical branch fragments already rolled back by the current atomic batch.
3809///
3810/// Providers commonly emit one removed notification per log after one signal
3811/// has already drained the complete dropped block (and every retained
3812/// descendant). Explicit reorg controls can be followed by the same redundant
3813/// lifecycle records. Exact identities decide whether removal recovery is
3814/// redundant; numeric spans are retained only as same-batch proof for a
3815/// parentless replacement after those exact journal entries were drained.
3816#[derive(Default)]
3817struct BatchDroppedCanonical {
3818    identities: HashSet<(u64, B256)>,
3819    implicit_spans: Vec<(u64, u64)>,
3820}
3821
3822impl BatchDroppedCanonical {
3823    fn covers_implicit_number(&self, number: u64) -> bool {
3824        self.implicit_spans
3825            .iter()
3826            .any(|(from, through)| number >= *from && number <= *through)
3827    }
3828
3829    fn contains(&self, block: &BlockRef) -> bool {
3830        self.identities.contains(&(block.number, block.hash))
3831    }
3832
3833    fn record_identity(&mut self, block: &BlockRef) {
3834        self.identities.insert((block.number, block.hash));
3835    }
3836
3837    fn record_explicit(&mut self, _common_ancestor: &BlockRef, old_tip: &BlockRef) {
3838        self.identities.insert((old_tip.number, old_tip.hash));
3839    }
3840
3841    fn record_drained(&mut self, blocks: &[BlockRef]) {
3842        let Some(from) = blocks.iter().map(|block| block.number).min() else {
3843            return;
3844        };
3845        let through = blocks
3846            .iter()
3847            .map(|block| block.number)
3848            .max()
3849            .expect("a non-empty drained set has a maximum");
3850        self.implicit_spans.push((from, through));
3851        self.identities
3852            .extend(blocks.iter().map(|block| (block.number, block.hash)));
3853    }
3854}
3855
3856/// Registry and router for provider-neutral reactive handlers.
3857///
3858/// The registry stores pure [`ReactiveHandler`]s in registration order, exposes
3859/// consolidated provider-side log filters for subscription setup, and routes
3860/// provider logs back to the exact matching log interests. Consolidated filters
3861/// may be safe supersets; [`Self::route_log`] always re-checks the original
3862/// [`LogInterest`] and its local matcher before returning a route.
3863pub struct ReactiveRegistry<N: Network = Ethereum> {
3864    handlers: BTreeMap<u128, RegisteredHandler<N>>,
3865    handler_positions: HashMap<HandlerId, u128>,
3866    next_handler_position: u128,
3867    indexed_log_handlers: HashMap<LogRouteKey, BTreeSet<u128>>,
3868    fallback_log_handlers: BTreeSet<u128>,
3869    data_slice_shapes: HashMap<(usize, usize), usize>,
3870}
3871
3872struct RegisteredHandler<N: Network = Ethereum> {
3873    id: HandlerId,
3874    handler: Arc<dyn ReactiveHandler<N>>,
3875    interests: Vec<ReactiveInterest<N>>,
3876    has_log_interests: bool,
3877    log_route_index: Option<LogRouteIndex>,
3878}
3879
3880impl<N: Network> Default for ReactiveRegistry<N> {
3881    fn default() -> Self {
3882        Self::new()
3883    }
3884}
3885
3886impl<N: Network> ReactiveRegistry<N> {
3887    /// Create an empty registry.
3888    pub fn new() -> Self {
3889        Self {
3890            handlers: BTreeMap::new(),
3891            handler_positions: HashMap::new(),
3892            next_handler_position: 0,
3893            indexed_log_handlers: HashMap::new(),
3894            fallback_log_handlers: BTreeSet::new(),
3895            data_slice_shapes: HashMap::new(),
3896        }
3897    }
3898
3899    /// Register a handler, preserving registration order.
3900    ///
3901    /// Duplicate handler ids are rejected with
3902    /// [`RegisterError::DuplicateHandler`].
3903    ///
3904    /// # Errors
3905    ///
3906    /// Returns [`RegisterError::DuplicateHandler`] when the id is already
3907    /// registered.
3908    pub fn register_handler(
3909        &mut self,
3910        handler: Arc<dyn ReactiveHandler<N>>,
3911    ) -> Result<(), RegisterError> {
3912        let id = handler.id();
3913        if self.handler_positions.contains_key(&id) {
3914            return Err(RegisterError::DuplicateHandler(id));
3915        }
3916        let interests = handler.interests();
3917        self.insert_handler_prepared(id, handler, interests);
3918        Ok(())
3919    }
3920
3921    fn insert_handler_prepared(
3922        &mut self,
3923        id: HandlerId,
3924        handler: Arc<dyn ReactiveHandler<N>>,
3925        interests: Vec<ReactiveInterest<N>>,
3926    ) {
3927        debug_assert!(!self.handler_positions.contains_key(&id));
3928        let has_log_interests = interests
3929            .iter()
3930            .any(|interest| matches!(interest, ReactiveInterest::Logs(_)));
3931        let log_route_index = handler.log_route_index();
3932        if self.next_handler_position == u128::MAX {
3933            self.compact_handler_positions();
3934        }
3935        let position = self.next_handler_position;
3936        self.next_handler_position += 1;
3937        self.handler_positions.insert(id.clone(), position);
3938        if let Some(index) = &log_route_index {
3939            for key in index.keys() {
3940                if let LogRouteKey::DataSlice { offset, value } = key {
3941                    *self
3942                        .data_slice_shapes
3943                        .entry((*offset, value.len()))
3944                        .or_default() += 1;
3945                }
3946                self.indexed_log_handlers
3947                    .entry(key.clone())
3948                    .or_default()
3949                    .insert(position);
3950            }
3951        } else if has_log_interests {
3952            self.fallback_log_handlers.insert(position);
3953        }
3954        self.handlers.insert(
3955            position,
3956            RegisteredHandler {
3957                id,
3958                handler,
3959                interests,
3960                has_log_interests,
3961                log_route_index,
3962            },
3963        );
3964    }
3965
3966    /// Remove one handler by id, leaving all other handlers and interests intact.
3967    ///
3968    /// Returns the removed handler when the id was registered. Cache eviction is
3969    /// intentionally outside this API: unregistering stops future routing and
3970    /// decode for the handler only.
3971    pub fn unregister_handler(&mut self, id: &HandlerId) -> Option<Arc<dyn ReactiveHandler<N>>> {
3972        let position = self.handler_positions.remove(id)?;
3973        let registered = self.handlers.remove(&position)?;
3974        if let Some(index) = &registered.log_route_index {
3975            for key in index.keys() {
3976                let remove_bucket = self
3977                    .indexed_log_handlers
3978                    .get_mut(key)
3979                    .is_some_and(|owners| {
3980                        owners.remove(&position);
3981                        owners.is_empty()
3982                    });
3983                if remove_bucket {
3984                    self.indexed_log_handlers.remove(key);
3985                }
3986                if let LogRouteKey::DataSlice { offset, value } = key {
3987                    let shape = (*offset, value.len());
3988                    let remove_shape =
3989                        self.data_slice_shapes.get_mut(&shape).is_some_and(|count| {
3990                            *count -= 1;
3991                            *count == 0
3992                        });
3993                    if remove_shape {
3994                        self.data_slice_shapes.remove(&shape);
3995                    }
3996                }
3997            }
3998        } else {
3999            self.fallback_log_handlers.remove(&position);
4000        }
4001        Some(registered.handler)
4002    }
4003
4004    /// Return true when `id` is currently registered.
4005    pub fn contains_handler(&self, id: &HandlerId) -> bool {
4006        self.handler_positions.contains_key(id)
4007    }
4008
4009    /// Ids of all registered handlers, in registration (= routing) order.
4010    pub fn handler_ids(&self) -> Vec<HandlerId> {
4011        self.handlers
4012            .values()
4013            .map(|handler| handler.id.clone())
4014            .collect()
4015    }
4016
4017    /// Borrow the interests owned by one handler.
4018    pub fn handler_interests(&self, id: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
4019        self.handler_positions
4020            .get(id)
4021            .and_then(|position| self.handlers.get(position))
4022            .map(|registered| registered.interests.as_slice())
4023    }
4024
4025    /// Return all registered interests in handler registration order.
4026    pub fn interests(&self) -> Vec<ReactiveInterest<N>> {
4027        self.handlers
4028            .values()
4029            .flat_map(|handler| handler.interests.clone())
4030            .collect()
4031    }
4032
4033    /// Return consolidated provider-side log filters.
4034    ///
4035    /// Filters are emitted in deterministic first-registration order by
4036    /// compatible block option. Within each returned filter, address and topic
4037    /// sets are unioned independently, which can intentionally overfetch. Use
4038    /// [`Self::route_log`] to enforce the exact original [`LogInterest`]s.
4039    pub fn log_subscription_filters(&self) -> Vec<Filter> {
4040        let mut filters = Vec::new();
4041        for interest in self.log_interests() {
4042            merge_log_subscription_filter(&mut filters, &interest.provider_filter);
4043        }
4044        filters
4045    }
4046
4047    /// Route a log to exact matching handler interests.
4048    ///
4049    /// Routes are returned in handler registration order. Each handler appears
4050    /// at most once for a log, using the first matching log interest declared by
4051    /// that handler.
4052    pub fn route_log(&self, log: &Log) -> Vec<ReactiveLogRoute> {
4053        self.log_handler_candidates(log)
4054            .into_iter()
4055            .filter_map(|handler| handler.route_log(log))
4056            .collect()
4057    }
4058
4059    fn log_handler_candidates(&self, log: &Log) -> Vec<&RegisteredHandler<N>> {
4060        let mut indexed_positions = Vec::new();
4061        if let Some(indexed) = self
4062            .indexed_log_handlers
4063            .get(&LogRouteKey::Emitter(log.address()))
4064        {
4065            indexed_positions.extend(indexed.iter().copied());
4066        }
4067        for (index, value) in log.topics().iter().copied().enumerate() {
4068            if let Some(indexed) = self
4069                .indexed_log_handlers
4070                .get(&LogRouteKey::Topic { index, value })
4071            {
4072                indexed_positions.extend(indexed.iter().copied());
4073            }
4074        }
4075        let data = log.inner.data.data.as_ref();
4076        for &(offset, len) in self.data_slice_shapes.keys() {
4077            let Some(end) = offset.checked_add(len) else {
4078                continue;
4079            };
4080            let Some(value) = data.get(offset..end) else {
4081                continue;
4082            };
4083            if let Some(indexed) = self.indexed_log_handlers.get(&LogRouteKey::DataSlice {
4084                offset,
4085                value: value.to_vec(),
4086            }) {
4087                indexed_positions.extend(indexed.iter().copied());
4088            }
4089        }
4090        if indexed_positions.is_empty() {
4091            if self.fallback_log_handlers.is_empty() {
4092                return Vec::new();
4093            }
4094            if !self.indexed_log_handlers.is_empty() {
4095                return self
4096                    .fallback_log_handlers
4097                    .iter()
4098                    .filter_map(|position| self.handlers.get(position))
4099                    .collect();
4100            }
4101            return self
4102                .handlers
4103                .values()
4104                .filter(|handler| handler.has_log_interests && handler.log_route_index.is_none())
4105                .collect();
4106        }
4107
4108        indexed_positions.extend(self.fallback_log_handlers.iter().copied());
4109        indexed_positions.sort_unstable();
4110        indexed_positions.dedup();
4111        indexed_positions
4112            .into_iter()
4113            .filter_map(|position| self.handlers.get(&position))
4114            .collect()
4115    }
4116
4117    fn handlers(&self) -> impl Iterator<Item = &RegisteredHandler<N>> {
4118        self.handlers.values()
4119    }
4120
4121    fn log_interests(&self) -> impl Iterator<Item = &LogInterest> {
4122        self.handlers.values().flat_map(|handler| {
4123            handler
4124                .interests
4125                .iter()
4126                .filter_map(|interest| match interest {
4127                    ReactiveInterest::Logs(interest) => Some(interest),
4128                    ReactiveInterest::Blocks(_) | ReactiveInterest::PendingTransactions(_) => None,
4129                })
4130        })
4131    }
4132
4133    fn compact_handler_positions(&mut self) {
4134        let handlers = std::mem::take(&mut self.handlers);
4135        self.handler_positions.clear();
4136        self.indexed_log_handlers.clear();
4137        self.fallback_log_handlers.clear();
4138        self.data_slice_shapes.clear();
4139
4140        for (position, (_, handler)) in handlers.into_iter().enumerate() {
4141            let position = position as u128;
4142            self.handler_positions.insert(handler.id.clone(), position);
4143            if let Some(index) = &handler.log_route_index {
4144                for key in index.keys() {
4145                    if let LogRouteKey::DataSlice { offset, value } = key {
4146                        *self
4147                            .data_slice_shapes
4148                            .entry((*offset, value.len()))
4149                            .or_default() += 1;
4150                    }
4151                    self.indexed_log_handlers
4152                        .entry(key.clone())
4153                        .or_default()
4154                        .insert(position);
4155                }
4156            } else if handler.has_log_interests {
4157                self.fallback_log_handlers.insert(position);
4158            }
4159            self.handlers.insert(position, handler);
4160        }
4161        self.next_handler_position = self.handlers.len() as u128;
4162    }
4163}
4164
4165impl<N: Network> ReactiveRuntime<N> {
4166    /// Create an empty runtime.
4167    pub fn new(config: ReactiveConfig) -> Self {
4168        Self {
4169            registry: ReactiveRegistry::new(),
4170            hooks: Vec::new(),
4171            config,
4172            journal: VecDeque::new(),
4173            coverage_head: None,
4174            log_coverage_head: None,
4175            pending_resyncs: Vec::new(),
4176            health: CacheHealth::Healthy,
4177            safe_head: None,
4178            finalized_head: None,
4179            metrics: CacheMetrics::default(),
4180            freshness: None,
4181            tracking: HashMap::new(),
4182            tracked_roots: HashMap::new(),
4183            root_gate_cadence: RootGateCadence::default(),
4184            last_gate_block: None,
4185            touched_since_gate: HashSet::new(),
4186            preconfirmed_branch: None,
4187        }
4188    }
4189
4190    fn checkpoint_state(&self) -> ReactiveRuntimeState<N> {
4191        ReactiveRuntimeState {
4192            journal: self.journal.clone(),
4193            coverage_head: self.coverage_head,
4194            log_coverage_head: self.log_coverage_head,
4195            pending_resyncs: self.pending_resyncs.clone(),
4196            health: self.health,
4197            safe_head: self.safe_head,
4198            finalized_head: self.finalized_head,
4199            freshness: self.freshness.clone(),
4200            tracking: self.tracking.clone(),
4201            tracked_roots: self.tracked_roots.clone(),
4202            root_gate_cadence: self.root_gate_cadence,
4203            last_gate_block: self.last_gate_block,
4204            touched_since_gate: self.touched_since_gate.clone(),
4205            metrics: self.metrics.snapshot(),
4206        }
4207    }
4208
4209    fn is_pristine_for_checkpoint_restore(&self) -> bool {
4210        self.preconfirmed_branch.is_none()
4211            && self.journal.is_empty()
4212            && self.coverage_head.is_none()
4213            && self.pending_resyncs.is_empty()
4214            && self.health == CacheHealth::Healthy
4215            && self.safe_head.is_none()
4216            && self.finalized_head.is_none()
4217            && self.tracked_roots.is_empty()
4218            && self.last_gate_block.is_none()
4219            && self.touched_since_gate.is_empty()
4220            && self.metrics.snapshot() == CacheMetricsSnapshot::default()
4221    }
4222
4223    fn adopted_baseline_only(&self) -> Option<BlockRef> {
4224        let baseline = self.coverage_head?;
4225        let journal_is_baseline_only = if self.config.journal_depth == 0 {
4226            self.journal.is_empty()
4227        } else {
4228            self.journal.len() == 1
4229                && self.journal.front().is_some_and(|entry| {
4230                    entry.block == baseline
4231                        && entry.inputs.is_empty()
4232                        && entry.applied.is_empty()
4233                        && entry.handler_ids.is_empty()
4234                        && entry.resynced.is_empty()
4235                        && entry.rollback_diffs.is_empty()
4236                })
4237        };
4238        (self.preconfirmed_branch.is_none()
4239            && journal_is_baseline_only
4240            && self.pending_resyncs.is_empty()
4241            && self.health == CacheHealth::Healthy
4242            && self.safe_head.is_none()
4243            && self.finalized_head.is_none()
4244            && self.tracked_roots.is_empty()
4245            && self.last_gate_block.is_none()
4246            && self.touched_since_gate.is_empty()
4247            && self.metrics.snapshot() == CacheMetricsSnapshot::default())
4248        .then_some(baseline)
4249    }
4250
4251    fn restore_state(&mut self, state: ReactiveRuntimeState<N>) {
4252        self.journal = state.journal;
4253        self.coverage_head = state.coverage_head;
4254        self.log_coverage_head = state.log_coverage_head;
4255        self.pending_resyncs = state.pending_resyncs;
4256        self.health = state.health;
4257        self.safe_head = state.safe_head;
4258        self.finalized_head = state.finalized_head;
4259        self.freshness = state.freshness;
4260        self.tracking = state.tracking;
4261        self.tracked_roots = state.tracked_roots;
4262        self.root_gate_cadence = state.root_gate_cadence;
4263        self.last_gate_block = state.last_gate_block;
4264        self.touched_since_gate = state.touched_since_gate;
4265        self.metrics.restore(state.metrics);
4266    }
4267
4268    fn restore_transaction_state(&mut self, state: ReactiveRuntimeState<N>) {
4269        // Metrics describe lifetime observations, including rejected attempts,
4270        // and are documented as monotonic. Roll back canonical/runtime state
4271        // without erasing the failure signal that caused the transaction to
4272        // abort.
4273        let metrics = self.metrics.snapshot();
4274        self.restore_state(state);
4275        self.metrics.restore(metrics);
4276    }
4277
4278    fn durable_checkpoint_bytes(&self) -> Result<Vec<u8>, ReactiveEngineError> {
4279        let checkpoint = DurableRuntimeCheckpoint {
4280            version: DURABLE_RUNTIME_CHECKPOINT_VERSION,
4281            safe_head: self.safe_head,
4282            finalized_head: self.finalized_head,
4283            health: self.health,
4284            pending_resyncs: self.pending_resyncs.clone(),
4285            coverage_head: self.coverage_head,
4286            log_coverage_head: self.log_coverage_head,
4287            journal: self
4288                .journal
4289                .iter()
4290                .map(|entry| DurableBlockJournal {
4291                    block: entry.block,
4292                    handler_ids: entry.handler_ids.clone(),
4293                    rollback_diffs: entry.rollback_diffs.clone(),
4294                })
4295                .collect(),
4296            freshness: self.freshness.clone(),
4297            tracking: self.tracking.clone(),
4298            tracked_roots: self.tracked_roots.clone(),
4299            root_gate_cadence: self.root_gate_cadence,
4300            last_gate_block: self.last_gate_block,
4301            touched_since_gate: self.touched_since_gate.clone(),
4302            metrics: self.metrics.snapshot(),
4303        };
4304        bincode::serialize(&checkpoint)
4305            .map_err(|error| ReactiveEngineError::RuntimeCheckpoint(error.to_string()))
4306    }
4307
4308    fn plan_durable_checkpoint_restore(
4309        &self,
4310        bytes: &[u8],
4311        expected_coverage: &BlockRef,
4312    ) -> Result<DurableRuntimeRestorePlan, ReactiveCheckpointRestoreError> {
4313        let mut cursor = std::io::Cursor::new(bytes);
4314        let mut checkpoint: DurableRuntimeCheckpoint = bincode::DefaultOptions::new()
4315            .with_fixint_encoding()
4316            .with_limit(bytes.len() as u64)
4317            .deserialize_from(&mut cursor)
4318            .map_err(|error| {
4319                ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(error.to_string())
4320            })?;
4321        if cursor.position() != bytes.len() as u64 {
4322            return Err(ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(
4323                "runtime checkpoint has trailing bytes".to_owned(),
4324            ));
4325        }
4326        if checkpoint.version != DURABLE_RUNTIME_CHECKPOINT_VERSION {
4327            return Err(ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(
4328                format!(
4329                    "unsupported runtime checkpoint version {}",
4330                    checkpoint.version
4331                ),
4332            ));
4333        }
4334        self.validate_durable_runtime_checkpoint(&checkpoint, expected_coverage)?;
4335
4336        let retained = self.config.journal_depth.min(checkpoint.journal.len());
4337        let discard = checkpoint.journal.len() - retained;
4338        checkpoint.journal.drain(..discard);
4339        Ok(DurableRuntimeRestorePlan {
4340            checkpoint: Some(checkpoint),
4341            fallback_history: Vec::new(),
4342        })
4343    }
4344
4345    fn apply_durable_checkpoint_restore(&mut self, plan: DurableRuntimeRestorePlan) {
4346        let Some(checkpoint) = plan.checkpoint else {
4347            self.journal = plan
4348                .fallback_history
4349                .into_iter()
4350                .map(|block| BlockJournal {
4351                    block,
4352                    inputs: Vec::new(),
4353                    applied: Vec::new(),
4354                    handler_ids: Vec::new(),
4355                    resynced: Vec::new(),
4356                    rollback_diffs: Vec::new(),
4357                })
4358                .collect();
4359            return;
4360        };
4361        self.safe_head = checkpoint.safe_head;
4362        self.finalized_head = checkpoint.finalized_head;
4363        self.health = checkpoint.health;
4364        self.pending_resyncs = checkpoint.pending_resyncs;
4365        self.coverage_head = checkpoint.coverage_head;
4366        self.log_coverage_head = checkpoint.log_coverage_head;
4367        self.journal = checkpoint
4368            .journal
4369            .into_iter()
4370            .map(|entry| BlockJournal {
4371                block: entry.block,
4372                inputs: Vec::new(),
4373                applied: Vec::new(),
4374                handler_ids: entry.handler_ids,
4375                resynced: Vec::new(),
4376                rollback_diffs: entry.rollback_diffs,
4377            })
4378            .collect();
4379        self.freshness = checkpoint.freshness;
4380        self.tracking = checkpoint.tracking;
4381        self.tracked_roots = checkpoint.tracked_roots;
4382        self.root_gate_cadence = checkpoint.root_gate_cadence;
4383        self.last_gate_block = checkpoint.last_gate_block;
4384        self.touched_since_gate = checkpoint.touched_since_gate;
4385        self.metrics.restore(checkpoint.metrics);
4386    }
4387
4388    fn validate_durable_runtime_checkpoint(
4389        &self,
4390        checkpoint: &DurableRuntimeCheckpoint,
4391        expected_coverage: &BlockRef,
4392    ) -> Result<(), ReactiveCheckpointRestoreError> {
4393        let invalid =
4394            |message: String| ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(message);
4395        let Some(coverage) = checkpoint.coverage_head.as_ref() else {
4396            return Err(invalid(
4397                "runtime checkpoint is missing its canonical coverage head".into(),
4398            ));
4399        };
4400        if !optional_block_refs_are_compatible(Some(coverage), Some(expected_coverage)) {
4401            return Err(invalid(format!(
4402                "runtime coverage {}:{:?} conflicts with checkpoint metadata {}:{:?}",
4403                coverage.number, coverage.hash, expected_coverage.number, expected_coverage.hash
4404            )));
4405        }
4406        for (label, head) in [
4407            ("safe", checkpoint.safe_head.as_ref()),
4408            ("finalized", checkpoint.finalized_head.as_ref()),
4409        ] {
4410            let Some(head) = head else { continue };
4411            if head.number > coverage.number
4412                || (head.number == coverage.number && head.hash != coverage.hash)
4413            {
4414                return Err(invalid(format!(
4415                    "{label} head {}:{:?} lies beyond or conflicts with canonical coverage {}:{:?}",
4416                    head.number, head.hash, coverage.number, coverage.hash
4417                )));
4418            }
4419            if head.number.checked_add(1) == Some(coverage.number)
4420                && coverage
4421                    .parent_hash
4422                    .is_some_and(|parent| parent != head.hash)
4423            {
4424                return Err(invalid(format!(
4425                    "canonical coverage does not descend from adjacent {label} head"
4426                )));
4427            }
4428        }
4429        if let (Some(finalized), Some(safe)) = (
4430            checkpoint.finalized_head.as_ref(),
4431            checkpoint.safe_head.as_ref(),
4432        ) {
4433            if finalized.number > safe.number
4434                || (finalized.number == safe.number && finalized.hash != safe.hash)
4435            {
4436                return Err(invalid(
4437                    "finalized head is above or conflicts with the safe head".into(),
4438                ));
4439            }
4440            if finalized.number.checked_add(1) == Some(safe.number)
4441                && safe.parent_hash != Some(finalized.hash)
4442            {
4443                return Err(invalid(
4444                    "adjacent safe head does not descend from finalized head".into(),
4445                ));
4446            }
4447        }
4448
4449        let mut previous: Option<&DurableBlockJournal> = None;
4450        for entry in &checkpoint.journal {
4451            if entry.block.number > coverage.number
4452                || (entry.block.number == coverage.number && entry.block.hash != coverage.hash)
4453            {
4454                return Err(invalid(format!(
4455                    "journal block {}:{:?} lies beyond or conflicts with canonical coverage",
4456                    entry.block.number, entry.block.hash
4457                )));
4458            }
4459            if let Some(previous) = previous {
4460                if entry.block.number <= previous.block.number {
4461                    return Err(invalid(
4462                        "runtime journal block numbers are not strictly increasing".into(),
4463                    ));
4464                }
4465                if previous.block.number.checked_add(1) == Some(entry.block.number)
4466                    && entry.block.parent_hash.is_some()
4467                    && entry.block.parent_hash != Some(previous.block.hash)
4468                {
4469                    return Err(invalid(
4470                        "adjacent runtime journal blocks are not parent-linked".into(),
4471                    ));
4472                }
4473            }
4474            for (label, head) in [
4475                ("safe", checkpoint.safe_head.as_ref()),
4476                ("finalized", checkpoint.finalized_head.as_ref()),
4477            ] {
4478                if let Some(head) = head
4479                    && head.number == entry.block.number
4480                    && !optional_block_refs_are_compatible(Some(head), Some(&entry.block))
4481                {
4482                    return Err(invalid(format!(
4483                        "{label} head conflicts with the retained journal at block {}",
4484                        head.number
4485                    )));
4486                }
4487            }
4488            let mut handler_ids = HashSet::new();
4489            if entry
4490                .handler_ids
4491                .iter()
4492                .any(|handler_id| !handler_ids.insert(handler_id))
4493            {
4494                return Err(invalid(
4495                    "runtime journal contains duplicate handler generation ids".into(),
4496                ));
4497            }
4498            previous = Some(entry);
4499        }
4500        if let Some(tail) = checkpoint.journal.last()
4501            && tail.block.number == coverage.number
4502            && !optional_block_refs_are_compatible(Some(&tail.block), Some(coverage))
4503        {
4504            return Err(invalid(format!(
4505                "runtime journal tail conflicts with canonical coverage at block {}",
4506                coverage.number
4507            )));
4508        }
4509        if let Some(tail) = checkpoint.journal.last()
4510            && tail.block.number.checked_add(1) == Some(coverage.number)
4511            && coverage
4512                .parent_hash
4513                .is_some_and(|parent_hash| parent_hash != tail.block.hash)
4514        {
4515            return Err(invalid(format!(
4516                "canonical coverage does not descend from adjacent runtime journal tail at block {}",
4517                tail.block.number
4518            )));
4519        }
4520
4521        if let Some(last_gate_block) = checkpoint.last_gate_block {
4522            if last_gate_block > coverage.number {
4523                return Err(invalid(
4524                    "root-gate cursor lies beyond canonical coverage".into(),
4525                ));
4526            }
4527        } else if !checkpoint.tracked_roots.is_empty() {
4528            return Err(invalid(
4529                "root-gate baselines exist without a completed gate cursor".into(),
4530            ));
4531        }
4532        for (address, baseline) in &checkpoint.tracked_roots {
4533            let Some(policy) = checkpoint.tracking.get(address) else {
4534                return Err(invalid(
4535                    "root-gate baseline has no corresponding tracking policy".into(),
4536                ));
4537            };
4538            if matches!(policy, TrackingPolicy::Slots { .. }) {
4539                return Err(invalid(
4540                    "slot-only tracking cannot carry an account root baseline".into(),
4541                ));
4542            }
4543            if baseline.last_block > coverage.number
4544                || checkpoint
4545                    .last_gate_block
4546                    .is_some_and(|last_gate| baseline.last_block > last_gate)
4547            {
4548                return Err(invalid(
4549                    "root-gate baseline lies beyond the committed gate window".into(),
4550                ));
4551            }
4552        }
4553        Ok(())
4554    }
4555
4556    /// Track `address` under `policy` for the per-block root gate (Phase-8 step 4).
4557    ///
4558    /// Tracking is strictly opt-in: a runtime with no tracked accounts runs the
4559    /// gate as a no-op. Registering an account clears any baseline it held (a
4560    /// policy change re-adopts on the next probe rather than diffing against a
4561    /// baseline captured under the old policy). Each [`RootGateCadence`]
4562    /// firing, the gate
4563    /// probes tracked [`WholeAccount`](TrackingPolicy::WholeAccount) and
4564    /// [`Scalars`](TrackingPolicy::Scalars) accounts' roots/fields via the
4565    /// account-proof seam and, on a move no decoder covered, emits a
4566    /// [`ReactiveReport::CoverageGap`] and schedules a
4567    /// [`ResyncReason::RootMoved`] repair. [`Slots`](TrackingPolicy::Slots)
4568    /// accounts are never root-gated (spec Decision 3).
4569    /// # Cost
4570    ///
4571    /// Tracking an account with a root-gated policy enrols it in the root gate,
4572    /// which issues one `eth_getProof` per tracked account every
4573    /// [`RootGateCadence`] window. That is the most expensive read this crate
4574    /// makes, and it is standing traffic for as long as the account is tracked;
4575    /// [`TrackingPolicy::Slots`] opts out of the gate entirely.
4576    pub fn track_account(&mut self, address: Address, policy: TrackingPolicy) {
4577        self.tracking.insert(address, policy);
4578        self.tracked_roots.remove(&address);
4579    }
4580
4581    /// Stop tracking `address`, dropping its policy and any adopted baseline.
4582    ///
4583    /// Returns `true` if the account was tracked.
4584    pub fn untrack_account(&mut self, address: Address) -> bool {
4585        self.tracked_roots.remove(&address);
4586        self.tracking.remove(&address).is_some()
4587    }
4588
4589    /// Set how often the root gate probes tracked accounts (default:
4590    /// [`RootGateCadence::default`] — every 16 canonical blocks; see the
4591    /// [`RootGateCadence`] docs for why skipping blocks loses no detection).
4592    ///
4593    /// Reconfiguring resets the gate's window bookkeeping (the touched-address
4594    /// accumulator and the last-fired block), so a stale window never leaks
4595    /// into the new cadence: the next canonical block fires the gate.
4596    pub fn set_root_gate_cadence(&mut self, cadence: RootGateCadence) {
4597        self.root_gate_cadence = cadence;
4598        self.last_gate_block = None;
4599        self.touched_since_gate.clear();
4600    }
4601
4602    /// Highest block a source has attested carries no unhealed log-notification
4603    /// loss, when any source has attested.
4604    ///
4605    /// `None` means unknown, not complete. A consumer deciding whether it may
4606    /// treat a buffered log set as authoritative must require a watermark at or
4607    /// above the block in question — never infer completeness from silence. See
4608    /// [`ChainControl::LogCoverage`].
4609    pub const fn log_coverage_head(&self) -> Option<&BlockRef> {
4610        self.log_coverage_head.as_ref()
4611    }
4612
4613    /// The configured [`RootGateCadence`].
4614    pub fn root_gate_cadence(&self) -> RootGateCadence {
4615        self.root_gate_cadence
4616    }
4617
4618    /// Enable freshness stamping of canonical event-derived writes (opt-in).
4619    ///
4620    /// Installs a [`FreshnessRegistry`] the runtime owns; while it is present,
4621    /// applying a canonical handler storage-slot effect for a block `N` stamps the
4622    /// touched `(address, slot)` as
4623    /// [`Validity::ValidThrough`](crate::freshness::Validity::ValidThrough)`(N)`.
4624    /// The slot is therefore not volatile *at* `N` (event-maintained, no need to
4625    /// re-verify) but ages to volatile once the clock passes `N`.
4626    ///
4627    /// Idempotent: if a registry is already installed it is left untouched, so an
4628    /// existing registry (and any stamps it holds) is never clobbered.
4629    pub fn enable_freshness_stamping(&mut self) {
4630        if self.freshness.is_none() {
4631            self.freshness = Some(FreshnessRegistry::new());
4632        }
4633    }
4634
4635    /// Borrow the runtime's freshness registry, if stamping was enabled.
4636    ///
4637    /// Returns `None` unless
4638    /// [`enable_freshness_stamping`](Self::enable_freshness_stamping) was called.
4639    pub fn freshness(&self) -> Option<&FreshnessRegistry> {
4640        self.freshness.as_ref()
4641    }
4642
4643    /// Mutably borrow the runtime's freshness registry, if stamping was enabled.
4644    ///
4645    /// Returns `None` unless
4646    /// [`enable_freshness_stamping`](Self::enable_freshness_stamping) was called.
4647    pub fn freshness_mut(&mut self) -> Option<&mut FreshnessRegistry> {
4648        self.freshness.as_mut()
4649    }
4650
4651    /// Return the current queryable [`CacheHealth`] of the runtime.
4652    pub fn health(&self) -> CacheHealth {
4653        self.health
4654    }
4655
4656    /// Return a point-in-time snapshot of the runtime's observability counters.
4657    pub fn metrics(&self) -> CacheMetricsSnapshot {
4658        self.metrics.snapshot()
4659    }
4660
4661    /// Complete the caller-driven self-heal by returning health to
4662    /// [`CacheHealth::Healthy`].
4663    ///
4664    /// A trust-loss event (a reorg deeper than the journal, or a detected missed
4665    /// block range) escalates health toward [`CacheHealth::Unhealthy`] as a
4666    /// "stop until rebuilt" signal that the caller must act on. Once the caller
4667    /// has resynced or rebuilt the affected state, it invokes this to clear the
4668    /// signal. It does not emit a [`ReactiveReport::Health`] report, since it is
4669    /// called outside an ingest cycle.
4670    pub fn reset_health(&mut self) {
4671        self.health = CacheHealth::Healthy;
4672    }
4673
4674    /// Escalate health one rung up the trust-loss ladder for a trust-loss event
4675    /// observed at `block`, returning a [`ReactiveReport::Health`] report when the
4676    /// state actually changes.
4677    ///
4678    /// The ladder is:
4679    /// - [`Healthy`](CacheHealth::Healthy) -> [`Degraded`](CacheHealth::Degraded)
4680    /// - [`Degraded`](CacheHealth::Degraded) -> [`Unhealthy`](CacheHealth::Unhealthy)
4681    /// - [`Unhealthy`](CacheHealth::Unhealthy) -> no change (`None`)
4682    ///
4683    /// A first event degrades; a second escalates to the terminal
4684    /// [`Unhealthy`](CacheHealth::Unhealthy) stop signal. This is shared by both
4685    /// trust-loss paths (deep reorg beyond the journal and missed-range
4686    /// detection) so mixed event types climb the same ladder.
4687    fn escalate_trust(&mut self, block: u64) -> Option<Arc<ReactiveReport<N>>> {
4688        let to = match self.health {
4689            CacheHealth::Healthy => CacheHealth::Degraded { since_block: block },
4690            CacheHealth::Degraded { .. } => CacheHealth::Unhealthy { since_block: block },
4691            CacheHealth::Unhealthy { .. } => return None,
4692        };
4693        self.transition_health(to, Some(block))
4694    }
4695
4696    /// Transition health to `to`, returning a [`ReactiveReport::Health`] report
4697    /// when the state actually changes.
4698    ///
4699    /// The returned report must be threaded into the ingest cycle's dispatched
4700    /// reports so it reaches hooks and appears in
4701    /// [`ReactiveBatchReport::reports`]. Returns `None` when `to` equals the
4702    /// current state (no transition, no report).
4703    fn transition_health(
4704        &mut self,
4705        to: CacheHealth,
4706        block: Option<u64>,
4707    ) -> Option<Arc<ReactiveReport<N>>> {
4708        if to == self.health {
4709            return None;
4710        }
4711        let from = self.health;
4712        self.health = to;
4713        Some(Arc::new(ReactiveReport::Health(HealthReport {
4714            from,
4715            to,
4716            block,
4717            _network: PhantomData,
4718        })))
4719    }
4720
4721    /// Register a handler.
4722    ///
4723    /// # Errors
4724    ///
4725    /// Returns [`RegisterError::DuplicateHandler`] when the id is already
4726    /// registered.
4727    pub fn register_handler(
4728        &mut self,
4729        handler: Arc<dyn ReactiveHandler<N>>,
4730    ) -> Result<(), RegisterError> {
4731        self.registry.register_handler(handler)
4732    }
4733
4734    /// Remove one handler from the runtime registry without resetting runtime state.
4735    ///
4736    /// This delegates to [`ReactiveRegistry::unregister_handler`] only. It does
4737    /// not clear the reorg journal, health, metrics, hooks, pending resyncs,
4738    /// tracking policy, freshness registry, or root-gate baselines, and it does
4739    /// not purge [`EvmCache`] state. Callers that want cache eviction must issue
4740    /// explicit `StateUpdate::purge` updates or use cache purge APIs separately.
4741    pub fn unregister_handler(&mut self, id: &HandlerId) -> Option<Arc<dyn ReactiveHandler<N>>> {
4742        self.registry.unregister_handler(id)
4743    }
4744
4745    /// Return true when the runtime has a registered handler with `id`.
4746    pub fn contains_handler(&self, id: &HandlerId) -> bool {
4747        self.registry.contains_handler(id)
4748    }
4749
4750    /// Ids of all registered handlers, in registration (= routing) order.
4751    pub fn handler_ids(&self) -> Vec<HandlerId> {
4752        self.registry.handler_ids()
4753    }
4754
4755    /// Borrow the interests owned by one registered handler.
4756    pub fn handler_interests(&self, id: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
4757        self.registry.handler_interests(id)
4758    }
4759
4760    /// The most recently journaled canonical block, if any.
4761    ///
4762    /// This is the runtime's current chain position: the canonical block most
4763    /// recently recorded by ingestion. Reorged blocks are dropped from the
4764    /// journal during recovery, so a rolled-back head does not linger here.
4765    /// [`ReactiveEngine::register_handler`] uses it as the default backfill
4766    /// anchor for handlers registered mid-lifecycle. An ordered barrier may
4767    /// advance this coverage position across an empty event range. `None` until
4768    /// the first canonical input or barrier is accepted.
4769    pub fn last_canonical_block(&self) -> Option<BlockRef> {
4770        self.coverage_head
4771    }
4772
4773    /// Adopt an exact RPC snapshot block as this runtime's canonical starting
4774    /// position without applying effects or dispatching reports.
4775    ///
4776    /// Handlers, hooks, tracking policy, and freshness configuration may be
4777    /// installed before adoption, but no chain input, finality, resync,
4778    /// root-gate observation, or health transition may have occurred. An exact
4779    /// repeat is idempotent; a different repeat and any active runtime fail
4780    /// closed. Prefer [`ReactiveEngine::adopt_canonical_baseline`] when a cache
4781    /// and subscriber are available so chain identity and the cache's exact
4782    /// hash pin are validated too.
4783    ///
4784    /// # Errors
4785    ///
4786    /// Returns [`ReactiveBaselineError::ActiveRuntime`] after any runtime
4787    /// activity, or [`ReactiveBaselineError::ConflictingBaseline`] when a
4788    /// different baseline has already been adopted.
4789    pub fn adopt_canonical_baseline(
4790        &mut self,
4791        baseline: BlockRef,
4792    ) -> Result<(), ReactiveBaselineError> {
4793        self.validate_canonical_baseline_adoption(baseline)?;
4794        if self.adopted_baseline_only().is_some() {
4795            return Ok(());
4796        }
4797
4798        self.coverage_head = Some(baseline);
4799        if self.config.journal_depth > 0 {
4800            self.journal.push_back(BlockJournal {
4801                block: baseline,
4802                inputs: Vec::new(),
4803                applied: Vec::new(),
4804                handler_ids: Vec::new(),
4805                resynced: Vec::new(),
4806                rollback_diffs: Vec::new(),
4807            });
4808        }
4809        Ok(())
4810    }
4811
4812    fn validate_canonical_baseline_adoption(
4813        &self,
4814        baseline: BlockRef,
4815    ) -> Result<(), ReactiveBaselineError> {
4816        if let Some(existing) = self.adopted_baseline_only() {
4817            return if existing == baseline {
4818                Ok(())
4819            } else {
4820                Err(ReactiveBaselineError::ConflictingBaseline {
4821                    existing_number: existing.number,
4822                    existing_hash: existing.hash,
4823                    requested_number: baseline.number,
4824                    requested_hash: baseline.hash,
4825                })
4826            };
4827        }
4828        if !self.is_pristine_for_checkpoint_restore() {
4829            return Err(ReactiveBaselineError::ActiveRuntime);
4830        }
4831        Ok(())
4832    }
4833
4834    /// Most recent safe head explicitly reported by the event source.
4835    pub const fn safe_head(&self) -> Option<&BlockRef> {
4836        self.safe_head.as_ref()
4837    }
4838
4839    /// Most recent finalized head explicitly reported by the event source.
4840    pub const fn finalized_head(&self) -> Option<&BlockRef> {
4841        self.finalized_head.as_ref()
4842    }
4843
4844    /// Return whether the retained reorg journal still contains an applied
4845    /// record for `handler_id`.
4846    ///
4847    /// The record is retained even when the handler emitted only resync work,
4848    /// so an owner can keep an explicit cache-eviction fence active for exactly
4849    /// as long as a later rollback could restore effects from that handler
4850    /// generation. This query is bounded by [`ReactiveConfig::journal_depth`].
4851    pub fn has_journaled_handler_effects(&self, handler_id: &HandlerId) -> bool {
4852        self.journal
4853            .iter()
4854            .any(|entry| entry.handler_ids.contains(handler_id))
4855    }
4856
4857    /// Return the distinct handler generations represented in the retained
4858    /// reorg journal.
4859    ///
4860    /// This scans the bounded journal once, allowing a lifecycle owner to age a
4861    /// large set of cache-eviction fences without rescanning the journal for
4862    /// every handler.
4863    pub fn journaled_handler_ids(&self) -> HashSet<HandlerId> {
4864        self.journal
4865            .iter()
4866            .flat_map(|entry| entry.handler_ids.iter().cloned())
4867            .collect()
4868    }
4869
4870    /// Queued resync requests: surfaced by handlers but not yet executed by an
4871    /// [`ingest_batch_with_resync`](Self::ingest_batch_with_resync) pass.
4872    ///
4873    /// Callers driving resync execution themselves (plain
4874    /// [`ingest_batch`](Self::ingest_batch) loops) can read the ledger here;
4875    /// reorg recovery cancels entries whose pinned blocks were dropped, and
4876    /// [`cancel_pending_resync`](Self::cancel_pending_resync) drops exact
4877    /// generation-owned work, while
4878    /// [`cancel_pending_resyncs`](Self::cancel_pending_resyncs) drops entries
4879    /// for exclusively torn-down accounts.
4880    pub fn pending_resyncs(&self) -> &[ResyncRequest] {
4881        &self.pending_resyncs
4882    }
4883
4884    /// Cancel every queued request with the exact logical `id`.
4885    ///
4886    /// Unlike [`cancel_pending_resyncs`](Self::cancel_pending_resyncs), this
4887    /// removes whole requests and never touches other work merely because it
4888    /// targets the same account. It is therefore the safe primitive for
4889    /// generation-scoped owner teardown when the caller maintains an
4890    /// owner-to-[`ResyncId`] index. Requests already returned to the caller in
4891    /// an earlier batch report cannot be recalled.
4892    pub fn cancel_pending_resync(&mut self, id: &ResyncId) -> Vec<ResyncRequest> {
4893        self.cancel_pending_resyncs_by_id(std::slice::from_ref(id))
4894    }
4895
4896    /// Cancel queued requests whose logical ids occur in `ids` in one queue pass.
4897    ///
4898    /// Duplicate and unknown ids are harmless. Cancelled requests retain their
4899    /// pending-queue order, independent of caller id order. This is the batch
4900    /// teardown primitive for owners that can have many pending repairs; it
4901    /// avoids rescanning the complete pending queue once per owned id.
4902    pub fn cancel_pending_resyncs_by_id(&mut self, ids: &[ResyncId]) -> Vec<ResyncRequest> {
4903        if ids.is_empty() {
4904            return Vec::new();
4905        }
4906        let ids: HashSet<&ResyncId> = ids.iter().collect();
4907        let mut cancelled = Vec::new();
4908        self.pending_resyncs.retain(|request| {
4909            if ids.contains(&request.id) {
4910                cancelled.push(request.clone());
4911                false
4912            } else {
4913                true
4914            }
4915        });
4916        cancelled
4917    }
4918
4919    /// Cancel queued resync work that targets `address`, returning the
4920    /// cancelled portions.
4921    ///
4922    /// Every pending [`ResyncRequest`] target referencing `address` is removed;
4923    /// a request reduced to zero targets is dropped entirely, while
4924    /// mixed-target requests keep their other accounts queued. Each returned
4925    /// request mirrors the original id/reason/block/priority and carries only
4926    /// the targets that were cancelled.
4927    ///
4928    /// This is appropriate only when the caller owns the complete account. For
4929    /// a pool sharing a vault or emitter with other owners, cancel its exact
4930    /// request IDs through
4931    /// [`cancel_pending_resync`](Self::cancel_pending_resync) instead. It cannot
4932    /// recall requests already returned to the caller in earlier batch reports.
4933    pub fn cancel_pending_resyncs(&mut self, address: Address) -> Vec<ResyncRequest> {
4934        let mut cancelled = Vec::new();
4935        self.pending_resyncs.retain_mut(|request| {
4936            let (matching, remaining): (Vec<_>, Vec<_>) = request
4937                .targets
4938                .drain(..)
4939                .partition(|target| resync_target_address(target) == address);
4940            request.targets = remaining;
4941            if !matching.is_empty() {
4942                cancelled.push(ResyncRequest {
4943                    id: request.id.clone(),
4944                    reason: request.reason.clone(),
4945                    block: request.block.clone(),
4946                    targets: matching,
4947                    priority: request.priority,
4948                });
4949            }
4950            !request.targets.is_empty()
4951        });
4952        cancelled
4953    }
4954
4955    /// Register a hook.
4956    ///
4957    /// # Errors
4958    ///
4959    /// This implementation is currently infallible; the `Result` preserves the
4960    /// registration contract for future hook validation.
4961    pub fn register_hook(&mut self, hook: Arc<dyn ReactiveHook<N>>) -> Result<(), RegisterError> {
4962        self.hooks.push(hook);
4963        Ok(())
4964    }
4965
4966    /// Return all registered interests in handler registration order.
4967    pub fn interests(&self) -> Vec<ReactiveInterest<N>> {
4968        self.registry.interests()
4969    }
4970
4971    /// Ingest a batch, apply valid direct state effects, and dispatch reports.
4972    ///
4973    /// The commit is atomic on `Err`: cache state and canonical runtime state are
4974    /// restored before the error returns, and hooks see no reports. Monotonic
4975    /// observability counters still retain rejected-attempt signals.
4976    /// The current rollback guard snapshots complete mutable cache state once per
4977    /// batch, so callers should preserve transport batching rather than splitting
4978    /// one delivery into many one-record calls.
4979    ///
4980    /// # Errors
4981    ///
4982    /// Returns [`ReactiveError`] when records or controls are invalid, canonical
4983    /// continuity cannot be proven, a handler rejects input, or an effect cannot
4984    /// be applied. A pre-confirmed batch additionally requires an adopted
4985    /// canonical coverage head and must identify its exact child by number and
4986    /// parent hash. Cache and canonical runtime state are restored before
4987    /// return; a lineage failure revokes any active speculative branch.
4988    pub fn ingest_batch(
4989        &mut self,
4990        cache: &mut EvmCache,
4991        batch: ReactiveInputBatch<N>,
4992    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
4993        let preconfirmation = batch_preconfirmation(&batch)?;
4994        if let Some(flashblock) = preconfirmation.as_ref() {
4995            self.prepare_preconfirmed_branch(cache, flashblock)?;
4996        } else {
4997            self.discard_preconfirmed_branch(cache);
4998        }
4999        let cache_state = EvmCacheStateSnapshot::capture(cache);
5000        let runtime_state = self.checkpoint_state();
5001        let batch_report = match self.ingest_batch_direct(cache, batch) {
5002            Ok(report) => report,
5003            Err(error) => {
5004                cache_state.restore(cache);
5005                self.restore_transaction_state(runtime_state);
5006                return Err(error);
5007            }
5008        };
5009        if let Some(flashblock) = preconfirmation {
5010            self.restore_transaction_state(runtime_state);
5011            if let Some(branch) = self.preconfirmed_branch.as_mut() {
5012                branch.flashblock = flashblock;
5013            }
5014        }
5015        self.dispatch_reports(&batch_report.reports);
5016        let _ = &self.config;
5017        Ok(batch_report)
5018    }
5019
5020    /// Ingest a batch, then execute surfaced storage resync requests.
5021    ///
5022    /// This entrypoint preserves [`ingest_batch`](Self::ingest_batch) behavior for
5023    /// direct handler effects, then runs a synchronous resync phase over the
5024    /// collected [`ResyncRequest`]s. Storage targets are fetched through
5025    /// [`EvmCache::storage_batch_fetcher`] grouped by [`ResyncBlock`], successful
5026    /// values are applied as [`StateUpdate::slot`] updates through
5027    /// [`EvmCache::apply_updates`], and unsupported or failed targets are reported
5028    /// in [`ResyncReport::failed`]. It does not start subscribers, background
5029    /// workers, or network transport.
5030    ///
5031    /// # Errors
5032    ///
5033    /// Returns [`ReactiveError`] for the same validation, continuity, handler,
5034    /// or direct-effect failures as [`ingest_batch`](Self::ingest_batch). Failed
5035    /// resync targets are reported in the successful batch report instead.
5036    pub fn ingest_batch_with_resync(
5037        &mut self,
5038        cache: &mut EvmCache,
5039        batch: ReactiveInputBatch<N>,
5040    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
5041        let preconfirmation = batch_preconfirmation(&batch)?;
5042        if let Some(flashblock) = preconfirmation.as_ref() {
5043            self.prepare_preconfirmed_branch(cache, flashblock)?;
5044        } else {
5045            self.discard_preconfirmed_branch(cache);
5046        }
5047        let cache_state = EvmCacheStateSnapshot::capture(cache);
5048        let runtime_state = self.checkpoint_state();
5049        let batch_report = match self.ingest_batch_with_resync_direct(cache, batch) {
5050            Ok(report) => report,
5051            Err(error) => {
5052                cache_state.restore(cache);
5053                self.restore_transaction_state(runtime_state);
5054                return Err(error);
5055            }
5056        };
5057
5058        if let Some(flashblock) = preconfirmation {
5059            self.restore_transaction_state(runtime_state);
5060            if let Some(branch) = self.preconfirmed_branch.as_mut() {
5061                branch.flashblock = flashblock;
5062            }
5063        }
5064
5065        self.dispatch_reports(&batch_report.reports);
5066        let _ = &self.config;
5067        Ok(batch_report)
5068    }
5069
5070    /// Active speculative Flashblock snapshot, when the cache currently
5071    /// includes pre-confirmed effects.
5072    pub fn active_preconfirmation(&self) -> Option<&FlashblockRef> {
5073        self.preconfirmed_branch
5074            .as_ref()
5075            .map(|branch| &branch.flashblock)
5076    }
5077
5078    /// Restore the cache to its canonical state and discard any speculative
5079    /// Flashblock effects.
5080    pub fn discard_preconfirmation(&mut self, cache: &mut EvmCache) {
5081        self.discard_preconfirmed_branch(cache);
5082    }
5083
5084    fn discard_preconfirmed_branch(&mut self, cache: &mut EvmCache) {
5085        if let Some(branch) = self.preconfirmed_branch.take() {
5086            branch.canonical_cache.restore(cache);
5087        }
5088    }
5089
5090    fn prepare_preconfirmed_branch(
5091        &mut self,
5092        cache: &mut EvmCache,
5093        incoming: &FlashblockRef,
5094    ) -> Result<(), ReactiveError> {
5095        let Some(canonical) = self.coverage_head else {
5096            self.discard_preconfirmed_branch(cache);
5097            return Err(ReactiveError::InvalidInputRecord {
5098                message: "pre-confirmed state requires an exact canonical coverage baseline".into(),
5099            });
5100        };
5101        if canonical.number.checked_add(1) != Some(incoming.block_number) {
5102            self.discard_preconfirmed_branch(cache);
5103            return Err(ReactiveError::InvalidInputRecord {
5104                message: format!(
5105                    "pre-confirmed block {} is not the exact successor of canonical block {}",
5106                    incoming.block_number, canonical.number
5107                ),
5108            });
5109        }
5110        if incoming.parent_hash != Some(canonical.hash) {
5111            self.discard_preconfirmed_branch(cache);
5112            return Err(ReactiveError::InvalidInputRecord {
5113                message: "pre-confirmed block parent does not match the canonical coverage hash"
5114                    .into(),
5115            });
5116        }
5117        if let Some(active) = self.preconfirmed_branch.as_ref()
5118            && active.flashblock.same_payload(incoming)
5119        {
5120            if let (Some(active_index), Some(incoming_index)) =
5121                (active.flashblock.index, incoming.index)
5122                && incoming_index < active_index
5123            {
5124                return Err(ReactiveError::InvalidInputRecord {
5125                    message: format!(
5126                        "Flashblock index regressed from {active_index} to {incoming_index}"
5127                    ),
5128                });
5129            }
5130            if active.flashblock.index.is_some()
5131                && active.flashblock.index == incoming.index
5132                && active.flashblock.content_hash != incoming.content_hash
5133            {
5134                self.discard_preconfirmed_branch(cache);
5135                return Err(ReactiveError::InvalidInputRecord {
5136                    message: "same Flashblock payload/index carried conflicting cumulative content"
5137                        .into(),
5138                });
5139            }
5140            install_preconfirmed_cache_context(cache, incoming);
5141            return Ok(());
5142        }
5143
5144        self.discard_preconfirmed_branch(cache);
5145        self.preconfirmed_branch = Some(PreconfirmedBranch {
5146            flashblock: incoming.clone(),
5147            canonical_cache: EvmCacheStateSnapshot::capture(cache),
5148        });
5149        install_preconfirmed_cache_context(cache, incoming);
5150        Ok(())
5151    }
5152
5153    fn ingest_batch_with_resync_direct(
5154        &mut self,
5155        cache: &mut EvmCache,
5156        batch: ReactiveInputBatch<N>,
5157    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
5158        let mut batch_report = self.ingest_batch_direct(cache, batch)?;
5159        if !batch_report.resyncs.is_empty() {
5160            let resync_report = execute_resync_requests(cache, &batch_report.resyncs);
5161            // Count unique logical requests: several handlers may emit the same
5162            // ResyncId in one batch, and duplicates fan out per-origin in the
5163            // report but are one unit of resync work for the metric.
5164            let unique_requests = resync_report
5165                .requested
5166                .iter()
5167                .map(|request| &request.id)
5168                .collect::<HashSet<_>>()
5169                .len();
5170            self.metrics
5171                .resync_requests
5172                .fetch_add(unique_requests as u64, Ordering::Relaxed);
5173            self.metrics
5174                .resync_failures
5175                .fetch_add(resync_report.failed.len() as u64, Ordering::Relaxed);
5176            self.remove_pending_resyncs(batch_report.resyncs.iter().map(|request| &request.id));
5177            self.record_journal_resync(&resync_report);
5178            batch_report
5179                .reports
5180                .push(Arc::new(ReactiveReport::Resynced(resync_report)));
5181        }
5182        Ok(batch_report)
5183    }
5184
5185    fn ingest_batch_direct(
5186        &mut self,
5187        cache: &mut EvmCache,
5188        batch: ReactiveInputBatch<N>,
5189    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
5190        let (records, chain_controls, batch_chain_id) = batch.into_runtime_parts();
5191        if let Some(chain_id) = batch_chain_id
5192            && chain_id != cache.chain_id()
5193        {
5194            return Err(ReactiveError::InvalidInputRecord {
5195                message: format!(
5196                    "batch chain id {chain_id} does not match cache chain id {}",
5197                    cache.chain_id()
5198                ),
5199            });
5200        }
5201        if !chain_controls.is_empty() && batch_chain_id.is_none() {
5202            return Err(ReactiveError::InvalidChainControl {
5203                message: "chain-control batches require an authoritative batch chain id".into(),
5204            });
5205        }
5206        for (record, _, _) in &records {
5207            record.validated_identity()?;
5208            if let Some(chain_id) = record.context.chain_id
5209                && chain_id != cache.chain_id()
5210            {
5211                return Err(ReactiveError::InvalidInputRecord {
5212                    message: format!(
5213                        "input chain id {chain_id} does not match cache chain id {}",
5214                        cache.chain_id()
5215                    ),
5216                });
5217            }
5218        }
5219        let records = sort_scoped_records(dedupe_scoped_records(records)?);
5220
5221        let mut batch_report = ReactiveBatchReport::default();
5222        let mut reports_to_dispatch = Vec::new();
5223        let control_split = validate_control_phase_order(&chain_controls)?;
5224        let (pre_record_controls, post_record_controls) = chain_controls.split_at(control_split);
5225        let pre_record_state =
5226            self.validate_ingest_sequence(pre_record_controls, post_record_controls, &records)?;
5227        self.validate_owner_catchup_against_journal(&pre_record_state, &records)?;
5228        let mut batch_dropped = BatchDroppedCanonical::default();
5229        for control in pre_record_controls {
5230            if let ChainControl::Reorg {
5231                common_ancestor,
5232                old_tip,
5233                ..
5234            } = control
5235            {
5236                batch_dropped.record_explicit(common_ancestor, old_tip);
5237                let drained = self
5238                    .journal
5239                    .iter()
5240                    .filter(|entry| entry.block.number > common_ancestor.number)
5241                    .map(|entry| entry.block)
5242                    .collect::<Vec<_>>();
5243                batch_dropped.record_drained(&drained);
5244            }
5245        }
5246        let certified_progress_through = post_record_controls
5247            .iter()
5248            .filter_map(canonical_coverage_control_block)
5249            .map(|block| block.number)
5250            .max();
5251        for control in pre_record_controls.iter().cloned() {
5252            self.apply_chain_control(cache, control, &mut batch_report, &mut reports_to_dispatch);
5253        }
5254        // Phase-8 step 4: accumulate the addresses a decoder actually wrote this
5255        // batch (union of applied `StateDiff` addresses) and the batch's canonical
5256        // block number, so the per-block root gate can run once after the record
5257        // loop with the full touched set.
5258        let mut touched_addrs: HashSet<Address> = HashSet::new();
5259        let mut canonical_batch_block: Option<u64> = None;
5260
5261        for (record, audience, delivery_scope) in records {
5262            let raw_canonical_block = canonical_record_block(&record).copied();
5263            let canonical_block = raw_canonical_block.map(|block| {
5264                pre_record_state
5265                    .resolved_canonical_blocks
5266                    .get(&(block.number, block.hash))
5267                    .copied()
5268                    .unwrap_or(block)
5269            });
5270            let input_ref = record.input_ref();
5271            reports_to_dispatch.push(Arc::new(ReactiveReport::Input(InputReport {
5272                input_ref,
5273                context: record.context.clone(),
5274                provider: record.provider.clone(),
5275                _network: PhantomData,
5276            })));
5277
5278            let recovered_reorg = if delivery_scope.advances_canonical_state() {
5279                if let Some(block) = canonical_block.as_ref() {
5280                    let gap_is_certified = delivery_scope == DeliveryScope::CanonicalProgress
5281                        && certified_progress_through
5282                            .is_some_and(|through| block.number <= through);
5283                    let parentless_replacement_is_proven = raw_canonical_block.is_some_and(|raw| {
5284                        raw.parent_hash.is_none()
5285                            && batch_dropped.covers_implicit_number(raw.number)
5286                    });
5287                    self.recover_for_canonical_input(
5288                        cache,
5289                        block,
5290                        gap_is_certified,
5291                        parentless_replacement_is_proven,
5292                        &mut reports_to_dispatch,
5293                    )
5294                } else {
5295                    None
5296                }
5297            } else {
5298                None
5299            };
5300            let recovered_reorg_for_input = recovered_reorg.is_some();
5301            if let Some(reorg_report) = recovered_reorg {
5302                self.metrics
5303                    .reorgs_recovered
5304                    .fetch_add(1, Ordering::Relaxed);
5305                remove_canceled_resyncs_from_batch(
5306                    &mut batch_report.resyncs,
5307                    &reorg_report.canceled_resyncs,
5308                );
5309                reports_to_dispatch.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
5310            }
5311
5312            // Removed/reorged records are lifecycle signals, never handler
5313            // data. Canonical scopes may roll back state; owner-only catch-up
5314            // scopes deliberately cannot, but both must suppress ordinary
5315            // decoding even when the referenced block is unknown, aged out of
5316            // the journal, or has already been removed once.
5317            if reorg_signal_block(&record).is_some() {
5318                if delivery_scope.advances_canonical_state()
5319                    && let Some(reorg_report) = self.recover_for_reorged_input(
5320                        cache,
5321                        &record,
5322                        &mut batch_dropped,
5323                        &mut reports_to_dispatch,
5324                    )
5325                {
5326                    self.metrics
5327                        .reorgs_recovered
5328                        .fetch_add(1, Ordering::Relaxed);
5329                    remove_canceled_resyncs_from_batch(
5330                        &mut batch_report.resyncs,
5331                        &reorg_report.canceled_resyncs,
5332                    );
5333                    reports_to_dispatch.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
5334                }
5335                continue;
5336            }
5337
5338            // Preflight validates owner history against the journal state at
5339            // batch entry. A canonical record earlier in this same transaction
5340            // may legitimately replace and drain that block, so close the
5341            // resulting TOCTOU window immediately before any owner handler can
5342            // mutate the cache. The outer transaction guard restores every
5343            // earlier record in the batch on failure.
5344            if delivery_scope == DeliveryScope::OwnerCatchup {
5345                self.validate_owner_catchup_record_against_current_journal(&record)?;
5346            }
5347
5348            if delivery_scope.advances_canonical_state()
5349                && let Some(block) = canonical_block.as_ref()
5350            {
5351                // Phase-8 step 4: remember the batch's canonical block (the last
5352                // canonical record wins) so the root gate probes at that height.
5353                canonical_batch_block = Some(block.number);
5354                self.record_journal_input(block, input_ref);
5355            }
5356
5357            // Keep every lazy provider read pinned to the exact event block
5358            // before handlers run. A full header installs the complete EVM env;
5359            // compact log-only progress installs NUMBER/timestamp and clears
5360            // unknown header-only fields. A later record for the same retained
5361            // canonical block can preserve an already-installed full env.
5362            if delivery_scope.advances_canonical_state()
5363                && let Some(block) = canonical_block.as_ref()
5364            {
5365                match advance_block_for_canonical_record(cache, &record) {
5366                    Some(Ok(())) => {
5367                        cache.advance_compact_block(block.number, block.hash, block.timestamp, true)
5368                    }
5369                    Some(Err(err)) => {
5370                        cache.advance_compact_block(
5371                            block.number,
5372                            block.hash,
5373                            block.timestamp,
5374                            false,
5375                        );
5376                        reports_to_dispatch.push(Arc::new(ReactiveReport::Error(
5377                            ReactiveErrorReport {
5378                                input_ref: Some(input_ref),
5379                                message: err.to_string(),
5380                                _network: PhantomData,
5381                            },
5382                        )));
5383                    }
5384                    None => cache.advance_compact_block(
5385                        block.number,
5386                        block.hash,
5387                        block.timestamp,
5388                        !recovered_reorg_for_input,
5389                    ),
5390                }
5391            }
5392
5393            let executions = self.execute_handlers(cache, &record, input_ref, &audience)?;
5394            if executions.is_empty() {
5395                continue;
5396            }
5397
5398            reports_to_dispatch.push(Arc::new(ReactiveReport::Decoded(DecodedReport {
5399                input_ref,
5400                handler_ids: executions
5401                    .iter()
5402                    .map(|execution| execution.handler_id.clone())
5403                    .collect(),
5404                _network: PhantomData,
5405            })));
5406
5407            detect_conflicts(input_ref, &executions)?;
5408
5409            // Phase-8 step 3: canonical block number for freshness stamping.
5410            // Copied out as a plain `u64` (dropping the borrow of `record`) so it
5411            // can be used while `self.freshness_mut()` mutably borrows `self`
5412            // inside the execution loop. `None` for pending/removed/reorged
5413            // records — those never stamp canonical freshness.
5414            let canonical_block_number = delivery_scope
5415                .advances_canonical_state()
5416                .then_some(canonical_block)
5417                .flatten()
5418                .map(|block| block.number);
5419
5420            for execution in executions {
5421                let diff = if execution.state_updates.is_empty() {
5422                    StateDiff::default()
5423                } else {
5424                    cache.apply_updates(&execution.state_updates)
5425                };
5426
5427                batch_report
5428                    .resyncs
5429                    .extend(execution.resyncs.iter().cloned());
5430                self.pending_resyncs
5431                    .extend(execution.resyncs.iter().cloned());
5432                batch_report
5433                    .speculative
5434                    .extend(execution.speculative.iter().cloned());
5435
5436                let applied = AppliedReport {
5437                    input_ref,
5438                    handler_id: execution.handler_id,
5439                    quality: execution.quality,
5440                    tags: execution.tags,
5441                    diff,
5442                    state_updates: execution.state_updates,
5443                    invalidations: execution.invalidations,
5444                    resyncs: execution.resyncs,
5445                    speculative: execution.speculative,
5446                    hook_signals: execution.hook_signals,
5447                    _network: PhantomData,
5448                };
5449                // Phase-8 step 3 (opt-in): stamp every touched `(address, slot)`
5450                // from this canonical handler write as `ValidThrough(N)`, so an
5451                // event-maintained slot stops being re-verified until the clock
5452                // passes its write block. Read the changed slots straight off
5453                // `applied.diff` (which borrows the local, not `self`) and stamp
5454                // via `self.freshness`, done before `applied` is moved into the
5455                // journal/batch below. Only genuinely-changed slots appear here,
5456                // since a no-op re-write records no `SlotChange`.
5457                if let (Some(number), Some(registry)) =
5458                    (canonical_block_number, self.freshness.as_mut())
5459                {
5460                    for change in &applied.diff.slots {
5461                        registry.valid_through_slot(change.address, change.slot, number);
5462                    }
5463                }
5464
5465                // Phase-8 step 4: record every address this decoder actually wrote
5466                // (or attempted to write) so the root gate can tell a
5467                // decoder-covered root move from an uncovered coverage gap. Fold in
5468                // the full `StateDiff` address footprint — real changes
5469                // (`slots`/`accounts`/`purged`) and cold-skipped attempts alike, so
5470                // a decoder that tried to write a cold slot still counts as
5471                // covering the account.
5472                if delivery_scope.advances_canonical_state() {
5473                    collect_diff_addresses(&applied.diff, &mut touched_addrs);
5474                }
5475
5476                let report = Arc::new(ReactiveReport::Applied(applied.clone()));
5477                reports_to_dispatch.push(report);
5478                if let Some(block) = canonical_block.as_ref() {
5479                    if delivery_scope.advances_canonical_state() {
5480                        self.record_journal_applied(block, applied.clone());
5481                    } else {
5482                        self.record_journal_applied_if_present(block, applied.clone());
5483                    }
5484                }
5485                batch_report.applied.push(applied);
5486            }
5487        }
5488
5489        // Coverage/finality controls certify the records that precede them.
5490        // Applying them here also leaves the live cache pinned to a certified
5491        // zero-event tail rather than the last block that happened to emit a
5492        // matching log. Reorg controls were applied before the record loop.
5493        for control in post_record_controls.iter().cloned() {
5494            if let Some(block) = canonical_coverage_control_block(&control) {
5495                canonical_batch_block = Some(
5496                    canonical_batch_block.map_or(block.number, |current| current.max(block.number)),
5497                );
5498            }
5499            self.apply_chain_control(cache, control, &mut batch_report, &mut reports_to_dispatch);
5500        }
5501
5502        // Phase-8 step 4 + §6.2 cadence: accumulate this batch's touched
5503        // addresses (after all handler effects, so the set is complete), then
5504        // fire the root gate only on cadence boundaries. The gate diffs
5505        // against persisted baselines, so skipped blocks lose no detection —
5506        // but the touched set must be the union since the last firing, or a
5507        // decoder-covered write in a skipped block would false-positive as a
5508        // CoverageGap. Fired resyncs surface in `batch_report.resyncs` (so
5509        // callers see them and `ingest_batch_with_resync` executes them) and
5510        // coverage reports go into the dispatched reports.
5511        if self.root_gate_runnable(cache) {
5512            self.touched_since_gate
5513                .extend(touched_addrs.iter().copied());
5514            if self.root_gate_due(canonical_batch_block) {
5515                let accumulated = std::mem::take(&mut self.touched_since_gate);
5516                self.run_root_gate(
5517                    cache,
5518                    canonical_batch_block,
5519                    &accumulated,
5520                    &mut batch_report.resyncs,
5521                    &mut reports_to_dispatch,
5522                );
5523                self.last_gate_block = canonical_batch_block;
5524            }
5525        } else {
5526            // A gate that cannot run (disabled, nothing root-gated, or no
5527            // proof fetcher) must not grow the accumulator unboundedly.
5528            // Dropping it is safe: without a runnable gate no baselines exist
5529            // (a fetcher cannot be uninstalled, and untracking drops the
5530            // baseline), so there is nothing a lost touched set could falsely
5531            // gap against later.
5532            self.touched_since_gate.clear();
5533        }
5534
5535        batch_report.reports = reports_to_dispatch;
5536        Ok(batch_report)
5537    }
5538
5539    /// Prove that every owner-only historical effect can be attached to an
5540    /// compatible retained canonical journal entry before any chain control or
5541    /// handler mutation is applied. Number/hash are exact. Parent/timestamp are
5542    /// optional enrichment, but two present values must agree; this matches the
5543    /// [`BlockRef`] compatibility rule used for cross-source deduplication.
5544    ///
5545    /// Owner catch-up deliberately does not advance canonical coverage. Its
5546    /// effects are appended to the already-existing journal entry so a later
5547    /// reorg can roll them back with the rest of that block. Accepting a block
5548    /// outside the journal would make the cache mutation irreversible. A reorg
5549    /// control in the same batch also invalidates entries above its ancestor,
5550    /// so those entries are rejected even though they still exist at this
5551    /// preflight point.
5552    fn validate_owner_catchup_against_journal(
5553        &self,
5554        control_state: &ChainControlState,
5555        records: &[(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)],
5556    ) -> Result<(), ReactiveError> {
5557        for (record, _, delivery_scope) in records {
5558            if *delivery_scope != DeliveryScope::OwnerCatchup {
5559                continue;
5560            }
5561            // Removed/reorged inputs are lifecycle signals only. Owner catch-up
5562            // cannot make them canonical and the record loop deliberately skips
5563            // handler execution, so there is no effect that needs attaching to
5564            // a rollback journal entry.
5565            if reorg_signal_block(record).is_some() {
5566                continue;
5567            }
5568            let context_block = canonical_record_block(record).ok_or_else(|| {
5569                ReactiveError::InvalidChainControl {
5570                    message: "owner catch-up input has no canonical block identity".into(),
5571                }
5572            })?;
5573            let block = resolve_record_block_payload_metadata(record, *context_block)?;
5574            let invalidated_by_control = control_state
5575                .journal_invalidated_from
5576                .is_some_and(|from| block.number >= from);
5577            let rollbackable = !invalidated_by_control
5578                && self.journal.iter().any(|entry| {
5579                    optional_block_refs_are_compatible(Some(&entry.block), Some(&block))
5580                });
5581            if !rollbackable {
5582                return Err(ReactiveError::OwnerCatchupOutsideJournal {
5583                    number: block.number,
5584                    hash: block.hash,
5585                });
5586            }
5587        }
5588        Ok(())
5589    }
5590
5591    fn validate_owner_catchup_record_against_current_journal(
5592        &self,
5593        record: &ReactiveInputRecord<N>,
5594    ) -> Result<(), ReactiveError> {
5595        let context_block =
5596            canonical_record_block(record).ok_or_else(|| ReactiveError::InvalidChainControl {
5597                message: "owner catch-up input has no canonical block identity".into(),
5598            })?;
5599        let block = resolve_record_block_payload_metadata(record, *context_block)?;
5600        if self
5601            .journal
5602            .iter()
5603            .any(|entry| optional_block_refs_are_compatible(Some(&entry.block), Some(&block)))
5604        {
5605            return Ok(());
5606        }
5607        Err(ReactiveError::OwnerCatchupOutsideJournal {
5608            number: block.number,
5609            hash: block.hash,
5610        })
5611    }
5612
5613    /// Whether the root gate could produce any signal at all: some tracked
5614    /// account is root-gated (`Slots` never is) and a proof fetcher exists.
5615    /// When this is false the touched accumulator is dropped rather than
5616    /// grown (see the ingest call site for why that is safe).
5617    fn root_gate_runnable(&self, cache: &EvmCache) -> bool {
5618        if matches!(self.root_gate_cadence, RootGateCadence::Disabled) {
5619            return false;
5620        }
5621        let has_gated_targets = self
5622            .tracking
5623            .values()
5624            .any(|policy| !matches!(policy, TrackingPolicy::Slots { .. }));
5625        has_gated_targets && cache.account_proof_fetcher().is_some()
5626    }
5627
5628    /// Whether the root gate is due at this batch's canonical block (§6.2):
5629    /// the first canonical block ever seen always fires (baseline adoption
5630    /// must not wait a full window), then at most once every `n` blocks.
5631    fn root_gate_due(&self, canonical_block: Option<u64>) -> bool {
5632        let Some(block) = canonical_block else {
5633            return false;
5634        };
5635        match self.root_gate_cadence {
5636            RootGateCadence::Disabled => false,
5637            RootGateCadence::EveryNBlocks(n) => match self.last_gate_block {
5638                None => true,
5639                Some(last) => block >= last.saturating_add(n.get()),
5640            },
5641        }
5642    }
5643
5644    /// The `storageHash` root gate (Phase-8 step 4), fired per
5645    /// [`RootGateCadence`] window (§6.2).
5646    ///
5647    /// Runs at the firing batch's canonical block, with `touched` carrying the
5648    /// union of decoder-touched addresses since the previous firing. For each tracked
5649    /// [`WholeAccount`](TrackingPolicy::WholeAccount) / [`Scalars`](TrackingPolicy::Scalars)
5650    /// account, probe the root (and account fields) via the account-proof seam and
5651    /// apply the spec §4 table:
5652    ///
5653    /// - No baseline yet ⇒ **adopt** (no gap, no resync — adoption is not a gap).
5654    /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root unchanged ⇒ nothing.
5655    /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root moved, `addr ∈ touched`
5656    ///   ⇒ a decoder covered it; re-adopt, no gap.
5657    /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root moved, `addr ∉ touched`
5658    ///   ⇒ emit [`ReactiveReport::CoverageGap`], count it, schedule a
5659    ///   [`ResyncReason::RootMoved`] account resync, re-adopt.
5660    /// - [`Scalars`](TrackingPolicy::Scalars) ⇒ compare balance/nonce/code-hash to
5661    ///   the baseline (native field changes never move the storage root); on a move
5662    ///   with `addr ∉ touched`, schedule a [`ResyncReason::RootMoved`] account
5663    ///   resync for the changed fields and re-adopt.
5664    ///
5665    /// No-op when the tracking registry is empty, when the batch has no canonical
5666    /// block, or when the cache has no account-proof fetcher installed.
5667    /// [`Slots`](TrackingPolicy::Slots) accounts are never root-gated (spec
5668    /// Decision 3).
5669    fn run_root_gate(
5670        &mut self,
5671        cache: &EvmCache,
5672        canonical_block: Option<u64>,
5673        touched: &HashSet<Address>,
5674        resyncs: &mut Vec<ResyncRequest>,
5675        reports: &mut Vec<Arc<ReactiveReport<N>>>,
5676    ) {
5677        if self.tracking.is_empty() {
5678            return;
5679        }
5680        let Some(block) = canonical_block else {
5681            return;
5682        };
5683        let Some(fetcher) = cache.account_proof_fetcher().cloned() else {
5684            return;
5685        };
5686
5687        // Collect the root-gated targets (Slots opts out) in a stable order so a
5688        // single-block sequence of resyncs/reports is deterministic.
5689        let mut targets: Vec<(Address, bool)> = self
5690            .tracking
5691            .iter()
5692            .filter_map(|(address, policy)| match policy {
5693                TrackingPolicy::Slots { .. } => None,
5694                TrackingPolicy::WholeAccount => Some((*address, true)),
5695                TrackingPolicy::Scalars => Some((*address, false)),
5696            })
5697            .collect();
5698        if targets.is_empty() {
5699            return;
5700        }
5701        targets.sort_by_key(|(address, _)| *address);
5702
5703        let block_id = BlockId::number(block);
5704        // ONE seam invocation carries every root-gated target (root-only
5705        // probes: no storage keys needed). eth_getProof is single-address at
5706        // the RPC level, so batching here lets the fetcher fan the requests
5707        // out concurrently instead of paying N sequential round trips.
5708        let mut probes: HashMap<Address, StorageFetchResult<AccountProof>> = (fetcher)(
5709            targets
5710                .iter()
5711                .map(|&(address, _)| (address, vec![]))
5712                .collect(),
5713            block_id,
5714        )
5715        .into_iter()
5716        .collect();
5717        for (address, whole_account) in targets {
5718            let Some(Ok(proof)) = probes.remove(&address) else {
5719                // A failed/omitted probe carries no signal; leave the baseline
5720                // untouched and try again next block.
5721                continue;
5722            };
5723
5724            let baseline = self.tracked_roots.get(&address).cloned();
5725            let Some(baseline) = baseline else {
5726                // First observation: adopt the baseline. Not a coverage gap.
5727                self.adopt_root(address, block, &proof);
5728                continue;
5729            };
5730
5731            // A stale probe (a batch whose canonical block is not newer than the
5732            // last one we baselined this account against) carries no forward
5733            // signal: skip it rather than diff against — or clobber — a newer
5734            // baseline.
5735            if block <= baseline.last_block {
5736                continue;
5737            }
5738
5739            if whole_account {
5740                if proof.storage_hash == baseline.last_root {
5741                    // Tight steady-state path: unchanged root ⇒ nothing.
5742                    continue;
5743                }
5744                // Root moved.
5745                if !touched.contains(&address) {
5746                    // Moved with no covering decoder — the coverage gap.
5747                    reports.push(Arc::new(ReactiveReport::CoverageGap(CoverageGapReport {
5748                        address,
5749                        block,
5750                        _network: PhantomData,
5751                    })));
5752                    self.metrics.coverage_gaps.fetch_add(1, Ordering::Relaxed);
5753                    resyncs.push(root_moved_account_resync(
5754                        address,
5755                        block,
5756                        AccountFieldMask {
5757                            balance: true,
5758                            nonce: true,
5759                            code: true,
5760                        },
5761                    ));
5762                }
5763                // Adopt the new root whether or not a decoder covered it.
5764                self.adopt_root(address, block, &proof);
5765            } else {
5766                // Scalars: compare the account fields directly (native changes do
5767                // not move the storage root).
5768                let balance_moved = proof.balance != baseline.balance;
5769                let nonce_moved = proof.nonce != baseline.nonce;
5770                let code_moved = proof.code_hash != baseline.code_hash;
5771                if (balance_moved || nonce_moved || code_moved) && !touched.contains(&address) {
5772                    resyncs.push(root_moved_account_resync(
5773                        address,
5774                        block,
5775                        AccountFieldMask {
5776                            balance: balance_moved,
5777                            nonce: nonce_moved,
5778                            code: code_moved,
5779                        },
5780                    ));
5781                }
5782                self.adopt_root(address, block, &proof);
5783            }
5784        }
5785    }
5786
5787    /// Adopt (or re-adopt) `proof` as the baseline for `address` at `block`.
5788    fn adopt_root(&mut self, address: Address, block: u64, proof: &AccountProof) {
5789        self.tracked_roots.insert(
5790            address,
5791            TrackedRoot {
5792                last_root: proof.storage_hash,
5793                last_block: block,
5794                balance: proof.balance,
5795                nonce: proof.nonce,
5796                code_hash: proof.code_hash,
5797            },
5798        );
5799    }
5800
5801    fn execute_handlers(
5802        &self,
5803        cache: &EvmCache,
5804        record: &ReactiveInputRecord<N>,
5805        input_ref: InputRef,
5806        audience: &DeliveryAudience,
5807    ) -> Result<Vec<HandlerExecution>, ReactiveError> {
5808        let mut executions = Vec::new();
5809        let candidates: Vec<_> = match &record.input {
5810            ReactiveInput::Log(log) => self.registry.log_handler_candidates(log),
5811            ReactiveInput::BlockHeader(_)
5812            | ReactiveInput::FullBlock(_)
5813            | ReactiveInput::PendingTxHash(_)
5814            | ReactiveInput::PendingTx(_) => self.registry.handlers().collect(),
5815        };
5816        for registered in candidates {
5817            match audience {
5818                DeliveryAudience::Owners(owners) if !owners.contains(&registered.id) => continue,
5819                DeliveryAudience::AllExcept(excluded) if excluded.contains(&registered.id) => {
5820                    continue;
5821                }
5822                DeliveryAudience::All
5823                | DeliveryAudience::Owners(_)
5824                | DeliveryAudience::AllExcept(_) => {}
5825            }
5826            if !registered.matches(&record.input) {
5827                continue;
5828            }
5829
5830            let outcome = registered
5831                .handler
5832                .handle(&record.context, &record.input, cache)
5833                .map_err(|source| ReactiveError::HandlerFailed {
5834                    handler_id: registered.id.clone(),
5835                    source,
5836                })?;
5837
5838            if let Err(error) =
5839                validate_effects(input_ref, &record.context, &registered.id, &outcome.effects)
5840            {
5841                if matches!(error, ReactiveError::InvalidPendingEffect { .. }) {
5842                    self.metrics
5843                        .pending_contamination
5844                        .fetch_add(1, Ordering::Relaxed);
5845                }
5846                return Err(error);
5847            }
5848            executions.push(HandlerExecution::from_outcome(
5849                registered.id.clone(),
5850                input_ref,
5851                outcome,
5852                matches!(
5853                    record.context.chain_status,
5854                    ChainStatus::Preconfirmed { .. }
5855                ),
5856            ));
5857        }
5858        Ok(executions)
5859    }
5860
5861    fn dispatch_reports(&self, reports: &[Arc<ReactiveReport<N>>]) {
5862        for report in reports {
5863            for hook in &self.hooks {
5864                hook.on_report(report.clone());
5865            }
5866        }
5867    }
5868
5869    fn apply_chain_control(
5870        &mut self,
5871        cache: &mut EvmCache,
5872        control: ChainControl,
5873        batch_report: &mut ReactiveBatchReport<N>,
5874        reports: &mut Vec<Arc<ReactiveReport<N>>>,
5875    ) {
5876        match &control {
5877            ChainControl::Safe(block) => set_or_enrich_block_ref(&mut self.safe_head, block),
5878            ChainControl::Finalized(block) => {
5879                set_or_enrich_block_ref(&mut self.finalized_head, block);
5880            }
5881            ChainControl::CanonicalProgress(block)
5882            | ChainControl::Barrier {
5883                block: Some(block), ..
5884            } => {
5885                let preserve_env = self.coverage_head.as_ref().is_some_and(|current| {
5886                    optional_block_refs_are_compatible(Some(current), Some(block))
5887                });
5888                cache.advance_compact_block(
5889                    block.number,
5890                    block.hash,
5891                    block.timestamp,
5892                    preserve_env,
5893                );
5894                advance_or_enrich_coverage(&mut self.coverage_head, block);
5895                let enriched = self.journal_entry_mut(block).block;
5896                advance_or_enrich_coverage(&mut self.coverage_head, &enriched);
5897                self.trim_journal();
5898            }
5899            ChainControl::LogCoverage(block) => {
5900                // An attestation, not progress: never advances the pinned block
5901                // or the canonical coverage head.
5902                set_or_enrich_block_ref(&mut self.log_coverage_head, block);
5903            }
5904            ChainControl::Barrier { block: None, .. } => {}
5905            ChainControl::Reorg {
5906                common_ancestor,
5907                old_tip,
5908                ..
5909            } => {
5910                cache.invalidate_cached_block_hashes_from(common_ancestor.number.saturating_add(1));
5911                self.rebase_validation_state_from(common_ancestor.number.saturating_add(1));
5912                let dropped = if let Some(ancestor_index) = self.journal.iter().rposition(|entry| {
5913                    entry.block.number == common_ancestor.number
5914                        && entry.block.hash == common_ancestor.hash
5915                }) {
5916                    self.drain_journal_after(ancestor_index)
5917                } else {
5918                    // Sparse journals are expected for blocks with no matching
5919                    // events. If the oldest retained entry is at or below the
5920                    // ancestor, every effect above it is still present and the
5921                    // rollback is complete even without an exact anchor.
5922                    if self
5923                        .journal
5924                        .front()
5925                        .is_none_or(|entry| entry.block.number > common_ancestor.number)
5926                    {
5927                        reports.extend(
5928                            self.warn_under_recovery(common_ancestor.number.saturating_add(1)),
5929                        );
5930                    }
5931                    self.drain_journal_from_number(common_ancestor.number.saturating_add(1))
5932                };
5933
5934                let reorg_report = self
5935                    .recover_dropped_journals(cache, dropped, ReorgReason::Explicit)
5936                    .unwrap_or_else(|| ReorgReport {
5937                        dropped: Some(*old_tip),
5938                        dropped_blocks: Vec::new(),
5939                        dropped_inputs: Vec::new(),
5940                        rollback_updates: Vec::new(),
5941                        rollback_diff: StateDiff::default(),
5942                        purge_updates: Vec::new(),
5943                        purge_diff: StateDiff::default(),
5944                        canceled_resyncs: self
5945                            .cancel_resyncs_for_dropped_blocks(std::slice::from_ref(old_tip)),
5946                        reason: ReorgReason::Explicit,
5947                        _network: PhantomData,
5948                    });
5949                remove_canceled_resyncs_from_batch(
5950                    &mut batch_report.resyncs,
5951                    &reorg_report.canceled_resyncs,
5952                );
5953                self.metrics
5954                    .reorgs_recovered
5955                    .fetch_add(1, Ordering::Relaxed);
5956                reports.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
5957
5958                if self.safe_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.safe_head = None;
5964                }
5965                if self.finalized_head.as_ref().is_some_and(|head| {
5966                    head.number > common_ancestor.number
5967                        || (head.number == common_ancestor.number
5968                            && head.hash != common_ancestor.hash)
5969                }) {
5970                    self.finalized_head = None;
5971                }
5972                let mut enriched_ancestor = *common_ancestor;
5973                if let Some(entry) = self.journal.iter().find(|entry| {
5974                    entry.block.number == common_ancestor.number
5975                        && entry.block.hash == common_ancestor.hash
5976                }) {
5977                    enrich_block_ref(&mut enriched_ancestor, &entry.block);
5978                }
5979                if let Some(current) = self.coverage_head.as_ref()
5980                    && current.number == common_ancestor.number
5981                    && current.hash == common_ancestor.hash
5982                {
5983                    enrich_block_ref(&mut enriched_ancestor, current);
5984                }
5985                self.coverage_head = Some(enriched_ancestor);
5986                cache.advance_compact_block(
5987                    enriched_ancestor.number,
5988                    enriched_ancestor.hash,
5989                    enriched_ancestor.timestamp,
5990                    false,
5991                );
5992                let enriched_ancestor = self.journal_entry_mut(&enriched_ancestor).block;
5993                self.coverage_head = Some(enriched_ancestor);
5994                self.trim_journal();
5995            }
5996        }
5997        reports.push(Arc::new(ReactiveReport::ChainControl(ChainControlReport {
5998            control,
5999        })));
6000    }
6001
6002    fn validate_ingest_sequence(
6003        &self,
6004        pre_record_controls: &[ChainControl],
6005        post_record_controls: &[ChainControl],
6006        records: &[(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)],
6007    ) -> Result<ChainControlState, ReactiveError> {
6008        let mut controls =
6009            Vec::with_capacity(pre_record_controls.len() + post_record_controls.len());
6010        controls.extend_from_slice(pre_record_controls);
6011        controls.extend_from_slice(post_record_controls);
6012        let state = CanonicalSequenceState::new(
6013            self.journal.iter().map(|entry| entry.block).collect(),
6014            self.coverage_head,
6015            self.safe_head,
6016            self.finalized_head,
6017        )
6018        .with_log_coverage_head(self.log_coverage_head);
6019        let record_metadata = records
6020            .iter()
6021            .map(|(record, _, scope)| (record, *scope))
6022            .collect::<Vec<_>>();
6023        let validation = validate_canonical_sequence_parts(
6024            &state,
6025            &controls,
6026            &record_metadata,
6027            CanonicalSequenceValidationPolicy::ObserveIncompleteRollback,
6028        )
6029        .map_err(CanonicalSequenceError::into_reactive_error)?;
6030        let mut resolved_canonical_blocks = HashMap::new();
6031        for mutation in validation.mutations() {
6032            if let CanonicalSequenceMutation::Canonical(block) = mutation {
6033                resolved_canonical_blocks
6034                    .entry((block.number, block.hash))
6035                    .and_modify(|known| enrich_block_ref(known, block))
6036                    .or_insert(*block);
6037            }
6038        }
6039        Ok(ChainControlState {
6040            journal_invalidated_from: pre_record_controls
6041                .iter()
6042                .filter_map(|control| match control {
6043                    ChainControl::Reorg {
6044                        common_ancestor, ..
6045                    } => Some(common_ancestor.number.saturating_add(1)),
6046                    _ => None,
6047                })
6048                .min(),
6049            resolved_canonical_blocks,
6050        })
6051    }
6052
6053    fn recover_for_canonical_input(
6054        &mut self,
6055        cache: &mut EvmCache,
6056        block: &BlockRef,
6057        gap_is_certified: bool,
6058        parentless_replacement_is_proven: bool,
6059        health_reports: &mut Vec<Arc<ReactiveReport<N>>>,
6060    ) -> Option<ReorgReport<N>> {
6061        let latest = self
6062            .coverage_head
6063            .or_else(|| self.journal.back().map(|entry| entry.block))?;
6064
6065        if latest.number == block.number && latest.hash == block.hash {
6066            return None;
6067        }
6068
6069        if self
6070            .journal
6071            .iter()
6072            .any(|entry| entry.block.hash == block.hash && entry.block.number == block.number)
6073        {
6074            return None;
6075        }
6076
6077        if latest.number.checked_add(1) == Some(block.number)
6078            && (block.parent_hash == Some(latest.hash)
6079                || (parentless_replacement_is_proven && block.parent_hash.is_none()))
6080        {
6081            return None;
6082        }
6083
6084        if latest
6085            .number
6086            .checked_add(1)
6087            .is_some_and(|next| block.number > next)
6088        {
6089            // A forward gap: blocks between the journaled head and the arriving
6090            // block were never observed (e.g. a disconnect). A historical
6091            // canonical-progress delivery can instead be covered by a
6092            // compatible post-record progress/barrier certificate proving the
6093            // sparse interval contained no matching events. Live canonical
6094            // gaps remain observable and escalate health.
6095            if !gap_is_certified {
6096                self.metrics.missed_ranges.fetch_add(1, Ordering::Relaxed);
6097                health_reports.extend(self.escalate_trust(block.number));
6098                health_reports.push(Arc::new(ReactiveReport::MissedBlockRange(
6099                    MissedRangeReport {
6100                        from: latest.number + 1,
6101                        to: block.number - 1,
6102                        block: block.number,
6103                        _network: PhantomData,
6104                    },
6105                )));
6106            }
6107            return None;
6108        }
6109
6110        let (dropped, authenticated_anchor) = if let Some(parent_hash) = block.parent_hash {
6111            if let Some(parent_index) = self.journal.iter().rposition(|entry| {
6112                entry.block.number.checked_add(1) == Some(block.number)
6113                    && entry.block.hash == parent_hash
6114            }) {
6115                let parent = self.journal[parent_index].block;
6116                cache.invalidate_cached_block_hashes_from(parent.number.saturating_add(1));
6117                (self.drain_journal_after(parent_index), Some(parent))
6118            } else {
6119                // An unknown immediate parent proves exactly N-1 and nothing
6120                // earlier. Preserve a prefix only when the accepted path is an
6121                // immediate child of the runtime's exact finalized anchor;
6122                // otherwise every cached BLOCKHASH may belong to the displaced
6123                // branch and must be cleared fail-closed.
6124                let proven_finalized_anchor = self.finalized_head.filter(|finalized| {
6125                    finalized.number.checked_add(1) == Some(block.number)
6126                        && parent_hash == finalized.hash
6127                });
6128                let invalidated_from = proven_finalized_anchor
6129                    .map_or(0, |finalized| finalized.number.saturating_add(1));
6130                cache.invalidate_cached_block_hashes_from(invalidated_from);
6131                if block.number > 0 {
6132                    // Even when the parent falls outside the retained journal,
6133                    // the arriving child authenticates its exact hash. Restore
6134                    // that one known value after clearing the displaced branch.
6135                    cache.set_cached_block_hash(block.number.saturating_sub(1), parent_hash);
6136                }
6137                health_reports.extend(self.warn_under_recovery(block.number));
6138                let dropped = if let Some(finalized) = proven_finalized_anchor {
6139                    self.drain_journal_from_number(finalized.number.saturating_add(1))
6140                } else {
6141                    self.drain_journal_from_number(0)
6142                };
6143                (dropped, proven_finalized_anchor)
6144            }
6145        } else {
6146            // No parent identity authenticates any prefix of the arriving path.
6147            cache.invalidate_cached_block_hashes_from(0);
6148            health_reports.extend(self.warn_under_recovery(block.number));
6149            (self.drain_journal_from_number(0), None)
6150        };
6151
6152        self.rebase_validation_state_from(
6153            authenticated_anchor.map_or(0, |anchor| anchor.number.saturating_add(1)),
6154        );
6155        let report = self
6156            .recover_dropped_journals(cache, dropped, ReorgReason::ParentMismatch)
6157            .or_else(|| {
6158                Some(ReorgReport {
6159                    dropped: Some(latest),
6160                    dropped_blocks: Vec::new(),
6161                    dropped_inputs: Vec::new(),
6162                    rollback_updates: Vec::new(),
6163                    rollback_diff: StateDiff::default(),
6164                    purge_updates: Vec::new(),
6165                    purge_diff: StateDiff::default(),
6166                    canceled_resyncs: self
6167                        .cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&latest)),
6168                    reason: ReorgReason::ParentMismatch,
6169                    _network: PhantomData,
6170                })
6171            });
6172        self.coverage_head = authenticated_anchor;
6173        for head in [&mut self.safe_head, &mut self.finalized_head] {
6174            if head.is_some_and(|head| {
6175                authenticated_anchor.is_none_or(|anchor| {
6176                    head.number > anchor.number
6177                        || (head.number == anchor.number && head.hash != anchor.hash)
6178                })
6179            }) {
6180                *head = None;
6181            }
6182        }
6183        if let Some(anchor) = authenticated_anchor {
6184            cache.advance_compact_block(anchor.number, anchor.hash, anchor.timestamp, false);
6185        }
6186        report
6187    }
6188
6189    fn recover_for_reorged_input(
6190        &mut self,
6191        cache: &mut EvmCache,
6192        record: &ReactiveInputRecord<N>,
6193        batch_dropped: &mut BatchDroppedCanonical,
6194        health_reports: &mut Vec<Arc<ReactiveReport<N>>>,
6195    ) -> Option<ReorgReport<N>> {
6196        let (incoming_dropped_block, reason) = reorg_signal_block(record)?;
6197        if batch_dropped.contains(&incoming_dropped_block) {
6198            // A previous signal in this atomic batch already drained this
6199            // block/span. Preserve the lifecycle input report, but do not
6200            // repeat rollback or classify the provider's per-log removals as a
6201            // deep reorg. Exact hash-pinned repairs still need cancellation.
6202            let canceled_resyncs = self
6203                .cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&incoming_dropped_block));
6204            return (!canceled_resyncs.is_empty()).then(|| ReorgReport {
6205                dropped: Some(incoming_dropped_block),
6206                dropped_blocks: vec![incoming_dropped_block],
6207                dropped_inputs: Vec::new(),
6208                rollback_updates: Vec::new(),
6209                rollback_diff: StateDiff::default(),
6210                purge_updates: Vec::new(),
6211                purge_diff: StateDiff::default(),
6212                canceled_resyncs,
6213                reason,
6214                _network: PhantomData,
6215            });
6216        }
6217        let exact_index = self.journal.iter().position(|entry| {
6218            entry.block.number == incoming_dropped_block.number
6219                && entry.block.hash == incoming_dropped_block.hash
6220        });
6221        let mut dropped_block = exact_index
6222            .map(|index| self.journal[index].block)
6223            .or_else(|| {
6224                self.coverage_head.filter(|known| {
6225                    known.number == incoming_dropped_block.number
6226                        && known.hash == incoming_dropped_block.hash
6227                })
6228            })
6229            .unwrap_or(incoming_dropped_block);
6230        enrich_block_ref(&mut dropped_block, &incoming_dropped_block);
6231        let replacement_is_known = exact_index.is_none()
6232            && (self.journal.iter().any(|entry| {
6233                entry.block.number == dropped_block.number && entry.block.hash != dropped_block.hash
6234            }) || self.coverage_head.is_some_and(|head| {
6235                head.number == dropped_block.number && head.hash != dropped_block.hash
6236            }));
6237
6238        if replacement_is_known {
6239            // A delayed/duplicate removed log for the displaced hash is
6240            // idempotent. Draining by number here would destroy the already
6241            // installed replacement branch at the same height.
6242            let canceled_resyncs =
6243                self.cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&dropped_block));
6244            return (!canceled_resyncs.is_empty()).then(|| ReorgReport {
6245                dropped: Some(dropped_block),
6246                dropped_blocks: vec![dropped_block],
6247                dropped_inputs: Vec::new(),
6248                rollback_updates: Vec::new(),
6249                rollback_diff: StateDiff::default(),
6250                purge_updates: Vec::new(),
6251                purge_diff: StateDiff::default(),
6252                canceled_resyncs,
6253                reason,
6254                _network: PhantomData,
6255            });
6256        }
6257
6258        let authenticated_anchor = exact_index.and_then(|index| {
6259            let ancestor_number = dropped_block.number.checked_sub(1)?;
6260            let retained = self
6261                .journal
6262                .iter()
6263                .take(index)
6264                .rev()
6265                .find(|entry| entry.block.number == ancestor_number)
6266                .map(|entry| entry.block);
6267            let synthetic_parent = dropped_block.parent_hash.map(|hash| BlockRef {
6268                number: ancestor_number,
6269                hash,
6270                parent_hash: None,
6271                timestamp: None,
6272            });
6273            let finalized_fallback = self
6274                .finalized_head
6275                .filter(|head| head.number == ancestor_number);
6276            let mut anchor = retained.or(synthetic_parent).or(finalized_fallback)?;
6277            for head in [self.safe_head.as_ref(), self.finalized_head.as_ref()]
6278                .into_iter()
6279                .flatten()
6280            {
6281                if head.number == anchor.number && head.hash == anchor.hash {
6282                    enrich_block_ref(&mut anchor, head);
6283                }
6284            }
6285            Some(anchor)
6286        });
6287
6288        cache.invalidate_cached_block_hashes_from(dropped_block.number);
6289        let dropped = if let Some(index) = exact_index {
6290            self.drain_journal_from(index)
6291        } else {
6292            health_reports.extend(self.warn_under_recovery(dropped_block.number));
6293            self.drain_journal_from_number(dropped_block.number)
6294        };
6295        let drained_blocks = dropped.iter().map(|entry| entry.block).collect::<Vec<_>>();
6296        batch_dropped.record_drained(&drained_blocks);
6297        batch_dropped.record_identity(&dropped_block);
6298        self.rebase_validation_state_from(dropped_block.number);
6299
6300        let recovered_journal = !dropped.is_empty();
6301        let report = if !recovered_journal {
6302            let canceled_resyncs =
6303                self.cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&dropped_block));
6304            Some(ReorgReport {
6305                dropped: Some(dropped_block),
6306                dropped_blocks: Vec::new(),
6307                dropped_inputs: Vec::new(),
6308                rollback_updates: Vec::new(),
6309                rollback_diff: StateDiff::default(),
6310                purge_updates: Vec::new(),
6311                purge_diff: StateDiff::default(),
6312                canceled_resyncs,
6313                reason,
6314                _network: PhantomData,
6315            })
6316        } else {
6317            self.recover_dropped_journals(cache, dropped, reason)
6318        };
6319
6320        if recovered_journal {
6321            if let Some(anchor) = authenticated_anchor {
6322                self.coverage_head = Some(anchor);
6323            }
6324            let coverage = self.coverage_head;
6325            for head in [&mut self.safe_head, &mut self.finalized_head] {
6326                if head.is_some_and(|head| {
6327                    coverage.is_none_or(|coverage| {
6328                        head.number > coverage.number
6329                            || (head.number == coverage.number && head.hash != coverage.hash)
6330                    })
6331                }) {
6332                    *head = None;
6333                }
6334            }
6335        }
6336
6337        if recovered_journal
6338            && report.is_some()
6339            && let Some(head) = self.coverage_head
6340        {
6341            cache.advance_compact_block(head.number, head.hash, head.timestamp, false);
6342        }
6343        report
6344    }
6345
6346    /// Warn that a reorg references a block no longer resident in the journal, so
6347    /// recovery is limited to the blocks still journaled — effects from aged-out
6348    /// blocks are neither rolled back nor purged (the freshness/validation loop is
6349    /// the backstop). Makes the under-recovery observable instead of silent.
6350    ///
6351    /// This is a deep reorg: it increments the `deep_reorgs` counter and escalates
6352    /// health along the trust-loss ladder via [`escalate_trust`](Self::escalate_trust)
6353    /// (a first event degrades to [`CacheHealth::Degraded`], a second escalates to
6354    /// [`CacheHealth::Unhealthy`]). Any resulting [`ReactiveReport::Health`]
6355    /// transition is returned so the caller can thread it into the ingest cycle's
6356    /// dispatched reports.
6357    fn warn_under_recovery(&mut self, reorg_number: u64) -> Option<Arc<ReactiveReport<N>>> {
6358        let oldest_journaled = self.journal.front().map(|entry| entry.block.number);
6359        tracing::warn!(
6360            reorg_block = reorg_number,
6361            oldest_journaled = ?oldest_journaled,
6362            journal_depth = self.config.journal_depth,
6363            "reactive reorg recovery is incomplete: the reorged block is no longer \
6364             in the journal, so effects from blocks aged out of the journal are \
6365             neither rolled back nor purged (the freshness/validation loop is the \
6366             backstop). Increase ReactiveConfig::journal_depth to recover deeper \
6367             reorgs precisely."
6368        );
6369
6370        self.metrics.deep_reorgs.fetch_add(1, Ordering::Relaxed);
6371
6372        self.escalate_trust(reorg_number)
6373    }
6374
6375    fn record_journal_input(&mut self, block: &BlockRef, input_ref: InputRef) {
6376        advance_or_enrich_coverage(&mut self.coverage_head, block);
6377        let entry = self.journal_entry_mut(block);
6378        let enriched = entry.block;
6379        if !entry.inputs.contains(&input_ref) {
6380            entry.inputs.push(input_ref);
6381        }
6382        advance_or_enrich_coverage(&mut self.coverage_head, &enriched);
6383        self.trim_journal();
6384    }
6385
6386    fn record_journal_applied(&mut self, block: &BlockRef, applied: AppliedReport<N>) {
6387        let entry = self.journal_entry_mut(block);
6388        if !entry.handler_ids.contains(&applied.handler_id) {
6389            entry.handler_ids.push(applied.handler_id.clone());
6390        }
6391        entry.rollback_diffs.push(applied.diff.clone());
6392        entry.applied.push(applied);
6393        self.trim_journal();
6394    }
6395
6396    fn record_journal_applied_if_present(&mut self, block: &BlockRef, applied: AppliedReport<N>) {
6397        let Some(entry) = self
6398            .journal
6399            .iter_mut()
6400            .find(|entry| entry.block.number == block.number && entry.block.hash == block.hash)
6401        else {
6402            return;
6403        };
6404        if !entry.handler_ids.contains(&applied.handler_id) {
6405            entry.handler_ids.push(applied.handler_id.clone());
6406        }
6407        entry.rollback_diffs.push(applied.diff.clone());
6408        entry.applied.push(applied);
6409    }
6410
6411    fn record_journal_resync(&mut self, report: &ResyncReport) {
6412        if report.diff.is_empty() {
6413            return;
6414        }
6415        let Some(block) = single_hash_pinned_resync_block(report) else {
6416            return;
6417        };
6418        let entry = self.journal_entry_mut(&block);
6419        entry.rollback_diffs.push(report.diff.clone());
6420        entry.resynced.push(report.clone());
6421        self.trim_journal();
6422    }
6423
6424    fn journal_entry_mut(&mut self, block: &BlockRef) -> &mut BlockJournal<N> {
6425        if let Some(index) = self
6426            .journal
6427            .iter()
6428            .position(|entry| entry.block.hash == block.hash && entry.block.number == block.number)
6429        {
6430            enrich_block_ref(&mut self.journal[index].block, block);
6431            return &mut self.journal[index];
6432        }
6433
6434        self.journal.push_back(BlockJournal {
6435            block: *block,
6436            inputs: Vec::new(),
6437            applied: Vec::new(),
6438            handler_ids: Vec::new(),
6439            resynced: Vec::new(),
6440            rollback_diffs: Vec::new(),
6441        });
6442        let index = self.journal.len() - 1;
6443        &mut self.journal[index]
6444    }
6445
6446    fn trim_journal(&mut self) {
6447        if self.config.journal_depth == 0 {
6448            self.journal.clear();
6449            return;
6450        }
6451        while self.journal.len() > self.config.journal_depth {
6452            self.journal.pop_front();
6453        }
6454    }
6455
6456    fn drain_journal_after(&mut self, index: usize) -> Vec<BlockJournal<N>> {
6457        self.journal.drain((index + 1)..).collect()
6458    }
6459
6460    fn drain_journal_from(&mut self, index: usize) -> Vec<BlockJournal<N>> {
6461        self.journal.drain(index..).collect()
6462    }
6463
6464    fn drain_journal_from_number(&mut self, number: u64) -> Vec<BlockJournal<N>> {
6465        let Some(index) = self
6466            .journal
6467            .iter()
6468            .position(|entry| entry.block.number >= number)
6469        else {
6470            return Vec::new();
6471        };
6472        self.drain_journal_from(index)
6473    }
6474
6475    fn recover_dropped_journals(
6476        &mut self,
6477        cache: &mut EvmCache,
6478        dropped: Vec<BlockJournal<N>>,
6479        reason: ReorgReason,
6480    ) -> Option<ReorgReport<N>> {
6481        if dropped.is_empty() {
6482            return None;
6483        }
6484
6485        let first_dropped_block = dropped
6486            .iter()
6487            .map(|entry| entry.block.number)
6488            .min()
6489            .expect("non-empty dropped journal set");
6490        self.rebase_validation_state_from(first_dropped_block);
6491        if self
6492            .safe_head
6493            .is_some_and(|head| head.number >= first_dropped_block)
6494        {
6495            self.safe_head = None;
6496        }
6497
6498        let dropped_blocks: Vec<_> = dropped.iter().map(|entry| entry.block).collect();
6499        let dropped_inputs: Vec<_> = dropped
6500            .iter()
6501            .flat_map(|entry| entry.inputs.iter().copied())
6502            .collect();
6503        let canceled_resyncs = self.cancel_resyncs_for_dropped_blocks(&dropped_blocks);
6504        let purge_scopes = purge_scopes_for_dropped_journals(&dropped);
6505        let rollback_updates = rollback_updates_for_dropped_journals(&dropped, &purge_scopes);
6506        let purge_updates: Vec<_> = purge_scopes
6507            .iter()
6508            .map(|(address, scope)| StateUpdate::purge(*address, scope.clone()))
6509            .collect();
6510
6511        let rollback_diff = if rollback_updates.is_empty() {
6512            StateDiff::default()
6513        } else {
6514            cache.apply_updates(&rollback_updates)
6515        };
6516        let purge_diff = if purge_updates.is_empty() {
6517            StateDiff::default()
6518        } else {
6519            cache.apply_updates(&purge_updates)
6520        };
6521        self.coverage_head = self.journal.back().map(|entry| entry.block);
6522
6523        Some(ReorgReport {
6524            dropped: dropped_blocks.first().cloned(),
6525            dropped_blocks,
6526            dropped_inputs,
6527            rollback_updates,
6528            rollback_diff,
6529            purge_updates,
6530            purge_diff,
6531            canceled_resyncs,
6532            reason,
6533            _network: PhantomData,
6534        })
6535    }
6536
6537    fn rebase_validation_state_from(&mut self, first_dropped_block: u64) {
6538        if let Some(freshness) = self.freshness.as_mut() {
6539            freshness.invalidate_valid_through_from(first_dropped_block);
6540        }
6541        self.tracked_roots
6542            .retain(|_, baseline| baseline.last_block < first_dropped_block);
6543        if self
6544            .last_gate_block
6545            .is_some_and(|block| block >= first_dropped_block)
6546        {
6547            self.last_gate_block = self
6548                .tracked_roots
6549                .values()
6550                .map(|baseline| baseline.last_block)
6551                .max();
6552        }
6553        // Touch provenance is window-relative. Once any block in that window
6554        // is dropped, retaining the union could incorrectly mark a replacement
6555        // branch root move as decoder-covered.
6556        self.touched_since_gate.clear();
6557    }
6558
6559    fn cancel_resyncs_for_dropped_blocks(
6560        &mut self,
6561        dropped_blocks: &[BlockRef],
6562    ) -> Vec<ResyncRequest> {
6563        let mut canceled = Vec::new();
6564        self.pending_resyncs.retain(|request| {
6565            let should_cancel = resync_request_targets_dropped_block(request, dropped_blocks);
6566            if should_cancel {
6567                canceled.push(request.clone());
6568            }
6569            !should_cancel
6570        });
6571        canceled
6572    }
6573
6574    fn remove_pending_resyncs<'a>(&mut self, ids: impl IntoIterator<Item = &'a ResyncId>) {
6575        let ids: HashSet<_> = ids.into_iter().cloned().collect();
6576        self.pending_resyncs
6577            .retain(|request| !ids.contains(&request.id));
6578    }
6579}
6580
6581fn install_preconfirmed_cache_context(cache: &mut EvmCache, flashblock: &FlashblockRef) {
6582    cache.set_block(BlockId::pending());
6583    cache.set_block_context(Some(flashblock.block_number), flashblock.base_fee_per_gas);
6584    cache.set_coinbase(flashblock.beneficiary);
6585    cache.set_prevrandao(flashblock.prevrandao);
6586    cache.set_block_gas_limit(flashblock.gas_limit);
6587    cache.set_timestamp(flashblock.timestamp);
6588}
6589
6590/// Validate one provider-neutral delivery envelope without mutating runtime or
6591/// cache state.
6592///
6593/// This is the canonical metadata contract shared by [`ReactiveRuntime`] and
6594/// composite/remote subscribers. It validates explicit reorg controls before
6595/// records, canonical record identity and implicit-reorg finality, then
6596/// progress/barrier/safe/finalized controls. All identity assertions in the
6597/// envelope must agree at each height. Retained history may be sparse; an
6598/// explicit common ancestor need not itself be retained when the oldest
6599/// retained entry is at or below it. Ancestors and removed blocks outside that
6600/// rollback horizon are rejected, so a durable caller cannot persist a partial
6601/// rollback. The runtime uses this same implementation with an internal
6602/// observable-deep-reorg policy for its deliberately non-durable ingest path.
6603///
6604/// The returned state and mutations are cache-free. Callers that durably stage
6605/// delivery should publish/persist them only at their own acknowledgement
6606/// boundary.
6607///
6608/// This validator is deliberately chain-agnostic and does not compare
6609/// [`ReactiveInputBatch::chain_id`] because [`CanonicalSequenceState`] carries
6610/// no chain id. Cross-service/composite callers must bind one authoritative
6611/// chain identity outside this state before sharing or advancing it; runtime
6612/// ingestion separately checks the batch id against [`EvmCache`].
6613///
6614/// # Errors
6615///
6616/// Returns [`ReactiveError::InvalidInputRecord`] when record identity/payload
6617/// metadata is malformed or conflicting, and
6618/// [`ReactiveError::InvalidChainControl`] when the snapshot or envelope has an
6619/// invalid canonical transition, incomplete rollback proof, contradictory
6620/// identity, or invalid coverage/finality relationship.
6621pub fn validate_canonical_sequence<N: Network>(
6622    state: &CanonicalSequenceState,
6623    batch: &ReactiveInputBatch<N>,
6624) -> Result<CanonicalSequenceValidation, ReactiveError> {
6625    validate_canonical_sequence_diagnostic(state, batch)
6626        .map_err(CanonicalSequenceError::into_reactive_error)
6627}
6628
6629/// Validate one provider-neutral delivery envelope and retain structured
6630/// rollback diagnostics.
6631///
6632/// This is the diagnostic counterpart to [`validate_canonical_sequence`]. Use
6633/// it at durable/composite source boundaries that need to distinguish malformed
6634/// input from an otherwise valid transition whose rollback ancestor has aged
6635/// out of the retained history. Callers should branch on
6636/// [`CanonicalSequenceError`] rather than parsing error text.
6637///
6638/// # Errors
6639///
6640/// Returns [`CanonicalSequenceError::Invalid`] for malformed or contradictory
6641/// state/input and [`CanonicalSequenceError::IncompleteRollback`] when more
6642/// retained canonical history is required to prove the transition.
6643pub fn validate_canonical_sequence_diagnostic<N: Network>(
6644    state: &CanonicalSequenceState,
6645    batch: &ReactiveInputBatch<N>,
6646) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
6647    validate_canonical_sequence_internal(
6648        state,
6649        batch,
6650        CanonicalSequenceValidationPolicy::RequireCompleteRollback,
6651    )
6652}
6653
6654/// Validate a composite-source envelope and normalize harmless coverage
6655/// overlap.
6656///
6657/// This has the same fail-closed rollback/finality/identity contract as
6658/// [`validate_canonical_sequence`]. In addition, an equal or older
6659/// [`ChainControl::CanonicalProgress`] whose exact compatible identity is
6660/// retained is omitted from [`CanonicalSequenceValidation::normalized_chain_controls`].
6661/// A compatible stale blockful [`ChainControl::Barrier`] is retained with the
6662/// same opaque id and `block: None`, preserving the synchronization event
6663/// without forwarding regressive coverage. An equal-height control that fills
6664/// absent parent/timestamp metadata is retained and applied. Older compatible
6665/// metadata enrichment is deliberately dropped together with its non-forwarded
6666/// control so the returned state remains identical to what the runtime will
6667/// observe. Unknown or conflicting stale identities remain errors.
6668///
6669/// # Errors
6670///
6671/// Returns [`ReactiveError::InvalidInputRecord`] for malformed or conflicting
6672/// record identity/payload metadata, and
6673/// [`ReactiveError::InvalidChainControl`] when canonical overlap cannot be
6674/// proven redundant or when rollback, adjacency, identity, coverage, or
6675/// finality validation fails.
6676pub fn normalize_and_validate_canonical_sequence<N: Network>(
6677    state: &CanonicalSequenceState,
6678    batch: &ReactiveInputBatch<N>,
6679) -> Result<CanonicalSequenceValidation, ReactiveError> {
6680    normalize_and_validate_canonical_sequence_diagnostic(state, batch)
6681        .map_err(CanonicalSequenceError::into_reactive_error)
6682}
6683
6684/// Validate and normalize one composite-source envelope while retaining
6685/// structured rollback diagnostics.
6686///
6687/// This is the diagnostic counterpart to
6688/// [`normalize_and_validate_canonical_sequence`]. It has identical transition
6689/// and normalization semantics, but reports history exhaustion as
6690/// [`CanonicalSequenceError::IncompleteRollback`] instead of folding it into a
6691/// prose [`ReactiveError::InvalidChainControl`].
6692///
6693/// # Errors
6694///
6695/// Returns [`CanonicalSequenceError::Invalid`] for malformed, contradictory, or
6696/// non-normalizable input and [`CanonicalSequenceError::IncompleteRollback`]
6697/// when the retained history cannot prove a complete rollback.
6698pub fn normalize_and_validate_canonical_sequence_diagnostic<N: Network>(
6699    state: &CanonicalSequenceState,
6700    batch: &ReactiveInputBatch<N>,
6701) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
6702    validate_canonical_sequence_internal(
6703        state,
6704        batch,
6705        CanonicalSequenceValidationPolicy::RequireCompleteRollbackNormalizeCoverage,
6706    )
6707}
6708
6709fn validate_canonical_sequence_internal<N: Network>(
6710    state: &CanonicalSequenceState,
6711    batch: &ReactiveInputBatch<N>,
6712    policy: CanonicalSequenceValidationPolicy,
6713) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
6714    let records = batch
6715        .records()
6716        .iter()
6717        .enumerate()
6718        .map(|(index, record)| {
6719            (
6720                record.clone(),
6721                DeliveryAudience::All,
6722                batch
6723                    .record_delivery_scope(index)
6724                    .expect("enumerated record always has a delivery scope"),
6725            )
6726        })
6727        .collect::<Vec<_>>();
6728    let records = sort_scoped_records(dedupe_scoped_records(records)?);
6729    let records = records
6730        .iter()
6731        .map(|(record, _, scope)| (record, *scope))
6732        .collect::<Vec<_>>();
6733    validate_canonical_sequence_parts(state, batch.chain_controls(), &records, policy)
6734}
6735
6736#[derive(Clone, Copy)]
6737enum CanonicalSequenceValidationPolicy {
6738    RequireCompleteRollback,
6739    RequireCompleteRollbackNormalizeCoverage,
6740    ObserveIncompleteRollback,
6741}
6742
6743/// Stable category for a canonical transition that needs older retained
6744/// history before it can be durably accepted.
6745#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6746#[non_exhaustive]
6747pub enum CanonicalRollbackKind {
6748    /// An explicit reorg control names an ancestor outside retained history.
6749    Explicit,
6750    /// A removed/reorged record names a block outside retained history.
6751    Removed,
6752    /// An implicit canonical replacement has no retained parent proof.
6753    ImplicitParent,
6754    /// A removed block is not followed by a provable replacement/anchor.
6755    MissingReplacement,
6756}
6757
6758/// Structured failure returned by canonical-sequence diagnostic validation.
6759///
6760/// This type is intentionally independent of diagnostic prose so remote and
6761/// composite subscribers can select recovery behavior without string matching.
6762#[derive(Debug, thiserror::Error)]
6763#[non_exhaustive]
6764pub enum CanonicalSequenceError {
6765    /// The snapshot or envelope is intrinsically malformed or contradictory.
6766    #[error(transparent)]
6767    Invalid(#[from] ReactiveError),
6768    /// The transition may be valid, but its rollback proof lies outside the
6769    /// supplied retained canonical history.
6770    #[error(
6771        "{kind:?} rollback after block {common_ancestor} exceeds retained canonical history starting at {oldest_retained:?}"
6772    )]
6773    IncompleteRollback {
6774        /// Last ancestor height required to prove the rollback.
6775        common_ancestor: u64,
6776        /// Oldest retained canonical height supplied by the caller.
6777        oldest_retained: Option<u64>,
6778        /// Stable reason the history window is insufficient.
6779        kind: CanonicalRollbackKind,
6780    },
6781}
6782
6783#[derive(Clone, Copy, Debug)]
6784struct RequiredReorgAnchor {
6785    number: u64,
6786    block: Option<BlockRef>,
6787    permits_missing_child_parent: bool,
6788    must_be_consumed: bool,
6789}
6790
6791#[derive(Debug)]
6792struct SequenceRewind {
6793    common_ancestor: Option<BlockRef>,
6794    dropped: Vec<BlockRef>,
6795}
6796
6797impl RequiredReorgAnchor {
6798    const fn hash(self) -> Option<B256> {
6799        match self.block {
6800            Some(block) => Some(block.hash),
6801            None => None,
6802        }
6803    }
6804}
6805
6806impl CanonicalSequenceError {
6807    /// Whether retrying with an older retained history window may prove this
6808    /// same transition.
6809    pub const fn requires_history(&self) -> bool {
6810        matches!(self, Self::IncompleteRollback { .. })
6811    }
6812
6813    /// Fold this structured diagnostic into the legacy ergonomic runtime error.
6814    pub fn into_reactive_error(self) -> ReactiveError {
6815        match self {
6816            Self::Invalid(error) => error,
6817            Self::IncompleteRollback {
6818                common_ancestor,
6819                oldest_retained,
6820                kind,
6821            } => ReactiveError::InvalidChainControl {
6822                message: format!(
6823                    "{kind:?} rollback after block {common_ancestor} exceeds retained canonical history starting at {oldest_retained:?}"
6824                ),
6825            },
6826        }
6827    }
6828}
6829
6830impl CanonicalSequenceValidationPolicy {
6831    const fn requires_complete_rollback(self) -> bool {
6832        matches!(
6833            self,
6834            Self::RequireCompleteRollback | Self::RequireCompleteRollbackNormalizeCoverage
6835        )
6836    }
6837
6838    const fn normalizes_coverage(self) -> bool {
6839        matches!(self, Self::RequireCompleteRollbackNormalizeCoverage)
6840    }
6841}
6842
6843fn validate_canonical_sequence_parts<N: Network>(
6844    initial: &CanonicalSequenceState,
6845    controls: &[ChainControl],
6846    records: &[(&ReactiveInputRecord<N>, DeliveryScope)],
6847    policy: CanonicalSequenceValidationPolicy,
6848) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
6849    validate_canonical_sequence_snapshot(initial)?;
6850    let control_split = validate_control_phase_order(controls)?;
6851    let (pre_record_controls, post_record_controls) = controls.split_at(control_split);
6852    let mut state = initial.clone();
6853    let mut asserted_blocks = HashMap::<u64, BlockRef>::new();
6854    let mut mutations = Vec::new();
6855    let mut normalized_chain_controls = Vec::with_capacity(controls.len());
6856    let mut batch_dropped = BatchDroppedCanonical::default();
6857    let mut removed_assertions = HashMap::<(u64, B256), BlockRef>::new();
6858    let mut removed_heights_by_hash = HashMap::<B256, u64>::new();
6859    let mut record_proof_control_identities = HashSet::<(u64, B256)>::new();
6860    let rollback_oldest = initial
6861        .retained_canonical_history
6862        .first()
6863        .map(|block| block.number);
6864
6865    for control in pre_record_controls {
6866        normalized_chain_controls.push(control.clone());
6867        validate_sequence_control(&state, control)?;
6868        assert_chain_control_identities(&mut asserted_blocks, control)?;
6869        let ChainControl::Reorg {
6870            common_ancestor,
6871            old_tip,
6872            ..
6873        } = control
6874        else {
6875            unreachable!("phase validation leaves only reorg controls before records")
6876        };
6877        let exact_ancestor = state.retained_canonical_history.iter().any(|block| {
6878            block.number == common_ancestor.number && block.hash == common_ancestor.hash
6879        });
6880        let rollback_horizon_covers_ancestor = state
6881            .retained_canonical_history
6882            .first()
6883            .is_some_and(|oldest| oldest.number <= common_ancestor.number);
6884        if policy.requires_complete_rollback()
6885            && !exact_ancestor
6886            && !rollback_horizon_covers_ancestor
6887        {
6888            return Err(CanonicalSequenceError::IncompleteRollback {
6889                common_ancestor: common_ancestor.number,
6890                oldest_retained: rollback_oldest,
6891                kind: CanonicalRollbackKind::Explicit,
6892            });
6893        }
6894        let dropped = state
6895            .retained_canonical_history
6896            .iter()
6897            .copied()
6898            .filter(|block| block.number > common_ancestor.number)
6899            .collect::<Vec<_>>();
6900        state
6901            .retained_canonical_history
6902            .retain(|block| block.number <= common_ancestor.number);
6903        upsert_sequence_history(&mut state.retained_canonical_history, common_ancestor)?;
6904        let mut enriched_ancestor = *common_ancestor;
6905        if let Some(retained) = state.retained_canonical_history.iter().find(|block| {
6906            block.number == common_ancestor.number && block.hash == common_ancestor.hash
6907        }) {
6908            enrich_block_ref(&mut enriched_ancestor, retained);
6909        }
6910        if let Some(coverage) = state.coverage_head.as_ref()
6911            && coverage.number == common_ancestor.number
6912            && coverage.hash == common_ancestor.hash
6913        {
6914            enrich_block_ref(&mut enriched_ancestor, coverage);
6915        }
6916        upsert_sequence_history(&mut state.retained_canonical_history, &enriched_ancestor)?;
6917        state.coverage_head = Some(enriched_ancestor);
6918        clear_sequence_heads_above(&mut state, &enriched_ancestor);
6919        batch_dropped.record_explicit(common_ancestor, old_tip);
6920        batch_dropped.record_drained(&dropped);
6921        mutations.push(CanonicalSequenceMutation::Rewind {
6922            common_ancestor: Some(enriched_ancestor),
6923            dropped,
6924        });
6925    }
6926    let pre_record_state = state.clone();
6927    let mut required_reorg_anchor = None::<RequiredReorgAnchor>;
6928
6929    for (record, scope) in records {
6930        if !scope.advances_canonical_state() {
6931            continue;
6932        }
6933        if let Some((incoming_dropped_block, _)) = reorg_signal_block(record) {
6934            let incoming_dropped_block =
6935                resolve_record_block_payload_metadata(record, incoming_dropped_block)?;
6936            validate_sequence_matching_metadata(&state, &incoming_dropped_block, "removed record")?;
6937            validate_sequence_adjacent_parent_identity(
6938                &state,
6939                &incoming_dropped_block,
6940                "removed record",
6941            )?;
6942            let mut dropped_block = state
6943                .retained_canonical_history
6944                .iter()
6945                .find(|known| {
6946                    known.number == incoming_dropped_block.number
6947                        && known.hash == incoming_dropped_block.hash
6948                })
6949                .copied()
6950                .or_else(|| {
6951                    state.coverage_head.filter(|known| {
6952                        known.number == incoming_dropped_block.number
6953                            && known.hash == incoming_dropped_block.hash
6954                    })
6955                })
6956                .unwrap_or(incoming_dropped_block);
6957            enrich_block_ref(&mut dropped_block, &incoming_dropped_block);
6958            validate_sequence_implicit_finality(&state, record, None)?;
6959            if dropped_block.number == 0 {
6960                return Err(ReactiveError::InvalidChainControl {
6961                    message: "a removed/reorged genesis block has no canonical parent anchor"
6962                        .into(),
6963                }
6964                .into());
6965            }
6966            let removed_identity = (dropped_block.number, dropped_block.hash);
6967            if let Some(previous_number) =
6968                removed_heights_by_hash.insert(dropped_block.hash, dropped_block.number)
6969                && previous_number != dropped_block.number
6970            {
6971                return Err(ReactiveError::InvalidChainControl {
6972                    message: format!(
6973                        "removed hash {:?} is reused at heights {} and {}",
6974                        dropped_block.hash, previous_number, dropped_block.number
6975                    ),
6976                }
6977                .into());
6978            }
6979            if let Some(previous) = removed_assertions.get_mut(&removed_identity) {
6980                if !optional_block_refs_are_compatible(Some(previous), Some(&dropped_block)) {
6981                    return Err(ReactiveError::InvalidChainControl {
6982                        message: format!(
6983                            "duplicate removed block {}:{:?} carries conflicting metadata",
6984                            dropped_block.number, dropped_block.hash
6985                        ),
6986                    }
6987                    .into());
6988                }
6989                enrich_block_ref(previous, &dropped_block);
6990            } else {
6991                removed_assertions.insert(removed_identity, dropped_block);
6992            }
6993            if asserted_blocks
6994                .get(&dropped_block.number)
6995                .is_some_and(|asserted| asserted.hash == dropped_block.hash)
6996            {
6997                return Err(ReactiveError::InvalidChainControl {
6998                    message: format!(
6999                        "removed block {}:{:?} is asserted canonical by the same envelope",
7000                        dropped_block.number, dropped_block.hash
7001                    ),
7002                }
7003                .into());
7004            }
7005            if batch_dropped.contains(&dropped_block) {
7006                continue;
7007            }
7008            if let Some(index) = state.retained_canonical_history.iter().position(|block| {
7009                block.number == dropped_block.number && block.hash == dropped_block.hash
7010            }) {
7011                let dropped = state.retained_canonical_history.split_off(index);
7012                batch_dropped.record_drained(&dropped);
7013                let ancestor_number = dropped_block
7014                    .number
7015                    .checked_sub(1)
7016                    .expect("genesis removal was rejected above");
7017                let retained_anchor = state
7018                    .retained_canonical_history
7019                    .iter()
7020                    .rev()
7021                    .find(|head| head.number == ancestor_number)
7022                    .copied();
7023                let authenticated_anchor = retained_anchor
7024                    .or_else(|| {
7025                        dropped_block.parent_hash.map(|hash| BlockRef {
7026                            number: ancestor_number,
7027                            hash,
7028                            parent_hash: None,
7029                            timestamp: None,
7030                        })
7031                    })
7032                    .or_else(|| {
7033                        state
7034                            .finalized_head
7035                            .filter(|head| head.number == ancestor_number)
7036                    });
7037                let authenticated_anchor = authenticated_anchor.map(|mut anchor| {
7038                    for head in [state.safe_head.as_ref(), state.finalized_head.as_ref()]
7039                        .into_iter()
7040                        .flatten()
7041                    {
7042                        if head.number == anchor.number && head.hash == anchor.hash {
7043                            enrich_block_ref(&mut anchor, head);
7044                        }
7045                    }
7046                    anchor
7047                });
7048                required_reorg_anchor = Some(RequiredReorgAnchor {
7049                    number: ancestor_number,
7050                    block: authenticated_anchor,
7051                    permits_missing_child_parent: retained_anchor.is_some(),
7052                    must_be_consumed: authenticated_anchor.is_none()
7053                        && state.retained_canonical_history.is_empty(),
7054                });
7055                state.coverage_head = authenticated_anchor
7056                    .or_else(|| state.retained_canonical_history.last().copied());
7057                if let Some(head) = state.coverage_head {
7058                    clear_sequence_heads_above(&mut state, &head);
7059                } else {
7060                    state.safe_head = None;
7061                    state.finalized_head = None;
7062                }
7063                mutations.push(CanonicalSequenceMutation::Rewind {
7064                    common_ancestor: state.coverage_head,
7065                    dropped,
7066                });
7067            } else {
7068                let replacement_is_known = state.retained_canonical_history.iter().any(|block| {
7069                    block.number == dropped_block.number && block.hash != dropped_block.hash
7070                }) || state.coverage_head.is_some_and(|head| {
7071                    head.number == dropped_block.number && head.hash != dropped_block.hash
7072                });
7073                if !replacement_is_known {
7074                    // Ordinary runtime ingestion deliberately keeps an unknown
7075                    // deep removal observable and lets the recovery path
7076                    // degrade health. With no exact retained rollback proof,
7077                    // this validator must not fabricate a new canonical head.
7078                    if policy.requires_complete_rollback() {
7079                        return Err(CanonicalSequenceError::IncompleteRollback {
7080                            common_ancestor: dropped_block
7081                                .number
7082                                .checked_sub(1)
7083                                .expect("genesis removal was rejected above"),
7084                            oldest_retained: rollback_oldest,
7085                            kind: CanonicalRollbackKind::Removed,
7086                        });
7087                    }
7088                    continue;
7089                }
7090            }
7091            continue;
7092        }
7093
7094        let Some(context_block) = canonical_record_block(record) else {
7095            continue;
7096        };
7097        let incoming_block = resolve_record_block_payload_metadata(record, *context_block)?;
7098        if post_record_controls
7099            .iter()
7100            .filter_map(canonical_coverage_control_block)
7101            .any(|asserted| {
7102                asserted.number == incoming_block.number
7103                    && asserted.hash == incoming_block.hash
7104                    && optional_block_refs_are_compatible(Some(asserted), Some(&incoming_block))
7105                    && ((incoming_block.parent_hash.is_none() && asserted.parent_hash.is_some())
7106                        || (incoming_block.timestamp.is_none() && asserted.timestamp.is_some()))
7107            })
7108        {
7109            record_proof_control_identities.insert((incoming_block.number, incoming_block.hash));
7110        }
7111        let mut resolved_block = incoming_block;
7112        if let Some(asserted) = asserted_blocks
7113            .get(&incoming_block.number)
7114            .filter(|asserted| asserted.hash == incoming_block.hash)
7115        {
7116            if !optional_block_refs_are_compatible(Some(asserted), Some(&incoming_block)) {
7117                return Err(ReactiveError::InvalidChainControl {
7118                    message: format!(
7119                        "canonical record {}:{:?} conflicts with the same envelope's asserted metadata",
7120                        incoming_block.number, incoming_block.hash
7121                    ),
7122                }
7123                .into());
7124            }
7125            enrich_block_ref(&mut resolved_block, asserted);
7126        }
7127        for asserted in post_record_controls
7128            .iter()
7129            .filter_map(chain_control_canonical_assertion)
7130            .filter(|asserted| {
7131                asserted.number == incoming_block.number && asserted.hash == incoming_block.hash
7132            })
7133        {
7134            if !optional_block_refs_are_compatible(Some(&resolved_block), Some(asserted)) {
7135                return Err(ReactiveError::InvalidChainControl {
7136                    message: format!(
7137                        "canonical record {}:{:?} conflicts with the same envelope's asserted metadata",
7138                        incoming_block.number, incoming_block.hash
7139                    ),
7140                }
7141                .into());
7142            }
7143            enrich_block_ref(&mut resolved_block, asserted);
7144        }
7145        let replacement_anchor =
7146            required_reorg_anchor.filter(|required| resolved_block.number > required.number);
7147        if resolved_block.parent_hash.is_none()
7148            && replacement_anchor.is_some_and(|anchor| {
7149                anchor.permits_missing_child_parent
7150                    && anchor.number.checked_add(1) == Some(resolved_block.number)
7151            })
7152        {
7153            resolved_block.parent_hash = replacement_anchor.and_then(RequiredReorgAnchor::hash);
7154        }
7155        let block = &resolved_block;
7156        if removed_assertions.contains_key(&(block.number, block.hash)) {
7157            return Err(ReactiveError::InvalidChainControl {
7158                message: format!(
7159                    "canonical block {}:{:?} is also removed by the same envelope",
7160                    block.number, block.hash
7161                ),
7162            }
7163            .into());
7164        }
7165        if let Some(removed_number) = removed_heights_by_hash.get(&block.hash)
7166            && *removed_number != block.number
7167        {
7168            return Err(ReactiveError::InvalidChainControl {
7169                message: format!(
7170                    "canonical hash {:?} at height {} is removed at height {} by the same envelope",
7171                    block.hash, block.number, removed_number
7172                ),
7173            }
7174            .into());
7175        }
7176        let replacement_proven_by_removal =
7177            validate_replacement_reorg_anchor(replacement_anchor, block, policy, rollback_oldest)?;
7178        if replacement_anchor.is_some() {
7179            required_reorg_anchor = None;
7180        }
7181        validate_sequence_matching_metadata(&state, block, "canonical record")?;
7182        validate_sequence_implicit_finality(&state, record, Some(block))?;
7183        let implicit_replacement_requires_history = if replacement_proven_by_removal {
7184            false
7185        } else {
7186            sequence_implicit_replacement_requires_history(&state, block, policy)?
7187        };
7188        if implicit_replacement_requires_history && policy.requires_complete_rollback() {
7189            return Err(CanonicalSequenceError::IncompleteRollback {
7190                common_ancestor: block.number.saturating_sub(1),
7191                oldest_retained: rollback_oldest,
7192                kind: CanonicalRollbackKind::ImplicitParent,
7193            });
7194        }
7195        assert_canonical_block_identity(&mut asserted_blocks, block, "canonical record")?;
7196        let allow_parentless_extension = replacement_anchor.is_some_and(|anchor| {
7197            anchor.permits_missing_child_parent
7198                && anchor.number.checked_add(1) == Some(block.number)
7199        });
7200        if let Some(rewind) =
7201            apply_sequence_canonical_block(&mut state, block, allow_parentless_extension)?
7202        {
7203            mutations.push(CanonicalSequenceMutation::Rewind {
7204                common_ancestor: rewind.common_ancestor,
7205                dropped: rewind.dropped,
7206            });
7207        }
7208        mutations.push(CanonicalSequenceMutation::Canonical(*block));
7209    }
7210
7211    for control in post_record_controls {
7212        if let Some(block) = chain_control_canonical_assertion(control)
7213            && removed_assertions.contains_key(&(block.number, block.hash))
7214        {
7215            return Err(ReactiveError::InvalidChainControl {
7216                message: format!(
7217                    "canonical block {}:{:?} is also removed by the same envelope",
7218                    block.number, block.hash
7219                ),
7220            }
7221            .into());
7222        }
7223        if let Some(block) = chain_control_canonical_assertion(control)
7224            && let Some(removed_number) = removed_heights_by_hash.get(&block.hash)
7225            && *removed_number != block.number
7226        {
7227            return Err(ReactiveError::InvalidChainControl {
7228                message: format!(
7229                    "canonical hash {:?} at height {} is removed at height {} by the same envelope",
7230                    block.hash, block.number, removed_number
7231                ),
7232            }
7233            .into());
7234        }
7235        let replacement_anchor = canonical_coverage_control_block(control).and_then(|block| {
7236            required_reorg_anchor.filter(|required| block.number > required.number)
7237        });
7238        if let Some(block) = canonical_coverage_control_block(control) {
7239            validate_replacement_reorg_anchor(replacement_anchor, block, policy, rollback_oldest)?;
7240            if replacement_anchor.is_some() {
7241                required_reorg_anchor = None;
7242            }
7243        }
7244        assert_chain_control_identities(&mut asserted_blocks, control)?;
7245        let preserves_record_proof =
7246            canonical_coverage_control_block(control).is_some_and(|block| {
7247                record_proof_control_identities.contains(&(block.number, block.hash))
7248            });
7249        if policy.normalizes_coverage()
7250            && !preserves_record_proof
7251            && let Some(block) = canonical_coverage_control_block(control)
7252            && state
7253                .coverage_head
7254                .is_some_and(|head| block.number <= head.number)
7255        {
7256            let is_equal_coverage = state
7257                .coverage_head
7258                .is_some_and(|head| block.number == head.number);
7259            let known = state
7260                .coverage_head
7261                .as_ref()
7262                .filter(|head| head.number == block.number && head.hash == block.hash)
7263                .or_else(|| {
7264                    state
7265                        .retained_canonical_history
7266                        .iter()
7267                        .find(|entry| entry.number == block.number && entry.hash == block.hash)
7268                });
7269            if let Some(known) = known
7270                && optional_block_refs_are_compatible(Some(known), Some(block))
7271                && (!is_equal_coverage || !sequence_block_adds_metadata(&state, block))
7272            {
7273                if let ChainControl::Barrier { id, .. } = control {
7274                    normalized_chain_controls.push(ChainControl::Barrier {
7275                        id: id.clone(),
7276                        block: None,
7277                    });
7278                }
7279                continue;
7280            }
7281        }
7282        validate_sequence_control(&state, control)?;
7283        normalized_chain_controls.push(control.clone());
7284        match control {
7285            ChainControl::Safe(block) => {
7286                set_or_enrich_block_ref(&mut state.safe_head, block);
7287                mutations.push(CanonicalSequenceMutation::Safe(
7288                    state.safe_head.expect("safe head was just installed"),
7289                ));
7290            }
7291            ChainControl::Finalized(block) => {
7292                set_or_enrich_block_ref(&mut state.finalized_head, block);
7293                mutations.push(CanonicalSequenceMutation::Finalized(
7294                    state
7295                        .finalized_head
7296                        .expect("finalized head was just installed"),
7297                ));
7298            }
7299            ChainControl::CanonicalProgress(block)
7300            | ChainControl::Barrier {
7301                block: Some(block), ..
7302            } => {
7303                let allow_parentless_extension = replacement_anchor.is_some_and(|anchor| {
7304                    anchor.permits_missing_child_parent
7305                        && anchor.number.checked_add(1) == Some(block.number)
7306                }) || (replacement_anchor.is_none()
7307                    && block.parent_hash.is_none()
7308                    && state
7309                        .coverage_head
7310                        .is_some_and(|head| head.number.checked_add(1) == Some(block.number)));
7311                if let Some(rewind) =
7312                    apply_sequence_canonical_block(&mut state, block, allow_parentless_extension)?
7313                {
7314                    mutations.push(CanonicalSequenceMutation::Rewind {
7315                        common_ancestor: rewind.common_ancestor,
7316                        dropped: rewind.dropped,
7317                    });
7318                }
7319                mutations.push(CanonicalSequenceMutation::Canonical(*block));
7320            }
7321            ChainControl::LogCoverage(block) => {
7322                set_or_enrich_block_ref(&mut state.log_coverage_head, block);
7323                mutations.push(CanonicalSequenceMutation::LogCoverage(
7324                    state
7325                        .log_coverage_head
7326                        .expect("log coverage head was just installed"),
7327                ));
7328            }
7329            ChainControl::Barrier { block: None, .. } => {}
7330            ChainControl::Reorg { .. } => {
7331                unreachable!("phase validation excludes post-record reorg controls")
7332            }
7333        }
7334    }
7335
7336    if let Some(required) = required_reorg_anchor
7337        && required.must_be_consumed
7338        && policy.requires_complete_rollback()
7339    {
7340        return Err(CanonicalSequenceError::IncompleteRollback {
7341            common_ancestor: required.number,
7342            oldest_retained: rollback_oldest,
7343            kind: CanonicalRollbackKind::MissingReplacement,
7344        });
7345    }
7346
7347    validate_canonical_sequence_snapshot(&state)?;
7348    Ok(CanonicalSequenceValidation {
7349        pre_record_state,
7350        next_state: state,
7351        mutations,
7352        normalized_chain_controls,
7353    })
7354}
7355
7356fn validate_canonical_sequence_snapshot(
7357    state: &CanonicalSequenceState,
7358) -> Result<(), ReactiveError> {
7359    let invalid = |message: String| ReactiveError::InvalidChainControl { message };
7360    let supplied_blocks = state
7361        .retained_canonical_history
7362        .iter()
7363        .chain(state.coverage_head.iter())
7364        .chain(state.safe_head.iter())
7365        .chain(state.finalized_head.iter())
7366        .collect::<Vec<_>>();
7367    validate_known_parent_hash_heights(&supplied_blocks)?;
7368    let mut prior = None::<BlockRef>;
7369    for block in &state.retained_canonical_history {
7370        if let Some(previous) = prior {
7371            if block.number < previous.number {
7372                return Err(invalid(
7373                    "retained canonical history is not ordered by block number".into(),
7374                ));
7375            }
7376            if block.number == previous.number {
7377                let qualifier = if optional_block_refs_are_compatible(Some(&previous), Some(block))
7378                {
7379                    "duplicate"
7380                } else {
7381                    "conflicting"
7382                };
7383                return Err(invalid(format!(
7384                    "retained canonical history contains {qualifier} identities at block {}",
7385                    block.number
7386                )));
7387            }
7388            if previous.number.checked_add(1) == Some(block.number)
7389                && block.parent_hash.is_some()
7390                && block.parent_hash != Some(previous.hash)
7391            {
7392                return Err(invalid(format!(
7393                    "adjacent retained block {}:{:?} does not descend from {}:{:?}",
7394                    block.number, block.hash, previous.number, previous.hash
7395                )));
7396            }
7397        }
7398        prior = Some(*block);
7399    }
7400    if state.coverage_head.is_none() && !state.retained_canonical_history.is_empty() {
7401        return Err(invalid(
7402            "retained canonical history requires an authoritative coverage head".into(),
7403        ));
7404    }
7405    if let Some(head) = state.coverage_head.as_ref() {
7406        if let Some(retained) = state
7407            .retained_canonical_history
7408            .iter()
7409            .find(|entry| entry.number == head.number)
7410            && !optional_block_refs_are_compatible(Some(retained), Some(head))
7411        {
7412            return Err(invalid(format!(
7413                "coverage head {}:{:?} conflicts with retained identity {:?}",
7414                head.number, head.hash, retained
7415            )));
7416        }
7417        if state
7418            .retained_canonical_history
7419            .last()
7420            .is_some_and(|retained| retained.number > head.number)
7421        {
7422            return Err(invalid(
7423                "retained canonical history advances beyond the coverage head".into(),
7424            ));
7425        }
7426        if let Some(retained) = state.retained_canonical_history.last()
7427            && retained.number.checked_add(1) == Some(head.number)
7428            && head.parent_hash.is_some()
7429            && head.parent_hash != Some(retained.hash)
7430        {
7431            return Err(invalid(format!(
7432                "coverage head {}:{:?} does not descend from adjacent retained block {}:{:?}",
7433                head.number, head.hash, retained.number, retained.hash
7434            )));
7435        }
7436    }
7437    if let Some(safe) = state.safe_head.as_ref() {
7438        validate_sequence_known_identity(state, safe, "safe")?;
7439        validate_sequence_head_within_coverage(state, safe, "safe")?;
7440        validate_coverage_descends_from_adjacent_head(state.coverage_head.as_ref(), safe, "safe")?;
7441    }
7442    if let Some(finalized) = state.finalized_head.as_ref() {
7443        validate_sequence_known_identity(state, finalized, "finalized")?;
7444        validate_sequence_head_within_coverage(state, finalized, "finalized")?;
7445        validate_coverage_descends_from_adjacent_head(
7446            state.coverage_head.as_ref(),
7447            finalized,
7448            "finalized",
7449        )?;
7450    }
7451    validate_adjacent_finality(state.finalized_head.as_ref(), state.safe_head.as_ref())?;
7452    if let (Some(finalized), Some(safe)) = (state.finalized_head, state.safe_head)
7453        && (finalized.number > safe.number
7454            || (finalized.number == safe.number && finalized.hash != safe.hash))
7455    {
7456        return Err(invalid(
7457            "finalized head cannot advance beyond or conflict with safe head".into(),
7458        ));
7459    }
7460    Ok(())
7461}
7462
7463fn validate_known_parent_hash_heights(blocks: &[&BlockRef]) -> Result<(), ReactiveError> {
7464    let mut heights_by_hash = HashMap::<B256, u64>::with_capacity(blocks.len());
7465    let mut resolved_by_height = HashMap::<u64, BlockRef>::with_capacity(blocks.len());
7466    for block in blocks.iter().copied() {
7467        if let Some(previous_height) = heights_by_hash.insert(block.hash, block.number)
7468            && previous_height != block.number
7469        {
7470            return Err(ReactiveError::InvalidChainControl {
7471                message: format!(
7472                    "canonical hash {:?} is reused at heights {} and {}",
7473                    block.hash, previous_height, block.number
7474                ),
7475            });
7476        }
7477        if let Some(resolved) = resolved_by_height.get_mut(&block.number) {
7478            if !optional_block_refs_are_compatible(Some(resolved), Some(block)) {
7479                return Err(ReactiveError::InvalidChainControl {
7480                    message: format!(
7481                        "canonical aliases at height {} carry conflicting identities or metadata",
7482                        block.number
7483                    ),
7484                });
7485            }
7486            enrich_block_ref(resolved, block);
7487        } else {
7488            resolved_by_height.insert(block.number, *block);
7489        }
7490    }
7491    for child in resolved_by_height.values() {
7492        let Some(parent_hash) = child.parent_hash else {
7493            continue;
7494        };
7495        if let Some(parent_number) = heights_by_hash.get(&parent_hash)
7496            && parent_number.checked_add(1) != Some(child.number)
7497        {
7498            return Err(ReactiveError::InvalidChainControl {
7499                message: format!(
7500                    "block {}:{:?} names hash {:?} from known height {} as a non-adjacent parent",
7501                    child.number, child.hash, parent_hash, parent_number
7502                ),
7503            });
7504        }
7505        if let Some(parent_number) = child.number.checked_sub(1)
7506            && let Some(parent) = resolved_by_height.get(&parent_number)
7507            && parent.hash != parent_hash
7508        {
7509            return Err(ReactiveError::InvalidChainControl {
7510                message: format!(
7511                    "block {}:{:?} does not descend from supplied adjacent identity {}:{:?}",
7512                    child.number, child.hash, parent.number, parent.hash
7513                ),
7514            });
7515        }
7516    }
7517    Ok(())
7518}
7519
7520fn validate_coverage_descends_from_adjacent_head(
7521    coverage: Option<&BlockRef>,
7522    head: &BlockRef,
7523    label: &str,
7524) -> Result<(), ReactiveError> {
7525    let Some(coverage) = coverage else {
7526        return Ok(());
7527    };
7528    if head.number.checked_add(1) == Some(coverage.number)
7529        && coverage
7530            .parent_hash
7531            .is_some_and(|parent| parent != head.hash)
7532    {
7533        return Err(ReactiveError::InvalidChainControl {
7534            message: format!(
7535                "canonical coverage {}:{:?} does not descend from adjacent {label} head {}:{:?}",
7536                coverage.number, coverage.hash, head.number, head.hash
7537            ),
7538        });
7539    }
7540    Ok(())
7541}
7542
7543fn validate_sequence_control(
7544    state: &CanonicalSequenceState,
7545    control: &ChainControl,
7546) -> Result<(), ReactiveError> {
7547    let invalid = |message: String| ReactiveError::InvalidChainControl { message };
7548    match control {
7549        ChainControl::Safe(block) => {
7550            validate_sequence_known_identity(state, block, "safe")?;
7551            validate_sequence_head_within_coverage(state, block, "safe")?;
7552            if let Some(current) = state.safe_head.as_ref()
7553                && (block.number < current.number
7554                    || (block.number == current.number
7555                        && (block.hash != current.hash
7556                            || !optional_block_refs_are_compatible(Some(block), Some(current)))))
7557            {
7558                return Err(invalid(format!(
7559                    "safe head {}:{:?} conflicts with current {}:{:?}",
7560                    block.number, block.hash, current.number, current.hash
7561                )));
7562            }
7563            if let Some(finalized) = state.finalized_head.as_ref()
7564                && (block.number < finalized.number
7565                    || (block.number == finalized.number && block.hash != finalized.hash))
7566            {
7567                return Err(invalid(
7568                    "safe head cannot precede or conflict with finalized head".into(),
7569                ));
7570            }
7571            validate_adjacent_finality(state.finalized_head.as_ref(), Some(block))?;
7572        }
7573        ChainControl::Finalized(block) => {
7574            validate_sequence_known_identity(state, block, "finalized")?;
7575            validate_sequence_head_within_coverage(state, block, "finalized")?;
7576            if let Some(current) = state.finalized_head.as_ref()
7577                && (block.number < current.number
7578                    || (block.number == current.number
7579                        && (block.hash != current.hash
7580                            || !optional_block_refs_are_compatible(Some(block), Some(current)))))
7581            {
7582                return Err(invalid(format!(
7583                    "finalized head {}:{:?} conflicts with current {}:{:?}",
7584                    block.number, block.hash, current.number, current.hash
7585                )));
7586            }
7587            if let Some(safe) = state.safe_head.as_ref()
7588                && (block.number > safe.number
7589                    || (block.number == safe.number && block.hash != safe.hash))
7590            {
7591                return Err(invalid(
7592                    "finalized head cannot advance beyond or conflict with safe head".into(),
7593                ));
7594            }
7595            validate_adjacent_finality(Some(block), state.safe_head.as_ref())?;
7596        }
7597        ChainControl::CanonicalProgress(block)
7598        | ChainControl::Barrier {
7599            block: Some(block), ..
7600        } => {
7601            validate_sequence_known_identity(state, block, "canonical coverage")?;
7602            if let Some(current) = state.coverage_head.as_ref()
7603                && (block.number < current.number
7604                    || (block.number == current.number && block.hash != current.hash))
7605            {
7606                return Err(invalid(format!(
7607                    "canonical coverage {}:{:?} conflicts with current {}:{:?}",
7608                    block.number, block.hash, current.number, current.hash
7609                )));
7610            }
7611            if let Some(current) = state.coverage_head.as_ref()
7612                && current.number.checked_add(1) == Some(block.number)
7613                && block.parent_hash.is_some()
7614                && block.parent_hash != Some(current.hash)
7615            {
7616                return Err(invalid(format!(
7617                    "canonical coverage {}:{:?} does not descend from current {}:{:?}",
7618                    block.number, block.hash, current.number, current.hash
7619                )));
7620            }
7621        }
7622        ChainControl::LogCoverage(block) => {
7623            validate_sequence_known_identity(state, block, "log coverage")?;
7624            // Monotonic: an attestation may be re-sent for the same block but
7625            // must never retreat, or a consumer could widen a window it had
7626            // already narrowed.
7627            if let Some(current) = state.log_coverage_head.as_ref()
7628                && (block.number < current.number
7629                    || (block.number == current.number && block.hash != current.hash))
7630            {
7631                return Err(invalid(format!(
7632                    "log coverage {}:{:?} conflicts with current {}:{:?}",
7633                    block.number, block.hash, current.number, current.hash
7634                )));
7635            }
7636            // Logs cannot be attested complete for a block the source has not
7637            // established canonical coverage for.
7638            if let Some(coverage) = state.coverage_head.as_ref()
7639                && block.number > coverage.number
7640            {
7641                return Err(invalid(format!(
7642                    "log coverage {}:{:?} is ahead of canonical coverage {}:{:?}",
7643                    block.number, block.hash, coverage.number, coverage.hash
7644                )));
7645            }
7646        }
7647        ChainControl::Barrier { block: None, .. } => {}
7648        ChainControl::Reorg {
7649            common_ancestor,
7650            old_tip,
7651            new_tip,
7652        } => {
7653            validate_sequence_known_identity(state, common_ancestor, "reorg common ancestor")?;
7654            validate_reorg_ancestor_against_retained_branch(state, common_ancestor)?;
7655            validate_sequence_known_hash_height(state, old_tip, "reorg old tip")?;
7656            validate_sequence_known_hash_height(state, new_tip, "reorg new tip")?;
7657            validate_sequence_known_parent_height(state, old_tip, "reorg old tip")?;
7658            validate_sequence_known_parent_height(state, new_tip, "reorg new tip")?;
7659            validate_sequence_adjacent_parent_identity(state, old_tip, "reorg old tip")?;
7660            if let Some(current) = state.coverage_head.as_ref()
7661                && (old_tip.number != current.number
7662                    || old_tip.hash != current.hash
7663                    || !optional_block_refs_are_compatible(Some(old_tip), Some(current)))
7664            {
7665                return Err(invalid(format!(
7666                    "reorg old tip {}:{:?} does not exactly match current metadata {}:{:?}",
7667                    old_tip.number, old_tip.hash, current.number, current.hash
7668                )));
7669            }
7670            if common_ancestor.number > old_tip.number || common_ancestor.number > new_tip.number {
7671                return Err(invalid(
7672                    "reorg common ancestor cannot be above either branch tip".into(),
7673                ));
7674            }
7675            if common_ancestor.number == old_tip.number || common_ancestor.number == new_tip.number
7676            {
7677                return Err(invalid(
7678                    "reorg must replace non-empty old and new branches above the common ancestor"
7679                        .into(),
7680                ));
7681            }
7682            if old_tip.number == new_tip.number && old_tip.hash == new_tip.hash {
7683                return Err(invalid(
7684                    "reorg old and new tips cannot have the same canonical identity".into(),
7685                ));
7686            }
7687            for (label, tip) in [("old", old_tip), ("new", new_tip)] {
7688                if common_ancestor.number.checked_add(1) == Some(tip.number)
7689                    && tip.parent_hash != Some(common_ancestor.hash)
7690                {
7691                    return Err(invalid(format!(
7692                        "reorg {label} tip does not descend from the common ancestor"
7693                    )));
7694                }
7695            }
7696            if let Some(finalized) = state.finalized_head.as_ref()
7697                && (common_ancestor.number < finalized.number
7698                    || (common_ancestor.number == finalized.number
7699                        && common_ancestor.hash != finalized.hash))
7700            {
7701                return Err(invalid(
7702                    "reorg would cross or conflict with the finalized head".into(),
7703                ));
7704            }
7705        }
7706    }
7707    Ok(())
7708}
7709
7710fn validate_sequence_known_identity(
7711    state: &CanonicalSequenceState,
7712    block: &BlockRef,
7713    label: &str,
7714) -> Result<(), ReactiveError> {
7715    validate_sequence_known_hash_height(state, block, label)?;
7716    validate_sequence_known_parent_height(state, block, label)?;
7717    let known = state
7718        .coverage_head
7719        .as_ref()
7720        .filter(|head| head.number == block.number)
7721        .or_else(|| {
7722            state
7723                .retained_canonical_history
7724                .iter()
7725                .find(|entry| entry.number == block.number)
7726        });
7727    if let Some(known) = known
7728        && !optional_block_refs_are_compatible(Some(known), Some(block))
7729    {
7730        return Err(ReactiveError::InvalidChainControl {
7731            message: format!(
7732                "{label} block {}:{:?} conflicts with known canonical block {:?}",
7733                block.number, block.hash, known
7734            ),
7735        });
7736    }
7737    Ok(())
7738}
7739
7740fn validate_sequence_known_parent_height(
7741    state: &CanonicalSequenceState,
7742    block: &BlockRef,
7743    label: &str,
7744) -> Result<(), ReactiveError> {
7745    let Some(parent_hash) = block.parent_hash else {
7746        return Ok(());
7747    };
7748    let known_parent = state
7749        .retained_canonical_history
7750        .iter()
7751        .chain(state.coverage_head.iter())
7752        .chain(state.safe_head.iter())
7753        .chain(state.finalized_head.iter())
7754        .find(|known| known.hash == parent_hash);
7755    if let Some(parent) = known_parent
7756        && parent.number.checked_add(1) != Some(block.number)
7757    {
7758        return Err(ReactiveError::InvalidChainControl {
7759            message: format!(
7760                "{label} block {}:{:?} names hash {:?} from known height {} as a non-adjacent parent",
7761                block.number, block.hash, parent.hash, parent.number
7762            ),
7763        });
7764    }
7765    Ok(())
7766}
7767
7768fn validate_sequence_head_within_coverage(
7769    state: &CanonicalSequenceState,
7770    block: &BlockRef,
7771    label: &str,
7772) -> Result<(), ReactiveError> {
7773    let Some(coverage) = state.coverage_head.as_ref() else {
7774        return Err(ReactiveError::InvalidChainControl {
7775            message: format!("{label} head requires an authoritative coverage head"),
7776        });
7777    };
7778    if block.number > coverage.number
7779        || (block.number == coverage.number
7780            && !optional_block_refs_are_compatible(Some(block), Some(coverage)))
7781    {
7782        return Err(ReactiveError::InvalidChainControl {
7783            message: format!(
7784                "{label} head {}:{:?} advances beyond or conflicts with coverage {}:{:?}",
7785                block.number, block.hash, coverage.number, coverage.hash
7786            ),
7787        });
7788    }
7789    Ok(())
7790}
7791
7792fn validate_sequence_matching_metadata(
7793    state: &CanonicalSequenceState,
7794    block: &BlockRef,
7795    label: &str,
7796) -> Result<(), ReactiveError> {
7797    validate_sequence_known_hash_height(state, block, label)?;
7798    validate_sequence_known_parent_height(state, block, label)?;
7799    let known = state
7800        .coverage_head
7801        .as_ref()
7802        .filter(|head| head.number == block.number && head.hash == block.hash)
7803        .or_else(|| {
7804            state
7805                .retained_canonical_history
7806                .iter()
7807                .find(|entry| entry.number == block.number && entry.hash == block.hash)
7808        });
7809    if let Some(known) = known
7810        && !optional_block_refs_are_compatible(Some(known), Some(block))
7811    {
7812        return Err(ReactiveError::InvalidChainControl {
7813            message: format!(
7814                "{label} block {}:{:?} carries metadata conflicting with known canonical block {:?}",
7815                block.number, block.hash, known
7816            ),
7817        });
7818    }
7819    Ok(())
7820}
7821
7822fn validate_sequence_known_hash_height(
7823    state: &CanonicalSequenceState,
7824    block: &BlockRef,
7825    label: &str,
7826) -> Result<(), ReactiveError> {
7827    let known = state
7828        .retained_canonical_history
7829        .iter()
7830        .chain(state.coverage_head.iter())
7831        .chain(state.safe_head.iter())
7832        .chain(state.finalized_head.iter())
7833        .find(|known| known.hash == block.hash);
7834    if let Some(known) = known
7835        && known.number != block.number
7836    {
7837        return Err(ReactiveError::InvalidChainControl {
7838            message: format!(
7839                "{label} block {}:{:?} reuses a canonical hash already known at height {}",
7840                block.number, block.hash, known.number
7841            ),
7842        });
7843    }
7844    Ok(())
7845}
7846
7847fn validate_reorg_ancestor_against_retained_branch(
7848    state: &CanonicalSequenceState,
7849    ancestor: &BlockRef,
7850) -> Result<(), ReactiveError> {
7851    let adjacent_number = ancestor.number.checked_add(1);
7852    for retained in state
7853        .retained_canonical_history
7854        .iter()
7855        .chain(state.coverage_head.iter())
7856        .chain(state.safe_head.iter())
7857        .chain(state.finalized_head.iter())
7858    {
7859        if Some(retained.number) == adjacent_number
7860            && retained
7861                .parent_hash
7862                .is_some_and(|parent| parent != ancestor.hash)
7863        {
7864            return Err(ReactiveError::InvalidChainControl {
7865                message: format!(
7866                    "reorg common ancestor {}:{:?} conflicts with retained child {}:{:?} parent {:?}",
7867                    ancestor.number,
7868                    ancestor.hash,
7869                    retained.number,
7870                    retained.hash,
7871                    retained.parent_hash
7872                ),
7873            });
7874        }
7875        if retained.parent_hash == Some(ancestor.hash) && Some(retained.number) != adjacent_number {
7876            return Err(ReactiveError::InvalidChainControl {
7877                message: format!(
7878                    "reorg common ancestor {}:{:?} is named as the non-adjacent parent of retained block {}:{:?}",
7879                    ancestor.number, ancestor.hash, retained.number, retained.hash
7880                ),
7881            });
7882        }
7883    }
7884    Ok(())
7885}
7886
7887fn validate_sequence_adjacent_parent_identity(
7888    state: &CanonicalSequenceState,
7889    block: &BlockRef,
7890    label: &str,
7891) -> Result<(), ReactiveError> {
7892    let Some(parent_hash) = block.parent_hash else {
7893        return Ok(());
7894    };
7895    let Some(parent_number) = block.number.checked_sub(1) else {
7896        return Ok(());
7897    };
7898    let known_parent = state
7899        .retained_canonical_history
7900        .iter()
7901        .chain(state.coverage_head.iter())
7902        .chain(state.safe_head.iter())
7903        .chain(state.finalized_head.iter())
7904        .find(|known| known.number == parent_number);
7905    if let Some(known_parent) = known_parent
7906        && known_parent.hash != parent_hash
7907    {
7908        return Err(ReactiveError::InvalidChainControl {
7909            message: format!(
7910                "{label} block {}:{:?} names parent {:?}, which conflicts with known adjacent block {}:{:?}",
7911                block.number, block.hash, parent_hash, known_parent.number, known_parent.hash
7912            ),
7913        });
7914    }
7915    Ok(())
7916}
7917
7918fn sequence_block_adds_metadata(state: &CanonicalSequenceState, incoming: &BlockRef) -> bool {
7919    state
7920        .coverage_head
7921        .iter()
7922        .chain(state.retained_canonical_history.iter())
7923        .filter(|known| known.number == incoming.number && known.hash == incoming.hash)
7924        .any(|known| {
7925            (known.parent_hash.is_none() && incoming.parent_hash.is_some())
7926                || (known.timestamp.is_none() && incoming.timestamp.is_some())
7927        })
7928}
7929
7930fn validate_sequence_implicit_finality<N: Network>(
7931    state: &CanonicalSequenceState,
7932    record: &ReactiveInputRecord<N>,
7933    resolved_canonical_block: Option<&BlockRef>,
7934) -> Result<(), ReactiveError> {
7935    let Some(finalized) = state.finalized_head.as_ref() else {
7936        return Ok(());
7937    };
7938    if let Some((dropped, _)) = reorg_signal_block(record) {
7939        if dropped.number <= finalized.number {
7940            return Err(ReactiveError::InvalidChainControl {
7941                message: format!(
7942                    "implicit reorg at {}:{:?} would cross finalized head {}:{:?}",
7943                    dropped.number, dropped.hash, finalized.number, finalized.hash
7944                ),
7945            });
7946        }
7947        return Ok(());
7948    }
7949    let Some(block) = resolved_canonical_block.or_else(|| canonical_record_block(record)) else {
7950        return Ok(());
7951    };
7952    let Some(latest) = state.coverage_head.as_ref() else {
7953        return Ok(());
7954    };
7955    if (block.number == latest.number && block.hash == latest.hash)
7956        || state
7957            .retained_canonical_history
7958            .iter()
7959            .any(|entry| entry.number == block.number && entry.hash == block.hash)
7960        || (latest.number.checked_add(1) == Some(block.number)
7961            && block.parent_hash == Some(latest.hash))
7962        || latest
7963            .number
7964            .checked_add(1)
7965            .is_some_and(|next| block.number > next)
7966    {
7967        return Ok(());
7968    }
7969    let crosses_finalized = if block.number <= finalized.number {
7970        true
7971    } else if let Some(parent_hash) = block.parent_hash {
7972        if finalized.number.checked_add(1) == Some(block.number) && parent_hash == finalized.hash {
7973            false
7974        } else if let Some(parent_index) =
7975            state.retained_canonical_history.iter().rposition(|entry| {
7976                entry.number.checked_add(1) == Some(block.number) && entry.hash == parent_hash
7977            })
7978        {
7979            state
7980                .retained_canonical_history
7981                .iter()
7982                .skip(parent_index + 1)
7983                .any(|entry| entry.number <= finalized.number)
7984        } else {
7985            true
7986        }
7987    } else {
7988        true
7989    };
7990    if crosses_finalized {
7991        return Err(ReactiveError::InvalidChainControl {
7992            message: format!(
7993                "canonical input {}:{:?} would replace finalized head {}:{:?}",
7994                block.number, block.hash, finalized.number, finalized.hash
7995            ),
7996        });
7997    }
7998    Ok(())
7999}
8000
8001fn validate_required_reorg_anchor(
8002    required: Option<RequiredReorgAnchor>,
8003    block: &BlockRef,
8004) -> Result<(), ReactiveError> {
8005    let Some(required) = required else {
8006        return Ok(());
8007    };
8008    let ancestor_hash = required.hash();
8009    let restores_ancestor =
8010        block.number == required.number && ancestor_hash.is_some_and(|hash| block.hash == hash);
8011    let replaces_removed_child = required.number.checked_add(1) == Some(block.number)
8012        && ancestor_hash.is_some()
8013        && (block.parent_hash == ancestor_hash
8014            || (block.parent_hash.is_none() && required.permits_missing_child_parent));
8015    if restores_ancestor || replaces_removed_child {
8016        return Ok(());
8017    }
8018    Err(ReactiveError::InvalidChainControl {
8019        message: format!(
8020            "canonical replacement {}:{:?} does not prove the removed tip's parent at block {}",
8021            block.number, block.hash, required.number
8022        ),
8023    })
8024}
8025
8026fn validate_replacement_reorg_anchor(
8027    required: Option<RequiredReorgAnchor>,
8028    block: &BlockRef,
8029    policy: CanonicalSequenceValidationPolicy,
8030    oldest_retained: Option<u64>,
8031) -> Result<bool, CanonicalSequenceError> {
8032    let Some(required) = required else {
8033        return Ok(false);
8034    };
8035    match validate_required_reorg_anchor(Some(required), block) {
8036        Ok(()) => Ok(true),
8037        Err(error) if required.block.is_some() => Err(error.into()),
8038        Err(_) if policy.requires_complete_rollback() => {
8039            Err(CanonicalSequenceError::IncompleteRollback {
8040                common_ancestor: required.number,
8041                oldest_retained,
8042                kind: CanonicalRollbackKind::MissingReplacement,
8043            })
8044        }
8045        Err(_) => Ok(false),
8046    }
8047}
8048
8049fn apply_sequence_canonical_block(
8050    state: &mut CanonicalSequenceState,
8051    block: &BlockRef,
8052    allow_parentless_adjacent_extension: bool,
8053) -> Result<Option<SequenceRewind>, ReactiveError> {
8054    let latest = state.coverage_head;
8055    let already_known = state
8056        .retained_canonical_history
8057        .iter()
8058        .any(|entry| entry.number == block.number && entry.hash == block.hash);
8059    let repeats_tip =
8060        latest.is_some_and(|head| head.number == block.number && head.hash == block.hash);
8061    let extends_tip = latest.is_some_and(|head| {
8062        head.number.checked_add(1) == Some(block.number)
8063            && (block.parent_hash == Some(head.hash)
8064                || (allow_parentless_adjacent_extension && block.parent_hash.is_none()))
8065    });
8066    let forward_gap = latest.is_some_and(|head| {
8067        head.number
8068            .checked_add(1)
8069            .is_some_and(|next| block.number > next)
8070    });
8071    let mut rewind = None;
8072
8073    if latest.is_some() && !already_known && !repeats_tip && !extends_tip && !forward_gap {
8074        let retained_parent = block.parent_hash.and_then(|parent_hash| {
8075            state
8076                .retained_canonical_history
8077                .iter()
8078                .rposition(|entry| {
8079                    entry.number.checked_add(1) == Some(block.number) && entry.hash == parent_hash
8080                })
8081                .map(|index| (index, state.retained_canonical_history[index]))
8082        });
8083        let finalized_parent = block.parent_hash.and_then(|parent_hash| {
8084            state.finalized_head.filter(|finalized| {
8085                finalized.number.checked_add(1) == Some(block.number)
8086                    && finalized.hash == parent_hash
8087            })
8088        });
8089        let (common_ancestor, dropped) = if let Some((parent_index, parent)) = retained_parent {
8090            let dropped = state.retained_canonical_history.split_off(parent_index + 1);
8091            (Some(parent), dropped)
8092        } else if let Some(finalized) = finalized_parent {
8093            let dropped = state
8094                .retained_canonical_history
8095                .iter()
8096                .position(|entry| entry.number > finalized.number)
8097                .map_or_else(Vec::new, |index| {
8098                    state.retained_canonical_history.split_off(index)
8099                });
8100            (Some(finalized), dropped)
8101        } else {
8102            // The observable runtime policy may continue after an incomplete
8103            // rollback proof so it can degrade health and repair. The metadata
8104            // validator must nevertheless avoid claiming any old prefix is an
8105            // ancestor of the arriving branch: without the exact N-1 parent,
8106            // no retained identity is authenticated.
8107            (None, std::mem::take(&mut state.retained_canonical_history))
8108        };
8109        state.coverage_head = common_ancestor;
8110        if let Some(common_ancestor) = common_ancestor {
8111            clear_sequence_heads_above(state, &common_ancestor);
8112        } else {
8113            state.safe_head = None;
8114            state.finalized_head = None;
8115        }
8116        rewind = Some(SequenceRewind {
8117            common_ancestor,
8118            dropped,
8119        });
8120    }
8121    upsert_sequence_history(&mut state.retained_canonical_history, block)?;
8122    advance_or_enrich_coverage(&mut state.coverage_head, block);
8123    Ok(rewind)
8124}
8125
8126fn sequence_implicit_replacement_requires_history(
8127    state: &CanonicalSequenceState,
8128    block: &BlockRef,
8129    policy: CanonicalSequenceValidationPolicy,
8130) -> Result<bool, ReactiveError> {
8131    let Some(latest) = state.coverage_head else {
8132        return Ok(false);
8133    };
8134    let already_known = state
8135        .retained_canonical_history
8136        .iter()
8137        .any(|entry| entry.number == block.number && entry.hash == block.hash);
8138    let repeats_tip = block.number == latest.number && block.hash == latest.hash;
8139    let extends_tip = latest.number.checked_add(1) == Some(block.number)
8140        && block.parent_hash == Some(latest.hash);
8141    let forward_gap = latest
8142        .number
8143        .checked_add(1)
8144        .is_some_and(|next| block.number > next);
8145    if already_known || repeats_tip || extends_tip || forward_gap {
8146        return Ok(false);
8147    }
8148    let Some(parent_hash) = block.parent_hash else {
8149        if policy.requires_complete_rollback() {
8150            return Err(ReactiveError::InvalidChainControl {
8151                message: format!(
8152                    "implicit canonical replacement {}:{:?} must identify its parent",
8153                    block.number, block.hash
8154                ),
8155            });
8156        }
8157        return Ok(true);
8158    };
8159    let known_adjacent_parent = block.number.checked_sub(1).and_then(|parent_number| {
8160        state
8161            .retained_canonical_history
8162            .iter()
8163            .chain(state.coverage_head.iter())
8164            .chain(state.safe_head.iter())
8165            .chain(state.finalized_head.iter())
8166            .find(|known| known.number == parent_number)
8167    });
8168    if let Some(known_parent) = known_adjacent_parent
8169        && known_parent.hash != parent_hash
8170        && policy.requires_complete_rollback()
8171    {
8172        return Err(ReactiveError::InvalidChainControl {
8173            message: format!(
8174                "implicit canonical replacement {}:{:?} names parent {:?}, which conflicts with known adjacent block {}:{:?}",
8175                block.number, block.hash, parent_hash, known_parent.number, known_parent.hash
8176            ),
8177        });
8178    }
8179    let retained_parent = state.retained_canonical_history.iter().any(|entry| {
8180        entry.number.checked_add(1) == Some(block.number) && entry.hash == parent_hash
8181    });
8182    let finalized_parent = state.finalized_head.is_some_and(|finalized| {
8183        finalized.number.checked_add(1) == Some(block.number) && parent_hash == finalized.hash
8184    });
8185    Ok(!retained_parent && !finalized_parent)
8186}
8187
8188fn upsert_sequence_history(
8189    history: &mut Vec<BlockRef>,
8190    block: &BlockRef,
8191) -> Result<(), ReactiveError> {
8192    if let Some(existing) = history
8193        .iter_mut()
8194        .find(|entry| entry.number == block.number)
8195    {
8196        if existing.hash != block.hash {
8197            return Err(ReactiveError::InvalidChainControl {
8198                message: format!(
8199                    "canonical block {}:{:?} conflicts with retained identity {:?}",
8200                    block.number, block.hash, existing
8201                ),
8202            });
8203        }
8204        if !optional_block_refs_are_compatible(Some(existing), Some(block)) {
8205            return Err(ReactiveError::InvalidChainControl {
8206                message: format!(
8207                    "canonical block {}:{:?} carries conflicting retained metadata",
8208                    block.number, block.hash
8209                ),
8210            });
8211        }
8212        enrich_block_ref(existing, block);
8213    } else {
8214        history.push(*block);
8215        history.sort_by_key(|entry| entry.number);
8216    }
8217    Ok(())
8218}
8219
8220fn clear_sequence_heads_above(state: &mut CanonicalSequenceState, ancestor: &BlockRef) {
8221    if state.safe_head.as_ref().is_some_and(|head| {
8222        head.number > ancestor.number
8223            || (head.number == ancestor.number && head.hash != ancestor.hash)
8224    }) {
8225        state.safe_head = None;
8226    }
8227    if state.finalized_head.as_ref().is_some_and(|head| {
8228        head.number > ancestor.number
8229            || (head.number == ancestor.number && head.hash != ancestor.hash)
8230    }) {
8231        state.finalized_head = None;
8232    }
8233}
8234
8235fn validate_control_phase_order(controls: &[ChainControl]) -> Result<usize, ReactiveError> {
8236    let split = controls
8237        .iter()
8238        .position(|control| !matches!(control, ChainControl::Reorg { .. }))
8239        .unwrap_or(controls.len());
8240    if controls[split..]
8241        .iter()
8242        .any(|control| matches!(control, ChainControl::Reorg { .. }))
8243    {
8244        return Err(ReactiveError::InvalidChainControl {
8245            message: "reorg controls must precede records and all post-record controls in a batch"
8246                .into(),
8247        });
8248    }
8249    Ok(split)
8250}
8251
8252fn canonical_coverage_control_block(control: &ChainControl) -> Option<&BlockRef> {
8253    match control {
8254        ChainControl::CanonicalProgress(block)
8255        | ChainControl::Barrier {
8256            block: Some(block), ..
8257        } => Some(block),
8258        // An attestation names a canonical block but claims no progress to it.
8259        ChainControl::Reorg { .. }
8260        | ChainControl::Safe(_)
8261        | ChainControl::Finalized(_)
8262        | ChainControl::LogCoverage(_)
8263        | ChainControl::Barrier { block: None, .. } => None,
8264    }
8265}
8266
8267fn chain_control_canonical_assertion(control: &ChainControl) -> Option<&BlockRef> {
8268    match control {
8269        ChainControl::Safe(block)
8270        | ChainControl::Finalized(block)
8271        | ChainControl::CanonicalProgress(block)
8272        | ChainControl::LogCoverage(block)
8273        | ChainControl::Barrier {
8274            block: Some(block), ..
8275        } => Some(block),
8276        ChainControl::Reorg { .. } | ChainControl::Barrier { block: None, .. } => None,
8277    }
8278}
8279
8280fn assert_chain_control_identities(
8281    asserted_blocks: &mut HashMap<u64, BlockRef>,
8282    control: &ChainControl,
8283) -> Result<(), ReactiveError> {
8284    match control {
8285        ChainControl::Safe(block)
8286        | ChainControl::Finalized(block)
8287        | ChainControl::CanonicalProgress(block)
8288        | ChainControl::LogCoverage(block)
8289        | ChainControl::Barrier {
8290            block: Some(block), ..
8291        } => assert_canonical_block_identity(asserted_blocks, block, "chain control"),
8292        ChainControl::Barrier { block: None, .. } => Ok(()),
8293        ChainControl::Reorg {
8294            common_ancestor,
8295            new_tip,
8296            ..
8297        } => {
8298            asserted_blocks.retain(|number, _| *number <= common_ancestor.number);
8299            assert_canonical_block_identity(
8300                asserted_blocks,
8301                common_ancestor,
8302                "reorg common ancestor",
8303            )?;
8304            assert_canonical_block_identity(asserted_blocks, new_tip, "reorg new tip")
8305        }
8306    }
8307}
8308
8309fn assert_canonical_block_identity(
8310    asserted_blocks: &mut HashMap<u64, BlockRef>,
8311    block: &BlockRef,
8312    label: &str,
8313) -> Result<(), ReactiveError> {
8314    for asserted in asserted_blocks.values() {
8315        if asserted.hash == block.hash && asserted.number != block.number {
8316            return Err(ReactiveError::InvalidChainControl {
8317                message: format!(
8318                    "{label} hash {:?} is already asserted at height {}, not {}",
8319                    block.hash, asserted.number, block.number
8320                ),
8321            });
8322        }
8323        if block
8324            .parent_hash
8325            .is_some_and(|parent| parent == asserted.hash)
8326            && asserted.number.checked_add(1) != Some(block.number)
8327        {
8328            return Err(ReactiveError::InvalidChainControl {
8329                message: format!(
8330                    "{label} block {}:{:?} names hash {:?} from known height {} as a non-adjacent parent",
8331                    block.number, block.hash, asserted.hash, asserted.number
8332                ),
8333            });
8334        }
8335        if asserted
8336            .parent_hash
8337            .is_some_and(|parent| parent == block.hash)
8338            && block.number.checked_add(1) != Some(asserted.number)
8339        {
8340            return Err(ReactiveError::InvalidChainControl {
8341                message: format!(
8342                    "block {}:{:?} asserted earlier names {label} hash {:?} from non-adjacent height {} as its parent",
8343                    asserted.number, asserted.hash, block.hash, block.number
8344                ),
8345            });
8346        }
8347    }
8348    if let Some(known) = asserted_blocks.get_mut(&block.number) {
8349        if !optional_block_refs_are_compatible(Some(known), Some(block)) {
8350            return Err(ReactiveError::InvalidChainControl {
8351                message: format!(
8352                    "{label} block {}:{:?} conflicts with block identity {:?} asserted earlier in the batch",
8353                    block.number, block.hash, known
8354                ),
8355            });
8356        }
8357        enrich_block_ref(known, block);
8358    } else {
8359        asserted_blocks.insert(block.number, *block);
8360    }
8361    Ok(())
8362}
8363
8364fn set_or_enrich_block_ref(current: &mut Option<BlockRef>, incoming: &BlockRef) {
8365    match current {
8366        Some(current) if current.number == incoming.number && current.hash == incoming.hash => {
8367            enrich_block_ref(current, incoming);
8368        }
8369        _ => *current = Some(*incoming),
8370    }
8371}
8372
8373fn advance_or_enrich_coverage(current: &mut Option<BlockRef>, incoming: &BlockRef) {
8374    match current {
8375        Some(current) if current.number == incoming.number && current.hash == incoming.hash => {
8376            enrich_block_ref(current, incoming);
8377        }
8378        Some(current) if current.number >= incoming.number => {}
8379        _ => *current = Some(*incoming),
8380    }
8381}
8382
8383fn validate_adjacent_finality(
8384    finalized: Option<&BlockRef>,
8385    safe: Option<&BlockRef>,
8386) -> Result<(), ReactiveError> {
8387    let Some((finalized, safe)) = finalized.zip(safe) else {
8388        return Ok(());
8389    };
8390    if finalized.number.checked_add(1) == Some(safe.number)
8391        && safe.parent_hash != Some(finalized.hash)
8392    {
8393        return Err(ReactiveError::InvalidChainControl {
8394            message: "adjacent safe head does not descend from finalized head".into(),
8395        });
8396    }
8397    Ok(())
8398}
8399
8400/// Fold every address a [`StateDiff`] references — genuine changes
8401/// (`slots`/`accounts`/`purged`) and cold-skipped attempts (`skipped*`) alike —
8402/// into `into`. Used by the per-block root gate to accumulate the batch's
8403/// decoder-touched address set: an account a decoder wrote (or tried to write) is
8404/// "covered," so a subsequent root move for it is not a coverage gap.
8405fn collect_diff_addresses(diff: &StateDiff, into: &mut HashSet<Address>) {
8406    into.extend(diff.slots.iter().map(|change| change.address));
8407    into.extend(diff.accounts.iter().map(|change| change.address));
8408    into.extend(diff.purged.iter().map(|purge| purge.address));
8409    into.extend(diff.skipped.iter().map(|skipped| skipped.address));
8410    into.extend(diff.skipped_balances.iter().map(|skipped| skipped.address));
8411    into.extend(diff.skipped_masks.iter().map(|skipped| skipped.address));
8412    into.extend(diff.skipped_accounts.iter().map(|skipped| skipped.address));
8413}
8414
8415/// Build the [`ResyncReason::RootMoved`] account resync the root gate schedules
8416/// for an uncovered move. Re-reads `address`'s `fields` at `block` through the
8417/// existing account-resync path (Wave 2). The id is derived from the address and
8418/// block so a repeated move on the same account/block coalesces deterministically.
8419fn root_moved_account_resync(
8420    address: Address,
8421    block: u64,
8422    fields: AccountFieldMask,
8423) -> ResyncRequest {
8424    ResyncRequest {
8425        id: ResyncId::new(format!("root-moved:{address:#x}:{block}")),
8426        reason: ResyncReason::RootMoved,
8427        block: ResyncBlock::Number(block),
8428        targets: vec![ResyncTarget::Account { address, fields }],
8429        priority: ResyncPriority::Normal,
8430    }
8431}
8432
8433fn batch_preconfirmation<N: Network>(
8434    batch: &ReactiveInputBatch<N>,
8435) -> Result<Option<FlashblockRef>, ReactiveError> {
8436    let mut flashblock: Option<FlashblockRef> = None;
8437    let mut has_non_preconfirmed = false;
8438    for (index, record) in batch.records().iter().enumerate() {
8439        match &record.context.chain_status {
8440            ChainStatus::Preconfirmed {
8441                flashblock: current,
8442            } => {
8443                if batch.record_delivery_scope(index) != Some(DeliveryScope::Preconfirmed) {
8444                    return Err(ReactiveError::InvalidInputRecord {
8445                        message: "pre-confirmed input requires pre-confirmed delivery scope".into(),
8446                    });
8447                }
8448                if flashblock
8449                    .as_ref()
8450                    .is_some_and(|known| known != current.as_ref())
8451                {
8452                    return Err(ReactiveError::InvalidInputRecord {
8453                        message: "one batch cannot mix distinct Flashblock snapshots".into(),
8454                    });
8455                }
8456                flashblock.get_or_insert_with(|| current.as_ref().clone());
8457            }
8458            _ => has_non_preconfirmed = true,
8459        }
8460    }
8461    if flashblock.is_some() && (has_non_preconfirmed || !batch.chain_controls().is_empty()) {
8462        return Err(ReactiveError::InvalidInputRecord {
8463            message: "pre-confirmed delivery cannot mix canonical inputs or chain controls".into(),
8464        });
8465    }
8466    Ok(flashblock)
8467}
8468
8469fn canonical_record_block<N: Network>(record: &ReactiveInputRecord<N>) -> Option<&BlockRef> {
8470    if matches!(&record.input, ReactiveInput::Log(log) if log.removed) {
8471        return None;
8472    }
8473    if is_canonical_status(&record.context.chain_status) {
8474        return context_block_ref(&record.context);
8475    }
8476    None
8477}
8478
8479fn resolve_record_block_payload_metadata<N: Network>(
8480    record: &ReactiveInputRecord<N>,
8481    mut block: BlockRef,
8482) -> Result<BlockRef, ReactiveError> {
8483    let ReactiveInput::Log(log) = &record.input else {
8484        return Ok(block);
8485    };
8486    if log.block_number != Some(block.number) || log.block_hash != Some(block.hash) {
8487        return Err(ReactiveError::InvalidInputRecord {
8488            message: "log payload and canonical context carry different block identities".into(),
8489        });
8490    }
8491    if let Some(timestamp) = log.block_timestamp {
8492        if block.timestamp.is_some_and(|known| known != timestamp) {
8493            return Err(ReactiveError::InvalidInputRecord {
8494                message: "log payload and canonical context carry different block timestamps"
8495                    .into(),
8496            });
8497        }
8498        block.timestamp = Some(timestamp);
8499    }
8500    Ok(block)
8501}
8502
8503fn validate_input_record<N: Network>(record: &ReactiveInputRecord<N>) -> Result<(), ReactiveError> {
8504    let invalid = |message: String| ReactiveError::InvalidInputRecord { message };
8505    if let ChainStatus::Preconfirmed { flashblock } = &record.context.chain_status
8506        && record.context.block != Some(flashblock.block_ref())
8507    {
8508        return Err(invalid(
8509            "pre-confirmed status and context carry different partial block identities".into(),
8510        ));
8511    }
8512    let status_block = match &record.context.chain_status {
8513        ChainStatus::Included { block, .. }
8514        | ChainStatus::Safe { block }
8515        | ChainStatus::Finalized { block }
8516        | ChainStatus::Reorged {
8517            dropped_from: block,
8518        } => Some(block),
8519        ChainStatus::Preconfirmed { .. } => record.context.block.as_ref(),
8520        ChainStatus::Pending => None,
8521    };
8522    match (status_block, record.context.block.as_ref()) {
8523        (Some(status), Some(context)) if status == context => {}
8524        (Some(_), Some(_)) => {
8525            return Err(invalid(
8526                "chain status and context carry different block identities".into(),
8527            ));
8528        }
8529        (Some(_), None) => {
8530            return Err(invalid(
8531                "included or reorged input is missing its context block".into(),
8532            ));
8533        }
8534        (None, Some(_)) => {
8535            return Err(invalid(
8536                "pending input cannot carry a canonical context block".into(),
8537            ));
8538        }
8539        (None, None) => {}
8540    }
8541
8542    match &record.input {
8543        ReactiveInput::Log(log) => {
8544            let Some(block) = status_block else {
8545                return Err(invalid(
8546                    "log input must carry an included or reorged block identity".into(),
8547                ));
8548            };
8549            if log.removed && !matches!(record.context.chain_status, ChainStatus::Reorged { .. }) {
8550                return Err(invalid(
8551                    "removed log must carry reorged chain status".into(),
8552                ));
8553            }
8554            let block_number = log
8555                .block_number
8556                .ok_or_else(|| invalid("log is missing its block number".into()))?;
8557            let block_hash = log
8558                .block_hash
8559                .ok_or_else(|| invalid("log is missing its block hash".into()))?;
8560            log.transaction_hash
8561                .ok_or_else(|| invalid("log is missing its transaction hash".into()))?;
8562            let transaction_index = log
8563                .transaction_index
8564                .ok_or_else(|| invalid("log is missing its transaction index".into()))?;
8565            let log_index = log
8566                .log_index
8567                .ok_or_else(|| invalid("log is missing its log index".into()))?;
8568            if block_number != block.number
8569                || block_hash != block.hash
8570                || !optional_metadata_compatible(
8571                    log.block_timestamp.as_ref(),
8572                    block.timestamp.as_ref(),
8573                )
8574            {
8575                return Err(invalid(
8576                    "log payload and context carry different block identities".into(),
8577                ));
8578            }
8579            if record.context.transaction_index != Some(transaction_index)
8580                || record.context.log_index != Some(log_index)
8581            {
8582                return Err(invalid(
8583                    "log payload and context carry different transaction/log positions".into(),
8584                ));
8585            }
8586        }
8587        ReactiveInput::BlockHeader(header) => {
8588            if let Some(block) = status_block {
8589                if header.number() != block.number
8590                    || header.hash() != block.hash
8591                    || Some(header.parent_hash()) != block.parent_hash
8592                    || Some(header.timestamp()) != block.timestamp
8593                {
8594                    return Err(invalid(
8595                        "block header payload and context carry different block identities".into(),
8596                    ));
8597                }
8598            } else if !matches!(record.context.chain_status, ChainStatus::Pending) {
8599                return Err(invalid("block header has an unsupported lifecycle".into()));
8600            }
8601            if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
8602                return Err(invalid(
8603                    "block header context cannot carry transaction/log positions".into(),
8604                ));
8605            }
8606        }
8607        ReactiveInput::FullBlock(block_response) => {
8608            let header = block_response.header();
8609            if let Some(block) = status_block {
8610                if header.number() != block.number
8611                    || header.hash() != block.hash
8612                    || Some(header.parent_hash()) != block.parent_hash
8613                    || Some(header.timestamp()) != block.timestamp
8614                {
8615                    return Err(invalid(
8616                        "full-block payload and context carry different block identities".into(),
8617                    ));
8618                }
8619            } else if !matches!(record.context.chain_status, ChainStatus::Pending) {
8620                return Err(invalid("full block has an unsupported lifecycle".into()));
8621            }
8622            if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
8623                return Err(invalid(
8624                    "full-block context cannot carry transaction/log positions".into(),
8625                ));
8626            }
8627            if let Some(transactions) = block_response.transactions().as_transactions() {
8628                for (index, transaction) in transactions.iter().enumerate() {
8629                    if transaction
8630                        .block_hash()
8631                        .is_some_and(|hash| hash != header.hash())
8632                        || transaction
8633                            .block_number()
8634                            .is_some_and(|number| number != header.number())
8635                        || transaction
8636                            .transaction_index()
8637                            .is_some_and(|position| position != index as u64)
8638                    {
8639                        return Err(invalid(format!(
8640                            "full-block transaction {index} carries contradictory inclusion metadata"
8641                        )));
8642                    }
8643                    if transaction
8644                        .chain_id()
8645                        .zip(record.context.chain_id)
8646                        .is_some_and(|(transaction, context)| transaction != context)
8647                    {
8648                        return Err(invalid(format!(
8649                            "full-block transaction {index} carries a chain id conflicting with its context"
8650                        )));
8651                    }
8652                }
8653            }
8654        }
8655        ReactiveInput::PendingTxHash(_) => {
8656            if !matches!(record.context.chain_status, ChainStatus::Pending) {
8657                return Err(invalid(
8658                    "pending transaction input must carry pending chain status".into(),
8659                ));
8660            }
8661            if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
8662                return Err(invalid(
8663                    "pending transaction context cannot carry canonical positions".into(),
8664                ));
8665            }
8666        }
8667        ReactiveInput::PendingTx(transaction) => {
8668            if !matches!(record.context.chain_status, ChainStatus::Pending) {
8669                return Err(invalid(
8670                    "pending transaction input must carry pending chain status".into(),
8671                ));
8672            }
8673            if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
8674                return Err(invalid(
8675                    "pending transaction context cannot carry canonical positions".into(),
8676                ));
8677            }
8678            if transaction.block_hash().is_some()
8679                || transaction.block_number().is_some()
8680                || transaction.transaction_index().is_some()
8681            {
8682                return Err(invalid(
8683                    "hydrated pending transaction cannot carry inclusion metadata".into(),
8684                ));
8685            }
8686            if transaction
8687                .chain_id()
8688                .zip(record.context.chain_id)
8689                .is_some_and(|(transaction, context)| transaction != context)
8690            {
8691                return Err(invalid(
8692                    "pending transaction carries a chain id conflicting with its context".into(),
8693                ));
8694            }
8695        }
8696    }
8697    Ok(())
8698}
8699
8700/// Best-effort per-block env refresh (Phase-8 step 2).
8701///
8702/// For a canonical record carrying a full header — a
8703/// [`ReactiveInput::BlockHeader`] or [`ReactiveInput::FullBlock`] — refresh the
8704/// cache's block env from that header via [`EvmCache::advance_block`]. Returns
8705/// `Some(result)` when a header was present (so the caller can surface a strict
8706/// validation error), and `None` for pending/reorged records or non-header
8707/// inputs, which must never drive a canonical env refresh.
8708fn advance_block_for_canonical_record<N: Network>(
8709    cache: &mut EvmCache,
8710    record: &ReactiveInputRecord<N>,
8711) -> Option<Result<(), BlockContextError>> {
8712    if !is_canonical_status(&record.context.chain_status) {
8713        return None;
8714    }
8715    match &record.input {
8716        ReactiveInput::BlockHeader(header) => Some(cache.advance_block(header)),
8717        ReactiveInput::FullBlock(block) => Some(cache.advance_block(block.header())),
8718        _ => None,
8719    }
8720}
8721
8722fn context_block_ref(ctx: &ReactiveContext) -> Option<&BlockRef> {
8723    match &ctx.chain_status {
8724        ChainStatus::Included { block, .. }
8725        | ChainStatus::Safe { block }
8726        | ChainStatus::Finalized { block } => Some(block),
8727        ChainStatus::Reorged { dropped_from } => Some(dropped_from),
8728        ChainStatus::Preconfirmed { .. } => ctx.block.as_ref(),
8729        ChainStatus::Pending => ctx.block.as_ref(),
8730    }
8731}
8732
8733fn reorg_signal_block<N: Network>(
8734    record: &ReactiveInputRecord<N>,
8735) -> Option<(BlockRef, ReorgReason)> {
8736    if matches!(&record.input, ReactiveInput::Log(log) if log.removed) {
8737        return block_ref_from_record(record).map(|block| (block, ReorgReason::RemovedLog));
8738    }
8739
8740    if let ChainStatus::Reorged { dropped_from } = &record.context.chain_status {
8741        return Some((*dropped_from, ReorgReason::ReorgedInput));
8742    }
8743
8744    None
8745}
8746
8747fn block_ref_from_record<N: Network>(record: &ReactiveInputRecord<N>) -> Option<BlockRef> {
8748    context_block_ref(&record.context)
8749        .cloned()
8750        .or_else(|| match &record.input {
8751            ReactiveInput::Log(log) => Some(BlockRef {
8752                number: log.block_number?,
8753                hash: log.block_hash?,
8754                parent_hash: None,
8755                timestamp: log.block_timestamp,
8756            }),
8757            ReactiveInput::BlockHeader(header) => Some(BlockRef {
8758                number: header.number(),
8759                hash: header.hash(),
8760                parent_hash: Some(header.parent_hash()),
8761                timestamp: Some(header.timestamp()),
8762            }),
8763            ReactiveInput::FullBlock(block) => {
8764                let header = block.header();
8765                Some(BlockRef {
8766                    number: header.number(),
8767                    hash: header.hash(),
8768                    parent_hash: Some(header.parent_hash()),
8769                    timestamp: Some(header.timestamp()),
8770                })
8771            }
8772            ReactiveInput::PendingTxHash(_) | ReactiveInput::PendingTx(_) => None,
8773        })
8774}
8775
8776fn remove_canceled_resyncs_from_batch(
8777    resyncs: &mut Vec<ResyncRequest>,
8778    canceled: &[ResyncRequest],
8779) {
8780    if canceled.is_empty() {
8781        return;
8782    }
8783    let canceled_ids: HashSet<_> = canceled.iter().map(|request| request.id.clone()).collect();
8784    resyncs.retain(|request| !canceled_ids.contains(&request.id));
8785}
8786
8787fn resync_target_address(target: &ResyncTarget) -> Address {
8788    match target {
8789        ResyncTarget::StorageSlot { address, .. }
8790        | ResyncTarget::StorageSlots { address, .. }
8791        | ResyncTarget::Account { address, .. } => *address,
8792    }
8793}
8794
8795fn resync_request_targets_dropped_block(
8796    request: &ResyncRequest,
8797    dropped_blocks: &[BlockRef],
8798) -> bool {
8799    let ResyncBlock::Hash { number, hash, .. } = &request.block else {
8800        return false;
8801    };
8802    dropped_blocks
8803        .iter()
8804        .any(|block| block.hash == *hash && block.number == *number)
8805}
8806
8807fn single_hash_pinned_resync_block(report: &ResyncReport) -> Option<BlockRef> {
8808    let first = report.requested.first()?.block.clone();
8809    if !report
8810        .requested
8811        .iter()
8812        .all(|request| request.block == first)
8813    {
8814        return None;
8815    }
8816
8817    let ResyncBlock::Hash { number, hash, .. } = first else {
8818        return None;
8819    };
8820
8821    Some(BlockRef {
8822        number,
8823        hash,
8824        parent_hash: None,
8825        timestamp: None,
8826    })
8827}
8828
8829fn purge_scopes_for_dropped_journals<N: Network>(
8830    dropped: &[BlockJournal<N>],
8831) -> Vec<(Address, PurgeScope)> {
8832    let mut scopes: Vec<(Address, PurgeScope)> = Vec::new();
8833    for entry in dropped.iter().rev() {
8834        for diff in entry.rollback_diffs.iter().rev() {
8835            merge_purge_scopes_for_diff(&mut scopes, diff);
8836        }
8837    }
8838    scopes
8839}
8840
8841fn rollback_updates_for_dropped_journals<N: Network>(
8842    dropped: &[BlockJournal<N>],
8843    purge_scopes: &[(Address, PurgeScope)],
8844) -> Vec<StateUpdate> {
8845    let purge_addresses: HashSet<_> = purge_scopes
8846        .iter()
8847        .map(|(address, _scope)| *address)
8848        .collect();
8849    let mut updates = Vec::new();
8850    for entry in dropped.iter().rev() {
8851        for diff in entry.rollback_diffs.iter().rev() {
8852            push_rollback_updates_for_diff(&mut updates, diff, &purge_addresses);
8853        }
8854    }
8855    updates
8856}
8857
8858fn merge_purge_scopes_for_diff(scopes: &mut Vec<(Address, PurgeScope)>, diff: &StateDiff) {
8859    for change in &diff.accounts {
8860        merge_purge_scope(scopes, change.address, PurgeScope::Account);
8861    }
8862    for record in &diff.purged {
8863        merge_purge_scope(scopes, record.address, record.scope.clone());
8864    }
8865}
8866
8867fn push_rollback_updates_for_diff(
8868    updates: &mut Vec<StateUpdate>,
8869    diff: &StateDiff,
8870    purge_addresses: &HashSet<Address>,
8871) {
8872    for change in diff.slots.iter().rev() {
8873        if purge_addresses.contains(&change.address) {
8874            continue;
8875        }
8876        updates.push(StateUpdate::slot(change.address, change.slot, change.old));
8877    }
8878}
8879
8880fn merge_purge_scope(scopes: &mut Vec<(Address, PurgeScope)>, address: Address, scope: PurgeScope) {
8881    if let Some((_existing_address, existing_scope)) = scopes
8882        .iter_mut()
8883        .find(|(existing_address, _scope)| *existing_address == address)
8884    {
8885        *existing_scope = merged_purge_scope(existing_scope.clone(), scope);
8886    } else {
8887        scopes.push((address, scope));
8888    }
8889}
8890
8891fn merged_purge_scope(left: PurgeScope, right: PurgeScope) -> PurgeScope {
8892    match (left, right) {
8893        (PurgeScope::Account, _) | (_, PurgeScope::Account) => PurgeScope::Account,
8894        (PurgeScope::AllStorage, _) | (_, PurgeScope::AllStorage) => PurgeScope::AllStorage,
8895        (PurgeScope::Slots(mut left), PurgeScope::Slots(right)) => {
8896            for slot in right {
8897                if !left.contains(&slot) {
8898                    left.push(slot);
8899                }
8900            }
8901            PurgeScope::Slots(left)
8902        }
8903    }
8904}
8905
8906#[derive(Clone, Debug)]
8907struct StorageFetchSlot {
8908    address: Address,
8909    slot: U256,
8910    origins: Vec<StorageFetchOrigin>,
8911}
8912
8913#[derive(Clone, Debug)]
8914struct StorageFetchOrigin {
8915    request_id: ResyncId,
8916    target: ResyncTarget,
8917}
8918
8919#[derive(Clone, Debug)]
8920struct StorageFetchGroup {
8921    block: ResyncBlock,
8922    slots: Vec<StorageFetchSlot>,
8923    seen: HashSet<(Address, U256)>,
8924}
8925
8926/// One account-target resync collected during request scanning, resolved through
8927/// the account proof fetcher after storage groups are processed.
8928#[derive(Clone, Debug)]
8929struct AccountResyncTarget {
8930    request_id: ResyncId,
8931    block: ResyncBlock,
8932    address: Address,
8933    fields: AccountFieldMask,
8934}
8935
8936fn resolve_trace_resyncs(
8937    cache: &EvmCache,
8938    storage_groups: &mut Vec<StorageFetchGroup>,
8939    account_targets: &mut Vec<AccountResyncTarget>,
8940    state_updates: &mut Vec<StateUpdate>,
8941) {
8942    let Some(fetcher) = cache.block_state_diff_fetcher().cloned() else {
8943        return;
8944    };
8945
8946    let mut blocks = Vec::new();
8947    let mut seen = HashSet::new();
8948    for block in storage_groups
8949        .iter()
8950        .map(|group| group.block.clone())
8951        .chain(account_targets.iter().map(|target| target.block.clone()))
8952    {
8953        if seen.insert(block.clone()) {
8954            blocks.push(block);
8955        }
8956    }
8957
8958    let mut traces = HashMap::new();
8959    for block in blocks {
8960        match (fetcher)(resync_block_to_block_id(&block)) {
8961            Ok(diff) => {
8962                traces.insert(block, diff);
8963            }
8964            Err(error) => {
8965                tracing::debug!(
8966                    block = ?block,
8967                    error = %error,
8968                    "block trace resync source failed; falling back to point resync"
8969                );
8970            }
8971        }
8972    }
8973
8974    for group in storage_groups.iter_mut() {
8975        let Some(trace) = traces.get(&group.block) else {
8976            continue;
8977        };
8978        group.slots.retain(|slot| {
8979            if let Some(value) = trace_storage_value(trace, slot.address, slot.slot) {
8980                state_updates.push(StateUpdate::slot(slot.address, slot.slot, value));
8981                return false;
8982            }
8983            cache
8984                .cached_storage_value(slot.address, slot.slot)
8985                .is_none()
8986        });
8987        group.seen = group
8988            .slots
8989            .iter()
8990            .map(|slot| (slot.address, slot.slot))
8991            .collect();
8992    }
8993    storage_groups.retain(|group| !group.slots.is_empty());
8994
8995    let mut unresolved_accounts = Vec::new();
8996    for mut account in account_targets.drain(..) {
8997        let Some(trace) = traces.get(&account.block) else {
8998            unresolved_accounts.push(account);
8999            continue;
9000        };
9001        let Some(trace_account) = trace
9002            .accounts
9003            .iter()
9004            .find(|diff| diff.address == account.address)
9005        else {
9006            unresolved_accounts.push(account);
9007            continue;
9008        };
9009
9010        let mut patch = AccountPatch::default();
9011        let mut unresolved = AccountFieldMask::default();
9012        if account.fields.balance {
9013            if let Some(balance) = trace_account.balance {
9014                patch = patch.balance(balance);
9015            } else {
9016                unresolved.balance = true;
9017            }
9018        }
9019        if account.fields.nonce {
9020            if let Some(nonce) = trace_account.nonce {
9021                patch = patch.nonce(nonce);
9022            } else {
9023                unresolved.nonce = true;
9024            }
9025        }
9026        if account.fields.code {
9027            if let Some(code) = &trace_account.code {
9028                patch = patch.code(code.clone());
9029            } else {
9030                unresolved.code = true;
9031            }
9032        }
9033
9034        if patch.balance.is_some() || patch.nonce.is_some() || patch.code.is_some() {
9035            state_updates.push(StateUpdate::account_upsert(account.address, patch));
9036        }
9037        if !account_field_mask_empty(unresolved) {
9038            account.fields = unresolved;
9039            unresolved_accounts.push(account);
9040        }
9041    }
9042    *account_targets = unresolved_accounts;
9043}
9044
9045fn trace_storage_value(trace: &BlockStateDiff, address: Address, slot: U256) -> Option<U256> {
9046    trace
9047        .accounts
9048        .iter()
9049        .find(|account| account.address == address)
9050        .and_then(|account| {
9051            account
9052                .storage
9053                .iter()
9054                .find(|entry| entry.slot == slot)
9055                .map(|entry| entry.value)
9056        })
9057}
9058
9059fn account_field_mask_empty(mask: AccountFieldMask) -> bool {
9060    !mask.balance && !mask.nonce && !mask.code
9061}
9062
9063fn execute_resync_requests(cache: &mut EvmCache, requests: &[ResyncRequest]) -> ResyncReport {
9064    let mut failed = Vec::new();
9065    let mut storage_groups: Vec<StorageFetchGroup> = Vec::new();
9066    let mut account_targets: Vec<AccountResyncTarget> = Vec::new();
9067
9068    for request in requests {
9069        for target in &request.targets {
9070            match target {
9071                ResyncTarget::StorageSlot { address, slot } => {
9072                    push_storage_resync_slot(
9073                        &mut storage_groups,
9074                        &request.id,
9075                        &request.block,
9076                        *address,
9077                        *slot,
9078                    );
9079                }
9080                ResyncTarget::StorageSlots { address, slots } => {
9081                    for slot in slots {
9082                        push_storage_resync_slot(
9083                            &mut storage_groups,
9084                            &request.id,
9085                            &request.block,
9086                            *address,
9087                            *slot,
9088                        );
9089                    }
9090                }
9091                ResyncTarget::Account { address, fields } => {
9092                    account_targets.push(AccountResyncTarget {
9093                        request_id: request.id.clone(),
9094                        block: request.block.clone(),
9095                        address: *address,
9096                        fields: *fields,
9097                    });
9098                }
9099            }
9100        }
9101    }
9102
9103    let mut state_updates = Vec::new();
9104    resolve_trace_resyncs(
9105        cache,
9106        &mut storage_groups,
9107        &mut account_targets,
9108        &mut state_updates,
9109    );
9110
9111    if !storage_groups.is_empty() {
9112        if let Some(fetcher) = cache.storage_batch_fetcher().cloned() {
9113            for group in storage_groups {
9114                let block = group.block.clone();
9115                let fetches: Vec<(Address, U256)> = group
9116                    .slots
9117                    .iter()
9118                    .map(|slot| (slot.address, slot.slot))
9119                    .collect();
9120                let results = (fetcher)(fetches, resync_block_to_block_id(&block));
9121                let mut pending: HashMap<(Address, U256), StorageFetchSlot> = group
9122                    .slots
9123                    .iter()
9124                    .cloned()
9125                    .map(|slot| ((slot.address, slot.slot), slot))
9126                    .collect();
9127
9128                for (address, slot, fetched) in results {
9129                    let Some(requested_slot) = pending.remove(&(address, slot)) else {
9130                        continue;
9131                    };
9132                    match fetched {
9133                        Ok(value) => state_updates.push(StateUpdate::slot(address, slot, value)),
9134                        Err(error) => {
9135                            let message = error.to_string();
9136                            push_resync_failures(
9137                                &mut failed,
9138                                &block,
9139                                requested_slot.origins,
9140                                ResyncFailureKind::StorageFetchFailed,
9141                                message,
9142                            );
9143                        }
9144                    }
9145                }
9146
9147                for requested_slot in group.slots {
9148                    if pending
9149                        .remove(&(requested_slot.address, requested_slot.slot))
9150                        .is_some()
9151                    {
9152                        push_resync_failures(
9153                            &mut failed,
9154                            &block,
9155                            requested_slot.origins,
9156                            ResyncFailureKind::StorageFetchOmitted,
9157                            "storage batch fetcher did not return a value for slot".to_string(),
9158                        );
9159                    }
9160                }
9161            }
9162        } else {
9163            for group in storage_groups {
9164                let block = group.block.clone();
9165                for slot in group.slots {
9166                    push_resync_failures(
9167                        &mut failed,
9168                        &block,
9169                        slot.origins,
9170                        ResyncFailureKind::MissingStorageFetcher,
9171                        "storage resync requires a storage batch fetcher".to_string(),
9172                    );
9173                }
9174            }
9175        }
9176    }
9177
9178    if !account_targets.is_empty() {
9179        if let Some(fetcher) = cache.account_proof_fetcher().cloned() {
9180            // ONE seam invocation per distinct resync block (targets may pin
9181            // different blocks): eth_getProof is single-address at the RPC
9182            // level, so batching the addresses lets the fetcher fan the
9183            // requests out concurrently instead of paying one round trip per
9184            // account. Root-only probes: account fields need no storage keys.
9185            let mut groups: Vec<(BlockId, Vec<_>)> = Vec::new();
9186            for account in account_targets {
9187                let block_id = resync_block_to_block_id(&account.block);
9188                match groups
9189                    .iter_mut()
9190                    .find(|(group_block, _)| *group_block == block_id)
9191                {
9192                    Some((_, group)) => group.push(account),
9193                    None => groups.push((block_id, vec![account])),
9194                }
9195            }
9196            for (block_id, group) in groups {
9197                let probes: HashMap<Address, StorageFetchResult<AccountProof>> = (fetcher)(
9198                    group
9199                        .iter()
9200                        .map(|account| (account.address, vec![]))
9201                        .collect(),
9202                    block_id,
9203                )
9204                .into_iter()
9205                .collect();
9206                for account in group {
9207                    // `get` + clone rather than `remove`: two targets for the
9208                    // same address in one group must both resolve from the
9209                    // single probe.
9210                    match probes.get(&account.address).cloned() {
9211                        Some(Ok(proof)) => {
9212                            // Build an authoritative account update from the requested
9213                            // field mask. Use the MATERIALIZING `account_upsert` so a
9214                            // resync applies even to a cold account (a partial `Account`
9215                            // patch on a cold address is silently skipped).
9216                            let mut patch = AccountPatch::default();
9217                            if account.fields.balance {
9218                                patch = patch.balance(proof.balance);
9219                            }
9220                            if account.fields.nonce {
9221                                patch = patch.nonce(proof.nonce);
9222                            }
9223                            // Note: `AccountProof` carries `code_hash`, not code bytes;
9224                            // the `eth_getProof` seam cannot supply runtime code, so a
9225                            // code-field resync is a no-op here (code freshness is
9226                            // handled by a later wave). We still materialize the account
9227                            // so requested balance/nonce fields take effect.
9228                            state_updates.push(StateUpdate::account_upsert(account.address, patch));
9229                        }
9230                        Some(Err(error)) => {
9231                            failed.push(ResyncFailure {
9232                                request_id: account.request_id,
9233                                block: account.block,
9234                                target: ResyncTarget::Account {
9235                                    address: account.address,
9236                                    fields: account.fields,
9237                                },
9238                                kind: ResyncFailureKind::AccountFetchFailed,
9239                                message: error.to_string(),
9240                            });
9241                        }
9242                        None => {
9243                            failed.push(ResyncFailure {
9244                                request_id: account.request_id,
9245                                block: account.block,
9246                                target: ResyncTarget::Account {
9247                                    address: account.address,
9248                                    fields: account.fields,
9249                                },
9250                                kind: ResyncFailureKind::AccountFetchOmitted,
9251                                message:
9252                                    "account proof fetcher did not return a result for address"
9253                                        .to_string(),
9254                            });
9255                        }
9256                    }
9257                }
9258            }
9259        } else {
9260            for account in account_targets {
9261                failed.push(ResyncFailure {
9262                    request_id: account.request_id,
9263                    block: account.block,
9264                    target: ResyncTarget::Account {
9265                        address: account.address,
9266                        fields: account.fields,
9267                    },
9268                    kind: ResyncFailureKind::MissingAccountFetcher,
9269                    message: "account resync requires an account proof fetcher".to_string(),
9270                });
9271            }
9272        }
9273    }
9274
9275    let diff = if state_updates.is_empty() {
9276        StateDiff::default()
9277    } else {
9278        cache.apply_updates(&state_updates)
9279    };
9280
9281    ResyncReport {
9282        requested: requests.to_vec(),
9283        state_updates,
9284        diff,
9285        failed,
9286    }
9287}
9288
9289fn push_resync_failures(
9290    failed: &mut Vec<ResyncFailure>,
9291    block: &ResyncBlock,
9292    origins: Vec<StorageFetchOrigin>,
9293    kind: ResyncFailureKind,
9294    message: String,
9295) {
9296    for origin in origins {
9297        failed.push(ResyncFailure {
9298            request_id: origin.request_id,
9299            block: block.clone(),
9300            target: origin.target,
9301            kind,
9302            message: message.clone(),
9303        });
9304    }
9305}
9306
9307fn push_storage_resync_slot(
9308    groups: &mut Vec<StorageFetchGroup>,
9309    request_id: &ResyncId,
9310    block: &ResyncBlock,
9311    address: Address,
9312    slot: U256,
9313) {
9314    let group_index = if let Some(index) = groups.iter().position(|group| group.block == *block) {
9315        index
9316    } else {
9317        groups.push(StorageFetchGroup {
9318            block: block.clone(),
9319            slots: Vec::new(),
9320            seen: HashSet::new(),
9321        });
9322        groups.len() - 1
9323    };
9324
9325    let group = &mut groups[group_index];
9326    let origin = StorageFetchOrigin {
9327        request_id: request_id.clone(),
9328        target: ResyncTarget::StorageSlot { address, slot },
9329    };
9330    if group.seen.insert((address, slot)) {
9331        group.slots.push(StorageFetchSlot {
9332            address,
9333            slot,
9334            origins: vec![origin],
9335        });
9336    } else if let Some(existing) = group
9337        .slots
9338        .iter_mut()
9339        .find(|existing| existing.address == address && existing.slot == slot)
9340    {
9341        existing.origins.push(origin);
9342    }
9343}
9344
9345fn resync_block_to_block_id(block: &ResyncBlock) -> BlockId {
9346    match block {
9347        ResyncBlock::Latest => BlockId::latest(),
9348        ResyncBlock::Pending => BlockId::pending(),
9349        ResyncBlock::Safe => BlockId::safe(),
9350        ResyncBlock::Finalized => BlockId::finalized(),
9351        ResyncBlock::Number(number) => BlockId::number(*number),
9352        ResyncBlock::Hash {
9353            number: _,
9354            hash,
9355            require_canonical,
9356        } => BlockId::from((*hash, Some(*require_canonical))),
9357    }
9358}
9359
9360impl<N: Network> RegisteredHandler<N> {
9361    fn matches(&self, input: &ReactiveInput<N>) -> bool {
9362        self.interests
9363            .iter()
9364            .any(|interest| interest_matches(interest, input))
9365    }
9366
9367    fn route_log(&self, log: &Log) -> Option<ReactiveLogRoute> {
9368        self.interests.iter().find_map(|interest| match interest {
9369            ReactiveInterest::Logs(interest) if interest.matches(log) => Some(ReactiveLogRoute {
9370                handler_id: self.id.clone(),
9371                route_key: interest.route_key(log),
9372            }),
9373            ReactiveInterest::Logs(_)
9374            | ReactiveInterest::Blocks(_)
9375            | ReactiveInterest::PendingTransactions(_) => None,
9376        })
9377    }
9378}
9379
9380fn merge_log_subscription_filter(filters: &mut Vec<Filter>, next: &Filter) {
9381    let mut candidate = next.clone();
9382    let mut insertion_index = filters.len();
9383    let mut index = 0;
9384    while index < filters.len() {
9385        if filters[index].block_option != candidate.block_option {
9386            index += 1;
9387            continue;
9388        }
9389        if let Some(merged) = exact_filter_union(&candidate, &filters[index]) {
9390            candidate = merged;
9391            insertion_index = insertion_index.min(index);
9392            filters.remove(index);
9393            index = 0;
9394        } else {
9395            index += 1;
9396        }
9397    }
9398    filters.insert(insertion_index.min(filters.len()), candidate);
9399}
9400
9401fn exact_filter_union(left: &Filter, right: &Filter) -> Option<Filter> {
9402    if filter_subsumes(left, right) {
9403        return Some(left.clone());
9404    }
9405    if filter_subsumes(right, left) {
9406        return Some(right.clone());
9407    }
9408    let differing_dimensions = usize::from(left.address != right.address)
9409        + left
9410            .topics
9411            .iter()
9412            .zip(right.topics.iter())
9413            .filter(|(left, right)| left != right)
9414            .count();
9415    if differing_dimensions != 1 {
9416        return None;
9417    }
9418
9419    let mut merged = left.clone();
9420    if merged.address != right.address {
9421        merge_filter_set(&mut merged.address, &right.address);
9422    } else {
9423        for (merged_topic, right_topic) in merged.topics.iter_mut().zip(right.topics.iter()) {
9424            if merged_topic != right_topic {
9425                merge_filter_set(merged_topic, right_topic);
9426                break;
9427            }
9428        }
9429    }
9430    Some(merged)
9431}
9432
9433fn filter_subsumes(left: &Filter, right: &Filter) -> bool {
9434    filter_set_subsumes(&left.address, &right.address)
9435        && left
9436            .topics
9437            .iter()
9438            .zip(right.topics.iter())
9439            .all(|(left, right)| filter_set_subsumes(left, right))
9440}
9441
9442fn filter_set_subsumes<T: Eq + Hash>(left: &FilterSet<T>, right: &FilterSet<T>) -> bool {
9443    left.is_empty()
9444        || (!right.is_empty()
9445            && right
9446                .iter()
9447                .all(|value| left.iter().any(|known| known == value)))
9448}
9449
9450fn merge_filter_set<T: Clone + Eq + Hash>(target: &mut FilterSet<T>, source: &FilterSet<T>) {
9451    if target.is_empty() {
9452        return;
9453    }
9454    if source.is_empty() {
9455        *target = FilterSet::default();
9456        return;
9457    }
9458    for value in source.iter() {
9459        target.insert(value.clone());
9460    }
9461}
9462
9463#[derive(Clone, Debug)]
9464struct HandlerExecution {
9465    handler_id: HandlerId,
9466    quality: StateEffectQuality,
9467    tags: Vec<ReportTag>,
9468    state_updates: Vec<StateUpdate>,
9469    invalidations: Vec<InvalidationRequest>,
9470    resyncs: Vec<ResyncRequest>,
9471    speculative: Vec<SpeculativeRequest>,
9472    hook_signals: Vec<HookSignal>,
9473}
9474
9475impl HandlerExecution {
9476    fn from_outcome(
9477        handler_id: HandlerId,
9478        input_ref: InputRef,
9479        outcome: HandlerOutcome,
9480        preconfirmed: bool,
9481    ) -> Self {
9482        let mut state_updates = Vec::new();
9483        let mut invalidations = Vec::new();
9484        let mut resyncs = Vec::new();
9485        let mut speculative = Vec::new();
9486        let mut hook_signals = Vec::new();
9487
9488        for effect in outcome.effects {
9489            match effect {
9490                ReactiveEffect::StateUpdate(update) => state_updates.push(update),
9491                ReactiveEffect::Invalidate(invalidation) => {
9492                    state_updates.push(StateUpdate::purge(
9493                        invalidation.address,
9494                        invalidation.scope.clone(),
9495                    ));
9496                    invalidations.push(invalidation);
9497                }
9498                ReactiveEffect::Resync(mut request) => {
9499                    if preconfirmed {
9500                        request.block = ResyncBlock::Pending;
9501                    }
9502                    resyncs.push(request);
9503                }
9504                ReactiveEffect::Hook(signal) => hook_signals.push(signal),
9505                ReactiveEffect::Speculative(mut request) => {
9506                    request.input_ref = input_ref;
9507                    speculative.push(request);
9508                }
9509            }
9510        }
9511
9512        Self {
9513            handler_id,
9514            quality: outcome.quality,
9515            tags: outcome.tags,
9516            state_updates,
9517            invalidations,
9518            resyncs,
9519            speculative,
9520            hook_signals,
9521        }
9522    }
9523}
9524
9525fn dedupe_records<N: Network>(
9526    records: Vec<ReactiveInputRecord<N>>,
9527) -> Result<Vec<ReactiveInputRecord<N>>, ReactiveError> {
9528    let mut positions = HashMap::<ReactiveInputIdentity, usize>::new();
9529    let mut deduped = Vec::with_capacity(records.len());
9530    for record in records {
9531        let identity = record.validated_identity()?;
9532        if !record.is_payload_deduplicable() {
9533            deduped.push(record);
9534            continue;
9535        }
9536        if let Some(index) = positions.get(&identity).copied() {
9537            let merged = deduped[index].merge_compatible_duplicate(&record)?;
9538            debug_assert!(merged, "same indexed identity is deduplicable");
9539        } else {
9540            positions.insert(identity, deduped.len());
9541            deduped.push(record);
9542        }
9543    }
9544    Ok(deduped)
9545}
9546
9547fn dedupe_scoped_records<N: Network>(
9548    records: Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>,
9549) -> Result<Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>, ReactiveError> {
9550    let mut positions: HashMap<ReactiveInputIdentity, usize> = HashMap::new();
9551    let mut deduped: Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)> =
9552        Vec::with_capacity(records.len());
9553    for (record, audience, delivery_scope) in records {
9554        let identity = record.validated_identity()?;
9555        if !record.is_payload_deduplicable() {
9556            deduped.push((record, audience, delivery_scope));
9557            continue;
9558        }
9559        if let Some(index) = positions.get(&identity).copied() {
9560            let merged = deduped[index].0.merge_compatible_duplicate(&record)?;
9561            debug_assert!(merged, "same indexed identity is deduplicable");
9562            merge_delivery_audience(&mut deduped[index].1, audience);
9563            merge_delivery_scope(&mut deduped[index].2, delivery_scope);
9564        } else {
9565            positions.insert(identity, deduped.len());
9566            deduped.push((record, audience, delivery_scope));
9567        }
9568    }
9569    Ok(deduped)
9570}
9571
9572fn merge_delivery_scope(into: &mut DeliveryScope, incoming: DeliveryScope) {
9573    *into = match (*into, incoming) {
9574        (DeliveryScope::Canonical, _) | (_, DeliveryScope::Canonical) => DeliveryScope::Canonical,
9575        (DeliveryScope::CanonicalProgress, _) | (_, DeliveryScope::CanonicalProgress) => {
9576            DeliveryScope::CanonicalProgress
9577        }
9578        (DeliveryScope::Preconfirmed, DeliveryScope::Preconfirmed)
9579        | (DeliveryScope::Preconfirmed, DeliveryScope::OwnerCatchup)
9580        | (DeliveryScope::OwnerCatchup, DeliveryScope::Preconfirmed) => DeliveryScope::Preconfirmed,
9581        (DeliveryScope::OwnerCatchup, DeliveryScope::OwnerCatchup) => DeliveryScope::OwnerCatchup,
9582    };
9583}
9584
9585fn merge_delivery_audience(into: &mut DeliveryAudience, incoming: DeliveryAudience) {
9586    match (&mut *into, incoming) {
9587        (DeliveryAudience::All, _) => {}
9588        (current, DeliveryAudience::All) => *current = DeliveryAudience::All,
9589        (DeliveryAudience::Owners(current), DeliveryAudience::Owners(incoming)) => {
9590            for owner in incoming {
9591                if !current.contains(&owner) {
9592                    current.push(owner);
9593                }
9594            }
9595        }
9596        (DeliveryAudience::AllExcept(current), DeliveryAudience::AllExcept(incoming)) => {
9597            current.retain(|owner| incoming.contains(owner));
9598        }
9599        (DeliveryAudience::AllExcept(excluded), DeliveryAudience::Owners(included)) => {
9600            excluded.retain(|owner| !included.contains(owner));
9601        }
9602        (current @ DeliveryAudience::Owners(_), DeliveryAudience::AllExcept(mut excluded)) => {
9603            let DeliveryAudience::Owners(included) = current else {
9604                unreachable!("match arm restricts the audience variant")
9605            };
9606            excluded.retain(|owner| !included.contains(owner));
9607            *current = DeliveryAudience::AllExcept(excluded);
9608        }
9609    }
9610}
9611
9612fn sort_records<N: Network>(records: Vec<ReactiveInputRecord<N>>) -> Vec<ReactiveInputRecord<N>> {
9613    let mut indexed: Vec<(usize, ReactiveInputRecord<N>)> =
9614        records.into_iter().enumerate().collect();
9615    indexed.sort_by_key(|(index, record)| record_sort_key(*index, record));
9616    indexed.into_iter().map(|(_, record)| record).collect()
9617}
9618
9619fn sort_scoped_records<N: Network>(
9620    records: Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>,
9621) -> Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)> {
9622    let mut indexed: Vec<_> = records.into_iter().enumerate().collect();
9623    indexed.sort_by_key(|(index, (record, _, _))| record_sort_key(*index, record));
9624    indexed
9625        .into_iter()
9626        .map(|(_, scoped_record)| scoped_record)
9627        .collect()
9628}
9629
9630fn record_sort_key<N: Network>(index: usize, record: &ReactiveInputRecord<N>) -> RecordSortKey {
9631    if let Some((block, _)) = reorg_signal_block(record) {
9632        return RecordSortKey {
9633            class: 0,
9634            block_number: block.number,
9635            record_class: 0,
9636            transaction_index: record.context.transaction_index.unwrap_or(u64::MAX),
9637            log_index: record.context.log_index.unwrap_or(u64::MAX),
9638            original_index: index,
9639        };
9640    }
9641    if is_canonical_status(&record.context.chain_status)
9642        && let Some(block) = record.context.block.as_ref()
9643    {
9644        let (record_class, transaction_index, log_index) = match &record.input {
9645            ReactiveInput::BlockHeader(_) | ReactiveInput::FullBlock(_) => (0, 0, 0),
9646            ReactiveInput::Log(log) if !log.removed => (
9647                1,
9648                log.transaction_index
9649                    .or(record.context.transaction_index)
9650                    .unwrap_or(u64::MAX),
9651                log.log_index
9652                    .or(record.context.log_index)
9653                    .unwrap_or(u64::MAX),
9654            ),
9655            ReactiveInput::Log(_)
9656            | ReactiveInput::PendingTxHash(_)
9657            | ReactiveInput::PendingTx(_) => (2, u64::MAX, u64::MAX),
9658        };
9659        return RecordSortKey {
9660            class: 1,
9661            block_number: block.number,
9662            record_class,
9663            transaction_index,
9664            log_index,
9665            original_index: index,
9666        };
9667    }
9668
9669    RecordSortKey {
9670        class: 2,
9671        block_number: 0,
9672        record_class: 0,
9673        transaction_index: 0,
9674        log_index: 0,
9675        original_index: index,
9676    }
9677}
9678
9679#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
9680struct RecordSortKey {
9681    class: u8,
9682    block_number: u64,
9683    record_class: u8,
9684    transaction_index: u64,
9685    log_index: u64,
9686    original_index: usize,
9687}
9688
9689fn interest_matches<N: Network>(interest: &ReactiveInterest<N>, input: &ReactiveInput<N>) -> bool {
9690    match (interest, input) {
9691        (ReactiveInterest::Logs(interest), ReactiveInput::Log(log)) => interest.matches(log),
9692        (
9693            ReactiveInterest::Blocks(BlockInterest {
9694                mode: BlockInterestMode::Header,
9695            }),
9696            ReactiveInput::BlockHeader(_),
9697        ) => true,
9698        (
9699            ReactiveInterest::Blocks(BlockInterest {
9700                mode: BlockInterestMode::FullBlock,
9701            }),
9702            ReactiveInput::FullBlock(_),
9703        ) => true,
9704        (ReactiveInterest::PendingTransactions(interest), ReactiveInput::PendingTxHash(_)) => {
9705            interest.matches_hash_only()
9706        }
9707        (ReactiveInterest::PendingTransactions(interest), ReactiveInput::PendingTx(tx)) => {
9708            interest.matches_tx(tx)
9709        }
9710        _ => false,
9711    }
9712}
9713
9714fn validate_effects(
9715    input_ref: InputRef,
9716    ctx: &ReactiveContext,
9717    handler_id: &HandlerId,
9718    effects: &[ReactiveEffect],
9719) -> Result<(), ReactiveError> {
9720    let pending = matches!(ctx.chain_status, ChainStatus::Pending)
9721        || matches!(input_ref, InputRef::PendingTx { .. });
9722    if !pending {
9723        return Ok(());
9724    }
9725
9726    for effect in effects {
9727        let effect_kind = match effect {
9728            ReactiveEffect::StateUpdate(_) => Some("state_update"),
9729            ReactiveEffect::Invalidate(_) => Some("invalidate"),
9730            ReactiveEffect::Resync(_) => Some("resync"),
9731            ReactiveEffect::Hook(_) | ReactiveEffect::Speculative(_) => None,
9732        };
9733        if let Some(effect_kind) = effect_kind {
9734            return Err(ReactiveError::InvalidPendingEffect {
9735                input_ref: Box::new(input_ref),
9736                handler_id: handler_id.clone(),
9737                effect_kind,
9738            });
9739        }
9740    }
9741    Ok(())
9742}
9743
9744fn detect_conflicts(
9745    input_ref: InputRef,
9746    executions: &[HandlerExecution],
9747) -> Result<(), ReactiveError> {
9748    let mut writes: HashMap<EffectTarget, (AbsoluteValue, HandlerId)> = HashMap::new();
9749    for execution in executions {
9750        for update in &execution.state_updates {
9751            for (target, value) in absolute_writes(update) {
9752                if let Some((previous_value, previous_handler)) = writes.get(&target) {
9753                    if previous_value != &value {
9754                        return Err(ReactiveError::ConflictingEffects {
9755                            input_ref: Box::new(input_ref),
9756                            target: Box::new(target),
9757                            first: previous_handler.clone(),
9758                            second: execution.handler_id.clone(),
9759                        });
9760                    }
9761                } else {
9762                    writes.insert(target, (value, execution.handler_id.clone()));
9763                }
9764            }
9765        }
9766    }
9767    Ok(())
9768}
9769
9770fn absolute_writes(update: &StateUpdate) -> Vec<(EffectTarget, AbsoluteValue)> {
9771    match update {
9772        StateUpdate::Slot {
9773            address,
9774            slot,
9775            value,
9776        } => vec![(
9777            EffectTarget::StorageSlot {
9778                address: *address,
9779                slot: *slot,
9780            },
9781            AbsoluteValue::U256(*value),
9782        )],
9783        StateUpdate::SlotMasked {
9784            address,
9785            slot,
9786            mask,
9787            value,
9788        } => vec![(
9789            EffectTarget::MaskedStorageSlot {
9790                address: *address,
9791                slot: *slot,
9792                mask: *mask,
9793            },
9794            AbsoluteValue::U256(*value),
9795        )],
9796        StateUpdate::Account { address, patch } | StateUpdate::AccountUpsert { address, patch } => {
9797            account_patch_writes(*address, patch)
9798        }
9799        StateUpdate::SlotDelta { .. }
9800        | StateUpdate::BalanceDelta { .. }
9801        | StateUpdate::Purge { .. } => Vec::new(),
9802    }
9803}
9804
9805fn account_patch_writes(
9806    address: Address,
9807    patch: &AccountPatch,
9808) -> Vec<(EffectTarget, AbsoluteValue)> {
9809    let mut writes = Vec::new();
9810    if let Some(balance) = patch.balance {
9811        writes.push((
9812            EffectTarget::AccountBalance { address },
9813            AbsoluteValue::U256(balance),
9814        ));
9815    }
9816    if let Some(nonce) = patch.nonce {
9817        writes.push((
9818            EffectTarget::AccountNonce { address },
9819            AbsoluteValue::U64(nonce),
9820        ));
9821    }
9822    if let Some(code) = &patch.code {
9823        writes.push((
9824            EffectTarget::AccountCode { address },
9825            AbsoluteValue::Bytes(code.clone()),
9826        ));
9827    }
9828    writes
9829}
9830
9831fn input_ref<N: Network>(input: &ReactiveInput<N>, ctx: &ReactiveContext) -> InputRef {
9832    match input {
9833        ReactiveInput::Log(log) => InputRef::Log {
9834            chain_id: ctx.chain_id,
9835            block_hash: log
9836                .block_hash
9837                .or(ctx.block.as_ref().map(|block| block.hash))
9838                .unwrap_or_default(),
9839            transaction_hash: log.transaction_hash.unwrap_or_default(),
9840            log_index: log.log_index.or(ctx.log_index).unwrap_or_default(),
9841        },
9842        ReactiveInput::PendingTxHash(hash) => InputRef::PendingTx {
9843            chain_id: ctx.chain_id,
9844            hash: *hash,
9845        },
9846        ReactiveInput::PendingTx(tx) => InputRef::PendingTx {
9847            chain_id: ctx.chain_id,
9848            hash: tx.tx_hash(),
9849        },
9850        ReactiveInput::BlockHeader(header) => InputRef::Block {
9851            chain_id: ctx.chain_id,
9852            hash: header.hash(),
9853            number: header.number(),
9854        },
9855        ReactiveInput::FullBlock(block) => {
9856            let header = block.header();
9857            InputRef::Block {
9858                chain_id: ctx.chain_id,
9859                hash: header.hash(),
9860                number: header.number(),
9861            }
9862        }
9863    }
9864}
9865
9866fn is_canonical_status(status: &ChainStatus) -> bool {
9867    matches!(
9868        status,
9869        ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. }
9870    )
9871}
9872
9873/// Adapter that wraps a legacy [`EventDecoder`] as a log-only reactive handler.
9874pub struct EventDecoderHandler {
9875    id: HandlerId,
9876    decoder: Arc<dyn EventDecoder>,
9877    interest: LogInterest,
9878}
9879
9880impl EventDecoderHandler {
9881    /// Create an adapter from a decoder and log interest.
9882    pub fn new(id: HandlerId, decoder: Arc<dyn EventDecoder>, interest: LogInterest) -> Self {
9883        Self {
9884            id,
9885            decoder,
9886            interest,
9887        }
9888    }
9889}
9890
9891impl<N: Network> ReactiveHandler<N> for EventDecoderHandler {
9892    fn id(&self) -> HandlerId {
9893        self.id.clone()
9894    }
9895
9896    fn interests(&self) -> Vec<ReactiveInterest<N>> {
9897        vec![ReactiveInterest::Logs(self.interest.clone())]
9898    }
9899
9900    fn handle(
9901        &self,
9902        _ctx: &ReactiveContext,
9903        input: &ReactiveInput<N>,
9904        state: &dyn StateView,
9905    ) -> Result<HandlerOutcome, HandlerError> {
9906        let ReactiveInput::Log(log) = input else {
9907            return Ok(HandlerOutcome::empty(StateEffectQuality::NoStateEffect));
9908        };
9909
9910        Ok(HandlerOutcome {
9911            effects: self
9912                .decoder
9913                .decode(&log.inner, state)
9914                .into_iter()
9915                .map(ReactiveEffect::StateUpdate)
9916                .collect(),
9917            quality: StateEffectQuality::ExactFromInput,
9918            tags: Vec::new(),
9919        })
9920    }
9921}
9922
9923/// One independently negotiable event-subscriber behavior.
9924#[derive(
9925    Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
9926)]
9927#[non_exhaustive]
9928pub enum SubscriberCapability {
9929    /// Emit EVM logs.
9930    Logs,
9931    /// Emit block headers.
9932    BlockHeaders,
9933    /// Emit full blocks with transaction bodies.
9934    FullBlocks,
9935    /// Emit pending transaction hashes.
9936    PendingTransactionHashes,
9937    /// Emit hydrated pending transactions.
9938    PendingTransactions,
9939    /// Fetch historical data from a caller-selected anchor.
9940    HistoricalBackfill,
9941    /// Follow live chain data.
9942    Live,
9943    /// Attest, via [`ChainControl::LogCoverage`], that no log-notification loss
9944    /// went unhealed at or below a stated block.
9945    ///
9946    /// Advertise this only when loss is actually detectable: a source that can
9947    /// silently drop a notification must not claim it, because a consumer treats
9948    /// the capability as licence to trust a delivered log set instead of
9949    /// re-fetching it. Absence of the capability and absence of an attestation
9950    /// mean the same thing — unknown — and neither may be read as complete.
9951    LogCoverageAttestation,
9952    /// Recover the complete committed consumer position after reconnect or
9953    /// restart, including any unacknowledged delivery.
9954    ///
9955    /// An implementation may satisfy this with native stream replay or with a
9956    /// durable cursor plus deterministic historical reconciliation of an
9957    /// ephemeral live child. The end-to-end subscriber must still prove there
9958    /// is no gap between the restored position and resumed live delivery. If an
9959    /// old delivery token is emitted again, that token must identify the same
9960    /// immutable delivery and pass the engine's witness check.
9961    DurableReplay,
9962    /// Preserve logical handler ownership on delivered batches.
9963    OwnerScopedDelivery,
9964    /// Add and remove interests without replacing the complete session.
9965    DynamicInterests,
9966    /// Emit explicit canonical branch transitions.
9967    ExplicitReorgs,
9968    /// Emit safe and finalized head updates.
9969    FinalityUpdates,
9970    /// Emit ordered synchronization or source-cutover barriers.
9971    Barriers,
9972    /// Emit sequencer pre-confirmations into a disposable state overlay.
9973    Preconfirmations,
9974}
9975
9976/// Capability set advertised by an [`EventSubscriber`].
9977///
9978/// The default is deliberately empty: callers can safely reject a topology
9979/// when an older or minimal implementation has not opted into a required
9980/// behavior.
9981#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
9982pub struct SubscriberCapabilities {
9983    supported: BTreeSet<SubscriberCapability>,
9984}
9985
9986impl SubscriberCapabilities {
9987    /// Construct a capability set from supported behaviors.
9988    pub fn new(capabilities: impl IntoIterator<Item = SubscriberCapability>) -> Self {
9989        Self {
9990            supported: capabilities.into_iter().collect(),
9991        }
9992    }
9993
9994    /// Test one independently negotiable behavior.
9995    pub fn supports(&self, capability: SubscriberCapability) -> bool {
9996        self.supported.contains(&capability)
9997    }
9998
9999    /// Iterate supported behaviors in stable order.
10000    pub fn iter(&self) -> impl Iterator<Item = SubscriberCapability> + '_ {
10001        self.supported.iter().copied()
10002    }
10003
10004    /// Whether the subscriber follows live chain data.
10005    pub fn supports_live(&self) -> bool {
10006        self.supports(SubscriberCapability::Live)
10007    }
10008
10009    /// Whether the subscriber can durably recover its committed position and
10010    /// any unacknowledged delivery without an event gap.
10011    pub fn supports_durable_replay(&self) -> bool {
10012        self.supports(SubscriberCapability::DurableReplay)
10013    }
10014
10015    /// Whether the subscriber emits explicit branch transitions.
10016    pub fn supports_explicit_reorgs(&self) -> bool {
10017        self.supports(SubscriberCapability::ExplicitReorgs)
10018    }
10019}
10020
10021impl FromIterator<SubscriberCapability> for SubscriberCapabilities {
10022    fn from_iter<T: IntoIterator<Item = SubscriberCapability>>(iter: T) -> Self {
10023        Self::new(iter)
10024    }
10025}
10026
10027/// Provider-agnostic subscriber interface.
10028pub trait EventSubscriber<N: Network = Ethereum>: Send {
10029    /// Chain identity attached to emitted records, when it has been resolved.
10030    ///
10031    /// Remote and provider-backed subscribers should cache one authoritative
10032    /// identity before exposing input. Returning `None` is reserved for
10033    /// synthetic or genuinely chain-agnostic subscribers; composite sources
10034    /// can use this hook to reject accidentally mixed networks.
10035    fn chain_id(&self) -> Option<u64> {
10036        None
10037    }
10038
10039    /// Behaviors this subscriber can uphold for topology validation.
10040    fn capabilities(&self) -> SubscriberCapabilities {
10041        SubscriberCapabilities::default()
10042    }
10043
10044    /// Replace all interests registered with the subscriber.
10045    ///
10046    /// Implementations may use this as a full setup/reset operation. The
10047    /// in-crate [`AlloySubscriber`] clears owner-scoped interest state and
10048    /// delivery/dedupe bookkeeping when this method is called.
10049    ///
10050    /// The returned operation must complete only after the replacement has
10051    /// committed to the subscriber's desired state. Remote implementations can
10052    /// use this asynchronous boundary to wait for an authoritative service-side
10053    /// acknowledgement before returning `Ok(())`. On error, or when the future
10054    /// is dropped before completion, the previously committed desired state
10055    /// must remain authoritative (or be reconciled before later delivery can
10056    /// expose the uncommitted change) so callers can safely retry.
10057    ///
10058    /// # Errors
10059    ///
10060    /// The returned operation reports [`SubscriberError`] when the replacement
10061    /// cannot be validated or committed by the underlying source.
10062    fn register_interests(
10063        &mut self,
10064        interests: &[ReactiveInterest<N>],
10065    ) -> SubscriberOperation<'_, ()>;
10066
10067    /// Return the next input batch, or `Ok(None)` when the stream is exhausted.
10068    ///
10069    /// The returned future must be cancellation-safe: dropping it while pending
10070    /// must not discard a complete input that a later call could otherwise
10071    /// deliver. Composite subscribers use this property to race historical and
10072    /// live sources without dedicating a task to each transport.
10073    ///
10074    /// # Errors
10075    ///
10076    /// The returned future reports [`SubscriberError`] for transport,
10077    /// continuity, decoding, or source-resource failures.
10078    fn next_batch(&mut self) -> SubscriberNextBatch<'_, N>;
10079
10080    /// Restore the subscriber's committed position before polling resumes.
10081    ///
10082    /// The engine invokes this synchronously from
10083    /// [`ReactiveEngine::resume_from_durable_checkpoint`] after decoding runtime
10084    /// recovery state and before publishing that state as resumed. Implementations
10085    /// should validate that provider/service cursors cannot regress and seed any
10086    /// source epoch or overlap history required for safe replay. A composite may
10087    /// rebuild an ephemeral live child from `coverage_head` plus historical
10088    /// reconciliation rather than require that child to replay bytes itself, but
10089    /// it may advertise [`SubscriberCapability::DurableReplay`] only when the
10090    /// complete restore closes that cutover gap before exposing live input. On
10091    /// error, either
10092    /// the prior position must remain authoritative, or the subscriber may retain
10093    /// this *exact* restore as pending intent; in the latter case it must block
10094    /// delivery and reject conflicting restores until retry/reconciliation commits
10095    /// the same position. This permits synchronous adapters over durable remote
10096    /// state without exposing a half-restored stream.
10097    ///
10098    /// # Errors
10099    ///
10100    /// Returns [`SubscriberError`] when the position is invalid, regresses or
10101    /// conflicts with committed source state, or cannot be restored durably.
10102    fn restore_position(
10103        &mut self,
10104        _position: &SubscriberResumePosition,
10105    ) -> Result<(), SubscriberError> {
10106        Ok(())
10107    }
10108
10109    /// Commit a subscriber-owned delivery token after runtime ingestion.
10110    ///
10111    /// Ephemeral subscribers can rely on this no-op default. Durable remote
10112    /// subscribers should make acknowledgement idempotent because cancellation
10113    /// or transport failure can cause a successfully ingested batch to replay.
10114    /// Re-emitting a token must reproduce the same immutable records, routing,
10115    /// chain controls, chain identity, and provider checkpoint; the checkpointed
10116    /// engine verifies its persisted delivery witness before skipping ingestion.
10117    ///
10118    /// # Errors
10119    ///
10120    /// The returned operation reports [`SubscriberError`] when the delivery
10121    /// token cannot be committed idempotently by the source.
10122    fn acknowledge_delivery(
10123        &mut self,
10124        _token: SubscriberDeliveryToken,
10125    ) -> SubscriberOperation<'_, ()> {
10126        Box::pin(async { Ok(()) })
10127    }
10128}
10129
10130/// Boxed, sendable future returned by subscriber lifecycle operations.
10131///
10132/// The output is generic so the same type can represent registration, removal,
10133/// and future acknowledgement values without requiring an async-trait helper.
10134pub type SubscriberOperation<'a, T> =
10135    Pin<Box<dyn Future<Output = Result<T, SubscriberError>> + Send + 'a>>;
10136
10137/// Boxed future returned by [`EventSubscriber::next_batch`].
10138pub type SubscriberNextBatch<'a, N> = Pin<
10139    Box<dyn Future<Output = Result<Option<ReactiveInputBatch<N>>, SubscriberError>> + Send + 'a>,
10140>;
10141
10142/// Boxed future returned by [`AlloySubscriber::next_scoped_batch`].
10143pub type SubscriberNextScopedBatch<'a, N> = Pin<
10144    Box<dyn Future<Output = Result<Option<SubscriberInputBatch<N>>, SubscriberError>> + Send + 'a>,
10145>;
10146
10147/// Subscriber mode requested for the Alloy subscriber.
10148#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
10149pub enum SubscriberMode {
10150    /// Prefer the default compiled transport.
10151    ///
10152    /// With the default `reactive-ws` feature this resolves to pubsub/WebSocket
10153    /// subscriptions. Without `reactive-ws`, it resolves to polling only when
10154    /// the opt-in `reactive-polling` feature is enabled.
10155    #[default]
10156    Auto,
10157    /// Use provider pubsub streams.
10158    PubSub,
10159    /// Use polling/watch APIs. Requires the `reactive-polling` feature.
10160    Polling,
10161}
10162
10163#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10164enum FlashblocksAdapter {
10165    NativeSubscriptions,
10166    PendingStatePolling,
10167}
10168
10169fn flashblocks_adapter(chain_id: u64) -> Option<FlashblocksAdapter> {
10170    match chain_id {
10171        8_453 | 84_532 => Some(FlashblocksAdapter::NativeSubscriptions),
10172        10 | 11_155_420 => Some(FlashblocksAdapter::PendingStatePolling),
10173        _ => None,
10174    }
10175}
10176
10177/// Subscriber configuration.
10178#[derive(Clone, Debug, PartialEq, Eq)]
10179pub struct SubscriberConfig {
10180    /// Flashblocks delivery policy. Provider support itself is configured by
10181    /// the transport's single `flashblocks` endpoint flag.
10182    pub preconfirmations: PreconfirmationMode,
10183    /// Cadence for certifying sealed canonical heads while connected to a
10184    /// Flashblocks endpoint whose `newHeads` stream may contain partial heads.
10185    pub canonical_head_poll_interval: Duration,
10186    /// Maximum time allowed for one provider request that certifies a
10187    /// canonical head while Flashblocks are active.
10188    pub canonical_head_request_timeout: Duration,
10189    /// Optimism pending-state sampling cadence.
10190    ///
10191    /// Base uses native `newFlashblocks` plus `pendingLogs`. Optimism providers
10192    /// currently expose the interoperable Flashblocks surface through
10193    /// `pending` RPC reads, so one generation-pinned sampler reads the
10194    /// cumulative pending block, its exact hash-addressed parent, filtered
10195    /// pending-block logs, and bounded exact transaction receipts.
10196    ///
10197    /// # Cost
10198    ///
10199    /// This is the most request-hungry setting in the crate, and unlike the
10200    /// canonical paths it cannot be made event-driven: the pending surface is
10201    /// only observable by asking. Every tick issues one pending-block read, one
10202    /// `eth_getLogs` **per provider-facing log filter**, and up to
10203    /// [`Self::max_pending_transaction_receipts_per_tick`] receipt calls. At the
10204    /// 250 ms default that is four ticks a second, bounded overall by
10205    /// [`Self::max_flashblock_rpc_requests_per_second`] — a ceiling of 40
10206    /// requests per second, or roughly 3.4 M per day on one chain.
10207    ///
10208    /// It is reached only by enabling pre-confirmations on a chain whose adapter
10209    /// samples pending state (Optimism and its testnet), which in a transport
10210    /// configuration means marking one of that chain's endpoints
10211    /// `flashblocks = true`. Raise this interval, lower
10212    /// `max_flashblock_rpc_requests_per_second`, or leave
10213    /// [`Self::preconfirmations`] disabled if that budget is not intended;
10214    /// [`AlloySubscriber::rpc_stats`] attributes the traffic to
10215    /// [`SubscriberRpcCause::PendingStateSample`] so it is visible before it
10216    /// arrives on an invoice.
10217    pub flashblock_poll_interval: Duration,
10218    /// Consecutive pending-state request failure allowance.
10219    ///
10220    /// A successful sampling tick resets this counter. Semantic integrity
10221    /// failures, such as non-monotonic transaction membership or malformed
10222    /// logs, are never retried through this allowance.
10223    pub max_consecutive_flashblock_poll_failures: usize,
10224    /// Maximum pending receipts per sampling tick.
10225    ///
10226    /// Receipts are requested by exact transaction hash in one JSON-RPC batch,
10227    /// because separate `eth_getBlockReceipts("pending")` responses can refer
10228    /// to a different cumulative Flashblock. The rolling total-method budget
10229    /// may impose a lower effective per-tick limit; with the defaults and one
10230    /// log filter, at most seven receipts are requested per tick.
10231    pub max_pending_transaction_receipts_per_tick: usize,
10232    /// Pending-state RPC method budget per rolling one-second window.
10233    ///
10234    /// The sampler reserves capacity for the pending-block, exact-parent, and
10235    /// filtered-log methods implied by its cadence and filter plan, plus the
10236    /// exact-parent canonical-head poll when block interests require it. Exact
10237    /// receipt hydration uses only an evenly apportioned remainder. Request
10238    /// timestamps enforce the ceiling across actual ticks, including delayed
10239    /// ticks. The default leaves headroom below common paid-provider limits of
10240    /// 50 requests per second.
10241    pub max_flashblock_rpc_requests_per_second: usize,
10242    /// Bounded notification capacity for pubsub log streams.
10243    ///
10244    /// `None` reuses [`Self::max_batch_size`]. Size this independently when a
10245    /// high-volume log filter shares a subscriber with small delivery batches:
10246    /// the transport drops notifications once the channel is full, and while
10247    /// that loss is now detected and healed by an exact-window refetch, each
10248    /// occurrence costs an `eth_getLogs`. Watch
10249    /// [`SubscriberStreamGapStats::lagged_notifications`] to tell whether this
10250    /// is too small.
10251    pub log_channel_size: Option<usize>,
10252    /// Hydrate pending transaction hashes into full bodies when possible.
10253    pub hydrate_pending_transactions: bool,
10254    /// Verify each canonical log's block identity through RPC and enrich its
10255    /// context with the exact parent hash before delivery.
10256    ///
10257    /// # This is not the way to trust a log stream
10258    ///
10259    /// Enabling this to decide whether delivered logs can be trusted is the
10260    /// expensive wrong answer: it costs a request per distinct canonical block
10261    /// and proves strictly less than [`ChainControl::LogCoverage`], which is
10262    /// free. Verification confirms that each log it *received* names a real
10263    /// block; it says nothing about logs that never arrived, which is the
10264    /// failure that matters. Use the attestation for completeness, and reserve
10265    /// this for a strict coordinator that needs exact parent-hash enrichment on
10266    /// log-only pubsub events.
10267    ///
10268    /// Enable this when a strict coordinator (such as a hybrid historical/live
10269    /// source) must prove canonical ancestry from log-only pubsub events.
10270    /// Verification is cached per block, so the provider is queried at most
10271    /// once for each distinct canonical block retained in the dedupe window.
10272    /// For high-volume pubsub filters, configure
10273    /// [`AlloySubscriber::with_log_verification_provider`] with a separate HTTP
10274    /// provider so verification responses cannot be starved by notifications.
10275    pub verify_log_block_context: bool,
10276    /// Maximum records to emit per batch.
10277    pub max_batch_size: usize,
10278    /// Maximum distinct contract addresses placed in one provider-side log
10279    /// subscription. Compatible logical owner filters are fanned into address
10280    /// supersets up to this limit; exact owner routing still happens locally.
10281    pub max_log_addresses_per_subscription: usize,
10282    /// Maximum records retained across the delivery queue and hidden
10283    /// transaction-aware reconcile buffer. Exceeding it fails the subscriber
10284    /// closed until a full interest reset, because dropping an event would
10285    /// create an unknowable continuity gap.
10286    pub max_pending_records: usize,
10287    /// Maximum lazy owner-backfill requests retained at once.
10288    pub max_pending_backfills: usize,
10289    /// Maximum approximate encoded bytes accepted from one historical log
10290    /// response (fixed log identity fields, topics, and data).
10291    pub max_backfill_log_bytes: usize,
10292    /// Maximum provider log requests concurrently in flight during bulk owner
10293    /// reconciliation.
10294    pub max_reconcile_requests_in_flight: usize,
10295    /// Reconnect policy for WebSocket/pubsub streams.
10296    pub reconnect: SubscriberReconnectConfig,
10297}
10298
10299impl Default for SubscriberConfig {
10300    fn default() -> Self {
10301        Self {
10302            log_channel_size: None,
10303            preconfirmations: PreconfirmationMode::Disabled,
10304            canonical_head_poll_interval: Duration::from_millis(500),
10305            canonical_head_request_timeout: Duration::from_secs(3),
10306            flashblock_poll_interval: Duration::from_millis(250),
10307            max_consecutive_flashblock_poll_failures: 10,
10308            max_pending_transaction_receipts_per_tick: 32,
10309            max_flashblock_rpc_requests_per_second: 40,
10310            hydrate_pending_transactions: false,
10311            verify_log_block_context: false,
10312            max_batch_size: 1024,
10313            max_log_addresses_per_subscription: 1024,
10314            max_pending_records: 16_384,
10315            max_pending_backfills: 4_096,
10316            max_backfill_log_bytes: 64 * 1024 * 1024,
10317            max_reconcile_requests_in_flight: 8,
10318            reconnect: SubscriberReconnectConfig::default(),
10319        }
10320    }
10321}
10322
10323/// Provider surface established for one Flashblocks generation.
10324#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10325pub enum FlashblocksDelivery {
10326    /// Native `newFlashblocks` plus filtered `pendingLogs` WebSocket streams.
10327    NativeSubscriptions,
10328    /// Generation-pinned `pending` block and log sampling.
10329    PendingStatePolling,
10330    /// Standardized updates supplied by an application-managed transport.
10331    #[cfg(feature = "raw-flashblocks-json")]
10332    ExternalUpdates,
10333}
10334
10335/// Request/response traffic issued by one Flashblocks subscriber generation.
10336#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10337pub struct FlashblocksRpcMetrics {
10338    capability_requests: u64,
10339    provider_pair_chain_requests: u64,
10340    canonical_head_requests: u64,
10341    pending_block_requests: u64,
10342    pending_log_requests: u64,
10343    pending_receipt_requests: u64,
10344    pending_receipts_completed: u64,
10345    pending_receipts_unavailable: u64,
10346    failed_requests: u64,
10347    raced_samples: u64,
10348    suppressed_canonical_head_polls: u64,
10349}
10350
10351impl FlashblocksRpcMetrics {
10352    /// Opportunistic `op_supportedCapabilities` probes attempted.
10353    pub const fn capability_requests(self) -> u64 {
10354        self.capability_requests
10355    }
10356
10357    /// Chain-identity requests used to verify an explicitly paired
10358    /// pending-state provider against the subscriber's stream provider.
10359    pub const fn provider_pair_chain_requests(self) -> u64 {
10360        self.provider_pair_chain_requests
10361    }
10362
10363    /// Exact parent-block requests used to fence pending and canonical state.
10364    pub const fn canonical_head_requests(self) -> u64 {
10365        self.canonical_head_requests
10366    }
10367
10368    /// Cumulative pending-block requests.
10369    pub const fn pending_block_requests(self) -> u64 {
10370        self.pending_block_requests
10371    }
10372
10373    /// Pending log-filter requests.
10374    pub const fn pending_log_requests(self) -> u64 {
10375        self.pending_log_requests
10376    }
10377
10378    /// Pending-state `eth_getTransactionReceipt` methods issued by exact hash.
10379    /// Several methods may share one JSON-RPC batch transport request.
10380    pub const fn pending_receipt_requests(self) -> u64 {
10381        self.pending_receipt_requests
10382    }
10383
10384    /// Exact pending transaction receipts returned successfully.
10385    pub const fn pending_receipts_completed(self) -> u64 {
10386        self.pending_receipts_completed
10387    }
10388
10389    /// Exact pending transaction receipts that were not materialized yet and remain
10390    /// eligible for retry on the next cumulative sample.
10391    pub const fn pending_receipts_unavailable(self) -> u64 {
10392        self.pending_receipts_unavailable
10393    }
10394
10395    /// Provider request failures observed by a pending-state sampler.
10396    pub const fn failed_requests(self) -> u64 {
10397        self.failed_requests
10398    }
10399
10400    /// Timer-driven canonical head polls that issued no request because the
10401    /// flashblock stream had already certified the head inside the poll window.
10402    ///
10403    /// On a chain whose flashblock cadence is faster than the poll interval,
10404    /// most ticks land here: the certification is event-driven and the timer is
10405    /// only a liveness fallback.
10406    pub const fn suppressed_canonical_head_polls(self) -> u64 {
10407        self.suppressed_canonical_head_polls
10408    }
10409
10410    /// Samples discarded because the pending-log response advanced beyond
10411    /// the separately fetched cumulative block. The next tick retries from a
10412    /// fresh block/log pair; no partial speculative view is published.
10413    pub const fn raced_samples(self) -> u64 {
10414        self.raced_samples
10415    }
10416
10417    /// Total request/response calls attributable to Flashblocks qualification
10418    /// and sampling.
10419    pub const fn total_requests(self) -> u64 {
10420        self.capability_requests
10421            .saturating_add(self.provider_pair_chain_requests)
10422            .saturating_add(self.canonical_head_requests)
10423            .saturating_add(self.pending_block_requests)
10424            .saturating_add(self.pending_log_requests)
10425            .saturating_add(self.pending_receipt_requests)
10426    }
10427}
10428
10429/// JSON-RPC method issued by the reactive stack on a consumer's behalf.
10430///
10431/// `EthSubscribe` covers every `eth_subscribe`/`eth_newFilter` handshake the
10432/// subscriber performs when it installs a stream source, including the OP Stack
10433/// `newFlashblocks` and `pendingLogs` channels. Notifications delivered over an
10434/// established subscription are not requests and are not counted here.
10435#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
10436#[non_exhaustive]
10437pub enum SubscriberRpcMethod {
10438    /// `eth_chainId`.
10439    EthChainId,
10440    /// `eth_blockNumber`.
10441    EthBlockNumber,
10442    /// `eth_getBlockByNumber`.
10443    EthGetBlockByNumber,
10444    /// `eth_getBlockByHash`.
10445    EthGetBlockByHash,
10446    /// `eth_getLogs`.
10447    EthGetLogs,
10448    /// `eth_getTransactionReceipt`.
10449    EthGetTransactionReceipt,
10450    /// Stream installation: `eth_subscribe`, or the polling transport's
10451    /// `eth_newFilter` handshake.
10452    EthSubscribe,
10453    /// `op_supportedCapabilities`.
10454    OpSupportedCapabilities,
10455}
10456
10457impl SubscriberRpcMethod {
10458    /// Every method the reactive stack can issue, in reporting order.
10459    pub const ALL: [Self; 8] = [
10460        Self::EthChainId,
10461        Self::EthBlockNumber,
10462        Self::EthGetBlockByNumber,
10463        Self::EthGetBlockByHash,
10464        Self::EthGetLogs,
10465        Self::EthGetTransactionReceipt,
10466        Self::EthSubscribe,
10467        Self::OpSupportedCapabilities,
10468    ];
10469
10470    /// Number of distinct methods.
10471    pub const COUNT: usize = Self::ALL.len();
10472
10473    /// Wire name, suitable for a metrics label.
10474    pub const fn as_str(self) -> &'static str {
10475        match self {
10476            Self::EthChainId => "eth_chainId",
10477            Self::EthBlockNumber => "eth_blockNumber",
10478            Self::EthGetBlockByNumber => "eth_getBlockByNumber",
10479            Self::EthGetBlockByHash => "eth_getBlockByHash",
10480            Self::EthGetLogs => "eth_getLogs",
10481            Self::EthGetTransactionReceipt => "eth_getTransactionReceipt",
10482            Self::EthSubscribe => "eth_subscribe",
10483            Self::OpSupportedCapabilities => "op_supportedCapabilities",
10484        }
10485    }
10486
10487    const fn index(self) -> usize {
10488        match self {
10489            Self::EthChainId => 0,
10490            Self::EthBlockNumber => 1,
10491            Self::EthGetBlockByNumber => 2,
10492            Self::EthGetBlockByHash => 3,
10493            Self::EthGetLogs => 4,
10494            Self::EthGetTransactionReceipt => 5,
10495            Self::EthSubscribe => 6,
10496            Self::OpSupportedCapabilities => 7,
10497        }
10498    }
10499}
10500
10501impl fmt::Display for SubscriberRpcMethod {
10502    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
10503        f.write_str(self.as_str())
10504    }
10505}
10506
10507/// Mechanism that caused the subscriber to issue a provider request.
10508///
10509/// This is the dimension that matters for an RPC budget: a consumer asks for
10510/// interests and reads batches, and every request below is a consequence the
10511/// consumer never named. Attributing by cause is what makes an unexpected bill
10512/// diagnosable from inside the process.
10513#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
10514#[non_exhaustive]
10515pub enum SubscriberRpcCause {
10516    /// Resolving the provider's chain identity once, before any record escapes.
10517    ChainIdentity,
10518    /// Installing a live stream source.
10519    StreamSubscription,
10520    /// Qualifying a Flashblocks endpoint: capability probe and paired
10521    /// pending-state provider verification.
10522    FlashblocksSetup,
10523    /// Certifying a sealed canonical head while Flashblocks are active, because
10524    /// a Flashblocks endpoint's `newHeads` may carry partial heads.
10525    CanonicalHeadCertification,
10526    /// Sampling OP Stack pending state on the bounded pre-confirmation cadence.
10527    PendingStateSample,
10528    /// Proving a canonical log's block identity when
10529    /// [`SubscriberConfig::verify_log_block_context`] is set.
10530    LogBlockVerification,
10531    /// Bulk owner catch-up requested through
10532    /// [`AlloySubscriber::reconcile_interest_owners`].
10533    OwnerReconcile,
10534    /// Draining a queued adoption or continuity backfill.
10535    LazyBackfill,
10536    /// Closing the window missed while a terminated stream was reconnecting.
10537    ReconnectBackfill,
10538    /// Closing the window a live stream dropped: the subscription stayed
10539    /// connected but notifications were lost, so only the missed range is
10540    /// refetched.
10541    GapBackfill,
10542}
10543
10544impl SubscriberRpcCause {
10545    /// Every cause the reactive stack can attribute a request to, in reporting
10546    /// order.
10547    pub const ALL: [Self; 10] = [
10548        Self::ChainIdentity,
10549        Self::StreamSubscription,
10550        Self::FlashblocksSetup,
10551        Self::CanonicalHeadCertification,
10552        Self::PendingStateSample,
10553        Self::LogBlockVerification,
10554        Self::OwnerReconcile,
10555        Self::LazyBackfill,
10556        Self::ReconnectBackfill,
10557        Self::GapBackfill,
10558    ];
10559
10560    /// Number of distinct causes.
10561    pub const COUNT: usize = Self::ALL.len();
10562
10563    /// Stable snake_case name, suitable for a metrics label.
10564    pub const fn as_str(self) -> &'static str {
10565        match self {
10566            Self::ChainIdentity => "chain_identity",
10567            Self::StreamSubscription => "stream_subscription",
10568            Self::FlashblocksSetup => "flashblocks_setup",
10569            Self::CanonicalHeadCertification => "canonical_head_certification",
10570            Self::PendingStateSample => "pending_state_sample",
10571            Self::LogBlockVerification => "log_block_verification",
10572            Self::OwnerReconcile => "owner_reconcile",
10573            Self::LazyBackfill => "lazy_backfill",
10574            Self::ReconnectBackfill => "reconnect_backfill",
10575            Self::GapBackfill => "gap_backfill",
10576        }
10577    }
10578
10579    const fn index(self) -> usize {
10580        match self {
10581            Self::ChainIdentity => 0,
10582            Self::StreamSubscription => 1,
10583            Self::FlashblocksSetup => 2,
10584            Self::CanonicalHeadCertification => 3,
10585            Self::PendingStateSample => 4,
10586            Self::LogBlockVerification => 5,
10587            Self::OwnerReconcile => 6,
10588            Self::LazyBackfill => 7,
10589            Self::ReconnectBackfill => 8,
10590            Self::GapBackfill => 9,
10591        }
10592    }
10593}
10594
10595impl fmt::Display for SubscriberRpcCause {
10596    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
10597        f.write_str(self.as_str())
10598    }
10599}
10600
10601/// Every provider request the reactive stack has issued, by method and by the
10602/// mechanism responsible for it.
10603///
10604/// Counts are **cumulative for the subscriber's lifetime**. They deliberately
10605/// survive reconnects, stream-topology changes, and delivery-state resets, so a
10606/// long-running process can report total RPC consumption; call
10607/// [`AlloySubscriber::reset_rpc_stats`] to measure a bounded window instead.
10608/// This is the difference from [`FlashblocksRpcMetrics`], which is scoped to one
10609/// Flashblocks subscriber generation and resets with it.
10610///
10611/// Every request the subscriber makes is counted here, including the ones also
10612/// tallied by `FlashblocksRpcMetrics` — reading both never requires adding them
10613/// together. `FlashblocksRpcMetrics` remains the place for outcomes that are not
10614/// request counts, such as receipts that were unavailable or samples discarded
10615/// for racing the pending block.
10616///
10617/// ```no_run
10618/// # use evm_fork_cache::reactive::{AlloySubscriber, SubscriberRpcCause, SubscriberRpcMethod};
10619/// # fn report<P, N: alloy_network::Network>(subscriber: &AlloySubscriber<P, N>) {
10620/// let stats = subscriber.rpc_stats();
10621/// println!("total provider requests: {}", stats.total());
10622/// println!("eth_getLogs: {}", stats.by_method(SubscriberRpcMethod::EthGetLogs));
10623/// for (cause, method, requests) in stats.nonzero() {
10624///     println!("{cause} / {method}: {requests}");
10625/// }
10626/// # }
10627/// ```
10628#[derive(Clone, Debug, PartialEq, Eq)]
10629pub struct SubscriberRpcStats {
10630    counts: [[u64; SubscriberRpcMethod::COUNT]; SubscriberRpcCause::COUNT],
10631}
10632
10633impl Default for SubscriberRpcStats {
10634    fn default() -> Self {
10635        Self {
10636            counts: [[0; SubscriberRpcMethod::COUNT]; SubscriberRpcCause::COUNT],
10637        }
10638    }
10639}
10640
10641impl SubscriberRpcStats {
10642    /// Requests issued for one exact cause/method pair.
10643    pub const fn get(&self, cause: SubscriberRpcCause, method: SubscriberRpcMethod) -> u64 {
10644        self.counts[cause.index()][method.index()]
10645    }
10646
10647    /// Requests issued for one cause, across every method.
10648    pub fn by_cause(&self, cause: SubscriberRpcCause) -> u64 {
10649        self.counts[cause.index()]
10650            .iter()
10651            .fold(0u64, |total, count| total.saturating_add(*count))
10652    }
10653
10654    /// Requests issued for one method, across every cause.
10655    pub fn by_method(&self, method: SubscriberRpcMethod) -> u64 {
10656        self.counts
10657            .iter()
10658            .fold(0u64, |total, row| total.saturating_add(row[method.index()]))
10659    }
10660
10661    /// Every provider request the subscriber has issued.
10662    pub fn total(&self) -> u64 {
10663        SubscriberRpcCause::ALL
10664            .into_iter()
10665            .fold(0u64, |total, cause| {
10666                total.saturating_add(self.by_cause(cause))
10667            })
10668    }
10669
10670    /// Every cause/method pair in reporting order, including zeroes.
10671    pub fn entries(
10672        &self,
10673    ) -> impl Iterator<Item = (SubscriberRpcCause, SubscriberRpcMethod, u64)> + '_ {
10674        SubscriberRpcCause::ALL.into_iter().flat_map(move |cause| {
10675            SubscriberRpcMethod::ALL
10676                .into_iter()
10677                .map(move |method| (cause, method, self.get(cause, method)))
10678        })
10679    }
10680
10681    /// Only the cause/method pairs that actually issued a request — the useful
10682    /// shape for logging or a metrics export.
10683    pub fn nonzero(
10684        &self,
10685    ) -> impl Iterator<Item = (SubscriberRpcCause, SubscriberRpcMethod, u64)> + '_ {
10686        self.entries().filter(|(_, _, requests)| *requests > 0)
10687    }
10688}
10689
10690/// Interior-mutable counter set behind [`SubscriberRpcStats`].
10691///
10692/// Shared through an `Arc` because provider work runs off the subscriber's
10693/// `&mut self`: bulk owner catch-up is driven as an independent future while
10694/// live events continue to drain, and the free functions it calls record without
10695/// any subscriber borrow. `Relaxed` ordering is correct for counters whose only
10696/// consumer is a diagnostic snapshot.
10697#[derive(Debug)]
10698pub(crate) struct SubscriberRpcCounters {
10699    counts: [[AtomicU64; SubscriberRpcMethod::COUNT]; SubscriberRpcCause::COUNT],
10700}
10701
10702impl Default for SubscriberRpcCounters {
10703    fn default() -> Self {
10704        Self {
10705            counts: std::array::from_fn(|_| std::array::from_fn(|_| AtomicU64::new(0))),
10706        }
10707    }
10708}
10709
10710/// Why a live subscription lost data without disconnecting.
10711///
10712/// Both cases were previously invisible: `alloy-pubsub`'s typed subscription
10713/// stream logs a lagged receiver at `debug` and continues, and discards an
10714/// undecodable notification the same way. A consumer whose contract is
10715/// *completeness* cannot build on a stream that loses records silently, so these
10716/// are surfaced and healed instead.
10717#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10718pub enum SubscriberStreamGap {
10719    /// The bounded notification channel overflowed and the transport dropped
10720    /// `skipped` notifications before this receiver observed them.
10721    ///
10722    /// This is backpressure, not a transport fault: the subscriber was not
10723    /// draining as fast as the endpoint pushed. Raising
10724    /// [`SubscriberConfig::log_channel_size`] is the direct remedy.
10725    Lagged {
10726        /// Notifications the transport dropped.
10727        skipped: u64,
10728    },
10729    /// A notification arrived but did not decode into the expected type.
10730    ///
10731    /// Treated as lost data rather than skipped, because a filter's matched set
10732    /// cannot be proven complete while one of its notifications is unreadable.
10733    Undecodable,
10734}
10735
10736impl SubscriberStreamGap {
10737    /// Notifications known to be missing, when the transport reported a count.
10738    pub const fn skipped(self) -> Option<u64> {
10739        match self {
10740            Self::Lagged { skipped } => Some(skipped),
10741            Self::Undecodable => None,
10742        }
10743    }
10744
10745    /// Stable snake_case name, suitable for a metrics label.
10746    pub const fn as_str(self) -> &'static str {
10747        match self {
10748            Self::Lagged { .. } => "lagged",
10749            Self::Undecodable => "undecodable",
10750        }
10751    }
10752}
10753
10754impl fmt::Display for SubscriberStreamGap {
10755    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
10756        match self {
10757            Self::Lagged { skipped } => write!(f, "lagged({skipped})"),
10758            Self::Undecodable => f.write_str("undecodable"),
10759        }
10760    }
10761}
10762
10763/// Notification loss observed on live subscriptions, and what it cost to heal.
10764///
10765/// A non-zero `lagged_notifications` means the subscriber could not keep up with
10766/// its endpoint. That is recoverable — the missed window is refetched — but each
10767/// occurrence buys an `eth_getLogs`, so a steadily climbing count is a signal to
10768/// raise [`SubscriberConfig::log_channel_size`] rather than to keep paying.
10769///
10770/// Counts are cumulative for the subscriber's lifetime, matching
10771/// [`SubscriberRpcStats`].
10772#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10773pub struct SubscriberStreamGapStats {
10774    lagged_notifications: u64,
10775    undecodable_notifications: u64,
10776    log_gaps_healed: u64,
10777    header_gaps: u64,
10778    preconfirmation_gaps: u64,
10779}
10780
10781impl SubscriberStreamGapStats {
10782    /// Notifications dropped by the transport because a bounded channel filled.
10783    pub const fn lagged_notifications(self) -> u64 {
10784        self.lagged_notifications
10785    }
10786
10787    /// Notifications that arrived but could not be decoded.
10788    pub const fn undecodable_notifications(self) -> u64 {
10789        self.undecodable_notifications
10790    }
10791
10792    /// Canonical log gaps closed by refetching the exact missed range.
10793    ///
10794    /// Each of these issued provider requests attributed to
10795    /// [`SubscriberRpcCause::GapBackfill`].
10796    pub const fn log_gaps_healed(self) -> u64 {
10797        self.log_gaps_healed
10798    }
10799
10800    /// Gaps observed on the canonical block-header stream.
10801    ///
10802    /// These are not refetched here: a consumer that walks a replacement
10803    /// header's parent lineage back to retained canonical history recovers the
10804    /// skipped blocks on the next header it receives. The count exists so that
10805    /// self-healing is visible rather than assumed.
10806    pub const fn header_gaps(self) -> u64 {
10807        self.header_gaps
10808    }
10809
10810    /// Gaps observed on a pre-confirmation log stream, each of which discarded
10811    /// the speculative snapshot rather than publishing an incomplete one.
10812    pub const fn preconfirmation_gaps(self) -> u64 {
10813        self.preconfirmation_gaps
10814    }
10815
10816    /// Every observed notification loss, across all stream kinds.
10817    pub const fn total_gaps(self) -> u64 {
10818        self.lagged_notifications
10819            .saturating_add(self.undecodable_notifications)
10820    }
10821}
10822
10823/// Interior-mutable counters behind [`SubscriberStreamGapStats`].
10824#[derive(Debug, Default)]
10825pub(crate) struct SubscriberStreamGapCounters {
10826    lagged_notifications: AtomicU64,
10827    undecodable_notifications: AtomicU64,
10828    log_gaps_healed: AtomicU64,
10829    header_gaps: AtomicU64,
10830    preconfirmation_gaps: AtomicU64,
10831}
10832
10833impl SubscriberStreamGapCounters {
10834    fn bump(counter: &AtomicU64, amount: u64) {
10835        counter.fetch_add(amount, Ordering::Relaxed);
10836    }
10837
10838    #[cfg(feature = "reactive-ws")]
10839    pub(crate) fn record_gap(&self, gap: SubscriberStreamGap) {
10840        match gap {
10841            SubscriberStreamGap::Lagged { skipped } => {
10842                Self::bump(&self.lagged_notifications, skipped.max(1));
10843            }
10844            SubscriberStreamGap::Undecodable => {
10845                Self::bump(&self.undecodable_notifications, 1);
10846            }
10847        }
10848    }
10849
10850    pub(crate) fn record_log_gap_healed(&self) {
10851        Self::bump(&self.log_gaps_healed, 1);
10852    }
10853
10854    pub(crate) fn record_header_gap(&self) {
10855        Self::bump(&self.header_gaps, 1);
10856    }
10857
10858    pub(crate) fn record_preconfirmation_gap(&self) {
10859        Self::bump(&self.preconfirmation_gaps, 1);
10860    }
10861
10862    pub(crate) fn snapshot(&self) -> SubscriberStreamGapStats {
10863        let load = |counter: &AtomicU64| counter.load(Ordering::Relaxed);
10864        SubscriberStreamGapStats {
10865            lagged_notifications: load(&self.lagged_notifications),
10866            undecodable_notifications: load(&self.undecodable_notifications),
10867            log_gaps_healed: load(&self.log_gaps_healed),
10868            header_gaps: load(&self.header_gaps),
10869            preconfirmation_gaps: load(&self.preconfirmation_gaps),
10870        }
10871    }
10872
10873    pub(crate) fn reset(&self) {
10874        for counter in [
10875            &self.lagged_notifications,
10876            &self.undecodable_notifications,
10877            &self.log_gaps_healed,
10878            &self.header_gaps,
10879            &self.preconfirmation_gaps,
10880        ] {
10881            counter.store(0, Ordering::Relaxed);
10882        }
10883    }
10884}
10885
10886impl SubscriberRpcCounters {
10887    /// Record one issued request.
10888    pub(crate) fn record(&self, cause: SubscriberRpcCause, method: SubscriberRpcMethod) {
10889        self.record_many(cause, method, 1);
10890    }
10891
10892    /// Record `requests` issued requests, for a batch that ships several calls
10893    /// of one method in a single round trip.
10894    pub(crate) fn record_many(
10895        &self,
10896        cause: SubscriberRpcCause,
10897        method: SubscriberRpcMethod,
10898        requests: u64,
10899    ) {
10900        self.counts[cause.index()][method.index()].fetch_add(requests, Ordering::Relaxed);
10901    }
10902
10903    /// Snapshot every counter.
10904    pub(crate) fn snapshot(&self) -> SubscriberRpcStats {
10905        SubscriberRpcStats {
10906            counts: std::array::from_fn(|cause| {
10907                std::array::from_fn(|method| self.counts[cause][method].load(Ordering::Relaxed))
10908            }),
10909        }
10910    }
10911
10912    /// Zero every counter.
10913    pub(crate) fn reset(&self) {
10914        for row in &self.counts {
10915            for counter in row {
10916                counter.store(0, Ordering::Relaxed);
10917            }
10918        }
10919    }
10920}
10921
10922/// Successful initial Flashblocks endpoint preflight.
10923///
10924/// This proves chain identity and either subscription acknowledgement for
10925/// Base's `newFlashblocks` plus every pool-filtered `pendingLogs` stream, method
10926/// support for OP's bounded pending block/log sampler, or the canonical stream
10927/// topology paired with an application-managed standardized source.
10928/// Notification liveness and active-interest coverage remain acceptance-window
10929/// checks: a successful preflight alone must not qualify a source for live use.
10930#[derive(Clone, Debug, PartialEq, Eq)]
10931pub struct FlashblocksPreflight {
10932    chain_id: u64,
10933    provider: ProviderRef,
10934    delivery: FlashblocksDelivery,
10935    pending_log_subscriptions: usize,
10936    pending_log_filters: usize,
10937    advertised_capabilities: Option<serde_json::Value>,
10938}
10939
10940impl FlashblocksPreflight {
10941    /// Chain identity read from the pinned provider lease.
10942    pub const fn chain_id(&self) -> u64 {
10943        self.chain_id
10944    }
10945
10946    /// Provider generation selected for speculative updates.
10947    ///
10948    /// Built-in profiles preflight this provider's coupled request/subscription
10949    /// surfaces. External profiles retain caller-supplied provenance while the
10950    /// application qualifies the supplemental socket separately.
10951    pub const fn provider(&self) -> &ProviderRef {
10952        &self.provider
10953    }
10954
10955    /// Provider surface selected for this chain.
10956    pub const fn delivery(&self) -> FlashblocksDelivery {
10957        self.delivery
10958    }
10959
10960    /// Number of acknowledged pool-filtered `pendingLogs` subscriptions.
10961    ///
10962    /// This is zero for sampled and externally managed delivery profiles.
10963    pub const fn pending_log_subscriptions(&self) -> usize {
10964        self.pending_log_subscriptions
10965    }
10966
10967    /// Number of provider-facing log filters whose interests must be covered by
10968    /// the selected native, sampled, or external delivery surface.
10969    pub const fn pending_log_filters(&self) -> usize {
10970        self.pending_log_filters
10971    }
10972
10973    /// Opaque response from `op_supportedCapabilities`, when the provider
10974    /// implements that optional RPC method.
10975    pub const fn advertised_capabilities(&self) -> Option<&serde_json::Value> {
10976        self.advertised_capabilities.as_ref()
10977    }
10978}
10979
10980/// WebSocket/pubsub reconnect policy.
10981///
10982/// Reconnects are applied after an established subscription stream terminates.
10983/// Initial subscription failures are still returned immediately so deployment
10984/// mistakes, unsupported transports, and bad endpoints fail fast.
10985#[derive(Clone, Debug, PartialEq, Eq)]
10986pub struct SubscriberReconnectConfig {
10987    /// Whether pubsub streams should be recreated after termination.
10988    pub enabled: bool,
10989    /// Delay before the first reconnect attempt.
10990    pub initial_delay: Duration,
10991    /// Delay before the second reconnect attempt. Later retries double this
10992    /// delay up to [`Self::max_delay`].
10993    pub retry_delay: Duration,
10994    /// Maximum delay between reconnect attempts.
10995    pub max_delay: Duration,
10996    /// Maximum reconnect attempts per terminated stream. `None` retries forever.
10997    pub max_attempts: Option<usize>,
10998    /// Number of recently emitted canonical input refs remembered to suppress
10999    /// duplicates across reconnect backfill and subscription replay.
11000    pub dedupe_window: usize,
11001}
11002
11003impl Default for SubscriberReconnectConfig {
11004    fn default() -> Self {
11005        Self {
11006            enabled: true,
11007            initial_delay: Duration::ZERO,
11008            retry_delay: Duration::from_millis(250),
11009            max_delay: Duration::from_secs(30),
11010            max_attempts: Some(3),
11011            dedupe_window: 4096,
11012        }
11013    }
11014}
11015
11016/// Historical log backfill requested when adding subscriber interests.
11017///
11018/// Backfill applies only to [`ReactiveInterest::Logs`] entries. Block and
11019/// pending-transaction interests are live-only. `AlloySubscriber` emits records
11020/// fetched through this policy as [`InputSource::Backfill`]. Continuity-safe
11021/// owner registration adopts/subscribes the desired live filter first, then
11022/// reconciles history behind that live fence; startup/global replacement commits
11023/// topology and historical work as one desired-state transaction. A drained
11024/// backfill seeds the filter's delivery anchor at its resolved upper bound (even
11025/// when the window held no logs), so the newly added filter gets the same
11026/// reconnect/catch-up protection an established one has.
11027#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11028pub struct SubscriberBackfill {
11029    from_block: u64,
11030    to_block: Option<u64>,
11031    retained_anchor: Option<BlockRef>,
11032}
11033
11034impl SubscriberBackfill {
11035    /// Backfill an inclusive block range.
11036    pub fn range(from_block: u64, to_block: u64) -> Self {
11037        Self {
11038            from_block,
11039            to_block: Some(to_block),
11040            retained_anchor: None,
11041        }
11042    }
11043
11044    /// Backfill from `from_block` through the provider's latest block.
11045    pub fn from_block(from_block: u64) -> Self {
11046        Self {
11047            from_block,
11048            to_block: None,
11049            retained_anchor: None,
11050        }
11051    }
11052
11053    /// Backfill inclusively from an exact retained canonical block.
11054    ///
11055    /// The Alloy subscriber verifies this number/hash against its provider
11056    /// before accepting any lazy catch-up response. Engine-managed mid-stream
11057    /// registration uses this form so owner replay cannot silently cross a
11058    /// reorged discovery boundary.
11059    pub fn from_canonical_block(block: BlockRef) -> Self {
11060        Self {
11061            from_block: block.number,
11062            to_block: None,
11063            retained_anchor: Some(block),
11064        }
11065    }
11066
11067    /// Backfill inclusively from an exact canonical block through an inclusive
11068    /// upper bound.
11069    ///
11070    /// # Errors
11071    ///
11072    /// Returns [`SubscriberError::InvalidConfig`] when `to_block` precedes the
11073    /// retained anchor.
11074    pub fn from_canonical_block_through(
11075        block: BlockRef,
11076        to_block: u64,
11077    ) -> Result<Self, SubscriberError> {
11078        if to_block < block.number {
11079            return Err(SubscriberError::InvalidConfig(
11080                "inclusive backfill upper bound precedes its retained anchor",
11081            ));
11082        }
11083        Ok(Self {
11084            from_block: block.number,
11085            to_block: Some(to_block),
11086            retained_anchor: Some(block),
11087        })
11088    }
11089
11090    /// Backfill strictly after an exact canonical state baseline.
11091    ///
11092    /// This is distinct from [`from_canonical_block`](Self::from_canonical_block):
11093    /// a restored cache already embodies every effect through `block`, so
11094    /// replaying that block would apply it twice. The retained block is still
11095    /// carried so the subscriber can prove that its provider is on the same
11096    /// canonical branch before accepting any post-baseline history.
11097    ///
11098    /// Returns an error at `u64::MAX`; silently saturating would turn an empty
11099    /// exclusive range into an inclusive replay of the baseline block.
11100    ///
11101    /// # Errors
11102    ///
11103    /// Returns [`SubscriberError::InvalidConfig`] when the baseline number is
11104    /// `u64::MAX` and therefore has no following block.
11105    pub fn after_canonical_block(block: BlockRef) -> Result<Self, SubscriberError> {
11106        Self::after_canonical_block_inner(block, None)
11107    }
11108
11109    /// Backfill strictly after an exact canonical baseline through an
11110    /// inclusive upper bound.
11111    ///
11112    /// `to_block == block.number` represents a deliberately empty certified
11113    /// interval. Bounds before the retained baseline are rejected.
11114    ///
11115    /// # Errors
11116    ///
11117    /// Returns [`SubscriberError::InvalidConfig`] when `to_block` precedes the
11118    /// baseline, or when a non-empty exclusive range would have to begin after
11119    /// block `u64::MAX`.
11120    pub fn after_canonical_block_through(
11121        block: BlockRef,
11122        to_block: u64,
11123    ) -> Result<Self, SubscriberError> {
11124        if to_block < block.number {
11125            return Err(SubscriberError::InvalidConfig(
11126                "exclusive backfill upper bound precedes its retained baseline",
11127            ));
11128        }
11129        Self::after_canonical_block_inner(block, Some(to_block))
11130    }
11131
11132    fn after_canonical_block_inner(
11133        block: BlockRef,
11134        to_block: Option<u64>,
11135    ) -> Result<Self, SubscriberError> {
11136        let from_block = block
11137            .number
11138            .checked_add(1)
11139            .ok_or(SubscriberError::InvalidConfig(
11140                "cannot construct an exclusive backfill after block u64::MAX",
11141            ))?;
11142        Ok(Self {
11143            from_block,
11144            to_block,
11145            retained_anchor: Some(block),
11146        })
11147    }
11148
11149    /// First block included in the backfill.
11150    pub fn start_block(&self) -> u64 {
11151        self.from_block
11152    }
11153
11154    /// Last block included in the backfill, or `None` for provider latest.
11155    pub fn end_block(&self) -> Option<u64> {
11156        self.to_block
11157    }
11158
11159    /// Exact retained start-block identity, when supplied.
11160    pub fn retained_anchor(&self) -> Option<&BlockRef> {
11161        self.retained_anchor.as_ref()
11162    }
11163}
11164
11165/// Opaque generation for one transaction-aware subscriber interest owner.
11166///
11167/// Epochs are allocated monotonically by [`AlloySubscriber`] and are never
11168/// reused, including after an aborted stage or a full interest replacement.
11169/// Lifecycle operations require the complete token so a delayed command for an
11170/// older registration cannot affect a replacement using the same [`HandlerId`].
11171#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
11172pub struct SubscriberOwnerEpoch {
11173    owner: HandlerId,
11174    sequence: u64,
11175}
11176
11177/// Delivery audience retained with a subscriber input record.
11178///
11179/// Canonical inputs are forwarded once to the runtime actor and may also name
11180/// staged epochs that need a buffered copy. Owner-only inputs are catch-up or
11181/// overlap records that must never be routed through existing canonical
11182/// handlers.
11183#[derive(Clone, Debug, PartialEq, Eq)]
11184#[non_exhaustive]
11185pub enum SubscriberInputScope {
11186    /// One canonical input plus any staged owners that matched at enqueue time.
11187    Canonical {
11188        /// Staged owner epochs that require a buffered copy.
11189        owners: Vec<SubscriberOwnerEpoch>,
11190    },
11191    /// Canonical input whose owner catch-up already delivered selected handler
11192    /// owners. The residual canonical copy must exclude those handlers while
11193    /// remaining authoritative for global chain progress.
11194    CanonicalResidual {
11195        /// Staged epoch owners that still require a buffered copy.
11196        owners: Vec<SubscriberOwnerEpoch>,
11197        /// Active compatibility owners already served by owner catch-up.
11198        excluded: Vec<HandlerId>,
11199    },
11200    /// Input delivered only to the listed staged owners.
11201    OwnerOnly {
11202        /// Exact staged owner epochs receiving the input.
11203        owners: Vec<SubscriberOwnerEpoch>,
11204    },
11205    /// Compatibility owner-only delivery keyed by stable handler id.
11206    OwnerOnlyHandlers {
11207        /// Exact active handlers receiving the catch-up input.
11208        owners: Vec<HandlerId>,
11209    },
11210    /// Flashblock input routed through ordinary matching handlers but applied
11211    /// only to the speculative overlay.
11212    Preconfirmed,
11213}
11214
11215impl SubscriberInputScope {
11216    /// Exact staged owner epochs attached to this input.
11217    pub fn owners(&self) -> &[SubscriberOwnerEpoch] {
11218        match self {
11219            Self::Canonical { owners }
11220            | Self::CanonicalResidual { owners, .. }
11221            | Self::OwnerOnly { owners } => owners,
11222            Self::OwnerOnlyHandlers { .. } | Self::Preconfirmed => &[],
11223        }
11224    }
11225
11226    /// Whether this input must be forwarded once through canonical routing.
11227    pub const fn is_canonical(&self) -> bool {
11228        matches!(
11229            self,
11230            Self::Canonical { .. } | Self::CanonicalResidual { .. }
11231        )
11232    }
11233
11234    /// Whether this input belongs only to the disposable preconfirmed overlay.
11235    pub const fn is_preconfirmed(&self) -> bool {
11236        matches!(self, Self::Preconfirmed)
11237    }
11238}
11239
11240/// Reactive input together with its canonical/owner-scoped delivery audience.
11241#[derive(Clone, Debug)]
11242#[cfg_attr(
11243    not(feature = "ingress-timing"),
11244    doc = r#"
11245The receipt API is absent unless `ingress-timing` is enabled:
11246```compile_fail
11247use evm_fork_cache::reactive::TransportReceipt;
11248```
11249```compile_fail
11250fn receipt(record: &evm_fork_cache::reactive::SubscriberInputRecord) {
11251    let _ = record.canonical_timing();
11252}
11253```
11254"#
11255)]
11256pub struct SubscriberInputRecord<N: Network = Ethereum> {
11257    record: ReactiveInputRecord<N>,
11258    scope: SubscriberInputScope,
11259    preconfirmation_timing: Option<FlashblockIngressTiming>,
11260    #[cfg(feature = "ingress-timing")]
11261    canonical_timing: Option<CanonicalIngressTiming>,
11262}
11263
11264impl<N: Network> SubscriberInputRecord<N> {
11265    /// Borrow the reactive input record.
11266    pub const fn record(&self) -> &ReactiveInputRecord<N> {
11267        &self.record
11268    }
11269
11270    /// Delivery audience captured when the record was enqueued.
11271    pub const fn scope(&self) -> &SubscriberInputScope {
11272        &self.scope
11273    }
11274
11275    /// Original typed source ingress when this is a preconfirmed record.
11276    pub const fn preconfirmation_timing(&self) -> Option<FlashblockIngressTiming> {
11277        self.preconfirmation_timing
11278    }
11279
11280    /// Actual transport and subscriber clocks, if an unambiguous receipt was supplied.
11281    #[cfg(feature = "ingress-timing")]
11282    pub const fn canonical_timing(&self) -> Option<CanonicalIngressTiming> {
11283        self.canonical_timing
11284    }
11285
11286    /// Consume the scoped value into its reactive input record.
11287    pub fn into_record(self) -> ReactiveInputRecord<N> {
11288        self.record
11289    }
11290}
11291
11292impl<N: Network> std::ops::Deref for SubscriberInputRecord<N> {
11293    type Target = ReactiveInputRecord<N>;
11294
11295    fn deref(&self) -> &Self::Target {
11296        &self.record
11297    }
11298}
11299
11300/// Batch of subscriber inputs with enqueue-time owner provenance.
11301#[derive(Clone, Debug)]
11302pub struct SubscriberInputBatch<N: Network = Ethereum> {
11303    records: Vec<SubscriberInputRecord<N>>,
11304    chain_id: Option<u64>,
11305    chain_controls: Vec<ChainControl>,
11306    preconfirmation_invalidated: bool,
11307    preconfirmation_timing: Option<FlashblockIngressTiming>,
11308}
11309
11310/// Result of polling a scoped subscriber batch against one driver control
11311/// future.
11312#[derive(Debug)]
11313#[non_exhaustive]
11314pub enum SubscriberDriverPoll<C, N: Network = Ethereum> {
11315    /// The control future completed first; subscriber delivery remains intact.
11316    Control(C),
11317    /// Subscriber polling completed first.
11318    Batch(Option<SubscriberInputBatch<N>>),
11319}
11320
11321impl<N: Network> SubscriberInputBatch<N> {
11322    /// Borrow every scoped record in delivery order.
11323    pub fn records(&self) -> &[SubscriberInputRecord<N>] {
11324        &self.records
11325    }
11326
11327    /// Consume the batch into its scoped records.
11328    pub fn into_records(self) -> Vec<SubscriberInputRecord<N>> {
11329        self.records
11330    }
11331
11332    /// Ordered chain controls committed after the preceding records.
11333    pub fn chain_controls(&self) -> &[ChainControl] {
11334        &self.chain_controls
11335    }
11336
11337    /// Whether the announcing Flashblocks generation lost continuity before
11338    /// this batch was returned.
11339    pub const fn preconfirmation_invalidated(&self) -> bool {
11340        self.preconfirmation_invalidated
11341    }
11342
11343    /// Earliest typed source ingress contributing to a preconfirmed batch.
11344    pub const fn preconfirmation_timing(&self) -> Option<FlashblockIngressTiming> {
11345        self.preconfirmation_timing
11346    }
11347
11348    /// Consume the scoped subscriber delivery into a runtime-ready batch.
11349    ///
11350    /// Delivery audiences and the preconfirmed/canonical boundary are retained,
11351    /// allowing downstream owner actors to forward a batch without rebuilding
11352    /// subscriber-internal scope metadata.
11353    pub fn into_reactive_batch(self) -> ReactiveInputBatch<N> {
11354        let chain_id = self.chain_id;
11355        let chain_controls = self.chain_controls;
11356        let preconfirmation_timing = self.preconfirmation_timing;
11357        let mut batch = ReactiveInputBatch::from_scoped_records_with_delivery_scope(
11358            self.records.into_iter().map(|scoped| {
11359                let source = scoped.record.context.source;
11360                let (audience, delivery_scope) = match scoped.scope {
11361                    SubscriberInputScope::Canonical { .. } => (
11362                        DeliveryAudience::All,
11363                        if source == InputSource::Backfill {
11364                            DeliveryScope::CanonicalProgress
11365                        } else {
11366                            DeliveryScope::Canonical
11367                        },
11368                    ),
11369                    SubscriberInputScope::CanonicalResidual { excluded, .. } => (
11370                        DeliveryAudience::AllExcept(excluded),
11371                        if source == InputSource::Backfill {
11372                            DeliveryScope::CanonicalProgress
11373                        } else {
11374                            DeliveryScope::Canonical
11375                        },
11376                    ),
11377                    SubscriberInputScope::OwnerOnly { owners } => {
11378                        let mut handler_ids = Vec::with_capacity(owners.len());
11379                        for epoch in owners {
11380                            if !handler_ids.contains(epoch.owner()) {
11381                                handler_ids.push(epoch.owner().clone());
11382                            }
11383                        }
11384                        (
11385                            DeliveryAudience::Owners(handler_ids),
11386                            DeliveryScope::OwnerCatchup,
11387                        )
11388                    }
11389                    SubscriberInputScope::OwnerOnlyHandlers { owners } => (
11390                        DeliveryAudience::Owners(owners),
11391                        DeliveryScope::OwnerCatchup,
11392                    ),
11393                    SubscriberInputScope::Preconfirmed => {
11394                        (DeliveryAudience::All, DeliveryScope::Preconfirmed)
11395                    }
11396                };
11397                (scoped.record, audience, delivery_scope)
11398            }),
11399        )
11400        .with_chain_controls(chain_controls);
11401        if let Some(chain_id) = chain_id {
11402            batch = batch.with_chain_id(chain_id);
11403        }
11404        if let Some(timing) = preconfirmation_timing {
11405            batch = batch.with_preconfirmation_timing(timing);
11406        }
11407        batch
11408    }
11409}
11410
11411impl SubscriberOwnerEpoch {
11412    /// Logical subscriber owner represented by this epoch.
11413    pub const fn owner(&self) -> &HandlerId {
11414        &self.owner
11415    }
11416
11417    /// Monotonic subscriber-local epoch sequence.
11418    pub const fn sequence(&self) -> u64 {
11419        self.sequence
11420    }
11421}
11422
11423/// Catch-up policy applied when staging a transaction-aware interest owner.
11424#[derive(Clone, Debug, PartialEq, Eq)]
11425#[non_exhaustive]
11426pub enum SubscriberOwnerStart {
11427    /// Start with live delivery only.
11428    Live,
11429    /// Start strictly after an already-applied post-block baseline.
11430    ///
11431    /// A baseline at block `N` schedules backfill from `N + 1`; block `N`
11432    /// itself is never replayed. Transaction-aware callers explicitly call
11433    /// [`AlloySubscriber::reconcile_interest_owner`] before activation; staged
11434    /// owners never use the legacy lazy-backfill queue.
11435    PostBlock(BlockRef),
11436}
11437
11438/// Transaction state of one epoch-scoped subscriber owner.
11439#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
11440#[non_exhaustive]
11441pub enum SubscriberOwnerState {
11442    /// Desired interests and owner-scoped buffering are installed but canonical
11443    /// routing has not yet committed.
11444    Staged,
11445    /// Canonical runtime routing has committed for this owner.
11446    Active,
11447    /// Removal is prepared behind a delivery fence but remains reversible.
11448    Removing,
11449}
11450
11451/// Hash-certified catch-up position reached by one subscriber owner epoch.
11452///
11453/// Progress means every owner-only record through this point has been fetched
11454/// and queued inside the subscriber. It does not mean the downstream actor has
11455/// drained or committed those records; that requires a separate delivery fence.
11456#[derive(Clone, Debug, PartialEq, Eq)]
11457pub struct SubscriberOwnerProgress {
11458    owner: SubscriberOwnerEpoch,
11459    through: BlockRef,
11460}
11461
11462impl SubscriberOwnerProgress {
11463    /// Exact owner epoch whose catch-up was reconciled.
11464    pub const fn owner(&self) -> &SubscriberOwnerEpoch {
11465        &self.owner
11466    }
11467
11468    /// Verified canonical block through which owner input was fetched.
11469    pub const fn through(&self) -> &BlockRef {
11470        &self.through
11471    }
11472}
11473
11474/// Error staging a transaction-aware subscriber owner.
11475#[derive(Debug, thiserror::Error)]
11476#[non_exhaustive]
11477pub enum SubscriberOwnerError {
11478    /// Subscriber configuration or interest validation failed.
11479    #[error(transparent)]
11480    Subscriber(#[from] SubscriberError),
11481    /// The logical owner already has desired interests installed.
11482    #[error("subscriber interest owner `{0}` is already registered")]
11483    AlreadyRegistered(HandlerId),
11484    /// A post-block baseline cannot be advanced to its first unapplied block.
11485    #[error("post-block subscriber baseline {0} has no following block")]
11486    PostBlockOverflow(u64),
11487    /// The monotonic subscriber owner epoch sequence was exhausted.
11488    #[error("subscriber owner epoch sequence exhausted")]
11489    EpochExhausted,
11490    /// The exact owner epoch is unknown or no longer staged.
11491    #[error("subscriber owner epoch is not staged")]
11492    NotStaged,
11493    /// Live-only staging has no historical baseline to reconcile.
11494    #[error("subscriber owner was staged live-only and has no catch-up baseline")]
11495    MissingBaseline,
11496    /// Post-block reconciliation currently covers log interests only.
11497    #[error("post-block subscriber owners support log interests only")]
11498    UnsupportedPostBlockInterest,
11499    /// The target block was absent from the provider.
11500    #[error("subscriber reconcile target block {0} was not found")]
11501    BlockUnavailable(u64),
11502    /// The provider's canonical identity did not match the requested target.
11503    #[error(
11504        "subscriber reconcile target mismatch: expected block {expected_number} {expected_hash}, got block {actual_number} {actual_hash}"
11505    )]
11506    BlockMismatch {
11507        /// Requested block number.
11508        expected_number: u64,
11509        /// Requested block hash.
11510        expected_hash: B256,
11511        /// Provider block number.
11512        actual_number: u64,
11513        /// Provider block hash.
11514        actual_hash: B256,
11515    },
11516    /// A reconcile target was older than the retained baseline/progress.
11517    #[error("subscriber reconcile target block {target} precedes current owner position {current}")]
11518    ProgressRegression {
11519        /// Retained baseline or progress block.
11520        current: u64,
11521        /// Rejected target block.
11522        target: u64,
11523    },
11524    /// A reconcile attempted to replace a retained block identity at the same
11525    /// height or cross an immediate parent that does not extend it.
11526    #[error(
11527        "subscriber reconcile conflicts with retained block {number} {current_hash}: target chain references {target_hash}"
11528    )]
11529    ProgressConflict {
11530        /// Retained baseline or progress block number.
11531        number: u64,
11532        /// Retained baseline or progress block hash.
11533        current_hash: B256,
11534        /// Conflicting target hash or immediate parent hash.
11535        target_hash: B256,
11536    },
11537    /// A provider returned a malformed or out-of-range catch-up log.
11538    #[error("subscriber reconcile returned an invalid catch-up log: {0}")]
11539    InvalidBackfillLog(&'static str),
11540}
11541
11542/// Extension trait for subscribers that can add and remove handler-owned
11543/// interests incrementally.
11544///
11545/// [`EventSubscriber::register_interests`] remains the full-replacement setup
11546/// API. Implement this trait when a subscriber can preserve unrelated live
11547/// sources and delivery state while one handler's interests are added or
11548/// removed. Implementations should make owner *replacement* continuity-safe:
11549/// updating an owner's interests must not silently discard delivery progress
11550/// the previous interests had already established (the in-crate
11551/// [`AlloySubscriber`] carries the owner's prior delivery anchor over to
11552/// changed filter shapes and automatically backfills the gap). Every mutating
11553/// operation is also a commit boundary: returning `Ok` means the new desired
11554/// state is authoritative, while errors or cancellation must preserve the
11555/// previous state or reconcile before exposing the uncommitted change.
11556pub trait InterestOwnerSubscriber<N: Network = Ethereum>: EventSubscriber<N> {
11557    /// Atomically add or replace several owners in one desired-state revision.
11558    ///
11559    /// Unrelated owners remain installed. Returning `Ok(())` is one commit
11560    /// boundary for the complete set; an error or cancellation must leave the
11561    /// previously committed owner topology authoritative. Durable remote
11562    /// subscribers should override this method so bootstrap creates one service
11563    /// revision and one activation barrier rather than one barrier per owner.
11564    ///
11565    /// # Errors
11566    ///
11567    /// The returned operation reports [`SubscriberError::Unsupported`] by
11568    /// default, or an implementation-specific validation or commit failure.
11569    fn upsert_interest_owners(
11570        &mut self,
11571        _owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
11572    ) -> SubscriberOperation<'_, ()> {
11573        Box::pin(async {
11574            Err(SubscriberError::Unsupported(
11575                "subscriber does not implement atomic bulk owner upsert",
11576            ))
11577        })
11578    }
11579
11580    /// Atomically replace the complete engine-managed owner topology without
11581    /// requesting history.
11582    ///
11583    /// This is the fresh-runtime bootstrap operation. Base/unowned interests,
11584    /// stale owners, queued delivery, and dedupe/source state from the prior
11585    /// topology must not survive a successful replacement. Errors and dropped
11586    /// futures leave the prior committed topology authoritative.
11587    ///
11588    /// # Errors
11589    ///
11590    /// The returned operation reports [`SubscriberError::Unsupported`] by
11591    /// default, or an implementation-specific validation or commit failure.
11592    fn replace_interest_owners(
11593        &mut self,
11594        _owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
11595    ) -> SubscriberOperation<'_, ()> {
11596        Box::pin(async {
11597            Err(SubscriberError::Unsupported(
11598                "subscriber does not implement atomic exact owner replacement",
11599            ))
11600        })
11601    }
11602
11603    /// Atomically replace the complete owner set and schedule one global
11604    /// historical log backfill in the same desired-state revision.
11605    ///
11606    /// This is the continuity-safe bootstrap operation for a runtime that has
11607    /// already processed canonical state while the subscriber's owner state is
11608    /// new or may have been lost. Implementations must commit the complete
11609    /// owner topology and all required historical work together: returning an
11610    /// error or dropping the future must leave the previously committed state
11611    /// authoritative. The default is deliberately unsupported rather than a
11612    /// sequence of partially committed single-owner updates.
11613    /// Historical records must be delivered through canonical global routing
11614    /// (`DeliveryAudience::All` / `DeliveryScope::CanonicalProgress`), not as
11615    /// owner catch-up, so their effects participate in the normal rollback
11616    /// journal before the source certifies the cutover. Base/unowned interests
11617    /// are replaced by this complete engine-managed topology. Any owner absent
11618    /// from `owners` must be removed together with its queued owner-only work, which closes
11619    /// the crash window where a subscriber committed registration but the
11620    /// runtime process died before installing the corresponding handler.
11621    ///
11622    /// # Errors
11623    ///
11624    /// The returned operation reports [`SubscriberError::Unsupported`] by
11625    /// default, or a backfill, validation, transport, or atomic-commit failure.
11626    fn replace_interest_owners_with_global_backfill(
11627        &mut self,
11628        _owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
11629        _backfill: SubscriberBackfill,
11630    ) -> SubscriberOperation<'_, ()> {
11631        Box::pin(async {
11632            Err(SubscriberError::Unsupported(
11633                "subscriber does not implement atomic owner replacement with global backfill",
11634            ))
11635        })
11636    }
11637
11638    /// Add or replace the interests owned by `owner`, awaiting the subscriber's
11639    /// commit boundary.
11640    ///
11641    /// Implementations must leave the previously committed owner state
11642    /// authoritative when the operation returns an error or is cancelled before
11643    /// completion.
11644    ///
11645    /// # Errors
11646    ///
11647    /// The returned operation reports [`SubscriberError`] when the owner update
11648    /// cannot be validated or committed.
11649    fn add_interest_owner(
11650        &mut self,
11651        owner: HandlerId,
11652        interests: &[ReactiveInterest<N>],
11653    ) -> SubscriberOperation<'_, ()>;
11654
11655    /// Add or replace owner interests and schedule log backfill for that owner,
11656    /// awaiting the subscriber's commit boundary.
11657    ///
11658    /// # Errors
11659    ///
11660    /// The returned operation reports [`SubscriberError`] when the owner update
11661    /// or requested backfill cannot be validated or committed.
11662    fn add_interest_owner_with_backfill(
11663        &mut self,
11664        owner: HandlerId,
11665        interests: &[ReactiveInterest<N>],
11666        backfill: SubscriberBackfill,
11667    ) -> SubscriberOperation<'_, ()>;
11668
11669    /// Add a handler discovered at retained canonical block `C` without
11670    /// opening a gap while registration commits.
11671    ///
11672    /// The subscriber must subscribe/adopt the new desired state first, then
11673    /// expose the new owner's matching records from `C` as owner catch-up and
11674    /// expose `C + 1` through the activation head as one globally ordered
11675    /// canonical catch-up over the complete active interest union. This split
11676    /// is deliberate: the runtime already has a rollback entry for `C`, while
11677    /// later blocks must run every handler and create normal canonical journal
11678    /// entries. Errors/cancellation preserve the prior committed topology.
11679    /// Implementations that cannot uphold this coordinated transaction must
11680    /// return `Unsupported`; emitting owner-only records past `C` is invalid.
11681    ///
11682    /// # Errors
11683    ///
11684    /// The returned operation reports [`SubscriberError::Unsupported`] by
11685    /// default, or a canonical-anchor, transport, or atomic-commit failure.
11686    fn add_interest_owner_with_canonical_catchup(
11687        &mut self,
11688        _owner: HandlerId,
11689        _interests: &[ReactiveInterest<N>],
11690        _retained: BlockRef,
11691    ) -> SubscriberOperation<'_, ()> {
11692        Box::pin(async {
11693            Err(SubscriberError::Unsupported(
11694                "subscriber does not implement coordinated canonical owner catch-up",
11695            ))
11696        })
11697    }
11698
11699    /// Remove one owner's interests, preserving unrelated interests, and await
11700    /// acknowledgement that the removal committed.
11701    ///
11702    /// On error the owner must remain authoritative, so the runtime handler is
11703    /// not removed while subscriber delivery may still target it.
11704    ///
11705    /// # Errors
11706    ///
11707    /// The returned operation reports [`SubscriberError`] when the removal
11708    /// cannot be committed while preserving unrelated owners.
11709    fn remove_interest_owner(
11710        &mut self,
11711        owner: &HandlerId,
11712    ) -> SubscriberOperation<'_, Option<Vec<ReactiveInterest<N>>>>;
11713
11714    /// Borrow the interests currently owned by `owner`.
11715    fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]>;
11716}
11717
11718/// Binds a [`ReactiveRuntime`] to an [`EventSubscriber`] for the common
11719/// subscribe-ingest lifecycle.
11720///
11721/// The engine treats the runtime registry as the single source of truth for
11722/// handler lifecycle: [`register_handler`](Self::register_handler) and
11723/// [`unregister_handler`](Self::unregister_handler) update runtime routing and
11724/// subscriber interests as one operation, keyed by the handler's stable
11725/// [`HandlerId`]. Registration is continuity-safe by default — once the runtime
11726/// has journaled canonical block *N*, a newly registered handler is live-adopted,
11727/// replayed owner-only at *N*, and then caught up globally with every handler
11728/// from *N + 1* through activation. A factory-discovered pool therefore misses
11729/// none of its own logs without making later history owner-local and
11730/// unrollbackable. The subscriber must absorb overlap that crosses batch
11731/// boundaries; the runtime validates and merges duplicate representations only
11732/// within one [`ReactiveInputBatch`].
11733///
11734/// Registration methods by intent:
11735///
11736/// | Method | Backfill |
11737/// |---|---|
11738/// | [`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) |
11739/// | [`register_handler_with_backfill`](Self::register_handler_with_backfill) | exactly one hash-certified block still retained by the rollback journal |
11740/// | [`register_handler_live_only`](Self::register_handler_live_only) | none — future logs only |
11741///
11742/// Unregistering a handler stops future subscription routing and runtime
11743/// decode for that handler; it deliberately does not evict [`EvmCache`] state
11744/// or undo runtime side effects. See
11745/// [`unregister_handler`](Self::unregister_handler) for the complete teardown
11746/// recipe.
11747///
11748/// The runtime and subscriber stay independently accessible through
11749/// [`runtime_mut`](Self::runtime_mut) / [`subscriber_mut`](Self::subscriber_mut)
11750/// for advanced use. One caution: avoid calling
11751/// [`EventSubscriber::register_interests`] (the full-replacement setup API) on
11752/// an engine-managed subscriber — implementations may clear owner-scoped
11753/// bookkeeping, after which per-handler unregistration no longer releases the
11754/// handler's transport subscriptions. To bootstrap the subscriber from a
11755/// runtime that already has handlers, use
11756/// [`sync_handler_interests`](Self::sync_handler_interests), which registers
11757/// one owner per handler instead of one unowned blob.
11758pub struct ReactiveEngine<S, N: Network = Ethereum> {
11759    runtime: ReactiveRuntime<N>,
11760    subscriber: S,
11761    pending_acknowledgement: Option<PendingAcknowledgement<N>>,
11762    pending_checkpoint: Option<PendingCheckpoint<N>>,
11763    last_checkpoint_block: Option<DurableCheckpointBlock>,
11764    last_checkpoint_delivery_token: Option<SubscriberDeliveryToken>,
11765    last_checkpoint_delivery_witness: Option<B256>,
11766    last_subscriber_checkpoint: Option<SubscriberCheckpoint>,
11767    checkpoint_identity: Option<DurableCheckpointIdentity>,
11768}
11769
11770struct PendingAcknowledgement<N: Network> {
11771    token: SubscriberDeliveryToken,
11772    report: ReactiveBatchReport<N>,
11773}
11774
11775struct PendingCheckpoint<N: Network> {
11776    metadata: DurableCheckpointMetadata,
11777    delivery_token: Option<SubscriberDeliveryToken>,
11778    report: ReactiveBatchReport<N>,
11779    saved_to: Option<PathBuf>,
11780    staged_generation: u64,
11781}
11782
11783struct CheckpointStage<N: Network> {
11784    incoming_block: Option<DurableCheckpointBlock>,
11785    delivery_token: Option<SubscriberDeliveryToken>,
11786    delivery_witness: Option<B256>,
11787    subscriber_checkpoint: Option<SubscriberCheckpoint>,
11788    staged_generation: u64,
11789    report: ReactiveBatchReport<N>,
11790}
11791
11792struct DurableResumePlan {
11793    runtime: DurableRuntimeRestorePlan,
11794    position: SubscriberResumePosition,
11795    delivery_witness: Option<B256>,
11796}
11797
11798enum HandlerRegistrationCatchup {
11799    LiveOnly,
11800    OwnerBackfill(SubscriberBackfill),
11801    CoordinatedCanonical(BlockRef),
11802}
11803
11804// 2: `ChainControl` gained `LogCoverage`, so a witness can encode a control
11805// shape version 1 readers cannot interpret. Bumping keeps a replayed token from
11806// an older process from being matched against a newer encoding.
11807const DELIVERY_WITNESS_VERSION: u32 = 2;
11808const DELIVERY_WITNESS_DOMAIN: &[u8] = b"evm-fork-cache/reactive-delivery-witness";
11809
11810#[derive(serde::Serialize)]
11811struct DeliveryWitnessEnvelope<'a> {
11812    version: u32,
11813    chain_id: Option<u64>,
11814    records: Vec<DeliveryRecordWitness<'a>>,
11815    chain_controls: &'a [ChainControl],
11816    subscriber_checkpoint: Option<&'a [u8]>,
11817    payload_commitment: Option<B256>,
11818}
11819
11820#[derive(serde::Serialize)]
11821struct DeliveryRecordWitness<'a> {
11822    identity: ReactiveInputIdentity,
11823    context: &'a ReactiveContext,
11824    audience: &'a DeliveryAudience,
11825    scope: DeliveryScope,
11826    payload: DeliveryPayloadWitness<'a>,
11827}
11828
11829#[derive(serde::Serialize)]
11830enum DeliveryPayloadWitness<'a> {
11831    /// Logs are the primary state-bearing event representation, so retain every
11832    /// RPC payload field in addition to the validated identity/context.
11833    Log {
11834        address: Address,
11835        topics: &'a [B256],
11836        data: &'a Bytes,
11837        block_hash: Option<B256>,
11838        block_number: Option<u64>,
11839        block_timestamp: Option<u64>,
11840        transaction_hash: Option<B256>,
11841        transaction_index: Option<u64>,
11842        log_index: Option<u64>,
11843        removed: bool,
11844    },
11845    /// Network-generic response bodies do not expose one stable complete serde
11846    /// contract. Their validated identity/context are witnessed here; batches
11847    /// containing headers, full blocks, or hydrated transactions additionally
11848    /// require the source's exact canonical wire-payload commitment. A generic
11849    /// header response can expose a supplied hash without proving that every
11850    /// handler-visible inner field recomputes to it.
11851    IdentityCommitted,
11852}
11853
11854fn durable_delivery_witness<N: Network>(
11855    batch: &ReactiveInputBatch<N>,
11856) -> Result<B256, ReactiveEngineError> {
11857    let requires_payload_commitment = batch.records.iter().any(|record| {
11858        matches!(
11859            &record.input,
11860            ReactiveInput::BlockHeader(_)
11861                | ReactiveInput::FullBlock(_)
11862                | ReactiveInput::PendingTx(_)
11863        )
11864    });
11865    if requires_payload_commitment && batch.payload_commitment.is_none() {
11866        return Err(ReactiveEngineError::MissingPayloadCommitment);
11867    }
11868    let records = batch
11869        .records
11870        .iter()
11871        .enumerate()
11872        .map(|(index, record)| {
11873            let payload = match &record.input {
11874                ReactiveInput::Log(log) => DeliveryPayloadWitness::Log {
11875                    address: log.address(),
11876                    topics: log.topics(),
11877                    data: &log.inner.data.data,
11878                    block_hash: log.block_hash,
11879                    block_number: log.block_number,
11880                    block_timestamp: log.block_timestamp,
11881                    transaction_hash: log.transaction_hash,
11882                    transaction_index: log.transaction_index,
11883                    log_index: log.log_index,
11884                    removed: log.removed,
11885                },
11886                ReactiveInput::BlockHeader(_)
11887                | ReactiveInput::FullBlock(_)
11888                | ReactiveInput::PendingTxHash(_)
11889                | ReactiveInput::PendingTx(_) => DeliveryPayloadWitness::IdentityCommitted,
11890            };
11891            Ok(DeliveryRecordWitness {
11892                identity: record.validated_identity()?,
11893                context: &record.context,
11894                audience: batch
11895                    .record_audience(index)
11896                    .expect("enumerated record always has an audience"),
11897                scope: batch
11898                    .record_delivery_scope(index)
11899                    .expect("enumerated record always has a delivery scope"),
11900                payload,
11901            })
11902        })
11903        .collect::<Result<Vec<_>, ReactiveError>>()?;
11904    let envelope = DeliveryWitnessEnvelope {
11905        version: DELIVERY_WITNESS_VERSION,
11906        chain_id: batch.chain_id,
11907        records,
11908        chain_controls: &batch.chain_controls,
11909        subscriber_checkpoint: batch
11910            .subscriber_checkpoint
11911            .as_ref()
11912            .map(SubscriberCheckpoint::as_bytes),
11913        payload_commitment: batch
11914            .payload_commitment
11915            .as_ref()
11916            .map(SubscriberPayloadCommitment::digest),
11917    };
11918    let encoded = bincode::DefaultOptions::new()
11919        .with_fixint_encoding()
11920        .serialize(&envelope)
11921        .map_err(|error| ReactiveEngineError::DeliveryWitness(error.to_string()))?;
11922    let mut witness = Keccak256::new();
11923    witness.update(DELIVERY_WITNESS_DOMAIN);
11924    witness.update(encoded);
11925    Ok(witness.finalize())
11926}
11927
11928impl<S, N> ReactiveEngine<S, N>
11929where
11930    N: Network,
11931    S: EventSubscriber<N>,
11932{
11933    /// Bind a runtime and subscriber.
11934    pub fn new(runtime: ReactiveRuntime<N>, subscriber: S) -> Self {
11935        Self {
11936            runtime,
11937            subscriber,
11938            pending_acknowledgement: None,
11939            pending_checkpoint: None,
11940            last_checkpoint_block: None,
11941            last_checkpoint_delivery_token: None,
11942            last_checkpoint_delivery_witness: None,
11943            last_subscriber_checkpoint: None,
11944            checkpoint_identity: None,
11945        }
11946    }
11947
11948    /// Split the engine into its runtime and subscriber parts when no commit is
11949    /// pending.
11950    ///
11951    /// A failed delivery acknowledgement or durable checkpoint commit remains
11952    /// live protocol state: dropping it would allow the caller to lose the
11953    /// already-applied report/token pair and poll past an uncommitted batch.
11954    /// In that case this returns the intact engine so the caller can repair the
11955    /// dependency and retry through the normal ingestion method.
11956    ///
11957    /// # Errors
11958    ///
11959    /// Returns the intact boxed engine when an acknowledgement or checkpoint
11960    /// commit is pending.
11961    pub fn into_parts(self) -> Result<(ReactiveRuntime<N>, S), Box<Self>> {
11962        if self.pending_acknowledgement.is_some() || self.pending_checkpoint.is_some() {
11963            return Err(Box::new(self));
11964        }
11965        Ok((self.runtime, self.subscriber))
11966    }
11967
11968    fn durable_resume_plan(
11969        &self,
11970        metadata: &DurableCheckpointMetadata,
11971    ) -> Result<DurableResumePlan, ReactiveCheckpointRestoreError> {
11972        if !self.subscriber.capabilities().supports_durable_replay() {
11973            return Err(ReactiveCheckpointRestoreError::SubscriberNotDurable);
11974        }
11975        self.ensure_subscriber_restore_chain(metadata.identity.chain_id)?;
11976        if !self.runtime.is_pristine_for_checkpoint_restore()
11977            || self.pending_acknowledgement.is_some()
11978            || self.pending_checkpoint.is_some()
11979            || self.last_checkpoint_block.is_some()
11980            || self.last_checkpoint_delivery_token.is_some()
11981            || self.last_checkpoint_delivery_witness.is_some()
11982            || self.last_subscriber_checkpoint.is_some()
11983            || self.checkpoint_identity.is_some()
11984        {
11985            return Err(ReactiveCheckpointRestoreError::ActiveRuntime);
11986        }
11987
11988        let block = BlockRef {
11989            number: metadata.block.number,
11990            hash: metadata.block.hash,
11991            parent_hash: metadata.block.parent_hash,
11992            timestamp: metadata.block.timestamp,
11993        };
11994        let runtime = match metadata.runtime_checkpoint.as_deref() {
11995            Some(bytes) => self
11996                .runtime
11997                .plan_durable_checkpoint_restore(bytes, &block)?,
11998            None => DurableRuntimeRestorePlan {
11999                checkpoint: None,
12000                fallback_history: (self.runtime.config.journal_depth > 0)
12001                    .then_some(block)
12002                    .into_iter()
12003                    .collect(),
12004            },
12005        };
12006        let delivery_token = metadata
12007            .delivery_token
12008            .clone()
12009            .map(SubscriberDeliveryToken::new);
12010        let subscriber_checkpoint = metadata
12011            .subscriber_checkpoint
12012            .clone()
12013            .map(SubscriberCheckpoint::new);
12014        let position = SubscriberResumePosition::new(
12015            metadata.identity.chain_id,
12016            block,
12017            runtime.canonical_history(),
12018            delivery_token,
12019            subscriber_checkpoint,
12020        );
12021        Ok(DurableResumePlan {
12022            runtime,
12023            position,
12024            delivery_witness: metadata.delivery_witness,
12025        })
12026    }
12027
12028    /// Preview the exact subscriber position a durable restore will install.
12029    ///
12030    /// This read-only step exists for durable subscribers that must complete
12031    /// asynchronous source or transport preparation before the engine invokes
12032    /// the synchronous [`EventSubscriber::restore_position`] hook. It decodes
12033    /// and validates the core runtime checkpoint, applies this runtime's
12034    /// configured journal retention to the preview, and returns the same
12035    /// [`SubscriberResumePosition`] that
12036    /// [`resume_from_durable_checkpoint`](Self::resume_from_durable_checkpoint)
12037    /// will later pass to the subscriber.
12038    ///
12039    /// Call this on the same fresh engine that will perform the restore. After
12040    /// subscriber preparation completes, pass the identical `metadata` to
12041    /// `resume_from_durable_checkpoint` (or restore the same loaded checkpoint
12042    /// through [`restore_durable_checkpoint`](Self::restore_durable_checkpoint))
12043    /// without mutating engine runtime or checkpoint state in between. The
12044    /// checkpoint identity and, for non-finalized state, its canonical block
12045    /// must still be validated by the caller before external preparation.
12046    ///
12047    /// This method does not mutate the runtime, subscriber, or checkpoint
12048    /// bookkeeping.
12049    ///
12050    /// # Errors
12051    ///
12052    /// Returns [`ReactiveCheckpointRestoreError`] when the subscriber is not
12053    /// durable, its chain identity conflicts with the checkpoint, the engine is
12054    /// not fresh, or the stored runtime checkpoint is malformed, unsupported,
12055    /// or internally inconsistent.
12056    pub fn preview_durable_resume_position(
12057        &self,
12058        metadata: &DurableCheckpointMetadata,
12059    ) -> Result<SubscriberResumePosition, ReactiveCheckpointRestoreError> {
12060        Ok(self.durable_resume_plan(metadata)?.position)
12061    }
12062
12063    /// Resume delivery bookkeeping and canonical continuity from a cache
12064    /// checkpoint that has already been identity- and hash-validated and
12065    /// restored into [`EvmCache`].
12066    ///
12067    /// Call this on a fresh engine. The anchor has no rollback effects of its
12068    /// own: it represents the state baseline embodied by the checkpoint, while
12069    /// newly ingested blocks are journaled normally above it.
12070    /// The subscriber must advertise [`SubscriberCapability::DurableReplay`];
12071    /// restoring an ephemeral stream would claim a restart guarantee it cannot
12072    /// uphold and is rejected before cache or runtime mutation.
12073    ///
12074    /// Prefer [`restore_durable_checkpoint`](Self::restore_durable_checkpoint)
12075    /// when the cache has not yet been restored: that helper rolls the cache
12076    /// back as well if runtime or subscriber activation fails.
12077    ///
12078    /// # Errors
12079    ///
12080    /// Returns [`ReactiveCheckpointRestoreError`] when the subscriber is not
12081    /// durable, chain identity conflicts, the runtime is not pristine, stored
12082    /// runtime state is invalid, or the subscriber rejects the restored
12083    /// position. Runtime state is restored on subscriber failure.
12084    pub fn resume_from_durable_checkpoint(
12085        &mut self,
12086        metadata: &DurableCheckpointMetadata,
12087    ) -> Result<(), ReactiveCheckpointRestoreError> {
12088        let plan = self.durable_resume_plan(metadata)?;
12089        let prior_runtime = self.runtime.checkpoint_state();
12090
12091        let DurableResumePlan {
12092            runtime,
12093            position,
12094            delivery_witness,
12095        } = plan;
12096        self.runtime.apply_durable_checkpoint_restore(runtime);
12097        self.runtime.coverage_head = Some(position.coverage_head);
12098        if let Err(error) = self.subscriber.restore_position(&position) {
12099            self.runtime.restore_state(prior_runtime);
12100            return Err(ReactiveCheckpointRestoreError::Subscriber(error));
12101        }
12102        if let Err(error) = self.ensure_subscriber_restore_chain(metadata.identity.chain_id) {
12103            self.runtime.restore_state(prior_runtime);
12104            return Err(error);
12105        }
12106        self.last_checkpoint_block = Some(metadata.block.clone());
12107        self.last_checkpoint_delivery_token = position.delivery_token;
12108        self.last_checkpoint_delivery_witness = delivery_witness;
12109        self.last_subscriber_checkpoint = position.subscriber_checkpoint;
12110        self.checkpoint_identity = Some(metadata.identity.clone());
12111        Ok(())
12112    }
12113
12114    /// Atomically restore cache, runtime, and subscriber position from one
12115    /// validated durable checkpoint.
12116    ///
12117    /// Inspect [`LoadedDurableCheckpoint::metadata`] and validate its canonical
12118    /// block against an authoritative RPC source before calling this method when
12119    /// the block is not finalized. Identity, cache-chain, runtime-state, and
12120    /// subscriber failures leave the cache and engine runtime unchanged. The
12121    /// subscriber follows [`EventSubscriber::restore_position`]'s retry contract.
12122    /// It must advertise [`SubscriberCapability::DurableReplay`].
12123    ///
12124    /// # Errors
12125    ///
12126    /// Returns [`ReactiveCheckpointRestoreError`] for checkpoint identity,
12127    /// cache-chain, runtime-state, subscriber-capability, subscriber-chain, or
12128    /// position-restore failures. Cache and runtime state remain unchanged.
12129    pub fn restore_durable_checkpoint(
12130        &mut self,
12131        cache: &mut EvmCache,
12132        loaded: LoadedDurableCheckpoint,
12133        expected: &DurableCheckpointIdentity,
12134    ) -> Result<DurableCheckpointMetadata, ReactiveCheckpointRestoreError> {
12135        if !self.subscriber.capabilities().supports_durable_replay() {
12136            return Err(ReactiveCheckpointRestoreError::SubscriberNotDurable);
12137        }
12138        self.ensure_subscriber_restore_chain(expected.chain_id)?;
12139        if !self.runtime.is_pristine_for_checkpoint_restore()
12140            || self.pending_acknowledgement.is_some()
12141            || self.pending_checkpoint.is_some()
12142            || self.last_checkpoint_block.is_some()
12143            || self.last_checkpoint_delivery_token.is_some()
12144            || self.last_checkpoint_delivery_witness.is_some()
12145            || self.last_subscriber_checkpoint.is_some()
12146            || self.checkpoint_identity.is_some()
12147        {
12148            return Err(ReactiveCheckpointRestoreError::ActiveRuntime);
12149        }
12150
12151        let prior_cache = EvmCacheStateSnapshot::capture(cache);
12152        let metadata = loaded.restore_into(cache, expected)?;
12153        if let Err(error) = self.resume_from_durable_checkpoint(&metadata) {
12154            prior_cache.restore(cache);
12155            return Err(error);
12156        }
12157        Ok(metadata)
12158    }
12159
12160    /// Borrow the runtime.
12161    pub fn runtime(&self) -> &ReactiveRuntime<N> {
12162        &self.runtime
12163    }
12164
12165    /// Mutably borrow the runtime.
12166    pub fn runtime_mut(&mut self) -> &mut ReactiveRuntime<N> {
12167        &mut self.runtime
12168    }
12169
12170    /// Borrow the subscriber.
12171    pub fn subscriber(&self) -> &S {
12172        &self.subscriber
12173    }
12174
12175    /// Mutably borrow the subscriber.
12176    pub fn subscriber_mut(&mut self) -> &mut S {
12177        &mut self.subscriber
12178    }
12179
12180    /// Adopt a hash-pinned RPC cache snapshot as the runtime's canonical
12181    /// cold-start baseline.
12182    ///
12183    /// The cache must use the exact canonical hash selector and block-number
12184    /// context named by `baseline`; when the baseline includes a timestamp, the
12185    /// cache timestamp must match too. Cache, baseline, and any already-resolved
12186    /// subscriber identity must name the same chain. No delivery or checkpoint
12187    /// commit may be pending. After this succeeds, call
12188    /// [`sync_handler_interests_with_backfill`](Self::sync_handler_interests_with_backfill)
12189    /// before polling: it exact-replaces subscriber owners and begins event
12190    /// catch-up at `C + 1`.
12191    ///
12192    /// # Errors
12193    ///
12194    /// Returns [`ReactiveEngineError`] when commit state is pending, the runtime
12195    /// is active or already has a conflicting baseline, cache/subscriber chain
12196    /// identity differs, or the cache is not pinned to the exact baseline.
12197    pub fn adopt_canonical_baseline(
12198        &mut self,
12199        cache: &EvmCache,
12200        baseline: ReactiveCanonicalBaseline,
12201    ) -> Result<(), ReactiveEngineError> {
12202        if self.pending_acknowledgement.is_some()
12203            || self.pending_checkpoint.is_some()
12204            || self.last_checkpoint_block.is_some()
12205            || self.last_checkpoint_delivery_token.is_some()
12206            || self.last_checkpoint_delivery_witness.is_some()
12207            || self.last_subscriber_checkpoint.is_some()
12208            || self.checkpoint_identity.is_some()
12209        {
12210            return Err(ReactiveBaselineError::ActiveRuntime.into());
12211        }
12212        // Establish deterministic lifecycle/idempotency semantics before
12213        // consulting mutable cache context. A conflicting repeat is a runtime
12214        // baseline conflict even if the caller also repointed the cache.
12215        self.runtime
12216            .validate_canonical_baseline_adoption(baseline.block)?;
12217        if baseline.chain_id != cache.chain_id() {
12218            return Err(ReactiveBaselineError::CacheChainMismatch {
12219                baseline_chain_id: baseline.chain_id,
12220                cache_chain_id: cache.chain_id(),
12221            }
12222            .into());
12223        }
12224        self.ensure_subscriber_chain(cache)?;
12225        let exact_selector = BlockId::from((baseline.block.hash, Some(true)));
12226        let context_matches = cache.block_number() == Some(baseline.block.number)
12227            && baseline
12228                .block
12229                .timestamp
12230                .is_none_or(|timestamp| cache.timestamp() == Some(timestamp));
12231        if cache.block() != exact_selector || !context_matches {
12232            return Err(ReactiveBaselineError::CacheBlockMismatch {
12233                number: baseline.block.number,
12234                hash: baseline.block.hash,
12235            }
12236            .into());
12237        }
12238        self.runtime.adopt_canonical_baseline(baseline.block)?;
12239        Ok(())
12240    }
12241
12242    /// Poll the subscriber for the next batch without ingesting it.
12243    ///
12244    /// This low-level escape hatch is unavailable while the engine owes an
12245    /// acknowledgement or checkpoint commit. Callers that use it must return
12246    /// any subscriber-owned delivery metadata through a combined
12247    /// [`next_ingest`](Self::next_ingest) helper; raw ingestion deliberately
12248    /// rejects that metadata so it cannot be discarded accidentally.
12249    ///
12250    /// # Errors
12251    ///
12252    /// Returns [`ReactiveEngineError`] when an acknowledgement/checkpoint commit
12253    /// is pending or subscriber and cache chain identities conflict.
12254    pub fn next_batch(
12255        &mut self,
12256        cache: &EvmCache,
12257    ) -> Result<SubscriberNextBatch<'_, N>, ReactiveEngineError> {
12258        if self.pending_checkpoint.is_some() {
12259            return Err(ReactiveEngineError::PendingCheckpointCommit);
12260        }
12261        if self.pending_acknowledgement.is_some() {
12262            return Err(ReactiveEngineError::PendingAcknowledgementCommit);
12263        }
12264        self.ensure_subscriber_chain(cache)?;
12265        Ok(self.subscriber.next_batch())
12266    }
12267
12268    /// Ingest one already-polled batch through the runtime (direct effects
12269    /// only; surfaced resync requests are reported, not executed).
12270    ///
12271    /// # Errors
12272    ///
12273    /// Returns [`ReactiveEngineError`] when commit state is pending, the batch
12274    /// carries subscriber-owned commit metadata, chain identity conflicts, or
12275    /// runtime ingestion fails.
12276    pub fn ingest_batch(
12277        &mut self,
12278        cache: &mut EvmCache,
12279        batch: ReactiveInputBatch<N>,
12280    ) -> Result<ReactiveBatchReport<N>, ReactiveEngineError> {
12281        self.ensure_raw_ingest_is_safe(cache, &batch)?;
12282        Ok(self.runtime.ingest_batch(cache, batch)?)
12283    }
12284
12285    /// Ingest one already-polled batch and execute the storage/account resyncs
12286    /// it surfaces, exactly like
12287    /// [`ReactiveRuntime::ingest_batch_with_resync`].
12288    ///
12289    /// # Errors
12290    ///
12291    /// Returns [`ReactiveEngineError`] when commit state is pending, the batch
12292    /// carries subscriber-owned commit metadata, chain identity conflicts, or
12293    /// runtime ingestion fails.
12294    pub fn ingest_batch_with_resync(
12295        &mut self,
12296        cache: &mut EvmCache,
12297        batch: ReactiveInputBatch<N>,
12298    ) -> Result<ReactiveBatchReport<N>, ReactiveEngineError> {
12299        self.ensure_raw_ingest_is_safe(cache, &batch)?;
12300        Ok(self.runtime.ingest_batch_with_resync(cache, batch)?)
12301    }
12302
12303    fn ensure_raw_ingest_is_safe(
12304        &self,
12305        cache: &EvmCache,
12306        batch: &ReactiveInputBatch<N>,
12307    ) -> Result<(), ReactiveEngineError> {
12308        if self.pending_checkpoint.is_some() {
12309            return Err(ReactiveEngineError::PendingCheckpointCommit);
12310        }
12311        if self.pending_acknowledgement.is_some() {
12312            return Err(ReactiveEngineError::PendingAcknowledgementCommit);
12313        }
12314        if batch.delivery_token().is_some() || batch.subscriber_checkpoint().is_some() {
12315            return Err(ReactiveEngineError::UncommittedDeliveryMetadata);
12316        }
12317        self.ensure_subscriber_chain(cache)?;
12318        Ok(())
12319    }
12320
12321    fn ensure_subscriber_chain(&self, cache: &EvmCache) -> Result<(), ReactiveEngineError> {
12322        if let Some(subscriber_chain_id) = self.subscriber.chain_id()
12323            && subscriber_chain_id != cache.chain_id()
12324        {
12325            return Err(ReactiveEngineError::SubscriberChainMismatch {
12326                subscriber_chain_id,
12327                cache_chain_id: cache.chain_id(),
12328            });
12329        }
12330        Ok(())
12331    }
12332
12333    fn ensure_subscriber_restore_chain(
12334        &self,
12335        checkpoint_chain_id: u64,
12336    ) -> Result<(), ReactiveCheckpointRestoreError> {
12337        if let Some(subscriber_chain_id) = self.subscriber.chain_id()
12338            && subscriber_chain_id != checkpoint_chain_id
12339        {
12340            return Err(ReactiveCheckpointRestoreError::SubscriberChainMismatch {
12341                subscriber_chain_id,
12342                checkpoint_chain_id,
12343            });
12344        }
12345        Ok(())
12346    }
12347
12348    /// Poll the subscriber once and ingest the returned batch when present
12349    /// (direct effects only).
12350    ///
12351    /// # Errors
12352    ///
12353    /// Returns [`ReactiveEngineError`] for subscriber/cache chain mismatch,
12354    /// pending checkpoint state, subscriber polling, runtime ingestion, or
12355    /// delivery-acknowledgement failure. A failed acknowledgement remains
12356    /// pending and is retried before polling again.
12357    pub async fn next_ingest(
12358        &mut self,
12359        cache: &mut EvmCache,
12360    ) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
12361        self.ensure_subscriber_chain(cache)?;
12362        if self.pending_checkpoint.is_some() {
12363            return Err(ReactiveEngineError::PendingCheckpointCommit);
12364        }
12365        if self.pending_acknowledgement.is_some() {
12366            return self.commit_pending_acknowledgement().await.map(Some);
12367        }
12368        let batch = self.subscriber.next_batch().await?;
12369        self.ensure_subscriber_chain(cache)?;
12370        let Some(mut batch) = batch else {
12371            return Ok(None);
12372        };
12373        let delivery_token = batch.take_delivery_token();
12374        let report = self.runtime.ingest_batch(cache, batch)?;
12375        self.stage_or_return_acknowledgement(delivery_token, report)
12376            .await
12377    }
12378
12379    /// Poll the subscriber once and ingest the returned batch with resync
12380    /// execution — the loop shape for consumers that rely on coverage-gap
12381    /// repair (root-gate resyncs, handler-requested re-reads).
12382    ///
12383    /// # Errors
12384    ///
12385    /// Returns [`ReactiveEngineError`] for subscriber/cache chain mismatch,
12386    /// pending checkpoint state, subscriber polling, runtime ingestion, or
12387    /// delivery-acknowledgement failure. A failed acknowledgement remains
12388    /// pending and is retried before polling again.
12389    pub async fn next_ingest_with_resync(
12390        &mut self,
12391        cache: &mut EvmCache,
12392    ) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
12393        self.ensure_subscriber_chain(cache)?;
12394        if self.pending_checkpoint.is_some() {
12395            return Err(ReactiveEngineError::PendingCheckpointCommit);
12396        }
12397        if self.pending_acknowledgement.is_some() {
12398            return self.commit_pending_acknowledgement().await.map(Some);
12399        }
12400        let batch = self.subscriber.next_batch().await?;
12401        self.ensure_subscriber_chain(cache)?;
12402        let Some(mut batch) = batch else {
12403            return Ok(None);
12404        };
12405        let delivery_token = batch.take_delivery_token();
12406        let report = self.runtime.ingest_batch_with_resync(cache, batch)?;
12407        self.stage_or_return_acknowledgement(delivery_token, report)
12408            .await
12409    }
12410
12411    /// Poll, ingest, atomically checkpoint, then acknowledge one batch.
12412    ///
12413    /// The ordering is strict: subscriber acknowledgement is never attempted
12414    /// until the complete cache checkpoint is synced. If checkpointing or
12415    /// acknowledgement fails, the in-memory pending commit is retried before
12416    /// any later batch is polled, so a transient disk failure cannot cause the
12417    /// already-applied batch to execute twice in the same process. Across a
12418    /// process restart, [`resume_from_durable_checkpoint`](Self::resume_from_durable_checkpoint)
12419    /// uses the stored delivery token and delivery witness to recognize and
12420    /// acknowledge an identical replay without re-ingestion. Reusing a token
12421    /// for different input or cursor state fails closed. Mutating the cache while
12422    /// a commit is pending also fails closed rather than binding newer state to
12423    /// older delivery metadata. Any explicit, implicit, or removed-log reorg
12424    /// that cannot be proven from the retained effect journal is rejected before
12425    /// mutation/save/ACK; configure
12426    /// [`ReactiveConfig::journal_depth`] to cover the subscriber's reorg horizon.
12427    /// Hooks are dispatched only after checkpoint staging
12428    /// succeeds, but remain in-process observers rather than a durable outbox;
12429    /// see [`ReactiveHook`]. The subscriber must advertise
12430    /// [`SubscriberCapability::DurableReplay`]; ephemeral subscribers are
12431    /// rejected before polling.
12432    ///
12433    /// # Errors
12434    ///
12435    /// Returns [`ReactiveEngineError`] when the subscriber lacks durable replay,
12436    /// identities or replay witnesses conflict, a checkpoint/ACK is already in
12437    /// an incompatible state, polling or ingestion fails, complete rollback
12438    /// proof is unavailable, the cache changes after staging, persistence
12439    /// fails, or delivery acknowledgement fails. Pending checkpoint/ACK work is
12440    /// retained for retry before another poll.
12441    pub async fn next_ingest_checkpointed(
12442        &mut self,
12443        cache: &mut EvmCache,
12444        store: &DurableCheckpointStore,
12445        identity: &DurableCheckpointIdentity,
12446    ) -> Result<Option<CheckpointedIngest<N>>, ReactiveEngineError> {
12447        if !self.subscriber.capabilities().supports_durable_replay() {
12448            return Err(ReactiveEngineError::SubscriberNotDurable);
12449        }
12450        self.ensure_subscriber_chain(cache)?;
12451        if self.pending_acknowledgement.is_some() {
12452            return Err(ReactiveEngineError::PendingAcknowledgementCommit);
12453        }
12454        self.ensure_checkpoint_identity(cache, identity)?;
12455        if self.pending_checkpoint.is_some() {
12456            return self.commit_pending_checkpoint(cache, store).await.map(Some);
12457        }
12458
12459        let batch = self.subscriber.next_batch().await?;
12460        self.ensure_subscriber_chain(cache)?;
12461        let Some(mut batch) = batch else {
12462            return Ok(None);
12463        };
12464        if batch_preconfirmation(&batch)?.is_some() {
12465            return Err(ReactiveEngineError::PreconfirmationNotCheckpointable);
12466        }
12467        self.runtime.discard_preconfirmed_branch(cache);
12468        let delivery_witness = batch
12469            .delivery_token()
12470            .map(|_| durable_delivery_witness(&batch))
12471            .transpose()?;
12472        let delivery_token = batch.take_delivery_token();
12473        let subscriber_checkpoint = batch.take_subscriber_checkpoint();
12474        if let (Some(replay_token), Some(committed_token)) = (
12475            delivery_token.as_ref(),
12476            self.last_checkpoint_delivery_token.as_ref(),
12477        ) && replay_token == committed_token
12478        {
12479            let committed_witness = self
12480                .last_checkpoint_delivery_witness
12481                .ok_or(ReactiveEngineError::MissingReplayWitness)?;
12482            if delivery_witness != Some(committed_witness) {
12483                return Err(ReactiveEngineError::ReplayDeliveryMismatch);
12484            }
12485            self.subscriber
12486                .acknowledge_delivery(replay_token.clone())
12487                .await
12488                .map_err(ReactiveEngineError::Acknowledgement)?;
12489            return Ok(Some(CheckpointedIngest::ReplayAcknowledged));
12490        }
12491
12492        self.ensure_checkpointable_reorgs(&batch)?;
12493
12494        let incoming_block = latest_canonical_batch_block(&batch);
12495        let cache_state = EvmCacheStateSnapshot::capture(cache);
12496        let runtime_state = self.runtime.checkpoint_state();
12497        let report = match self.runtime.ingest_batch_direct(cache, batch) {
12498            Ok(report) => report,
12499            Err(error) => {
12500                cache_state.restore(cache);
12501                self.runtime.restore_transaction_state(runtime_state);
12502                return Err(error.into());
12503            }
12504        };
12505        let reports = report.reports.clone();
12506        let stage = CheckpointStage {
12507            incoming_block,
12508            delivery_token,
12509            delivery_witness,
12510            subscriber_checkpoint,
12511            staged_generation: cache.snapshot_generation(),
12512            report,
12513        };
12514        if let Err(error) = self.stage_checkpoint(identity, stage) {
12515            cache_state.restore(cache);
12516            self.runtime.restore_transaction_state(runtime_state);
12517            return Err(error);
12518        }
12519        self.runtime.dispatch_reports(&reports);
12520        self.commit_pending_checkpoint(cache, store).await.map(Some)
12521    }
12522
12523    /// Checkpointed counterpart to [`next_ingest_with_resync`](Self::next_ingest_with_resync).
12524    /// Requires [`SubscriberCapability::DurableReplay`] and rejects an
12525    /// ephemeral subscriber before polling.
12526    ///
12527    /// # Errors
12528    ///
12529    /// Returns [`ReactiveEngineError`] for the same durability, identity,
12530    /// rollback-proof, replay-witness, polling, ingestion, persistence,
12531    /// mutation-fence, and acknowledgement failures as
12532    /// [`next_ingest_checkpointed`](Self::next_ingest_checkpointed).
12533    pub async fn next_ingest_with_resync_checkpointed(
12534        &mut self,
12535        cache: &mut EvmCache,
12536        store: &DurableCheckpointStore,
12537        identity: &DurableCheckpointIdentity,
12538    ) -> Result<Option<CheckpointedIngest<N>>, ReactiveEngineError> {
12539        if !self.subscriber.capabilities().supports_durable_replay() {
12540            return Err(ReactiveEngineError::SubscriberNotDurable);
12541        }
12542        self.ensure_subscriber_chain(cache)?;
12543        if self.pending_acknowledgement.is_some() {
12544            return Err(ReactiveEngineError::PendingAcknowledgementCommit);
12545        }
12546        self.ensure_checkpoint_identity(cache, identity)?;
12547        if self.pending_checkpoint.is_some() {
12548            return self.commit_pending_checkpoint(cache, store).await.map(Some);
12549        }
12550
12551        let batch = self.subscriber.next_batch().await?;
12552        self.ensure_subscriber_chain(cache)?;
12553        let Some(mut batch) = batch else {
12554            return Ok(None);
12555        };
12556        if batch_preconfirmation(&batch)?.is_some() {
12557            return Err(ReactiveEngineError::PreconfirmationNotCheckpointable);
12558        }
12559        self.runtime.discard_preconfirmed_branch(cache);
12560        let delivery_witness = batch
12561            .delivery_token()
12562            .map(|_| durable_delivery_witness(&batch))
12563            .transpose()?;
12564        let delivery_token = batch.take_delivery_token();
12565        let subscriber_checkpoint = batch.take_subscriber_checkpoint();
12566        if let (Some(replay_token), Some(committed_token)) = (
12567            delivery_token.as_ref(),
12568            self.last_checkpoint_delivery_token.as_ref(),
12569        ) && replay_token == committed_token
12570        {
12571            let committed_witness = self
12572                .last_checkpoint_delivery_witness
12573                .ok_or(ReactiveEngineError::MissingReplayWitness)?;
12574            if delivery_witness != Some(committed_witness) {
12575                return Err(ReactiveEngineError::ReplayDeliveryMismatch);
12576            }
12577            self.subscriber
12578                .acknowledge_delivery(replay_token.clone())
12579                .await
12580                .map_err(ReactiveEngineError::Acknowledgement)?;
12581            return Ok(Some(CheckpointedIngest::ReplayAcknowledged));
12582        }
12583
12584        self.ensure_checkpointable_reorgs(&batch)?;
12585
12586        let incoming_block = latest_canonical_batch_block(&batch);
12587        let cache_state = EvmCacheStateSnapshot::capture(cache);
12588        let runtime_state = self.runtime.checkpoint_state();
12589        let report = match self.runtime.ingest_batch_with_resync_direct(cache, batch) {
12590            Ok(report) => report,
12591            Err(error) => {
12592                cache_state.restore(cache);
12593                self.runtime.restore_transaction_state(runtime_state);
12594                return Err(error.into());
12595            }
12596        };
12597        let reports = report.reports.clone();
12598        let stage = CheckpointStage {
12599            incoming_block,
12600            delivery_token,
12601            delivery_witness,
12602            subscriber_checkpoint,
12603            staged_generation: cache.snapshot_generation(),
12604            report,
12605        };
12606        if let Err(error) = self.stage_checkpoint(identity, stage) {
12607            cache_state.restore(cache);
12608            self.runtime.restore_transaction_state(runtime_state);
12609            return Err(error);
12610        }
12611        self.runtime.dispatch_reports(&reports);
12612        self.commit_pending_checkpoint(cache, store).await.map(Some)
12613    }
12614
12615    fn stage_checkpoint(
12616        &mut self,
12617        identity: &DurableCheckpointIdentity,
12618        stage: CheckpointStage<N>,
12619    ) -> Result<(), ReactiveEngineError> {
12620        let CheckpointStage {
12621            incoming_block,
12622            delivery_token,
12623            delivery_witness,
12624            subscriber_checkpoint,
12625            staged_generation,
12626            report,
12627        } = stage;
12628        if delivery_token.is_some() != delivery_witness.is_some() {
12629            return Err(ReactiveEngineError::DeliveryWitness(
12630                "delivery token and witness must be staged together".into(),
12631            ));
12632        }
12633        let runtime_checkpoint = self.runtime.durable_checkpoint_bytes()?;
12634        let block = self
12635            .runtime
12636            .last_canonical_block()
12637            .map(|block| DurableCheckpointBlock {
12638                number: block.number,
12639                hash: block.hash,
12640                parent_hash: block.parent_hash,
12641                timestamp: block.timestamp,
12642            })
12643            .or(incoming_block)
12644            .or_else(|| self.last_checkpoint_block.clone())
12645            .ok_or(ReactiveEngineError::MissingCheckpointBlock)?;
12646        let metadata = DurableCheckpointMetadata {
12647            identity: identity.clone(),
12648            block,
12649            delivery_token: delivery_token
12650                .as_ref()
12651                .or(self.last_checkpoint_delivery_token.as_ref())
12652                .map(|token| token.as_bytes().to_vec()),
12653            delivery_witness: if delivery_token.is_some() {
12654                delivery_witness
12655            } else {
12656                self.last_checkpoint_delivery_witness
12657            },
12658            subscriber_checkpoint: subscriber_checkpoint
12659                .as_ref()
12660                .or(self.last_subscriber_checkpoint.as_ref())
12661                .map(|checkpoint| checkpoint.as_bytes().to_vec()),
12662            runtime_checkpoint: Some(runtime_checkpoint),
12663        };
12664        self.pending_checkpoint = Some(PendingCheckpoint {
12665            metadata,
12666            delivery_token,
12667            report,
12668            saved_to: None,
12669            staged_generation,
12670        });
12671        Ok(())
12672    }
12673
12674    fn ensure_checkpointable_reorgs(
12675        &self,
12676        batch: &ReactiveInputBatch<N>,
12677    ) -> Result<(), ReactiveEngineError> {
12678        let state = CanonicalSequenceState::new(
12679            self.runtime
12680                .journal
12681                .iter()
12682                .map(|entry| entry.block)
12683                .collect(),
12684            self.runtime.coverage_head,
12685            self.runtime.safe_head,
12686            self.runtime.finalized_head,
12687        )
12688        .with_log_coverage_head(self.runtime.log_coverage_head);
12689        match validate_canonical_sequence_internal(
12690            &state,
12691            batch,
12692            CanonicalSequenceValidationPolicy::RequireCompleteRollback,
12693        ) {
12694            Ok(_) => Ok(()),
12695            Err(CanonicalSequenceError::Invalid(error)) => Err(error.into()),
12696            Err(CanonicalSequenceError::IncompleteRollback {
12697                common_ancestor,
12698                oldest_retained,
12699                ..
12700            }) => Err(ReactiveEngineError::CheckpointReorgOutsideJournal {
12701                common_ancestor,
12702                oldest_journaled: oldest_retained,
12703                journal_depth: self.runtime.config.journal_depth,
12704            }),
12705        }
12706    }
12707
12708    async fn stage_or_return_acknowledgement(
12709        &mut self,
12710        delivery_token: Option<SubscriberDeliveryToken>,
12711        report: ReactiveBatchReport<N>,
12712    ) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
12713        let Some(token) = delivery_token else {
12714            return Ok(Some(report));
12715        };
12716        self.pending_acknowledgement = Some(PendingAcknowledgement { token, report });
12717        self.commit_pending_acknowledgement().await.map(Some)
12718    }
12719
12720    async fn commit_pending_acknowledgement(
12721        &mut self,
12722    ) -> Result<ReactiveBatchReport<N>, ReactiveEngineError> {
12723        let token = self
12724            .pending_acknowledgement
12725            .as_ref()
12726            .expect("caller checked pending acknowledgement")
12727            .token
12728            .clone();
12729        self.subscriber
12730            .acknowledge_delivery(token)
12731            .await
12732            .map_err(ReactiveEngineError::Acknowledgement)?;
12733        Ok(self
12734            .pending_acknowledgement
12735            .take()
12736            .expect("pending acknowledgement remains until commit")
12737            .report)
12738    }
12739
12740    async fn commit_pending_checkpoint(
12741        &mut self,
12742        cache: &EvmCache,
12743        store: &DurableCheckpointStore,
12744    ) -> Result<CheckpointedIngest<N>, ReactiveEngineError> {
12745        let pending = self
12746            .pending_checkpoint
12747            .as_mut()
12748            .expect("caller checked pending checkpoint");
12749        let cache_generation = cache.snapshot_generation();
12750        if cache_generation != pending.staged_generation {
12751            return Err(ReactiveEngineError::PendingCheckpointCacheChanged {
12752                staged_generation: pending.staged_generation,
12753                current_generation: cache_generation,
12754            });
12755        }
12756        if pending.saved_to.as_deref() != Some(store.path()) {
12757            store
12758                .save_async(cache, pending.metadata.clone())
12759                .await
12760                .map_err(ReactiveEngineError::Checkpoint)?;
12761            pending.saved_to = Some(store.path().to_path_buf());
12762        }
12763        if let Some(token) = pending.delivery_token.clone() {
12764            self.subscriber
12765                .acknowledge_delivery(token)
12766                .await
12767                .map_err(ReactiveEngineError::Acknowledgement)?;
12768        }
12769
12770        let pending = self
12771            .pending_checkpoint
12772            .take()
12773            .expect("pending checkpoint remains until commit");
12774        self.last_checkpoint_block = Some(pending.metadata.block);
12775        self.checkpoint_identity = Some(pending.metadata.identity);
12776        self.last_checkpoint_delivery_token = pending
12777            .metadata
12778            .delivery_token
12779            .map(SubscriberDeliveryToken::new);
12780        self.last_checkpoint_delivery_witness = pending.metadata.delivery_witness;
12781        self.last_subscriber_checkpoint = pending
12782            .metadata
12783            .subscriber_checkpoint
12784            .map(SubscriberCheckpoint::new);
12785        Ok(CheckpointedIngest::Applied(pending.report))
12786    }
12787
12788    fn ensure_checkpoint_identity(
12789        &self,
12790        cache: &EvmCache,
12791        identity: &DurableCheckpointIdentity,
12792    ) -> Result<(), ReactiveEngineError> {
12793        if identity.chain_id != cache.chain_id() {
12794            return Err(ReactiveEngineError::Checkpoint(
12795                DurableCheckpointError::CacheChainMismatch {
12796                    cache_chain_id: cache.chain_id(),
12797                    checkpoint_chain_id: identity.chain_id,
12798                },
12799            ));
12800        }
12801        if let Some(actual) = self.checkpoint_identity.as_ref()
12802            && actual != identity
12803        {
12804            return Err(ReactiveEngineError::Checkpoint(
12805                DurableCheckpointError::IdentityMismatch {
12806                    expected: identity.clone(),
12807                    actual: actual.clone(),
12808                },
12809            ));
12810        }
12811        if let Some(pending) = self.pending_checkpoint.as_ref()
12812            && &pending.metadata.identity != identity
12813        {
12814            return Err(ReactiveEngineError::Checkpoint(
12815                DurableCheckpointError::IdentityMismatch {
12816                    expected: identity.clone(),
12817                    actual: pending.metadata.identity.clone(),
12818                },
12819            ));
12820        }
12821        Ok(())
12822    }
12823}
12824
12825fn latest_canonical_batch_block<N: Network>(
12826    batch: &ReactiveInputBatch<N>,
12827) -> Option<DurableCheckpointBlock> {
12828    let record_block = batch
12829        .records()
12830        .iter()
12831        .enumerate()
12832        .filter(|(index, _)| {
12833            batch
12834                .record_delivery_scope(*index)
12835                .is_some_and(DeliveryScope::advances_canonical_state)
12836        })
12837        .filter_map(|(_, record)| canonical_record_block(record))
12838        .max_by_key(|block| block.number)
12839        .cloned();
12840    let control_block = batch
12841        .chain_controls()
12842        .iter()
12843        .filter_map(|control| match control {
12844            ChainControl::Reorg {
12845                common_ancestor, ..
12846            } => Some(common_ancestor),
12847            ChainControl::Barrier {
12848                block: Some(block), ..
12849            }
12850            | ChainControl::CanonicalProgress(block) => Some(block),
12851            ChainControl::Safe(_)
12852            | ChainControl::Finalized(_)
12853            | ChainControl::LogCoverage(_)
12854            | ChainControl::Barrier { block: None, .. } => None,
12855        })
12856        .max_by_key(|block| block.number)
12857        .cloned();
12858
12859    record_block
12860        .into_iter()
12861        .chain(control_block)
12862        .max_by_key(|block| block.number)
12863        .map(|block| DurableCheckpointBlock {
12864            number: block.number,
12865            hash: block.hash,
12866            parent_hash: block.parent_hash,
12867            timestamp: block.timestamp,
12868        })
12869}
12870
12871impl<S, N> ReactiveEngine<S, N>
12872where
12873    N: Network,
12874    S: InterestOwnerSubscriber<N>,
12875{
12876    /// Register a handler with both the runtime and subscriber, backfilling its
12877    /// log interests from the runtime's last canonical block.
12878    ///
12879    /// This is the continuity-safe default for mid-lifecycle registration. The
12880    /// subscriber adopts the live desired state first, delivers the new owner's
12881    /// matching records at retained block `C` as owner catch-up, then delivers
12882    /// `C + 1` through activation as global canonical catch-up over the complete
12883    /// handler union. No discovery gap opens, and every effect after `C` enters
12884    /// the ordinary global rollback journal. On a runtime that has not journaled any canonical block yet
12885    /// (fresh start, or `journal_depth` 0) registration is live-only, matching
12886    /// pre-ingestion bootstrap. Use
12887    /// [`register_handler_with_backfill`](Self::register_handler_with_backfill)
12888    /// for an explicit replay of one retained block or
12889    /// [`register_handler_live_only`](Self::register_handler_live_only) to opt
12890    /// out of backfill entirely.
12891    ///
12892    /// Subscriber registration commits before runtime routing is installed. If
12893    /// the subscriber operation fails or is cancelled, the runtime remains
12894    /// unchanged.
12895    ///
12896    /// # Errors
12897    ///
12898    /// Returns [`ReactiveEngineRegisterError`] when the handler id is already
12899    /// registered or the subscriber rejects/does not support the required
12900    /// owner update or coordinated catch-up.
12901    pub async fn register_handler(
12902        &mut self,
12903        handler: Arc<dyn ReactiveHandler<N>>,
12904    ) -> Result<(), ReactiveEngineRegisterError> {
12905        let backfill = self
12906            .runtime
12907            .last_canonical_block()
12908            .filter(|retained| {
12909                self.runtime.journal.iter().any(|entry| {
12910                    optional_block_refs_are_compatible(Some(&entry.block), Some(retained))
12911                })
12912            })
12913            .map(HandlerRegistrationCatchup::CoordinatedCanonical)
12914            .unwrap_or(HandlerRegistrationCatchup::LiveOnly);
12915        self.register_handler_inner(handler, backfill).await
12916    }
12917
12918    /// Register a handler and replay its matching logs at one exact retained
12919    /// canonical block.
12920    ///
12921    /// Owner-only effects are appended to that block's existing rollback
12922    /// journal entry. Consequently this method accepts only a bounded
12923    /// [`SubscriberBackfill`] whose start, end, and hash-certified retained
12924    /// anchor all identify the same journaled block. Wider/deeper recovery must
12925    /// use ordinary global canonical ingestion (for example startup catch-up),
12926    /// where every handler sees the records and the runtime advances coverage.
12927    ///
12928    /// If subscriber registration fails or is cancelled, the runtime remains
12929    /// unchanged.
12930    ///
12931    /// # Errors
12932    ///
12933    /// Returns [`ReactiveEngineRegisterError`] when the handler id is already
12934    /// registered, the requested backfill is not exactly one hash-certified
12935    /// retained journal block, or the subscriber update fails.
12936    pub async fn register_handler_with_backfill(
12937        &mut self,
12938        handler: Arc<dyn ReactiveHandler<N>>,
12939        backfill: SubscriberBackfill,
12940    ) -> Result<(), ReactiveEngineRegisterError> {
12941        self.register_handler_inner(handler, HandlerRegistrationCatchup::OwnerBackfill(backfill))
12942            .await
12943    }
12944
12945    /// Register a handler without any log backfill — only logs delivered after
12946    /// its live subscription starts are routed to it.
12947    ///
12948    /// If subscriber registration fails or is cancelled, the runtime remains
12949    /// unchanged.
12950    ///
12951    /// # Errors
12952    ///
12953    /// Returns [`ReactiveEngineRegisterError`] when the handler id is already
12954    /// registered or the subscriber cannot commit the owner update.
12955    pub async fn register_handler_live_only(
12956        &mut self,
12957        handler: Arc<dyn ReactiveHandler<N>>,
12958    ) -> Result<(), ReactiveEngineRegisterError> {
12959        self.register_handler_inner(handler, HandlerRegistrationCatchup::LiveOnly)
12960            .await
12961    }
12962
12963    async fn register_handler_inner(
12964        &mut self,
12965        handler: Arc<dyn ReactiveHandler<N>>,
12966        catchup: HandlerRegistrationCatchup,
12967    ) -> Result<(), ReactiveEngineRegisterError> {
12968        let id = handler.id();
12969        if self.runtime.contains_handler(&id) {
12970            return Err(RegisterError::DuplicateHandler(id).into());
12971        }
12972        let interests = handler.interests();
12973
12974        if let HandlerRegistrationCatchup::OwnerBackfill(backfill) = &catchup {
12975            let retained_anchor = backfill.retained_anchor().copied();
12976            let is_exact_retained_block = retained_anchor.is_some_and(|anchor| {
12977                backfill.start_block() == anchor.number
12978                    && backfill.end_block() == Some(anchor.number)
12979                    && self.runtime.journal.iter().any(|entry| {
12980                        optional_block_refs_are_compatible(Some(&entry.block), Some(&anchor))
12981                    })
12982            });
12983            if !is_exact_retained_block {
12984                return Err(ReactiveEngineRegisterError::BackfillOutsideJournal {
12985                    start_block: backfill.start_block(),
12986                    end_block: backfill.end_block(),
12987                    retained_anchor,
12988                });
12989            }
12990        }
12991
12992        let subscribed = match catchup {
12993            HandlerRegistrationCatchup::OwnerBackfill(backfill) => {
12994                self.subscriber
12995                    .add_interest_owner_with_backfill(id.clone(), &interests, backfill)
12996                    .await
12997            }
12998            HandlerRegistrationCatchup::CoordinatedCanonical(retained) => {
12999                self.subscriber
13000                    .add_interest_owner_with_canonical_catchup(id.clone(), &interests, retained)
13001                    .await
13002            }
13003            HandlerRegistrationCatchup::LiveOnly => {
13004                self.subscriber
13005                    .add_interest_owner(id.clone(), &interests)
13006                    .await
13007            }
13008        };
13009        if let Err(error) = subscribed {
13010            return Err(error.into());
13011        }
13012
13013        // `&mut self` excludes concurrent registry mutation between the
13014        // duplicate preflight and this commit. Registration is deliberately
13015        // subscriber-first: cancelling the awaited operation cannot leave a
13016        // runtime handler active without committed subscriber interests.
13017        self.runtime
13018            .registry
13019            .insert_handler_prepared(id, handler, interests);
13020        Ok(())
13021    }
13022
13023    /// Register every handler currently in the runtime registry as a subscriber
13024    /// interest owner.
13025    ///
13026    /// This is the no-history bootstrap path for a fresh runtime/subscriber pair
13027    /// before ingestion starts, or for reattaching an already-aligned durable
13028    /// subscriber whose exact owner state was restored independently. Each
13029    /// handler becomes its own owner through one exact bulk replacement;
13030    /// crash-stale owners and unowned/base interests are removed.
13031    ///
13032    /// No backfill is requested. It is therefore **not** the restart-recovery path for a new or
13033    /// potentially stale subscriber after the runtime has processed canonical
13034    /// state: use
13035    /// [`sync_handler_interests_with_backfill`](Self::sync_handler_interests_with_backfill),
13036    /// which exact-replaces the owner set and closes continuity from the
13037    /// restored runtime position.
13038    ///
13039    /// The complete exact set commits through one subscriber operation; an
13040    /// error or cancellation leaves the previously committed topology
13041    /// authoritative.
13042    ///
13043    /// # Errors
13044    ///
13045    /// Returns [`SubscriberError`] when the subscriber cannot atomically
13046    /// replace the complete owner topology.
13047    pub async fn sync_handler_interests(&mut self) -> Result<(), SubscriberError> {
13048        let owners = self
13049            .runtime
13050            .handler_ids()
13051            .into_iter()
13052            .map(|id| {
13053                let interests = self
13054                    .runtime
13055                    .handler_interests(&id)
13056                    .map(<[ReactiveInterest<N>]>::to_vec)
13057                    .unwrap_or_default();
13058                (id, interests)
13059            })
13060            .collect();
13061        self.subscriber.replace_interest_owners(owners).await
13062    }
13063
13064    /// Rebuild subscriber owner state from a runtime that already embodies a
13065    /// canonical checkpoint.
13066    ///
13067    /// The runtime registry is authoritative: the subscriber must atomically
13068    /// replace its complete owner set, removing crash-stale owners as well as
13069    /// adding the current ones. Log catch-up is routed globally through normal
13070    /// canonical ingestion and begins strictly at `C + 1`, where
13071    /// `C` is [`ReactiveRuntime::last_canonical_block`], because the restored
13072    /// cache already contains every effect through `C`. The exact number/hash
13073    /// identity of `C` remains attached as a retained baseline and must be
13074    /// validated by the subscriber before it exposes post-baseline records.
13075    /// Global routing is essential: startup catch-up effects enter the ordinary
13076    /// canonical journal and can be rolled back if the certified branch later
13077    /// reorganizes; owner-only catch-up is reserved for a true mid-lifecycle
13078    /// handler addition.
13079    ///
13080    /// A runtime without a canonical position must use
13081    /// [`sync_handler_interests`](Self::sync_handler_interests) instead. Block
13082    /// `u64::MAX` is rejected rather than wrapping or replaying the baseline.
13083    /// The replacement is one subscriber commit boundary: errors and
13084    /// cancellation leave the previous topology authoritative.
13085    ///
13086    /// # Errors
13087    ///
13088    /// Returns [`SubscriberError::InvalidConfig`] when no canonical baseline
13089    /// exists or no exclusive successor can be represented, and otherwise
13090    /// propagates subscriber validation, transport, or atomic-commit failures.
13091    pub async fn sync_handler_interests_with_backfill(&mut self) -> Result<(), SubscriberError> {
13092        let baseline =
13093            self.runtime
13094                .last_canonical_block()
13095                .ok_or(SubscriberError::InvalidConfig(
13096                    "cannot continuity-sync handlers before a canonical runtime position exists",
13097                ))?;
13098        let backfill = SubscriberBackfill::after_canonical_block(baseline)?;
13099        let owners = self
13100            .runtime
13101            .handler_ids()
13102            .into_iter()
13103            .map(|id| {
13104                let interests = self
13105                    .runtime
13106                    .handler_interests(&id)
13107                    .map(<[ReactiveInterest<N>]>::to_vec)
13108                    .unwrap_or_default();
13109                (id, interests)
13110            })
13111            .collect();
13112        self.subscriber
13113            .replace_interest_owners_with_global_backfill(owners, backfill)
13114            .await
13115    }
13116
13117    /// Unregister a handler from both the subscriber and runtime.
13118    ///
13119    /// Subscriber interests are removed first so no new live records are routed
13120    /// to a handler after it has left the runtime registry. Returns the removed
13121    /// handler when the id was registered. If subscriber removal fails or is
13122    /// cancelled, runtime routing remains installed.
13123    ///
13124    /// This is the routing/transport half of dropping an adapter. State the
13125    /// handler accumulated is deliberately left in place; the complete teardown
13126    /// for a pool or adapter that will not return is:
13127    ///
13128    /// ```text
13129    /// engine.unregister_handler(&id).await?;
13130    /// for request_id in handler_request_ids {
13131    ///     // Drop only this handler generation's queued repair work.
13132    ///     engine.runtime_mut().cancel_pending_resync(&request_id);
13133    /// }
13134    /// for address in exclusively_owned_addresses {
13135    ///     // Shared accounts require caller-side owner reference counting.
13136    ///     engine.runtime_mut().untrack_account(address);
13137    /// }
13138    /// // optional: evict cached state via StateUpdate::purge / cache purge APIs
13139    /// ```
13140    ///
13141    /// Health, metrics, the reorg journal, hooks, and freshness stamps are
13142    /// runtime-global and are never touched by handler removal.
13143    ///
13144    /// # Errors
13145    ///
13146    /// Returns [`SubscriberError`] when the subscriber cannot commit owner
13147    /// removal. In that case runtime routing remains installed.
13148    pub async fn unregister_handler(
13149        &mut self,
13150        id: &HandlerId,
13151    ) -> Result<Option<Arc<dyn ReactiveHandler<N>>>, SubscriberError> {
13152        self.subscriber.remove_interest_owner(id).await?;
13153        Ok(self.runtime.unregister_handler(id))
13154    }
13155}
13156
13157type FlashblockReconnectFuture<N> = Pin<
13158    Box<
13159        dyn Future<
13160                Output = (
13161                    SubscriberStreamSource,
13162                    Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError>,
13163                ),
13164            > + Send,
13165    >,
13166>;
13167
13168/// Alloy-backed event subscriber.
13169///
13170/// The default transport slice drives Alloy pubsub subscriptions for logs,
13171/// block headers, and pending transaction hashes. The HTTP polling `watch_*`
13172/// transport remains available behind the opt-in `reactive-polling` feature.
13173/// Pubsub streams reconnect automatically after termination, and log
13174/// subscriptions are backfilled from the last seen block. Owner-scoped log
13175/// additions can request backfill from an explicit block anchor. Full pending
13176/// transaction hydration and full block bodies remain explicit follow-up work.
13177///
13178/// Historical log fetching is deliberately a bounded live-subscriber aid, not
13179/// a high-volume indexer: each filter/window is issued as one complete-range
13180/// `eth_getLogs` request. [`SubscriberConfig::max_backfill_log_bytes`] rejects
13181/// an oversized decoded response, but the subscriber does not adaptively split
13182/// block ranges and cannot bypass an RPC provider's result cap. Keep owner
13183/// registration and reconnect windows modest; use an indexing source such as
13184/// HyperSync behind [`EventSubscriber`] for deep or high-density catch-up.
13185///
13186/// With no registered interests, [`EventSubscriber::next_batch`] returns
13187/// `Ok(None)`.
13188pub struct AlloySubscriber<P, N: Network = Ethereum> {
13189    provider: P,
13190    #[cfg(feature = "ingress-timing")]
13191    transport_receipts: Option<TransportReceiptLookup>,
13192    /// Stable identity of an application-managed standardized Flashblock
13193    /// update source. The application owns its transport and lifecycle.
13194    #[cfg(feature = "raw-flashblocks-json")]
13195    external_flashblocks_provider: Option<ProviderRef>,
13196    /// Receiving half of the optional bounded application-to-subscriber queue.
13197    #[cfg(feature = "raw-flashblocks-json")]
13198    external_flashblock_updates:
13199        Option<tokio::sync::mpsc::Receiver<raw_json_flashblocks::QueuedFlashblockUpdate>>,
13200    /// Whether an external update queue was opened for this subscriber.
13201    #[cfg(feature = "raw-flashblocks-json")]
13202    external_flashblock_update_channel_opened: bool,
13203    /// Highest external generation rejected by subscriber-level validation.
13204    #[cfg(feature = "raw-flashblocks-json")]
13205    rejected_external_flashblock_generation: Option<u64>,
13206    /// Last accepted externally standardized snapshot, retained so callers
13207    /// cannot bypass indexed-payload continuity enforced by the raw adapter.
13208    #[cfg(feature = "raw-flashblocks-json")]
13209    last_external_flashblock_snapshot: Option<FlashblockSnapshot>,
13210    /// Optional request/response half of the same configured provider lease.
13211    /// OP Flashblocks pending reads use this transport when WebSocket JSON-RPC
13212    /// does not expose the provider's pending-state surface.
13213    flashblocks_state_provider: Option<P>,
13214    /// Stable identity for the provider session used by Flashblocks and every
13215    /// follow-up pending-state read.
13216    provider_ref: Option<ProviderRef>,
13217    /// Optional provider dedicated to canonical log-context verification.
13218    /// Keeping this separate prevents a high-volume pubsub connection from
13219    /// starving its own verification requests behind log notifications.
13220    log_verification_provider: Option<P>,
13221    /// Provider chain identity, resolved once before any record can escape.
13222    chain_id: Option<u64>,
13223    mode: SubscriberMode,
13224    config: SubscriberConfig,
13225    base_interests: Vec<ReactiveInterest<N>>,
13226    owned_interests: Vec<OwnedSubscriberInterests<N>>,
13227    next_owner_epoch: u64,
13228    interests: Vec<ReactiveInterest<N>>,
13229    /// Stable source id per distinct provider-facing log filter. Ids key
13230    /// delivery anchors and live `SubscriberEvent`s; entries are retired (and
13231    /// their anchors pruned) when no planned stream references the filter, so
13232    /// long-lived owner churn cannot grow this map unboundedly.
13233    log_source_ids: HashMap<Filter, usize>,
13234    next_log_source_id: usize,
13235    pending_backfills: VecDeque<QueuedSubscriberBackfill>,
13236    /// Successfully connected sources whose subscribe-then-backfill step has
13237    /// not committed yet. Installation happens before the backfill await, so a
13238    /// cancelled reconcile keeps the live stream and retries only the missing
13239    /// historical window.
13240    pending_source_backfills: VecDeque<SubscriberStreamSource>,
13241    /// Set when interest bookkeeping changed since the last successful stream
13242    /// reconcile, so steady-state polling skips the desired-vs-live diff.
13243    sources_dirty: bool,
13244    /// Conservative generation of desired/live stream topology. Successful
13245    /// owner progress is activatable only against the same clean revision.
13246    stream_revision: u64,
13247    state: AlloySubscriberState<N>,
13248    pending_records: VecDeque<SubscriberInputRecord<N>>,
13249    pending_chain_controls: VecDeque<ChainControl>,
13250    /// Owner copies of live records consumed during an in-flight reconcile.
13251    /// These remain hidden from subscriber output until the owning reconcile
13252    /// commits and survive cancellation so subscribe-first adoption cannot
13253    /// lose an event at an await boundary.
13254    pending_reconcile_owner_records: VecDeque<BufferedSubscriberOwnerRecord<N>>,
13255    /// Sticky fail-closed capacity error. Once an event could not be retained,
13256    /// only a full replacement registration can establish a new baseline.
13257    resource_error: Option<String>,
13258    last_seen_log_blocks: HashMap<usize, u64>,
13259    verified_log_blocks: HashMap<(u64, B256), BlockRef>,
13260    verified_log_block_order: VecDeque<(u64, B256)>,
13261    recent_input_refs: VecDeque<InputRef>,
13262    recent_input_ref_set: HashSet<InputRef>,
13263    recent_owner_input_refs: HashMap<SubscriberOwnerEpoch, VecDeque<InputRef>>,
13264    recent_owner_input_ref_sets: HashMap<SubscriberOwnerEpoch, HashSet<InputRef>>,
13265    recent_compat_owner_input_refs: HashMap<HandlerId, VecDeque<InputRef>>,
13266    recent_compat_owner_input_ref_sets: HashMap<HandlerId, HashSet<InputRef>>,
13267    base_flashblock_header: Option<(FixedBytes<8>, BaseFlashblockBase)>,
13268    base_flashblock_transactions: Option<(FixedBytes<8>, u64, Vec<B256>, Vec<B256>)>,
13269    unmatched_pending_logs: VecDeque<(usize, Log, FlashblockIngressTiming)>,
13270    latest_preconfirmation: Option<FlashblockRef>,
13271    preconfirmed_seen_logs: HashSet<(B256, u64)>,
13272    /// OP transaction receipts already proven for the active cumulative
13273    /// payload. This avoids re-querying non-matching transactions while still
13274    /// retrying receipts that were temporarily unavailable.
13275    preconfirmed_receipted_transactions: HashSet<B256>,
13276    /// OP receipt hashes that returned `null` at least once for the active
13277    /// payload. Never-attempted hashes are scheduled ahead of this retry set so
13278    /// a lagging provider cache cannot let a few transactions monopolize the
13279    /// bounded request budget.
13280    preconfirmed_unavailable_receipts: HashSet<B256>,
13281    last_certified_canonical_head: Option<BlockRef>,
13282    /// When the canonical head was last certified against the provider.
13283    ///
13284    /// A Flashblocks endpoint replaces the `newHeads` subscription with a
13285    /// fixed-interval certification poll, because its `newHeads` may carry
13286    /// partial heads. Recording the last certification lets the timer suppress
13287    /// itself when the flashblock stream already proved a block sealed, so the
13288    /// poll spends a request only when nothing else did.
13289    last_canonical_head_certification: Option<Instant>,
13290    /// Set when a `newFlashblocks` payload opens a block, which means the
13291    /// previous block sealed and its canonical head is worth certifying.
13292    sealed_block_pending_certification: bool,
13293    /// Highest canonical block observed while every log source was whole, and
13294    /// the last value attested to the consumer. Advances only through
13295    /// `note_attestable_canonical_block`, which a live gap resets.
13296    attestable_canonical_head: Option<BlockRef>,
13297    attested_log_coverage: Option<BlockRef>,
13298    pending_preconfirmation_invalidation: bool,
13299    pending_flashblock_reconnects: FuturesUnordered<FlashblockReconnectFuture<N>>,
13300    pending_flashblock_reconnect_sources: Vec<SubscriberStreamSource>,
13301    flashblocks_rpc_metrics: FlashblocksRpcMetrics,
13302    /// Every provider request this subscriber has issued, by method and cause.
13303    /// Shared so catch-up futures and the free functions they call can record
13304    /// without borrowing the subscriber; see [`SubscriberRpcCounters`].
13305    rpc_counters: Arc<SubscriberRpcCounters>,
13306    /// Notification loss observed on live subscriptions. Shared for the same
13307    /// reason as `rpc_counters`: stream adapters record without a subscriber
13308    /// borrow.
13309    gap_counters: Arc<SubscriberStreamGapCounters>,
13310    consecutive_flashblock_poll_failures: usize,
13311    flashblock_rpc_request_times: VecDeque<Instant>,
13312    _network: PhantomData<N>,
13313}
13314
13315struct OwnedSubscriberInterests<N: Network = Ethereum> {
13316    owner: HandlerId,
13317    interests: Vec<ReactiveInterest<N>>,
13318    epoch: Option<SubscriberOwnerEpoch>,
13319    state: SubscriberOwnerState,
13320    baseline: Option<BlockRef>,
13321    progress: Option<SubscriberOwnerProgress>,
13322    progress_stream_revision: Option<u64>,
13323}
13324
13325#[derive(Clone)]
13326struct SubscriberOwnerReconcilePlan<N: Network = Ethereum> {
13327    epoch: SubscriberOwnerEpoch,
13328    interests: Vec<ReactiveInterest<N>>,
13329    retained: BlockRef,
13330    from_block: u64,
13331}
13332
13333struct SubscriberOwnerCatchup {
13334    logs: Vec<Log>,
13335    certified: BlockRef,
13336}
13337
13338#[derive(Clone, Copy)]
13339struct SubscriberOwnerCatchupOptions {
13340    target_preverified: bool,
13341    max_logs: usize,
13342    max_log_bytes: usize,
13343    max_requests_in_flight: usize,
13344    /// Which mechanism asked for this catch-up, so its provider requests are
13345    /// attributed to the caller rather than to the shared fetch helper.
13346    cause: SubscriberRpcCause,
13347}
13348
13349struct SubscriberOwnerReconcileFilter {
13350    filter: Filter,
13351    from_block: u64,
13352}
13353
13354struct BufferedSubscriberOwnerRecord<N: Network = Ethereum> {
13355    record: ReactiveInputRecord<N>,
13356    owners: Vec<SubscriberOwnerEpoch>,
13357}
13358
13359const OWNER_RECONCILE_FILTERS_PER_CHUNK: usize = 256;
13360
13361struct QueuedSubscriberBackfill {
13362    /// `None` means global canonical catch-up; `Some` is compatibility
13363    /// owner-only catch-up for true mid-lifecycle additions.
13364    owner: Option<HandlerId>,
13365    epoch: Option<SubscriberOwnerEpoch>,
13366    /// Complete logical filter set for one certified, globally ordered window.
13367    filters: Vec<Filter>,
13368    backfill: SubscriberBackfill,
13369}
13370
13371/// Best-effort installation of rustls' `ring` crypto provider as the process
13372/// default, so an `wss://` TLS handshake under `reactive-ws` does not panic with
13373/// "no process-level CryptoProvider available". Runs at most once and ignores the
13374/// error if a default provider is already installed (the host app may have set
13375/// its own).
13376#[cfg(feature = "reactive-ws")]
13377fn ensure_ring_crypto_provider() {
13378    use std::sync::Once;
13379    static INSTALL: Once = Once::new();
13380    INSTALL.call_once(|| {
13381        let _ = rustls::crypto::ring::default_provider().install_default();
13382    });
13383}
13384
13385impl<P, N: Network> AlloySubscriber<P, N> {
13386    /// Create a new Alloy subscriber.
13387    pub fn new(provider: P, mode: SubscriberMode, config: SubscriberConfig) -> Self {
13388        #[cfg(feature = "reactive-ws")]
13389        ensure_ring_crypto_provider();
13390        Self {
13391            provider,
13392            #[cfg(feature = "raw-flashblocks-json")]
13393            external_flashblocks_provider: None,
13394            #[cfg(feature = "raw-flashblocks-json")]
13395            external_flashblock_updates: None,
13396            #[cfg(feature = "raw-flashblocks-json")]
13397            external_flashblock_update_channel_opened: false,
13398            #[cfg(feature = "raw-flashblocks-json")]
13399            rejected_external_flashblock_generation: None,
13400            #[cfg(feature = "raw-flashblocks-json")]
13401            last_external_flashblock_snapshot: None,
13402            flashblocks_state_provider: None,
13403            provider_ref: None,
13404            #[cfg(feature = "ingress-timing")]
13405            transport_receipts: None,
13406            log_verification_provider: None,
13407            chain_id: None,
13408            mode,
13409            config,
13410            base_interests: Vec::new(),
13411            owned_interests: Vec::new(),
13412            next_owner_epoch: 0,
13413            interests: Vec::new(),
13414            log_source_ids: HashMap::new(),
13415            next_log_source_id: 0,
13416            pending_backfills: VecDeque::new(),
13417            pending_source_backfills: VecDeque::new(),
13418            sources_dirty: true,
13419            stream_revision: 0,
13420            state: AlloySubscriberState::Uninitialized,
13421            pending_records: VecDeque::new(),
13422            pending_chain_controls: VecDeque::new(),
13423            pending_reconcile_owner_records: VecDeque::new(),
13424            resource_error: None,
13425            last_seen_log_blocks: HashMap::new(),
13426            verified_log_blocks: HashMap::new(),
13427            verified_log_block_order: VecDeque::new(),
13428            recent_input_refs: VecDeque::new(),
13429            recent_input_ref_set: HashSet::new(),
13430            recent_owner_input_refs: HashMap::new(),
13431            recent_owner_input_ref_sets: HashMap::new(),
13432            recent_compat_owner_input_refs: HashMap::new(),
13433            recent_compat_owner_input_ref_sets: HashMap::new(),
13434            base_flashblock_header: None,
13435            base_flashblock_transactions: None,
13436            unmatched_pending_logs: VecDeque::new(),
13437            latest_preconfirmation: None,
13438            preconfirmed_seen_logs: HashSet::new(),
13439            preconfirmed_receipted_transactions: HashSet::new(),
13440            preconfirmed_unavailable_receipts: HashSet::new(),
13441            last_certified_canonical_head: None,
13442            last_canonical_head_certification: None,
13443            sealed_block_pending_certification: false,
13444            attestable_canonical_head: None,
13445            attested_log_coverage: None,
13446            pending_preconfirmation_invalidation: false,
13447            pending_flashblock_reconnects: FuturesUnordered::new(),
13448            pending_flashblock_reconnect_sources: Vec::new(),
13449            flashblocks_rpc_metrics: FlashblocksRpcMetrics::default(),
13450            rpc_counters: Arc::new(SubscriberRpcCounters::default()),
13451            gap_counters: Arc::new(SubscriberStreamGapCounters::default()),
13452            consecutive_flashblock_poll_failures: 0,
13453            flashblock_rpc_request_times: VecDeque::new(),
13454            _network: PhantomData,
13455        }
13456    }
13457
13458    /// Borrow the provider.
13459    pub fn provider(&self) -> &P {
13460        &self.provider
13461    }
13462
13463    /// Attach observational receipt lookup before subscriptions are connected.
13464    /// Ambiguity or missing data affects telemetry only, never delivery/readiness.
13465    #[cfg(feature = "ingress-timing")]
13466    pub fn with_transport_receipt_lookup(mut self, lookup: TransportReceiptLookup) -> Self {
13467        self.transport_receipts = Some(lookup);
13468        self
13469    }
13470
13471    /// Bind this subscriber to the concrete provider lease that supplies
13472    /// Flashblocks. Callers obtain the lease from a transport endpoint marked
13473    /// with the single `flashblocks = true` flag.
13474    #[must_use]
13475    pub fn with_provider_ref(mut self, provider: ProviderRef) -> Self {
13476        self.provider_ref = Some(provider);
13477        self
13478    }
13479
13480    /// Select application-managed standardized Flashblock updates before
13481    /// subscriber registration begins.
13482    ///
13483    /// This suppresses the subscriber's chain-specific native or pending-state
13484    /// Flashblocks source. Canonical logs and block headers continue through the
13485    /// configured subscriber transport. The application owns the raw socket,
13486    /// control frames, bounded queue, timeout, retry, backoff, and provider
13487    /// rotation, and passes decoded updates to
13488    /// [`Self::ingest_flashblock_update`].
13489    ///
13490    /// Call [`Self::ingest_flashblock_update`] directly while retaining mutable
13491    /// subscriber ownership, or open a bounded handoff with
13492    /// [`Self::open_external_flashblock_update_channel`] before moving the
13493    /// subscriber into another runtime owner.
13494    ///
13495    /// This is deliberately a fallible construction-time configuration method,
13496    /// not a live reconfiguration API. Replacing a source after canonical or
13497    /// speculative processing begins requires a new subscriber so existing
13498    /// streams and overlays cannot survive under ambiguous provider ownership.
13499    ///
13500    /// # Errors
13501    ///
13502    /// Returns [`SubscriberError::InvalidConfig`] when an external source was
13503    /// already selected or subscriber registration, stream installation, or
13504    /// event processing has begun.
13505    #[cfg(feature = "raw-flashblocks-json")]
13506    pub fn configure_external_flashblock_updates(
13507        &mut self,
13508        provider: ProviderRef,
13509    ) -> Result<(), SubscriberError> {
13510        if self.external_flashblocks_provider.is_some() {
13511            return Err(SubscriberError::InvalidConfig(
13512                "external Flashblock updates were already configured",
13513            ));
13514        }
13515        if self.external_flashblock_update_channel_opened
13516            || self.external_flashblock_updates.is_some()
13517            || self.chain_id.is_some()
13518            || !self.base_interests.is_empty()
13519            || !self.owned_interests.is_empty()
13520            || !self.interests.is_empty()
13521            || !self.pending_records.is_empty()
13522            || !self.pending_chain_controls.is_empty()
13523            || !self.pending_backfills.is_empty()
13524            || !matches!(self.state, AlloySubscriberState::Uninitialized)
13525        {
13526            return Err(SubscriberError::InvalidConfig(
13527                "external Flashblock updates must be configured before subscriber registration",
13528            ));
13529        }
13530        self.external_flashblocks_provider = Some(provider);
13531        Ok(())
13532    }
13533
13534    /// Open one bounded standardized-update queue and return its application handle.
13535    ///
13536    /// The queue is useful when the subscriber will be moved into a runtime
13537    /// driver: the application retains the cloneable sender while the subscriber
13538    /// continues to own all validation, speculative deduplication, and canonical
13539    /// reconciliation. Opening a queue does not create a socket or background
13540    /// task, and does not implement retry or backoff. Awaited sends complete
13541    /// only after subscriber validation; non-blocking sends return an explicit
13542    /// acknowledgement receipt.
13543    ///
13544    /// # Errors
13545    ///
13546    /// Returns [`SubscriberError::InvalidConfig`] if external updates were not
13547    /// selected first, `capacity` is zero, or a queue was already opened.
13548    #[cfg(feature = "raw-flashblocks-json")]
13549    pub fn open_external_flashblock_update_channel(
13550        &mut self,
13551        capacity: usize,
13552    ) -> Result<FlashblockUpdateSender, SubscriberError> {
13553        if capacity == 0 {
13554            return Err(SubscriberError::InvalidConfig(
13555                "external Flashblock update channel capacity must be greater than zero",
13556            ));
13557        }
13558        let provider = self.external_flashblocks_provider.clone().ok_or(
13559            SubscriberError::InvalidConfig(
13560                "external Flashblock update channel requires configure_external_flashblock_updates",
13561            ),
13562        )?;
13563        if self.external_flashblock_update_channel_opened {
13564            return Err(SubscriberError::InvalidConfig(
13565                "external Flashblock update channel was already opened",
13566            ));
13567        }
13568        let (sender, receiver) =
13569            raw_json_flashblocks::flashblock_update_channel(provider, capacity);
13570        self.external_flashblock_updates = Some(receiver);
13571        self.external_flashblock_update_channel_opened = true;
13572        self.sources_dirty = true;
13573        Ok(sender)
13574    }
13575
13576    fn uses_external_flashblock_updates(&self) -> bool {
13577        #[cfg(feature = "raw-flashblocks-json")]
13578        {
13579            self.external_flashblocks_provider.is_some()
13580        }
13581        #[cfg(not(feature = "raw-flashblocks-json"))]
13582        {
13583            false
13584        }
13585    }
13586
13587    /// Pair the subscriber's event transport with the request/response
13588    /// transport for the same configured provider ID and generation.
13589    ///
13590    /// Optimism pending block/log sampling uses this provider. Preflight reads
13591    /// its chain ID and rejects a mismatch before pending data can be emitted.
13592    /// Use type-erased Alloy providers when the WebSocket and HTTP transports
13593    /// have different concrete Rust types.
13594    #[must_use]
13595    pub fn with_flashblocks_state_provider(mut self, provider: P) -> Self {
13596        self.flashblocks_state_provider = Some(provider);
13597        self
13598    }
13599
13600    /// Use a separate provider for canonical log-context verification.
13601    ///
13602    /// This is recommended with
13603    /// [`SubscriberConfig::verify_log_block_context`] in high-volume pubsub
13604    /// deployments. The provider must target the same chain; every fetched
13605    /// block is still checked against the log's number, hash, and timestamp.
13606    #[must_use]
13607    pub fn with_log_verification_provider(mut self, provider: P) -> Self {
13608        self.log_verification_provider = Some(provider);
13609        self
13610    }
13611
13612    /// Subscriber mode.
13613    pub fn mode(&self) -> SubscriberMode {
13614        self.mode
13615    }
13616
13617    /// Subscriber config.
13618    pub fn config(&self) -> &SubscriberConfig {
13619        &self.config
13620    }
13621
13622    /// Request/response traffic issued for Flashblocks qualification and
13623    /// pending-state sampling since the last full interest reset.
13624    pub const fn flashblocks_rpc_metrics(&self) -> FlashblocksRpcMetrics {
13625        self.flashblocks_rpc_metrics
13626    }
13627
13628    /// Every provider request this subscriber has issued, attributed to the
13629    /// method and the mechanism responsible for it.
13630    ///
13631    /// Cumulative for the subscriber's lifetime: unlike
13632    /// [`flashblocks_rpc_metrics`](Self::flashblocks_rpc_metrics), these counts
13633    /// survive reconnects and delivery-state resets so a long-running process
13634    /// can report total RPC consumption. Use
13635    /// [`reset_rpc_stats`](Self::reset_rpc_stats) to measure a bounded window.
13636    pub fn rpc_stats(&self) -> SubscriberRpcStats {
13637        self.rpc_counters.snapshot()
13638    }
13639
13640    /// Zero every [`rpc_stats`](Self::rpc_stats) counter, starting a new
13641    /// measurement window. Takes `&self` so a window can be opened while
13642    /// catch-up work holds the subscriber.
13643    pub fn reset_rpc_stats(&self) {
13644        self.rpc_counters.reset();
13645    }
13646
13647    /// Notification loss observed on live subscriptions, and what healing it
13648    /// cost.
13649    ///
13650    /// A subscription that never lags reports zeroes here. That is evidence
13651    /// nothing was *lost*, which is necessary before treating the stream as
13652    /// authoritative — but it is not evidence that everything has *arrived*.
13653    /// Deciding a particular block's set is closed needs ordering evidence from
13654    /// the log stream itself; see [`ChainControl::LogCoverage`].
13655    pub fn stream_gap_stats(&self) -> SubscriberStreamGapStats {
13656        self.gap_counters.snapshot()
13657    }
13658
13659    /// Zero every [`stream_gap_stats`](Self::stream_gap_stats) counter.
13660    pub fn reset_stream_gap_stats(&self) {
13661        self.gap_counters.reset();
13662    }
13663
13664    /// Record one issued provider request against this subscriber's counters.
13665    fn record_rpc(&self, cause: SubscriberRpcCause, method: SubscriberRpcMethod) {
13666        self.rpc_counters.record(cause, method);
13667    }
13668
13669    /// Bounded notification capacity for a pubsub log stream.
13670    #[cfg(feature = "reactive-ws")]
13671    fn log_channel_size(&self) -> usize {
13672        self.config
13673            .log_channel_size
13674            .unwrap_or(self.config.max_batch_size)
13675            .max(1)
13676    }
13677
13678    /// Registered interests across base and owner-scoped registrations.
13679    pub fn registered_interests(&self) -> &[ReactiveInterest<N>] {
13680        &self.interests
13681    }
13682
13683    /// Stage a fresh, epoch-scoped interest owner without making its inputs
13684    /// canonically routable yet.
13685    ///
13686    /// The returned token is required by every later lifecycle operation. A
13687    /// staged owner participates in provider subscription planning immediately,
13688    /// while its matching input remains owner-scoped until
13689    /// [`activate_interest_owner`](Self::activate_interest_owner) succeeds.
13690    /// Post-block owners require hash-certified
13691    /// [`reconcile_interest_owner`](Self::reconcile_interest_owner) progress on
13692    /// the current clean stream revision before activation.
13693    ///
13694    /// # Errors
13695    ///
13696    /// Returns [`SubscriberOwnerError`] for invalid subscriber configuration,
13697    /// duplicate owners, unsupported post-block interests, unsupported
13698    /// transport interests, block-number overflow, or epoch exhaustion.
13699    pub fn stage_interest_owner(
13700        &mut self,
13701        owner: HandlerId,
13702        interests: &[ReactiveInterest<N>],
13703        start: SubscriberOwnerStart,
13704    ) -> Result<SubscriberOwnerEpoch, SubscriberOwnerError> {
13705        validate_subscriber_config(&self.config)?;
13706        if matches!(&start, SubscriberOwnerStart::PostBlock(_))
13707            && interests
13708                .iter()
13709                .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
13710        {
13711            return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
13712        }
13713        if self
13714            .owned_interests
13715            .iter()
13716            .any(|entry| entry.owner == owner)
13717        {
13718            return Err(SubscriberOwnerError::AlreadyRegistered(owner));
13719        }
13720
13721        let mut next_owned = self.clone_owned_interests();
13722        next_owned.push(OwnedSubscriberInterests {
13723            owner: owner.clone(),
13724            interests: interests.to_vec(),
13725            epoch: None,
13726            state: SubscriberOwnerState::Staged,
13727            baseline: None,
13728            progress: None,
13729            progress_stream_revision: None,
13730        });
13731        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
13732        validate_supported_interests(self.mode, &self.config, &next_registered)?;
13733
13734        let baseline = match start {
13735            SubscriberOwnerStart::Live => None,
13736            SubscriberOwnerStart::PostBlock(block) => {
13737                block
13738                    .number
13739                    .checked_add(1)
13740                    .ok_or(SubscriberOwnerError::PostBlockOverflow(block.number))?;
13741                Some(block)
13742            }
13743        };
13744        let sequence = self
13745            .next_owner_epoch
13746            .checked_add(1)
13747            .ok_or(SubscriberOwnerError::EpochExhausted)?;
13748        let epoch = SubscriberOwnerEpoch {
13749            owner: owner.clone(),
13750            sequence,
13751        };
13752
13753        self.next_owner_epoch = sequence;
13754        let entry = next_owned
13755            .last_mut()
13756            .expect("staged owner was appended during preflight");
13757        entry.epoch = Some(epoch.clone());
13758        entry.baseline = baseline;
13759        self.owned_interests = next_owned;
13760        self.interests = next_registered;
13761        self.sources_dirty = true;
13762
13763        Ok(epoch)
13764    }
13765
13766    /// Stage replacement interests for one currently active logical owner.
13767    ///
13768    /// The active epoch remains canonical while the replacement reconciles.
13769    /// Commit both epochs atomically with
13770    /// [`commit_interest_owner_replacement`](Self::commit_interest_owner_replacement),
13771    /// or abort the staged epoch with [`abort_interest_owner`](Self::abort_interest_owner).
13772    ///
13773    /// # Errors
13774    ///
13775    /// Returns [`SubscriberOwnerError`] for invalid subscriber configuration,
13776    /// missing/non-unique active owner state, unsupported post-block interests,
13777    /// unsupported transport interests, block-number overflow, or epoch
13778    /// exhaustion.
13779    pub fn stage_interest_owner_replacement(
13780        &mut self,
13781        owner: HandlerId,
13782        interests: &[ReactiveInterest<N>],
13783        start: SubscriberOwnerStart,
13784    ) -> Result<SubscriberOwnerEpoch, SubscriberOwnerError> {
13785        validate_subscriber_config(&self.config)?;
13786        if matches!(&start, SubscriberOwnerStart::PostBlock(_))
13787            && interests
13788                .iter()
13789                .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
13790        {
13791            return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
13792        }
13793        let active_count = self
13794            .owned_interests
13795            .iter()
13796            .filter(|entry| {
13797                entry.owner == owner
13798                    && entry.state == SubscriberOwnerState::Active
13799                    && entry.epoch.is_some()
13800            })
13801            .count();
13802        if active_count != 1
13803            || self
13804                .owned_interests
13805                .iter()
13806                .any(|entry| entry.owner == owner && entry.state != SubscriberOwnerState::Active)
13807        {
13808            return Err(SubscriberOwnerError::AlreadyRegistered(owner));
13809        }
13810
13811        let mut next_owned = self.clone_owned_interests();
13812        next_owned.push(OwnedSubscriberInterests {
13813            owner: owner.clone(),
13814            interests: interests.to_vec(),
13815            epoch: None,
13816            state: SubscriberOwnerState::Staged,
13817            baseline: None,
13818            progress: None,
13819            progress_stream_revision: None,
13820        });
13821        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
13822        validate_supported_interests(self.mode, &self.config, &next_registered)?;
13823
13824        let baseline = match start {
13825            SubscriberOwnerStart::Live => None,
13826            SubscriberOwnerStart::PostBlock(block) => {
13827                block
13828                    .number
13829                    .checked_add(1)
13830                    .ok_or(SubscriberOwnerError::PostBlockOverflow(block.number))?;
13831                Some(block)
13832            }
13833        };
13834        let sequence = self
13835            .next_owner_epoch
13836            .checked_add(1)
13837            .ok_or(SubscriberOwnerError::EpochExhausted)?;
13838        let epoch = SubscriberOwnerEpoch {
13839            owner: owner.clone(),
13840            sequence,
13841        };
13842
13843        self.next_owner_epoch = sequence;
13844        let entry = next_owned
13845            .last_mut()
13846            .expect("staged replacement owner was appended during preflight");
13847        entry.epoch = Some(epoch.clone());
13848        entry.baseline = baseline;
13849        self.owned_interests = next_owned;
13850        self.interests = next_registered;
13851        self.sources_dirty = true;
13852        Ok(epoch)
13853    }
13854
13855    /// Current transaction state for an exact owner epoch.
13856    pub fn interest_owner_state(
13857        &self,
13858        epoch: &SubscriberOwnerEpoch,
13859    ) -> Option<SubscriberOwnerState> {
13860        self.owned_interests
13861            .iter()
13862            .find(|entry| entry.epoch.as_ref() == Some(epoch))
13863            .map(|entry| entry.state)
13864    }
13865
13866    /// Latest hash-certified reconcile progress for an exact owner epoch.
13867    pub fn interest_owner_progress(
13868        &self,
13869        epoch: &SubscriberOwnerEpoch,
13870    ) -> Option<&SubscriberOwnerProgress> {
13871        self.owned_interests
13872            .iter()
13873            .find(|entry| entry.epoch.as_ref() == Some(epoch))
13874            .and_then(|entry| entry.progress.as_ref())
13875    }
13876
13877    /// Make a staged owner canonical after its actor-side installation commits.
13878    ///
13879    /// Returns `false` for stale tokens and owners not currently staged.
13880    pub fn activate_interest_owner(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
13881        let stream_revision = self.stream_revision;
13882        let sources_dirty = self.sources_dirty;
13883        let Some(entry) = self
13884            .owned_interests
13885            .iter_mut()
13886            .find(|entry| entry.epoch.as_ref() == Some(epoch))
13887        else {
13888            return false;
13889        };
13890        if entry.state != SubscriberOwnerState::Staged
13891            || (entry.baseline.is_some()
13892                && (entry.progress.is_none()
13893                    || entry.progress_stream_revision != Some(stream_revision)
13894                    || sources_dirty))
13895        {
13896            return false;
13897        }
13898        entry.state = SubscriberOwnerState::Active;
13899        true
13900    }
13901
13902    /// Atomically replace one active owner epoch with one reconciled staged epoch.
13903    pub fn commit_interest_owner_replacement(
13904        &mut self,
13905        active: &SubscriberOwnerEpoch,
13906        replacement: &SubscriberOwnerEpoch,
13907    ) -> bool {
13908        let Some(active_index) = self
13909            .owned_interests
13910            .iter()
13911            .position(|entry| entry.epoch.as_ref() == Some(active))
13912        else {
13913            return false;
13914        };
13915        let Some(replacement_index) = self
13916            .owned_interests
13917            .iter()
13918            .position(|entry| entry.epoch.as_ref() == Some(replacement))
13919        else {
13920            return false;
13921        };
13922        if active_index == replacement_index
13923            || active.owner() != replacement.owner()
13924            || self.owned_interests[active_index].state != SubscriberOwnerState::Active
13925            || self.owned_interests[replacement_index].state != SubscriberOwnerState::Staged
13926            || (self.owned_interests[replacement_index].baseline.is_some()
13927                && (self.owned_interests[replacement_index].progress.is_none()
13928                    || self.owned_interests[replacement_index].progress_stream_revision
13929                        != Some(self.stream_revision)
13930                    || self.sources_dirty))
13931        {
13932            return false;
13933        }
13934
13935        self.owned_interests[replacement_index].state = SubscriberOwnerState::Active;
13936        self.owned_interests.remove(active_index);
13937        self.purge_owner_epoch(active);
13938        self.rebuild_registered_interests();
13939        self.retire_unreferenced_filters();
13940        self.sources_dirty = true;
13941        true
13942    }
13943
13944    /// Prepare an exact active owner for removal without changing desired
13945    /// interests, streams, anchors, or queued canonical input.
13946    ///
13947    /// The caller establishes its delivery fence after this transition. Use
13948    /// [`abort_interest_owner`](Self::abort_interest_owner) to restore the owner
13949    /// on actor-side failure, or
13950    /// [`finalize_interest_owner_removal`](Self::finalize_interest_owner_removal)
13951    /// once canonical routing has been removed.
13952    pub fn prepare_interest_owner_removal(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
13953        let Some(entry) = self
13954            .owned_interests
13955            .iter_mut()
13956            .find(|entry| entry.epoch.as_ref() == Some(epoch))
13957        else {
13958            return false;
13959        };
13960        if entry.state != SubscriberOwnerState::Active {
13961            return false;
13962        }
13963        entry.state = SubscriberOwnerState::Removing;
13964        true
13965    }
13966
13967    /// Finalize a previously prepared exact owner removal.
13968    ///
13969    /// Returns the removed interests, or `None` for stale tokens and owners not
13970    /// currently in [`SubscriberOwnerState::Removing`]. Repeating finalization
13971    /// is therefore idempotent.
13972    pub fn finalize_interest_owner_removal(
13973        &mut self,
13974        epoch: &SubscriberOwnerEpoch,
13975    ) -> Option<Vec<ReactiveInterest<N>>> {
13976        let index = self.owned_interests.iter().position(|entry| {
13977            entry.epoch.as_ref() == Some(epoch) && entry.state == SubscriberOwnerState::Removing
13978        })?;
13979        let removed = self.owned_interests.remove(index).interests;
13980        self.purge_owner_epoch(epoch);
13981        self.rebuild_registered_interests();
13982        self.retire_unreferenced_filters();
13983        self.sources_dirty = true;
13984        Some(removed)
13985    }
13986
13987    /// Abort an epoch-scoped owner lifecycle operation.
13988    ///
13989    /// A staged owner is removed completely. A prepared removal is restored to
13990    /// active. Active and unknown epochs are unchanged. Repeating the same
13991    /// abort is therefore safe and returns `false` after the first effect.
13992    pub fn abort_interest_owner(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
13993        let Some(index) = self
13994            .owned_interests
13995            .iter()
13996            .position(|entry| entry.epoch.as_ref() == Some(epoch))
13997        else {
13998            return false;
13999        };
14000        match self.owned_interests[index].state {
14001            SubscriberOwnerState::Staged => {
14002                self.owned_interests.remove(index);
14003                self.purge_owner_epoch(epoch);
14004                self.rebuild_registered_interests();
14005                self.retire_unreferenced_filters();
14006                self.sources_dirty = true;
14007                true
14008            }
14009            SubscriberOwnerState::Removing => {
14010                self.owned_interests[index].state = SubscriberOwnerState::Active;
14011                true
14012            }
14013            SubscriberOwnerState::Active => false,
14014        }
14015    }
14016
14017    fn purge_owner_epoch(&mut self, epoch: &SubscriberOwnerEpoch) {
14018        self.pending_backfills
14019            .retain(|backfill| backfill.epoch.as_ref() != Some(epoch));
14020        self.pending_records
14021            .retain_mut(|pending| match &mut pending.scope {
14022                SubscriberInputScope::Canonical { owners }
14023                | SubscriberInputScope::CanonicalResidual { owners, .. } => {
14024                    owners.retain(|owner| owner != epoch);
14025                    true
14026                }
14027                SubscriberInputScope::OwnerOnly { owners } => {
14028                    owners.retain(|owner| owner != epoch);
14029                    !owners.is_empty()
14030                }
14031                SubscriberInputScope::OwnerOnlyHandlers { .. }
14032                | SubscriberInputScope::Preconfirmed => true,
14033            });
14034        self.pending_reconcile_owner_records.retain_mut(|pending| {
14035            pending.owners.retain(|owner| owner != epoch);
14036            !pending.owners.is_empty()
14037        });
14038        self.recent_owner_input_refs.remove(epoch);
14039        self.recent_owner_input_ref_sets.remove(epoch);
14040    }
14041
14042    /// Atomically add or replace several owners while preserving unrelated ones.
14043    ///
14044    /// # Errors
14045    ///
14046    /// Returns [`SubscriberError`] for invalid configuration, duplicate owners,
14047    /// mixed lifecycle APIs, unsupported interests, or backfill-capacity
14048    /// exhaustion. No owner state changes on error.
14049    pub fn upsert_interest_owners(
14050        &mut self,
14051        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
14052    ) -> Result<(), SubscriberError> {
14053        self.upsert_interest_owners_inner(owners, None)
14054    }
14055
14056    /// Atomically add or replace several owners and queue one common backfill
14057    /// policy for every log interest while preserving unrelated owners.
14058    ///
14059    /// # Errors
14060    ///
14061    /// Returns [`SubscriberError`] for invalid configuration, duplicate owners,
14062    /// mixed lifecycle APIs, unsupported interests, or backfill-capacity
14063    /// exhaustion. No owner or backfill state changes on error.
14064    pub fn upsert_interest_owners_with_backfill(
14065        &mut self,
14066        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
14067        backfill: SubscriberBackfill,
14068    ) -> Result<(), SubscriberError> {
14069        self.upsert_interest_owners_inner(owners, Some(backfill))
14070    }
14071
14072    fn upsert_interest_owners_inner(
14073        &mut self,
14074        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
14075        explicit_backfill: Option<SubscriberBackfill>,
14076    ) -> Result<(), SubscriberError> {
14077        validate_subscriber_config(&self.config)?;
14078        let mut seen = HashSet::with_capacity(owners.len());
14079        let mut next_owned = self.clone_owned_interests();
14080        for (owner, interests) in &owners {
14081            if !seen.insert(owner.clone()) {
14082                return Err(SubscriberError::InvalidConfig(
14083                    "bulk owner upsert contains a duplicate owner",
14084                ));
14085            }
14086            if self
14087                .owned_interests
14088                .iter()
14089                .any(|entry| &entry.owner == owner && entry.epoch.is_some())
14090            {
14091                return Err(SubscriberError::InvalidConfig(
14092                    "cannot mix compatibility and epoch-scoped owner lifecycle APIs",
14093                ));
14094            }
14095            if let Some(entry) = next_owned.iter_mut().find(|entry| &entry.owner == owner) {
14096                entry.interests = interests.clone();
14097                entry.state = SubscriberOwnerState::Active;
14098                entry.baseline = None;
14099                entry.progress = None;
14100                entry.progress_stream_revision = None;
14101            } else {
14102                next_owned.push(OwnedSubscriberInterests {
14103                    owner: owner.clone(),
14104                    interests: interests.clone(),
14105                    epoch: None,
14106                    state: SubscriberOwnerState::Active,
14107                    baseline: None,
14108                    progress: None,
14109                    progress_stream_revision: None,
14110                });
14111            }
14112        }
14113        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
14114        validate_supported_interests(self.mode, &self.config, &next_registered)?;
14115
14116        // Build every owner's replacement queue before the first mutation.
14117        // Besides keeping capacity failure atomic, this preserves continuity
14118        // for changed filter shapes when the caller did not provide a common
14119        // open-ended backfill that already covers the old delivery anchor.
14120        let mut replacement_backfills = Vec::new();
14121        for (owner, interests) in &owners {
14122            let previous_filters: Vec<Filter> = self
14123                .owner_interests(owner)
14124                .map(log_filters)
14125                .unwrap_or_default();
14126            let continuity_anchor = previous_filters
14127                .iter()
14128                .filter_map(|filter| self.log_anchor(filter))
14129                .min();
14130            let filters = log_filters(interests);
14131            if let Some(backfill) = explicit_backfill
14132                && !filters.is_empty()
14133            {
14134                replacement_backfills.push(QueuedSubscriberBackfill {
14135                    owner: Some(owner.clone()),
14136                    epoch: None,
14137                    filters: filters.clone(),
14138                    backfill,
14139                });
14140            }
14141            let explicit_covers = explicit_backfill.is_some_and(|explicit| {
14142                explicit.end_block().is_none()
14143                    && continuity_anchor.is_some_and(|anchor| explicit.start_block() <= anchor)
14144            });
14145            let continuity_filters: Vec<_> = filters
14146                .into_iter()
14147                .filter(|filter| !previous_filters.contains(filter))
14148                .collect();
14149            if let Some(anchor) = continuity_anchor
14150                && !continuity_filters.is_empty()
14151                && !explicit_covers
14152            {
14153                replacement_backfills.push(QueuedSubscriberBackfill {
14154                    owner: Some(owner.clone()),
14155                    epoch: None,
14156                    filters: continuity_filters,
14157                    backfill: SubscriberBackfill::from_block(anchor),
14158                });
14159            }
14160        }
14161
14162        let retained_backfills = self
14163            .pending_backfills
14164            .iter()
14165            .filter(|queued| {
14166                queued
14167                    .owner
14168                    .as_ref()
14169                    .is_none_or(|owner| !seen.contains(owner))
14170            })
14171            .map(|queued| queued.filters.len())
14172            .sum::<usize>();
14173        let replacement_units = replacement_backfills
14174            .iter()
14175            .map(|queued| queued.filters.len())
14176            .sum::<usize>();
14177        if retained_backfills.saturating_add(replacement_units) > self.config.max_pending_backfills
14178        {
14179            return Err(SubscriberError::ResourceExhausted(format!(
14180                "bulk owner update would queue more than {} lazy backfills",
14181                self.config.max_pending_backfills
14182            )));
14183        }
14184
14185        // All validation and capacity checks are complete. The remaining
14186        // assignments have no failure or cancellation point, so topology and
14187        // historical work become authoritative as one local commit.
14188        self.owned_interests = next_owned;
14189        self.interests = next_registered;
14190        for owner in &seen {
14191            self.recent_compat_owner_input_refs.remove(owner);
14192            self.recent_compat_owner_input_ref_sets.remove(owner);
14193        }
14194        self.retire_unreferenced_filters();
14195        self.sources_dirty = true;
14196        self.pending_backfills.retain(|queued| {
14197            queued
14198                .owner
14199                .as_ref()
14200                .is_none_or(|owner| !seen.contains(owner))
14201        });
14202        self.pending_backfills.extend(replacement_backfills);
14203        Ok(())
14204    }
14205
14206    /// Atomically replace every compatibility owner without requesting
14207    /// historical delivery.
14208    ///
14209    /// # Errors
14210    ///
14211    /// Returns [`SubscriberError`] for invalid configuration, duplicate owners,
14212    /// mixed lifecycle APIs, unsupported interests, or resource exhaustion.
14213    /// The previous topology remains authoritative on error.
14214    pub fn replace_interest_owners(
14215        &mut self,
14216        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
14217    ) -> Result<(), SubscriberError> {
14218        self.replace_interest_owners_inner(owners, None)
14219    }
14220
14221    /// Atomically replace every compatibility owner and queue one global
14222    /// post-baseline backfill for the resulting union of log interests.
14223    ///
14224    /// Base interests are replaced. Epoch-scoped lifecycle operations cannot
14225    /// be mixed with this compatibility replacement because silently deleting
14226    /// an in-flight epoch would violate its activation transaction.
14227    ///
14228    /// # Errors
14229    ///
14230    /// Returns [`SubscriberError`] for invalid configuration, duplicate owners,
14231    /// mixed lifecycle APIs, unsupported interests, or backfill-capacity
14232    /// exhaustion. The previous topology remains authoritative on error.
14233    pub fn replace_interest_owners_with_global_backfill(
14234        &mut self,
14235        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
14236        backfill: SubscriberBackfill,
14237    ) -> Result<(), SubscriberError> {
14238        self.replace_interest_owners_inner(owners, Some(backfill))
14239    }
14240
14241    fn replace_interest_owners_inner(
14242        &mut self,
14243        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
14244        backfill: Option<SubscriberBackfill>,
14245    ) -> Result<(), SubscriberError> {
14246        validate_subscriber_config(&self.config)?;
14247        if self
14248            .owned_interests
14249            .iter()
14250            .any(|entry| entry.epoch.is_some())
14251        {
14252            return Err(SubscriberError::InvalidConfig(
14253                "cannot replace compatibility owners while an epoch-scoped lifecycle exists",
14254            ));
14255        }
14256
14257        let mut seen = HashSet::with_capacity(owners.len());
14258        let mut next_owned = Vec::with_capacity(owners.len());
14259        for (owner, interests) in owners {
14260            if !seen.insert(owner.clone()) {
14261                return Err(SubscriberError::InvalidConfig(
14262                    "owner replacement contains a duplicate owner",
14263                ));
14264            }
14265            next_owned.push(OwnedSubscriberInterests {
14266                owner,
14267                interests,
14268                epoch: None,
14269                state: SubscriberOwnerState::Active,
14270                baseline: None,
14271                progress: None,
14272                progress_stream_revision: None,
14273            });
14274        }
14275        let next_registered = aggregate_interests(&[], &next_owned);
14276        validate_supported_interests(self.mode, &self.config, &next_registered)?;
14277        let mut filters = log_filters(&next_registered);
14278        let mut unique_filters = Vec::with_capacity(filters.len());
14279        for filter in filters.drain(..) {
14280            if !unique_filters.contains(&filter) {
14281                unique_filters.push(filter);
14282            }
14283        }
14284        let replacement_backfills: VecDeque<_> = match backfill {
14285            Some(backfill) if !unique_filters.is_empty() => {
14286                VecDeque::from([QueuedSubscriberBackfill {
14287                    owner: None,
14288                    epoch: None,
14289                    filters: unique_filters,
14290                    backfill,
14291                }])
14292            }
14293            Some(_) | None => VecDeque::new(),
14294        };
14295        let replacement_units = replacement_backfills
14296            .iter()
14297            .map(|queued| queued.filters.len())
14298            .sum::<usize>();
14299        if replacement_units > self.config.max_pending_backfills {
14300            return Err(SubscriberError::ResourceExhausted(format!(
14301                "owner replacement would queue more than {} lazy backfills",
14302                self.config.max_pending_backfills
14303            )));
14304        }
14305
14306        // No fallible work remains. The post-baseline range reconstructs every
14307        // delivery after the cache snapshot, so reset all stale delivery and
14308        // dedupe state from the prior topology before publishing the exact
14309        // replacement plus its global historical work.
14310        let revoke_preconfirmation = self.latest_preconfirmation.is_some()
14311            || self.pending_preconfirmation_invalidation
14312            || self.pending_records.iter().any(|record| {
14313                record.scope == SubscriberInputScope::Preconfirmed
14314                    || matches!(
14315                        &record.record.context.chain_status,
14316                        ChainStatus::Preconfirmed { .. }
14317                    )
14318            });
14319        self.base_interests.clear();
14320        self.owned_interests = next_owned;
14321        self.interests = next_registered;
14322        self.reset_delivery_state();
14323        self.pending_preconfirmation_invalidation = revoke_preconfirmation;
14324        self.pending_backfills = replacement_backfills;
14325        self.reset_stream_topology();
14326        Ok(())
14327    }
14328
14329    /// Add or replace the interests owned by `owner`.
14330    ///
14331    /// This preserves unrelated owners, queued/pending records, recent dedupe
14332    /// state, and last-seen log anchors. The live transport is reconciled on the
14333    /// next [`EventSubscriber::next_batch`] call so newly added log filters can
14334    /// be subscribed without rebuilding the whole subscriber object.
14335    ///
14336    /// Replacing an existing owner is continuity-safe: filters the owner
14337    /// already had keep their delivery anchors, and any changed or new filter
14338    /// shape is automatically backfilled from the owner's oldest prior anchor —
14339    /// growing a pool set on an established owner does not open a delivery gap
14340    /// for what the old subscription had already covered. A brand-new owner has
14341    /// no anchor to inherit; pass an explicit
14342    /// [`add_interest_owner_with_backfill`](Self::add_interest_owner_with_backfill)
14343    /// anchor (or register through [`ReactiveEngine::register_handler`], which
14344    /// anchors to the runtime's last canonical block).
14345    ///
14346    /// # Errors
14347    ///
14348    /// Returns [`SubscriberError`] for invalid configuration, incompatible
14349    /// lifecycle state, unsupported interests, or continuity-backfill capacity
14350    /// exhaustion. The prior owner state remains authoritative on error.
14351    pub fn add_interest_owner(
14352        &mut self,
14353        owner: HandlerId,
14354        interests: &[ReactiveInterest<N>],
14355    ) -> Result<(), SubscriberError> {
14356        self.set_interest_owner(owner, interests, None)
14357    }
14358
14359    /// Add or replace owner interests and schedule log backfill for that owner.
14360    ///
14361    /// Backfill is queued only for log interests; block and pending transaction
14362    /// interests are live-only. Queued records can be delivered immediately;
14363    /// the subsequent provider stream is then caught up from the seeded
14364    /// delivery anchor, and overlap is deduplicated — so the discovery boundary
14365    /// is closed end to end as long
14366    /// as `backfill` starts at (or before) the block the interest was
14367    /// discovered in. Continuity backfill for a replaced owner (see
14368    /// [`add_interest_owner`](Self::add_interest_owner)) is queued in addition,
14369    /// unless this explicit backfill is open-ended and already starts at or
14370    /// below the owner's prior anchor.
14371    ///
14372    /// # Errors
14373    ///
14374    /// Returns [`SubscriberError`] for invalid configuration, incompatible
14375    /// lifecycle state, unsupported interests, or backfill-capacity exhaustion.
14376    /// The prior owner state remains authoritative on error.
14377    pub fn add_interest_owner_with_backfill(
14378        &mut self,
14379        owner: HandlerId,
14380        interests: &[ReactiveInterest<N>],
14381        backfill: SubscriberBackfill,
14382    ) -> Result<(), SubscriberError> {
14383        self.set_interest_owner(owner, interests, Some(backfill))
14384    }
14385
14386    /// Add or replace one owner at retained canonical block `C`, then queue the
14387    /// coordinated cutover required by [`ReactiveEngine::register_handler`].
14388    ///
14389    /// The new owner alone receives matching records from `C` so its effects
14390    /// attach to the runtime's existing journal entry. Every matching log from
14391    /// `C + 1` through the activation head is then delivered canonically over
14392    /// the complete interest union. [`Self::next_scoped_batch`] installs the
14393    /// desired live streams before draining either window, closing the
14394    /// subscribe/backfill gap. Alloy cannot reconstruct historical block or
14395    /// pending-transaction deliveries through this log backfill path, so a
14396    /// mixed interest topology is rejected rather than silently underfilled.
14397    ///
14398    /// # Errors
14399    ///
14400    /// Returns [`SubscriberError`] for invalid configuration, incompatible
14401    /// lifecycle state, unsupported non-log catch-up, block-number overflow, or
14402    /// resource exhaustion. The prior owner state remains authoritative on
14403    /// error.
14404    pub fn add_interest_owner_with_canonical_catchup(
14405        &mut self,
14406        owner: HandlerId,
14407        interests: &[ReactiveInterest<N>],
14408        retained: BlockRef,
14409    ) -> Result<(), SubscriberError> {
14410        validate_subscriber_config(&self.config)?;
14411        if self
14412            .owned_interests
14413            .iter()
14414            .any(|entry| entry.owner == owner && entry.epoch.is_some())
14415        {
14416            return Err(SubscriberError::InvalidConfig(
14417                "cannot mix compatibility and epoch-scoped owner lifecycle APIs",
14418            ));
14419        }
14420
14421        let mut next_owned = self.clone_owned_interests();
14422        if let Some(entry) = next_owned.iter_mut().find(|entry| entry.owner == owner) {
14423            entry.interests = interests.to_vec();
14424            entry.state = SubscriberOwnerState::Active;
14425            entry.baseline = None;
14426            entry.progress = None;
14427            entry.progress_stream_revision = None;
14428            entry.epoch = None;
14429        } else {
14430            next_owned.push(OwnedSubscriberInterests {
14431                owner: owner.clone(),
14432                interests: interests.to_vec(),
14433                epoch: None,
14434                state: SubscriberOwnerState::Active,
14435                baseline: None,
14436                progress: None,
14437                progress_stream_revision: None,
14438            });
14439        }
14440        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
14441        validate_supported_interests(self.mode, &self.config, &next_registered)?;
14442        if next_registered
14443            .iter()
14444            .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
14445        {
14446            return Err(SubscriberError::Unsupported(
14447                "Alloy coordinated registration supports log-only interest topologies",
14448            ));
14449        }
14450
14451        let mut owner_filters = Vec::new();
14452        for filter in log_filters(interests) {
14453            if !owner_filters.contains(&filter) {
14454                owner_filters.push(filter);
14455            }
14456        }
14457        let mut global_filters = Vec::new();
14458        for filter in log_filters(&next_registered) {
14459            if !global_filters.contains(&filter) {
14460                global_filters.push(filter);
14461            }
14462        }
14463        let owner_backfill =
14464            SubscriberBackfill::from_canonical_block_through(retained, retained.number)?;
14465        let global_backfill = SubscriberBackfill::after_canonical_block(retained)?;
14466        let replacement_units = owner_filters.len().saturating_add(global_filters.len());
14467        let retained_units = self
14468            .pending_backfills
14469            .iter()
14470            .filter(|queued| queued.owner.as_ref() != Some(&owner))
14471            .map(|queued| queued.filters.len())
14472            .sum::<usize>();
14473        if retained_units.saturating_add(replacement_units) > self.config.max_pending_backfills {
14474            return Err(SubscriberError::ResourceExhausted(format!(
14475                "coordinated owner registration would queue more than {} lazy backfills",
14476                self.config.max_pending_backfills
14477            )));
14478        }
14479
14480        let mut replacement_backfills = VecDeque::new();
14481        if !owner_filters.is_empty() {
14482            replacement_backfills.push_back(QueuedSubscriberBackfill {
14483                owner: Some(owner.clone()),
14484                epoch: None,
14485                filters: owner_filters,
14486                backfill: owner_backfill,
14487            });
14488        }
14489        // Keep the global certification job even for an empty filter union: it
14490        // advances canonical coverage through a zero-event registration window.
14491        replacement_backfills.push_back(QueuedSubscriberBackfill {
14492            owner: None,
14493            epoch: None,
14494            filters: global_filters,
14495            backfill: global_backfill,
14496        });
14497
14498        // Every fallible preflight is complete. Publish topology and both
14499        // ordered windows as one synchronous local commit.
14500        self.owned_interests = next_owned;
14501        self.interests = next_registered;
14502        self.recent_compat_owner_input_refs.remove(&owner);
14503        self.recent_compat_owner_input_ref_sets.remove(&owner);
14504        self.pending_backfills
14505            .retain(|queued| queued.owner.as_ref() != Some(&owner));
14506        self.pending_backfills.extend(replacement_backfills);
14507        self.retire_unreferenced_filters();
14508        self.sources_dirty = true;
14509        Ok(())
14510    }
14511
14512    /// Remove one owner's interests, preserving unrelated owner/base interests.
14513    ///
14514    /// The owner's queued backfills are dropped, and source-id/anchor
14515    /// bookkeeping for filters no other owner references is retired. Live
14516    /// streams for retired filters are torn down on the next
14517    /// [`EventSubscriber::next_batch`] call (dropping an Alloy subscription
14518    /// unsubscribes provider-side); events already in flight from them stop
14519    /// matching the merged interest set and are discarded.
14520    pub fn remove_interest_owner(&mut self, owner: &HandlerId) -> Option<Vec<ReactiveInterest<N>>> {
14521        let index = self
14522            .owned_interests
14523            .iter()
14524            .position(|entry| &entry.owner == owner && entry.epoch.is_none())?;
14525        let removed = self.owned_interests.remove(index);
14526        if let Some(epoch) = &removed.epoch {
14527            self.purge_owner_epoch(epoch);
14528        } else {
14529            self.pending_backfills
14530                .retain(|backfill| backfill.owner.as_ref() != Some(owner));
14531            self.recent_compat_owner_input_refs.remove(owner);
14532            self.recent_compat_owner_input_ref_sets.remove(owner);
14533        }
14534        self.rebuild_registered_interests();
14535        self.retire_unreferenced_filters();
14536        self.sources_dirty = true;
14537        Some(removed.interests)
14538    }
14539
14540    /// Borrow the interests currently owned by `owner`.
14541    pub fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
14542        self.owned_interests
14543            .iter()
14544            .find(|entry| &entry.owner == owner)
14545            .map(|entry| entry.interests.as_slice())
14546    }
14547
14548    fn set_interest_owner(
14549        &mut self,
14550        owner: HandlerId,
14551        interests: &[ReactiveInterest<N>],
14552        backfill: Option<SubscriberBackfill>,
14553    ) -> Result<(), SubscriberError> {
14554        validate_subscriber_config(&self.config)?;
14555        if self
14556            .owned_interests
14557            .iter()
14558            .any(|entry| entry.owner == owner && entry.epoch.is_some())
14559        {
14560            return Err(SubscriberError::InvalidConfig(
14561                "cannot mix compatibility and epoch-scoped owner lifecycle APIs",
14562            ));
14563        }
14564
14565        let mut next_owned = self.clone_owned_interests();
14566        let replaced_epoch = match next_owned.iter_mut().find(|entry| entry.owner == owner) {
14567            Some(entry) => {
14568                entry.interests = interests.to_vec();
14569                entry.state = SubscriberOwnerState::Active;
14570                entry.baseline = None;
14571                entry.progress = None;
14572                entry.progress_stream_revision = None;
14573                entry.epoch.take()
14574            }
14575            None => {
14576                next_owned.push(OwnedSubscriberInterests {
14577                    owner: owner.clone(),
14578                    interests: interests.to_vec(),
14579                    epoch: None,
14580                    state: SubscriberOwnerState::Active,
14581                    baseline: None,
14582                    progress: None,
14583                    progress_stream_revision: None,
14584                });
14585                None
14586            }
14587        };
14588        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
14589        validate_supported_interests(self.mode, &self.config, &next_registered)?;
14590
14591        // Continuity capture, before the mutation lands: the owner's previous
14592        // filter shapes and the oldest delivery anchor among them. A changed
14593        // filter gets a fresh source id with no anchor, so without this
14594        // hand-off, replacing an owner's interests (the normal way to grow a
14595        // pool set) would silently discard the delivery watermark and open a
14596        // gap until some later explicit backfill.
14597        let previous_filters: Vec<Filter> = self
14598            .owner_interests(&owner)
14599            .map(log_filters)
14600            .unwrap_or_default();
14601        let continuity_anchor: Option<u64> = previous_filters
14602            .iter()
14603            .filter_map(|filter| self.log_anchor(filter))
14604            .min();
14605
14606        // Build the replacement queue before committing owner state. Capacity
14607        // failure is therefore atomic and cannot leave desired interests ahead
14608        // of the historical work required to make them continuous.
14609        let mut replacement_backfills = Vec::new();
14610        let filters = log_filters(interests);
14611        if let Some(backfill) = backfill
14612            && !filters.is_empty()
14613        {
14614            replacement_backfills.push(QueuedSubscriberBackfill {
14615                owner: Some(owner.clone()),
14616                epoch: None,
14617                filters: filters.clone(),
14618                backfill,
14619            });
14620        }
14621        let explicit_covers = backfill.is_some_and(|explicit| {
14622            explicit.end_block().is_none()
14623                && continuity_anchor.is_some_and(|anchor| explicit.start_block() <= anchor)
14624        });
14625        let continuity_filters: Vec<_> = filters
14626            .into_iter()
14627            .filter(|filter| !previous_filters.contains(filter))
14628            .collect();
14629        if let Some(anchor) = continuity_anchor
14630            && !continuity_filters.is_empty()
14631            && !explicit_covers
14632        {
14633            replacement_backfills.push(QueuedSubscriberBackfill {
14634                owner: Some(owner.clone()),
14635                epoch: None,
14636                filters: continuity_filters,
14637                backfill: SubscriberBackfill::from_block(anchor),
14638            });
14639        }
14640        let retained_backfills = self
14641            .pending_backfills
14642            .iter()
14643            .filter(|queued| queued.owner.as_ref() != Some(&owner))
14644            .map(|queued| queued.filters.len())
14645            .sum::<usize>();
14646        let replacement_units = replacement_backfills
14647            .iter()
14648            .map(|queued| queued.filters.len())
14649            .sum::<usize>();
14650        if retained_backfills.saturating_add(replacement_units) > self.config.max_pending_backfills
14651        {
14652            return Err(SubscriberError::ResourceExhausted(format!(
14653                "owner update would queue more than {} lazy backfills",
14654                self.config.max_pending_backfills
14655            )));
14656        }
14657
14658        self.owned_interests = next_owned;
14659        self.interests = next_registered;
14660        if let Some(epoch) = replaced_epoch {
14661            self.purge_owner_epoch(&epoch);
14662        } else {
14663            self.recent_compat_owner_input_refs.remove(&owner);
14664            self.recent_compat_owner_input_ref_sets.remove(&owner);
14665        }
14666        self.retire_unreferenced_filters();
14667        self.sources_dirty = true;
14668
14669        // Re-queue this owner's backfills from scratch: previously queued
14670        // entries may reference filter shapes that no longer exist.
14671        self.pending_backfills
14672            .retain(|queued| queued.owner.as_ref() != Some(&owner));
14673        self.pending_backfills.extend(replacement_backfills);
14674        Ok(())
14675    }
14676
14677    fn clone_owned_interests(&self) -> Vec<OwnedSubscriberInterests<N>> {
14678        self.owned_interests
14679            .iter()
14680            .map(|entry| OwnedSubscriberInterests {
14681                owner: entry.owner.clone(),
14682                interests: entry.interests.clone(),
14683                epoch: entry.epoch.clone(),
14684                state: entry.state,
14685                baseline: entry.baseline,
14686                progress: entry.progress.clone(),
14687                progress_stream_revision: entry.progress_stream_revision,
14688            })
14689            .collect()
14690    }
14691
14692    fn rebuild_registered_interests(&mut self) {
14693        self.interests = aggregate_interests(&self.base_interests, &self.owned_interests);
14694    }
14695
14696    /// Delivery anchor (last block known fully delivered) for `filter`, if the
14697    /// filter has a source id and has seen delivery.
14698    fn log_anchor(&self, filter: &Filter) -> Option<u64> {
14699        if let Some(anchor) = self
14700            .log_source_ids
14701            .get(filter)
14702            .and_then(|id| self.last_seen_log_blocks.get(id))
14703        {
14704            return Some(*anchor);
14705        }
14706
14707        // Logical owner filters may be represented by a broader provider
14708        // stream after fan-in. Its oldest live watermark is a conservative
14709        // continuity anchor: it can cause extra backfill, never a missed log.
14710        self.log_source_ids
14711            .values()
14712            .filter_map(|id| self.last_seen_log_blocks.get(id).copied())
14713            .min()
14714    }
14715
14716    /// Every logical log filter across base and owner interests, merged within
14717    /// each origin and deduplicated across origins. These shapes remain the
14718    /// exact routing and owner-continuity boundary; provider subscriptions may
14719    /// fan several of them into one broader filter.
14720    // `Filter` derives `Hash`/`Eq` and has no interior mutability; the
14721    // `mutable_key_type` lint is a known false positive for it.
14722    #[allow(clippy::mutable_key_type)]
14723    fn logical_log_filters(&self) -> Vec<Filter> {
14724        let mut filters = log_filters(&self.base_interests);
14725        for entry in &self.owned_interests {
14726            filters.extend(log_filters(&entry.interests));
14727        }
14728        let mut seen = HashSet::new();
14729        filters.retain(|filter| seen.insert(filter.clone()));
14730        filters
14731    }
14732
14733    /// Provider-facing log filters. Compatible logical filters fan into a
14734    /// small number of address/topic supersets, then split only when the
14735    /// configured address ceiling requires it. Exact matching remains local in
14736    /// `enqueue_event`, so this reduces subscriptions without broadening owner
14737    /// delivery.
14738    fn log_stream_filters(&self) -> Vec<Filter> {
14739        let mut merged = Vec::new();
14740        for filter in self.logical_log_filters() {
14741            merge_log_subscription_filter(&mut merged, &filter);
14742        }
14743
14744        let max_addresses = self.config.max_log_addresses_per_subscription.max(1);
14745        let mut planned = Vec::new();
14746        for filter in merged {
14747            let mut addresses: Vec<_> = filter.address.iter().copied().collect();
14748            if addresses.len() <= max_addresses {
14749                planned.push(filter);
14750                continue;
14751            }
14752            addresses.sort_unstable();
14753            for chunk in addresses.chunks(max_addresses) {
14754                let mut split = filter.clone();
14755                split.address = FilterSet::default();
14756                for address in chunk {
14757                    split.address.insert(*address);
14758                }
14759                planned.push(split);
14760            }
14761        }
14762        planned
14763    }
14764
14765    /// Drop source-id and anchor bookkeeping for filters no longer referenced
14766    /// by any base or owner interest, so long-lived owner churn cannot grow the
14767    /// maps unboundedly. Live streams for retired filters are pruned by the
14768    /// next reconcile.
14769    // `Filter` derives `Hash`/`Eq` and has no interior mutability; the
14770    // `mutable_key_type` lint is a known false positive for it.
14771    #[allow(clippy::mutable_key_type)]
14772    fn retire_unreferenced_filters(&mut self) {
14773        let mut live: HashSet<Filter> = self.log_stream_filters().into_iter().collect();
14774        if let AlloySubscriberState::Active(streams) = &self.state {
14775            for entry in &streams.entries {
14776                match &entry.source {
14777                    SubscriberStreamSource::PubSubLog { filter, .. }
14778                    | SubscriberStreamSource::BasePendingLog { filter, .. }
14779                    | SubscriberStreamSource::PollingLog { filter } => {
14780                        live.insert(filter.clone());
14781                    }
14782                    SubscriberStreamSource::BaseFlashblocks
14783                    | SubscriberStreamSource::OpPendingFlashblocks
14784                    | SubscriberStreamSource::CanonicalHeadPolling
14785                    | SubscriberStreamSource::PubSubPendingHashes
14786                    | SubscriberStreamSource::PubSubBlockHeaders
14787                    | SubscriberStreamSource::PollingPendingHashes => {}
14788                    #[cfg(feature = "raw-flashblocks-json")]
14789                    SubscriberStreamSource::ExternalFlashblockUpdates => {}
14790                }
14791            }
14792        }
14793        self.log_source_ids
14794            .retain(|filter, _| live.contains(filter));
14795        let live_ids: HashSet<usize> = self.log_source_ids.values().copied().collect();
14796        self.last_seen_log_blocks
14797            .retain(|id, _| live_ids.contains(id));
14798    }
14799
14800    /// Record a canonical header observed while every log source was whole.
14801    ///
14802    /// Called before the header is enqueued, so a gap discovered later in the
14803    /// same poll cannot retroactively attest a block whose logs it may have
14804    /// dropped: `reset_log_attestation` clears the candidate, and it only
14805    /// re-advances once a later header arrives after the gap was healed.
14806    fn note_attestable_canonical_block(&mut self, record: &ReactiveInputRecord<N>) {
14807        if !self.attests_log_coverage() {
14808            return;
14809        }
14810        let Some(block) = record.context.block.as_ref() else {
14811            return;
14812        };
14813        let advances = self
14814            .attestable_canonical_head
14815            .as_ref()
14816            .is_none_or(|current| block.number > current.number);
14817        if advances {
14818            self.attestable_canonical_head = Some(*block);
14819        }
14820    }
14821
14822    /// Withdraw the pending attestation candidate after detected loss.
14823    ///
14824    /// The healed window is refetched, but the candidate is still dropped: a
14825    /// consumer must not be told a block was whole on the strength of an
14826    /// observation made before the loss was known.
14827    fn reset_log_attestation(&mut self) {
14828        self.attestable_canonical_head = None;
14829    }
14830
14831    /// Whether this subscriber can prove the attestation it would emit.
14832    ///
14833    /// Mirrors [`SubscriberCapability::LogCoverageAttestation`]: only the pubsub
14834    /// log streams surface a dropped notification, and only a subscriber with log
14835    /// interests has anything to attest about.
14836    fn attests_log_coverage(&self) -> bool {
14837        matches!(
14838            resolve_subscriber_transport(self.mode),
14839            Ok(SubscriberTransport::PubSub)
14840        ) && self
14841            .interests
14842            .iter()
14843            .any(|interest| matches!(interest, ReactiveInterest::Logs(_)))
14844    }
14845
14846    /// Queue a `LogCoverage` control when the attested watermark advances.
14847    fn queue_log_coverage_attestation(&mut self) {
14848        if !self.attests_log_coverage() {
14849            return;
14850        }
14851        let Some(candidate) = self.attestable_canonical_head else {
14852            return;
14853        };
14854        let advances = self
14855            .attested_log_coverage
14856            .as_ref()
14857            .is_none_or(|attested| candidate.number > attested.number);
14858        if !advances {
14859            return;
14860        }
14861        self.attested_log_coverage = Some(candidate);
14862        self.pending_chain_controls
14863            .push_back(ChainControl::LogCoverage(candidate));
14864    }
14865
14866    fn drain_next_scoped_batch(&mut self) -> Option<SubscriberInputBatch<N>> {
14867        // Attest before the emptiness check: the watermark may be the only thing
14868        // this batch has to say. Controls still drain only once every record
14869        // ahead of them has left, so an attestation never precedes its header.
14870        self.queue_log_coverage_attestation();
14871        if self.pending_records.is_empty()
14872            && self.pending_chain_controls.is_empty()
14873            && !self.pending_preconfirmation_invalidation
14874        {
14875            return None;
14876        }
14877
14878        let first_preconfirmation = self.pending_records.front().and_then(|record| {
14879            if record.scope != SubscriberInputScope::Preconfirmed {
14880                return None;
14881            }
14882            match &record.record.context.chain_status {
14883                ChainStatus::Preconfirmed { flashblock } => Some(flashblock.clone()),
14884                _ => None,
14885            }
14886        });
14887        let len = self
14888            .pending_records
14889            .iter()
14890            .take(self.config.max_batch_size)
14891            .take_while(|record| match &first_preconfirmation {
14892                Some(expected) => {
14893                    record.scope == SubscriberInputScope::Preconfirmed
14894                        && matches!(
14895                            &record.record.context.chain_status,
14896                            ChainStatus::Preconfirmed { flashblock } if flashblock == expected
14897                        )
14898                }
14899                None => record.scope != SubscriberInputScope::Preconfirmed,
14900            })
14901            .count();
14902        let preconfirmation_timing = self
14903            .pending_records
14904            .iter()
14905            .take(len)
14906            .filter_map(SubscriberInputRecord::preconfirmation_timing)
14907            .reduce(FlashblockIngressTiming::earliest);
14908        let records = self.pending_records.drain(..len).collect();
14909        let chain_controls = if first_preconfirmation.is_none() && self.pending_records.is_empty() {
14910            self.pending_chain_controls.drain(..).collect()
14911        } else {
14912            Vec::new()
14913        };
14914        Some(SubscriberInputBatch {
14915            records,
14916            chain_id: self.chain_id,
14917            chain_controls,
14918            preconfirmation_invalidated: std::mem::take(
14919                &mut self.pending_preconfirmation_invalidation,
14920            ),
14921            preconfirmation_timing,
14922        })
14923    }
14924
14925    fn reset_delivery_state(&mut self) {
14926        self.pending_records.clear();
14927        self.pending_chain_controls.clear();
14928        self.pending_reconcile_owner_records.clear();
14929        self.resource_error = None;
14930        self.last_seen_log_blocks.clear();
14931        self.verified_log_blocks.clear();
14932        self.verified_log_block_order.clear();
14933        self.recent_input_refs.clear();
14934        self.recent_input_ref_set.clear();
14935        self.recent_owner_input_refs.clear();
14936        self.recent_owner_input_ref_sets.clear();
14937        self.recent_compat_owner_input_refs.clear();
14938        self.recent_compat_owner_input_ref_sets.clear();
14939        self.pending_backfills.clear();
14940        self.pending_source_backfills.clear();
14941        self.pending_preconfirmation_invalidation = false;
14942        self.pending_flashblock_reconnects.clear();
14943        self.pending_flashblock_reconnect_sources.clear();
14944        self.flashblocks_rpc_metrics = FlashblocksRpcMetrics::default();
14945        self.log_source_ids.clear();
14946        self.next_log_source_id = 0;
14947        self.sources_dirty = true;
14948        self.last_certified_canonical_head = None;
14949        self.last_canonical_head_certification = None;
14950        self.sealed_block_pending_certification = false;
14951        self.attestable_canonical_head = None;
14952        self.attested_log_coverage = None;
14953        self.reset_flashblock_tracking();
14954    }
14955
14956    fn reset_stream_topology(&mut self) {
14957        #[cfg(feature = "raw-flashblocks-json")]
14958        let external = match &mut self.state {
14959            AlloySubscriberState::Active(streams) => streams
14960                .entries
14961                .iter()
14962                .position(|entry| entry.source.is_external_flashblocks())
14963                .map(|index| streams.entries.remove(index)),
14964            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => None,
14965        };
14966
14967        #[cfg(feature = "raw-flashblocks-json")]
14968        if let Some(external) = external {
14969            let mut streams = SubscriberStreams::new();
14970            streams.entries.push(external);
14971            self.state = AlloySubscriberState::Active(streams);
14972            return;
14973        }
14974
14975        self.state = AlloySubscriberState::Uninitialized;
14976    }
14977
14978    fn reset_flashblock_tracking(&mut self) {
14979        self.base_flashblock_header = None;
14980        self.base_flashblock_transactions = None;
14981        self.unmatched_pending_logs.clear();
14982        self.latest_preconfirmation = None;
14983        self.preconfirmed_seen_logs.clear();
14984        self.preconfirmed_receipted_transactions.clear();
14985        self.preconfirmed_unavailable_receipts.clear();
14986        #[cfg(feature = "raw-flashblocks-json")]
14987        {
14988            self.last_external_flashblock_snapshot = None;
14989        }
14990        self.consecutive_flashblock_poll_failures = 0;
14991    }
14992
14993    /// Revoke only the active speculative snapshot while keeping the pinned
14994    /// provider session and its streams alive. A sampled OP pending view can
14995    /// legitimately be replaced, or a provider backend can briefly return an
14996    /// older cumulative view. Either observation makes the current signing
14997    /// authority unsafe, but does not prove that the transport generation is
14998    /// broken and should be reconnected.
14999    fn invalidate_preconfirmation_snapshot(&mut self) {
15000        self.pending_records
15001            .retain(|record| record.scope != SubscriberInputScope::Preconfirmed);
15002        self.pending_preconfirmation_invalidation = true;
15003        self.latest_preconfirmation = None;
15004        self.preconfirmed_seen_logs.clear();
15005        self.preconfirmed_receipted_transactions.clear();
15006        self.preconfirmed_unavailable_receipts.clear();
15007    }
15008
15009    fn bump_stream_revision(&mut self) {
15010        self.stream_revision = self.stream_revision.saturating_add(1);
15011    }
15012}
15013
15014impl<P, N> InterestOwnerSubscriber<N> for AlloySubscriber<P, N>
15015where
15016    P: Provider<N> + Send + Sync,
15017    N: Network + 'static,
15018    N::HeaderResponse: Send + 'static,
15019{
15020    fn upsert_interest_owners(
15021        &mut self,
15022        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
15023    ) -> SubscriberOperation<'_, ()> {
15024        Box::pin(async move {
15025            if !owners.is_empty() {
15026                self.ensure_chain_id().await?;
15027            }
15028            AlloySubscriber::upsert_interest_owners(self, owners)
15029        })
15030    }
15031
15032    fn replace_interest_owners(
15033        &mut self,
15034        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
15035    ) -> SubscriberOperation<'_, ()> {
15036        Box::pin(async move {
15037            if owners.iter().any(|(_, interests)| !interests.is_empty()) {
15038                self.ensure_chain_id().await?;
15039            }
15040            AlloySubscriber::replace_interest_owners(self, owners)
15041        })
15042    }
15043
15044    fn replace_interest_owners_with_global_backfill(
15045        &mut self,
15046        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
15047        backfill: SubscriberBackfill,
15048    ) -> SubscriberOperation<'_, ()> {
15049        Box::pin(async move {
15050            if owners.iter().any(|(_, interests)| !interests.is_empty()) {
15051                self.ensure_chain_id().await?;
15052            }
15053            AlloySubscriber::replace_interest_owners_with_global_backfill(self, owners, backfill)
15054        })
15055    }
15056
15057    fn add_interest_owner(
15058        &mut self,
15059        owner: HandlerId,
15060        interests: &[ReactiveInterest<N>],
15061    ) -> SubscriberOperation<'_, ()> {
15062        let interests = interests.to_vec();
15063        Box::pin(async move {
15064            if !interests.is_empty() {
15065                self.ensure_chain_id().await?;
15066            }
15067            AlloySubscriber::add_interest_owner(self, owner, &interests)
15068        })
15069    }
15070
15071    fn add_interest_owner_with_backfill(
15072        &mut self,
15073        owner: HandlerId,
15074        interests: &[ReactiveInterest<N>],
15075        backfill: SubscriberBackfill,
15076    ) -> SubscriberOperation<'_, ()> {
15077        let interests = interests.to_vec();
15078        Box::pin(async move {
15079            if !interests.is_empty() {
15080                self.ensure_chain_id().await?;
15081            }
15082            AlloySubscriber::add_interest_owner_with_backfill(self, owner, &interests, backfill)
15083        })
15084    }
15085
15086    fn add_interest_owner_with_canonical_catchup(
15087        &mut self,
15088        owner: HandlerId,
15089        interests: &[ReactiveInterest<N>],
15090        retained: BlockRef,
15091    ) -> SubscriberOperation<'_, ()> {
15092        let interests = interests.to_vec();
15093        Box::pin(async move {
15094            // Resolve provider identity before the synchronous topology commit;
15095            // cancellation or failure at this await leaves prior state intact.
15096            self.ensure_chain_id().await?;
15097            AlloySubscriber::add_interest_owner_with_canonical_catchup(
15098                self, owner, &interests, retained,
15099            )
15100        })
15101    }
15102
15103    fn remove_interest_owner(
15104        &mut self,
15105        owner: &HandlerId,
15106    ) -> SubscriberOperation<'_, Option<Vec<ReactiveInterest<N>>>> {
15107        let owner = owner.clone();
15108        Box::pin(async move { Ok(AlloySubscriber::remove_interest_owner(self, &owner)) })
15109    }
15110
15111    fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
15112        AlloySubscriber::owner_interests(self, owner)
15113    }
15114}
15115
15116enum AlloySubscriberState<N: Network> {
15117    Uninitialized,
15118    Active(SubscriberStreams<N>),
15119    Empty,
15120}
15121
15122struct SubscriberStreams<N: Network> {
15123    entries: Vec<SubscriberStreamEntry<N>>,
15124    next_index: usize,
15125}
15126
15127struct SubscriberStreamEntry<N: Network> {
15128    source: SubscriberStreamSource,
15129    stream: BoxStream<'static, SubscriberEvent<N>>,
15130}
15131
15132impl<N: Network> SubscriberStreams<N> {
15133    fn new() -> Self {
15134        Self {
15135            entries: Vec::new(),
15136            next_index: 0,
15137        }
15138    }
15139
15140    fn is_empty(&self) -> bool {
15141        self.entries.is_empty()
15142    }
15143
15144    fn push(
15145        &mut self,
15146        source: SubscriberStreamSource,
15147        stream: BoxStream<'static, SubscriberEvent<N>>,
15148    ) {
15149        self.entries.push(SubscriberStreamEntry { source, stream });
15150    }
15151
15152    #[cfg(all(test, any(feature = "reactive-polling", feature = "reactive-ws")))]
15153    fn len(&self) -> usize {
15154        self.entries.len()
15155    }
15156
15157    fn contains_source(&self, source: &SubscriberStreamSource) -> bool {
15158        self.entries
15159            .iter()
15160            .any(|entry| entry.source.same_key(source))
15161    }
15162
15163    fn retain_sources(&mut self, sources: &[SubscriberStreamSource]) {
15164        self.entries
15165            .retain(|entry| sources.iter().any(|source| entry.source.same_key(source)));
15166        self.normalize_next_index();
15167    }
15168
15169    fn normalize_next_index(&mut self) {
15170        if self.entries.is_empty() {
15171            self.next_index = 0;
15172        } else if self.next_index >= self.entries.len() {
15173            self.next_index %= self.entries.len();
15174        }
15175    }
15176
15177    async fn next(&mut self) -> Option<SubscriberEvent<N>> {
15178        poll_fn(|cx| {
15179            self.normalize_next_index();
15180            if self.entries.is_empty() {
15181                return std::task::Poll::Ready(None);
15182            }
15183
15184            let mut index = self.next_index;
15185            let mut checked = 0usize;
15186            while checked < self.entries.len() {
15187                if index >= self.entries.len() {
15188                    index = 0;
15189                }
15190                match self.entries[index].stream.as_mut().poll_next(cx) {
15191                    std::task::Poll::Ready(Some(event)) => {
15192                        if matches!(event, SubscriberEvent::StreamTerminated(_)) {
15193                            self.entries.remove(index);
15194                            self.next_index = if self.entries.is_empty() {
15195                                0
15196                            } else {
15197                                index % self.entries.len()
15198                            };
15199                        } else {
15200                            self.next_index = (index + 1) % self.entries.len();
15201                        }
15202                        return std::task::Poll::Ready(Some(event));
15203                    }
15204                    std::task::Poll::Ready(None) => {
15205                        self.entries.remove(index);
15206                        if self.entries.is_empty() {
15207                            self.next_index = 0;
15208                            return std::task::Poll::Ready(None);
15209                        }
15210                    }
15211                    std::task::Poll::Pending => {
15212                        checked += 1;
15213                        index += 1;
15214                    }
15215                }
15216            }
15217
15218            if self.entries.is_empty() {
15219                std::task::Poll::Ready(None)
15220            } else {
15221                self.next_index = index % self.entries.len();
15222                std::task::Poll::Pending
15223            }
15224        })
15225        .await
15226    }
15227}
15228
15229#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15230#[allow(dead_code)]
15231enum SubscriberTransport {
15232    PubSub,
15233    Polling,
15234}
15235
15236#[derive(Clone, Debug)]
15237enum SubscriberStreamSource {
15238    PubSubLog {
15239        id: usize,
15240        filter: Filter,
15241    },
15242    BasePendingLog {
15243        id: usize,
15244        filter: Filter,
15245    },
15246    BaseFlashblocks,
15247    OpPendingFlashblocks,
15248    CanonicalHeadPolling,
15249    PubSubPendingHashes,
15250    PubSubBlockHeaders,
15251    PollingLog {
15252        filter: Filter,
15253    },
15254    PollingPendingHashes,
15255    #[cfg(feature = "raw-flashblocks-json")]
15256    ExternalFlashblockUpdates,
15257}
15258
15259impl SubscriberStreamSource {
15260    fn label(&self) -> &'static str {
15261        match self {
15262            Self::PubSubLog { .. } => "pubsub log",
15263            Self::BasePendingLog { .. } => "OP Stack pendingLogs",
15264            Self::BaseFlashblocks => "OP Stack newFlashblocks",
15265            Self::OpPendingFlashblocks => "Optimism pending Flashblocks",
15266            Self::CanonicalHeadPolling => "certified canonical head",
15267            Self::PubSubPendingHashes => "pubsub pending transaction hash",
15268            Self::PubSubBlockHeaders => "pubsub block header",
15269            Self::PollingLog { .. } => "polling log",
15270            Self::PollingPendingHashes => "polling pending transaction hash",
15271            #[cfg(feature = "raw-flashblocks-json")]
15272            Self::ExternalFlashblockUpdates => "external standardized Flashblock update",
15273        }
15274    }
15275
15276    fn is_pubsub(&self) -> bool {
15277        matches!(
15278            self,
15279            Self::PubSubLog { .. }
15280                | Self::BasePendingLog { .. }
15281                | Self::BaseFlashblocks
15282                | Self::OpPendingFlashblocks
15283                | Self::PubSubPendingHashes
15284                | Self::PubSubBlockHeaders
15285        )
15286    }
15287
15288    fn is_flashblocks(&self) -> bool {
15289        matches!(
15290            self,
15291            Self::BasePendingLog { .. } | Self::BaseFlashblocks | Self::OpPendingFlashblocks
15292        )
15293    }
15294
15295    fn same_key(&self, other: &Self) -> bool {
15296        match (self, other) {
15297            (Self::PubSubLog { filter: left, .. }, Self::PubSubLog { filter: right, .. })
15298            | (
15299                Self::BasePendingLog { filter: left, .. },
15300                Self::BasePendingLog { filter: right, .. },
15301            )
15302            | (Self::PollingLog { filter: left }, Self::PollingLog { filter: right }) => {
15303                left == right
15304            }
15305            (Self::BaseFlashblocks, Self::BaseFlashblocks)
15306            | (Self::OpPendingFlashblocks, Self::OpPendingFlashblocks)
15307            | (Self::CanonicalHeadPolling, Self::CanonicalHeadPolling)
15308            | (Self::PubSubPendingHashes, Self::PubSubPendingHashes)
15309            | (Self::PubSubBlockHeaders, Self::PubSubBlockHeaders)
15310            | (Self::PollingPendingHashes, Self::PollingPendingHashes) => true,
15311            #[cfg(feature = "raw-flashblocks-json")]
15312            (Self::ExternalFlashblockUpdates, Self::ExternalFlashblockUpdates) => true,
15313            _ => false,
15314        }
15315    }
15316
15317    fn is_external_flashblocks(&self) -> bool {
15318        #[cfg(feature = "raw-flashblocks-json")]
15319        {
15320            matches!(self, Self::ExternalFlashblockUpdates)
15321        }
15322        #[cfg(not(feature = "raw-flashblocks-json"))]
15323        {
15324            false
15325        }
15326    }
15327}
15328
15329#[allow(dead_code)]
15330enum SubscriberEvent<N: Network> {
15331    Log {
15332        source_id: usize,
15333        log: Log,
15334        #[cfg(feature = "ingress-timing")]
15335        timing: Option<CanonicalIngressTiming>,
15336    },
15337    BackfilledLogs {
15338        source_id: usize,
15339        logs: Vec<Log>,
15340    },
15341    Logs(Vec<Log>),
15342    BlockHeader {
15343        header: N::HeaderResponse,
15344        #[cfg(feature = "ingress-timing")]
15345        timing: Option<CanonicalIngressTiming>,
15346    },
15347    PendingHash(B256),
15348    PendingHashes(Vec<B256>),
15349    BasePendingLog {
15350        source_id: usize,
15351        log: Log,
15352    },
15353    BasePendingLogTimed {
15354        source_id: usize,
15355        log: Log,
15356        timing: FlashblockIngressTiming,
15357    },
15358    BaseFlashblock(BaseFlashblockWirePayload),
15359    BaseFlashblockTimed {
15360        payload: BaseFlashblockWirePayload,
15361        timing: FlashblockIngressTiming,
15362    },
15363    OpFlashblockTick,
15364    OpFlashblockTickTimed(FlashblockIngressTiming),
15365    CanonicalHeadTick,
15366    PreconfirmedLogs {
15367        flashblock: FlashblockRef,
15368        logs: Vec<Log>,
15369        timing: FlashblockIngressTiming,
15370    },
15371    FlashblockInvalidated,
15372    FlashblockObserved,
15373    #[cfg(feature = "raw-flashblocks-json")]
15374    ExternalFlashblockUpdate(raw_json_flashblocks::QueuedFlashblockUpdate),
15375    StreamTerminated(SubscriberStreamSource),
15376    /// A live stream lost notifications without disconnecting. The subscription
15377    /// is still installed, so only the missed window is recovered rather than
15378    /// the source being reconnected.
15379    StreamGap {
15380        source: SubscriberStreamSource,
15381        gap: SubscriberStreamGap,
15382    },
15383}
15384
15385enum SubscriberReady<N: Network> {
15386    Event(Option<SubscriberEvent<N>>),
15387    FlashblockReconnect(
15388        SubscriberStreamSource,
15389        Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError>,
15390    ),
15391}
15392
15393#[derive(Debug)]
15394enum PendingFlashblockPollError {
15395    Request(SubscriberError),
15396    Integrity(SubscriberError),
15397}
15398
15399impl PendingFlashblockPollError {
15400    fn into_subscriber(self) -> SubscriberError {
15401        match self {
15402            Self::Request(error) | Self::Integrity(error) => error,
15403        }
15404    }
15405}
15406
15407fn pending_flashblock_request_error(error: impl fmt::Display) -> PendingFlashblockPollError {
15408    PendingFlashblockPollError::Request(provider_error(error))
15409}
15410
15411fn normalize_op_pending_block<N: Network>(
15412    mut value: serde_json::Value,
15413) -> Result<N::BlockResponse, SubscriberError> {
15414    let object = value.as_object_mut().ok_or_else(|| {
15415        SubscriberError::Provider("OP pending block response is not an object".into())
15416    })?;
15417    let transactions = object
15418        .get_mut("transactions")
15419        .and_then(serde_json::Value::as_array_mut)
15420        .ok_or_else(|| {
15421            SubscriberError::Provider(
15422                "OP pending block response is missing its transaction array".into(),
15423            )
15424        })?;
15425    for transaction in transactions {
15426        if transaction.is_string() {
15427            continue;
15428        }
15429        let hash = transaction
15430            .as_object()
15431            .and_then(|object| object.get("hash"))
15432            .filter(|hash| hash.is_string())
15433            .cloned()
15434            .ok_or_else(|| {
15435                SubscriberError::Provider("OP pending block transaction is missing its hash".into())
15436            })?;
15437        *transaction = hash;
15438    }
15439    if object.get("hash").is_none_or(serde_json::Value::is_null) {
15440        object.insert(
15441            "hash".into(),
15442            serde_json::Value::String(B256::ZERO.to_string()),
15443        );
15444    }
15445    if object.get("nonce").is_none_or(serde_json::Value::is_null) {
15446        object.insert(
15447            "nonce".into(),
15448            serde_json::Value::String("0x0000000000000000".into()),
15449        );
15450    }
15451    if object.get("miner").is_none_or(serde_json::Value::is_null)
15452        || object
15453            .get("beneficiary")
15454            .is_none_or(serde_json::Value::is_null)
15455    {
15456        object.insert(
15457            "miner".into(),
15458            serde_json::Value::String(Address::ZERO.to_string()),
15459        );
15460    }
15461    serde_json::from_value(value).map_err(|error| {
15462        SubscriberError::Provider(format!(
15463            "failed to decode normalized OP pending block: {error}"
15464        ))
15465    })
15466}
15467
15468fn normalize_pending_transaction_receipt(
15469    expected_transaction_hash: B256,
15470    value: serde_json::Value,
15471) -> Result<Option<Vec<Log>>, SubscriberError> {
15472    if value.is_null() {
15473        return Ok(None);
15474    }
15475    let receipt = value.as_object().ok_or_else(|| {
15476        SubscriberError::Provider("pending transaction receipt response is not an object".into())
15477    })?;
15478    let transaction_hash: B256 =
15479        serde_json::from_value(receipt.get("transactionHash").cloned().ok_or_else(|| {
15480            SubscriberError::Provider(
15481                "pending transaction receipt is missing its transaction hash".into(),
15482            )
15483        })?)
15484        .map_err(|error| {
15485            SubscriberError::Provider(format!(
15486                "failed to decode pending transaction receipt hash: {error}"
15487            ))
15488        })?;
15489    if transaction_hash != expected_transaction_hash {
15490        return Err(SubscriberError::Provider(
15491            "pending transaction receipt hash disagrees with its request".into(),
15492        ));
15493    }
15494    let receipt_logs = receipt
15495        .get("logs")
15496        .and_then(serde_json::Value::as_array)
15497        .ok_or_else(|| {
15498            SubscriberError::Provider("pending transaction receipt is missing its log array".into())
15499        })?;
15500    let mut logs = Vec::new();
15501    for log in receipt_logs {
15502        let log: Log = serde_json::from_value(log.clone()).map_err(|error| {
15503            SubscriberError::Provider(format!(
15504                "failed to decode pending transaction receipt log: {error}"
15505            ))
15506        })?;
15507        if log.transaction_hash != Some(expected_transaction_hash) {
15508            return Err(SubscriberError::Provider(
15509                "pending transaction receipt log hash disagrees with its receipt".into(),
15510            ));
15511        }
15512        logs.push(log);
15513    }
15514    Ok(Some(logs))
15515}
15516
15517impl<P, N> EventSubscriber<N> for AlloySubscriber<P, N>
15518where
15519    P: Provider<N> + Send + Sync,
15520    N: Network + 'static,
15521    N::HeaderResponse: Send + 'static,
15522{
15523    fn chain_id(&self) -> Option<u64> {
15524        self.chain_id
15525    }
15526
15527    fn capabilities(&self) -> SubscriberCapabilities {
15528        let Ok(transport) = resolve_subscriber_transport(self.mode) else {
15529            return SubscriberCapabilities::default();
15530        };
15531        let mut capabilities = vec![
15532            SubscriberCapability::Logs,
15533            SubscriberCapability::PendingTransactionHashes,
15534            SubscriberCapability::HistoricalBackfill,
15535            SubscriberCapability::Live,
15536            SubscriberCapability::OwnerScopedDelivery,
15537            SubscriberCapability::DynamicInterests,
15538        ];
15539        if transport == SubscriberTransport::PubSub {
15540            capabilities.push(SubscriberCapability::BlockHeaders);
15541            // Only the pubsub log streams are consumed through
15542            // `gap_observing_stream`, so only they can prove no loss went
15543            // unhealed. The polling transport's watcher cannot, and must not
15544            // claim it.
15545            capabilities.push(SubscriberCapability::LogCoverageAttestation);
15546        }
15547        if self.config.preconfirmations != PreconfirmationMode::Disabled
15548            && (self.uses_external_flashblock_updates()
15549                || (self.provider_ref.is_some()
15550                    && self.chain_id.and_then(flashblocks_adapter).is_some()))
15551        {
15552            capabilities.push(SubscriberCapability::Preconfirmations);
15553        }
15554        SubscriberCapabilities::new(capabilities)
15555    }
15556
15557    fn register_interests(
15558        &mut self,
15559        interests: &[ReactiveInterest<N>],
15560    ) -> SubscriberOperation<'_, ()> {
15561        let interests = interests.to_vec();
15562        Box::pin(async move {
15563            validate_subscriber_config(&self.config)?;
15564            validate_supported_interests(self.mode, &self.config, &interests)?;
15565            if !interests.is_empty() {
15566                self.ensure_chain_id().await?;
15567            }
15568            self.validate_flashblocks_setup()?;
15569
15570            self.base_interests = interests;
15571            self.owned_interests.clear();
15572            self.rebuild_registered_interests();
15573            self.reset_delivery_state();
15574            self.reset_stream_topology();
15575            Ok(())
15576        })
15577    }
15578
15579    fn next_batch(&mut self) -> SubscriberNextBatch<'_, N> {
15580        Box::pin(async {
15581            Ok(self
15582                .next_scoped_batch()
15583                .await?
15584                .map(SubscriberInputBatch::into_reactive_batch))
15585        })
15586    }
15587}
15588
15589impl<P, N> AlloySubscriber<P, N>
15590where
15591    P: Provider<N> + Send + Sync,
15592    N: Network + 'static,
15593    N::HeaderResponse: Send + 'static,
15594{
15595    /// Validate one configured provider generation and establish its selected
15596    /// Flashblocks delivery surface.
15597    ///
15598    /// The caller must register at least one active log interest first. The
15599    /// built-in profiles require a matching chain id and stable [`ProviderRef`].
15600    /// Base additionally requires pubsub, `newFlashblocks`, and one
15601    /// `pendingLogs` acknowledgement per planned provider filter. Optimism
15602    /// probes the bounded pending block/log/receipt surface.
15603    /// `op_supportedCapabilities` is queried opportunistically and retained as
15604    /// opaque evidence because provider implementations do not expose a uniform
15605    /// capability vocabulary.
15606    ///
15607    /// With `raw-flashblocks-json` and
15608    /// [`Self::configure_external_flashblock_updates`], preflight instead verifies
15609    /// the canonical subscriber chain and installed canonical stream topology.
15610    /// The application owns supplemental-source qualification, and this method
15611    /// performs no Flashblocks request/response calls for that profile.
15612    ///
15613    /// A successful return is deliberately not a liveness qualification. The
15614    /// acceptance window must still observe a Flashblock whose pending state
15615    /// advances and a correlated log for an active pool.
15616    pub async fn establish_flashblocks_preflight(
15617        &mut self,
15618        expected_chain_id: u64,
15619    ) -> Result<FlashblocksPreflight, SubscriberError> {
15620        validate_subscriber_config(&self.config)?;
15621        if self.config.preconfirmations == PreconfirmationMode::Disabled {
15622            return Err(SubscriberError::InvalidConfig(
15623                "Flashblocks preflight requires preconfirmations",
15624            ));
15625        }
15626        if !self
15627            .interests
15628            .iter()
15629            .any(|interest| matches!(interest, ReactiveInterest::Logs(_)))
15630        {
15631            return Err(SubscriberError::InvalidConfig(
15632                "Flashblocks preflight requires at least one active log interest",
15633            ));
15634        }
15635        let chain_id = self.ensure_chain_id().await?;
15636        if chain_id != expected_chain_id {
15637            return Err(SubscriberError::ChainMismatch {
15638                expected: expected_chain_id,
15639                actual: chain_id,
15640            });
15641        }
15642        self.validate_flashblocks_setup()?;
15643        #[cfg(feature = "raw-flashblocks-json")]
15644        if let Some(provider) = self.external_flashblocks_provider.clone() {
15645            self.ensure_streams().await?;
15646            return Ok(FlashblocksPreflight {
15647                chain_id,
15648                provider,
15649                delivery: FlashblocksDelivery::ExternalUpdates,
15650                pending_log_subscriptions: 0,
15651                pending_log_filters: self.log_stream_filters().len(),
15652                advertised_capabilities: None,
15653            });
15654        }
15655        let adapter = flashblocks_adapter(chain_id).ok_or(SubscriberError::Unsupported(
15656            "Flashblocks are currently implemented for Base and OP chains",
15657        ))?;
15658        let provider = self
15659            .provider_ref
15660            .clone()
15661            .ok_or(SubscriberError::InvalidConfig(
15662                "Flashblocks preflight requires a stable provider ref",
15663            ))?;
15664        self.record_rpc(
15665            SubscriberRpcCause::FlashblocksSetup,
15666            SubscriberRpcMethod::OpSupportedCapabilities,
15667        );
15668        self.flashblocks_rpc_metrics.capability_requests = self
15669            .flashblocks_rpc_metrics
15670            .capability_requests
15671            .saturating_add(1);
15672        let capability_provider = if adapter == FlashblocksAdapter::PendingStatePolling {
15673            self.flashblocks_state_provider
15674                .as_ref()
15675                .unwrap_or(&self.provider)
15676        } else {
15677            &self.provider
15678        };
15679        let advertised_capabilities = capability_provider
15680            .client()
15681            .request::<_, serde_json::Value>("op_supportedCapabilities", ())
15682            .await
15683            .ok();
15684
15685        self.ensure_streams().await?;
15686        let pending_log_filters = self.log_stream_filters();
15687        if adapter == FlashblocksAdapter::PendingStatePolling
15688            && self.pending_receipt_requests_per_tick_capacity() == 0
15689        {
15690            return Err(SubscriberError::InvalidConfig(
15691                "Flashblocks RPC budget leaves no capacity for OP transaction receipts",
15692            ));
15693        }
15694        let (delivery, pending_log_subscriptions) = match adapter {
15695            FlashblocksAdapter::NativeSubscriptions => {
15696                if resolve_subscriber_transport(self.mode)? != SubscriberTransport::PubSub {
15697                    return Err(SubscriberError::Unsupported(
15698                        "Base Flashblocks preflight requires pubsub",
15699                    ));
15700                }
15701                let pending_sources = self
15702                    .pubsub_stream_sources()
15703                    .into_iter()
15704                    .filter(|source| {
15705                        matches!(source, SubscriberStreamSource::BasePendingLog { .. })
15706                    })
15707                    .collect::<Vec<_>>();
15708                let AlloySubscriberState::Active(streams) = &self.state else {
15709                    return Err(SubscriberError::Provider(
15710                        "Flashblocks preflight subscriptions did not become active".to_owned(),
15711                    ));
15712                };
15713                if !streams.contains_source(&SubscriberStreamSource::BaseFlashblocks)
15714                    || pending_sources
15715                        .iter()
15716                        .any(|source| !streams.contains_source(source))
15717                {
15718                    return Err(SubscriberError::Provider(
15719                        "Base Flashblocks preflight did not retain both subscription lanes"
15720                            .to_owned(),
15721                    ));
15722                }
15723                (
15724                    FlashblocksDelivery::NativeSubscriptions,
15725                    pending_sources.len(),
15726                )
15727            }
15728            FlashblocksAdapter::PendingStatePolling => {
15729                if let Some(state_provider) = self.flashblocks_state_provider.as_ref() {
15730                    self.record_rpc(
15731                        SubscriberRpcCause::FlashblocksSetup,
15732                        SubscriberRpcMethod::EthChainId,
15733                    );
15734                    self.flashblocks_rpc_metrics.provider_pair_chain_requests = self
15735                        .flashblocks_rpc_metrics
15736                        .provider_pair_chain_requests
15737                        .saturating_add(1);
15738                    let actual = state_provider
15739                        .get_chain_id()
15740                        .await
15741                        .map_err(provider_error)?;
15742                    if actual != expected_chain_id {
15743                        return Err(SubscriberError::ChainMismatch {
15744                            expected: expected_chain_id,
15745                            actual,
15746                        });
15747                    }
15748                }
15749                let AlloySubscriberState::Active(streams) = &self.state else {
15750                    return Err(SubscriberError::Provider(
15751                        "Flashblocks preflight streams did not become active".to_owned(),
15752                    ));
15753                };
15754                if !streams.contains_source(&SubscriberStreamSource::OpPendingFlashblocks) {
15755                    return Err(SubscriberError::Provider(
15756                        "Optimism Flashblocks preflight did not retain its pending-state sampler"
15757                            .to_owned(),
15758                    ));
15759                }
15760                self.probe_pending_state(&pending_log_filters).await?;
15761                (FlashblocksDelivery::PendingStatePolling, 0)
15762            }
15763        };
15764        Ok(FlashblocksPreflight {
15765            chain_id,
15766            provider,
15767            delivery,
15768            pending_log_subscriptions,
15769            pending_log_filters: pending_log_filters.len(),
15770            advertised_capabilities,
15771        })
15772    }
15773
15774    /// Ingest one standardized update from an application-managed source.
15775    ///
15776    /// This method is synchronous and performs no provider I/O. The update is
15777    /// validated against the configured source identity, normalized through
15778    /// the same preconfirmation deduplication used by provider subscriptions,
15779    /// and queued for ordinary [`EventSubscriber`] delivery. Stale provider
15780    /// generations and stale invalidations cannot revoke newer speculative
15781    /// state. Indexed snapshots must begin at zero, advance exactly one index at
15782    /// a time, preserve their base identity and cumulative transaction prefix,
15783    /// and bind delta logs only to newly appended transactions.
15784    #[cfg(feature = "raw-flashblocks-json")]
15785    pub fn ingest_flashblock_update(
15786        &mut self,
15787        update: FlashblockUpdate,
15788    ) -> Result<(), SubscriberError> {
15789        self.ingest_flashblock_update_with_ingress(
15790            update,
15791            FlashblockIngressTiming::new(Instant::now()),
15792        )
15793    }
15794
15795    /// Ingest one standardized update with its original typed source arrival.
15796    ///
15797    /// This timing is observability-only and cannot mutate canonical state or
15798    /// grant trigger authority.
15799    ///
15800    /// # Errors
15801    ///
15802    /// Returns the same validation and resource errors as
15803    /// [`Self::ingest_flashblock_update`].
15804    #[cfg(feature = "raw-flashblocks-json")]
15805    pub fn ingest_flashblock_update_with_ingress(
15806        &mut self,
15807        update: FlashblockUpdate,
15808        timing: FlashblockIngressTiming,
15809    ) -> Result<(), SubscriberError> {
15810        validate_subscriber_config(&self.config)?;
15811        self.validate_flashblocks_setup()?;
15812        let configured =
15813            self.external_flashblocks_provider
15814                .as_ref()
15815                .ok_or(SubscriberError::InvalidConfig(
15816                    "standardized Flashblock updates require configure_external_flashblock_updates",
15817                ))?;
15818
15819        match update {
15820            FlashblockUpdate::Snapshot(batch) => {
15821                if batch.flashblock.provider.endpoint != configured.endpoint {
15822                    return Err(SubscriberError::Provider(
15823                        "external Flashblock update came from an unexpected provider endpoint"
15824                            .into(),
15825                    ));
15826                }
15827                if batch.flashblock.provider.generation < configured.generation
15828                    || self.latest_preconfirmation.as_ref().is_some_and(|latest| {
15829                        latest.provider.endpoint == batch.flashblock.provider.endpoint
15830                            && latest.provider.generation > batch.flashblock.provider.generation
15831                    })
15832                {
15833                    return Ok(());
15834                }
15835                if self
15836                    .rejected_external_flashblock_generation
15837                    .is_some_and(|rejected| batch.flashblock.provider.generation <= rejected)
15838                {
15839                    return Err(SubscriberError::Provider(
15840                        "external Flashblock provider generation was previously rejected".into(),
15841                    ));
15842                }
15843                validate_standard_flashblock_snapshot(&batch)?;
15844                if self.validate_external_flashblock_sequence(&batch)? {
15845                    return Ok(());
15846                }
15847                let required = self.pending_record_count().saturating_add(batch.logs.len());
15848                if required > self.config.max_pending_records {
15849                    self.invalidate_preconfirmation_snapshot();
15850                    self.last_external_flashblock_snapshot = None;
15851                    return Err(SubscriberError::ResourceExhausted(format!(
15852                        "external preconfirmation records require {required} pending records, above the configured limit of {}",
15853                        self.config.max_pending_records
15854                    )));
15855                }
15856                let accepted_snapshot = (*batch).clone();
15857                let FlashblockSnapshot { flashblock, logs } = *batch;
15858                let logs = self.filter_preconfirmed_logs(&flashblock, logs)?;
15859                self.last_external_flashblock_snapshot = Some(accepted_snapshot);
15860                if let Some(provider) = self.external_flashblocks_provider.as_mut() {
15861                    provider.generation = provider.generation.max(flashblock.provider.generation);
15862                }
15863                if !logs.is_empty() {
15864                    self.enqueue_event(SubscriberEvent::PreconfirmedLogs {
15865                        flashblock,
15866                        logs,
15867                        timing,
15868                    });
15869                }
15870            }
15871            FlashblockUpdate::Invalidated(invalidation) => {
15872                if invalidation.provider.endpoint != configured.endpoint {
15873                    return Err(SubscriberError::Provider(
15874                        "external Flashblock invalidation came from an unexpected provider endpoint"
15875                            .into(),
15876                    ));
15877                }
15878                if self.latest_preconfirmation.as_ref().is_some_and(|latest| {
15879                    latest.provider == invalidation.provider
15880                        && latest.payload_id == Some(invalidation.payload_id)
15881                }) {
15882                    self.invalidate_preconfirmation_snapshot();
15883                    self.last_external_flashblock_snapshot = None;
15884                }
15885            }
15886        }
15887        Ok(())
15888    }
15889
15890    #[cfg(feature = "raw-flashblocks-json")]
15891    fn validate_external_flashblock_sequence(
15892        &self,
15893        snapshot: &FlashblockSnapshot,
15894    ) -> Result<bool, SubscriberError> {
15895        let Some(previous) = self.last_external_flashblock_snapshot.as_ref() else {
15896            if snapshot.flashblock.index != Some(0) {
15897                return Err(SubscriberError::Provider(
15898                    "external Flashblock payload generation must begin at index zero".into(),
15899                ));
15900            }
15901            return Ok(false);
15902        };
15903
15904        if previous.flashblock.provider == snapshot.flashblock.provider
15905            && previous.flashblock.payload_id == snapshot.flashblock.payload_id
15906        {
15907            let previous_index = previous
15908                .flashblock
15909                .index
15910                .expect("validated indexed snapshot");
15911            let current_index = snapshot
15912                .flashblock
15913                .index
15914                .expect("validated indexed snapshot");
15915            if current_index == previous_index {
15916                if previous == snapshot {
15917                    return Ok(true);
15918                }
15919                return Err(SubscriberError::Provider(
15920                    "external Flashblock repeated the same index with conflicting content".into(),
15921                ));
15922            }
15923            if current_index < previous_index {
15924                return Err(SubscriberError::Provider(format!(
15925                    "external Flashblock index regressed from {previous_index} to {current_index}"
15926                )));
15927            }
15928            if current_index > previous_index.saturating_add(1) {
15929                return Err(SubscriberError::Provider(format!(
15930                    "external Flashblock index skipped from {previous_index} to {current_index}"
15931                )));
15932            }
15933            if current_index == previous_index.saturating_add(1)
15934                && !previous.flashblock.same_base_identity(&snapshot.flashblock)
15935            {
15936                return Err(SubscriberError::Provider(
15937                    "external Flashblock base identity changed within one payload generation"
15938                        .into(),
15939                ));
15940            }
15941            if current_index == previous_index.saturating_add(1)
15942                && !snapshot
15943                    .flashblock
15944                    .transaction_hashes
15945                    .starts_with(&previous.flashblock.transaction_hashes)
15946            {
15947                return Err(SubscriberError::Provider(
15948                    "external Flashblock cumulative transaction membership changed its prior prefix"
15949                        .into(),
15950                ));
15951            }
15952            let prior_transaction_count =
15953                u64::try_from(previous.flashblock.transaction_hashes.len()).unwrap_or(u64::MAX);
15954            if current_index == previous_index.saturating_add(1)
15955                && snapshot.logs.iter().any(|log| {
15956                    log.transaction_index
15957                        .is_some_and(|index| index < prior_transaction_count)
15958                })
15959            {
15960                return Err(SubscriberError::Provider(
15961                    "external Flashblock delta log does not belong to a newly appended transaction"
15962                        .into(),
15963                ));
15964            }
15965        } else if snapshot.flashblock.index != Some(0) {
15966            return Err(SubscriberError::Provider(
15967                "external Flashblock payload generation must begin at index zero".into(),
15968            ));
15969        }
15970        Ok(false)
15971    }
15972
15973    async fn probe_pending_state(&mut self, filters: &[Filter]) -> Result<(), SubscriberError> {
15974        self.flashblocks_rpc_metrics.pending_block_requests = self
15975            .flashblocks_rpc_metrics
15976            .pending_block_requests
15977            .saturating_add(1);
15978        let pending = self
15979            .fetch_op_pending_block()
15980            .await
15981            .map_err(PendingFlashblockPollError::into_subscriber)?
15982            .ok_or_else(|| {
15983                SubscriberError::Provider(
15984                    "provider returned no pending block during Flashblocks preflight".into(),
15985                )
15986            })?;
15987        self.certify_op_pending_parent(&pending)
15988            .await
15989            .map_err(PendingFlashblockPollError::into_subscriber)?;
15990        for filter in filters {
15991            self.record_rpc(
15992                SubscriberRpcCause::PendingStateSample,
15993                SubscriberRpcMethod::EthGetLogs,
15994            );
15995            self.flashblocks_rpc_metrics.pending_log_requests = self
15996                .flashblocks_rpc_metrics
15997                .pending_log_requests
15998                .saturating_add(1);
15999            self.flashblocks_state_provider
16000                .as_ref()
16001                .unwrap_or(&self.provider)
16002                .get_logs(
16003                    &filter
16004                        .clone()
16005                        .from_block(BlockNumberOrTag::Latest)
16006                        .to_block(BlockNumberOrTag::Pending),
16007                )
16008                .await
16009                .map_err(provider_error)?;
16010        }
16011        self.record_rpc(
16012            SubscriberRpcCause::PendingStateSample,
16013            SubscriberRpcMethod::EthGetTransactionReceipt,
16014        );
16015        self.flashblocks_rpc_metrics.pending_receipt_requests = self
16016            .flashblocks_rpc_metrics
16017            .pending_receipt_requests
16018            .saturating_add(1);
16019        let _: serde_json::Value = self
16020            .flashblocks_state_provider
16021            .as_ref()
16022            .unwrap_or(&self.provider)
16023            .raw_request(Cow::Borrowed("eth_getTransactionReceipt"), (B256::ZERO,))
16024            .await
16025            .map_err(provider_error)?;
16026        Ok(())
16027    }
16028
16029    async fn certify_op_pending_parent(
16030        &mut self,
16031        pending: &N::BlockResponse,
16032    ) -> Result<N::HeaderResponse, PendingFlashblockPollError> {
16033        let pending_header = pending.header();
16034        let pending_number = pending_header.number();
16035        let parent_hash = pending_header.parent_hash();
16036        if pending_number == 0 || parent_hash.is_zero() {
16037            return Err(PendingFlashblockPollError::Integrity(
16038                SubscriberError::Provider(
16039                    "OP pending block omitted a certifiable canonical parent".into(),
16040                ),
16041            ));
16042        }
16043        self.record_rpc(
16044            SubscriberRpcCause::CanonicalHeadCertification,
16045            SubscriberRpcMethod::EthGetBlockByHash,
16046        );
16047        self.flashblocks_rpc_metrics.canonical_head_requests = self
16048            .flashblocks_rpc_metrics
16049            .canonical_head_requests
16050            .saturating_add(1);
16051        let parent = self
16052            .flashblocks_state_provider
16053            .as_ref()
16054            .unwrap_or(&self.provider)
16055            .get_block_by_hash(parent_hash)
16056            .await
16057            .map_err(pending_flashblock_request_error)?
16058            .ok_or_else(|| {
16059                PendingFlashblockPollError::Request(SubscriberError::Provider(
16060                    "Flashblocks provider returned no exact OP pending parent block".into(),
16061                ))
16062            })?;
16063        let parent_header = parent.header();
16064        if parent_header.hash() != parent_hash
16065            || parent_header.number().checked_add(1) != Some(pending_number)
16066        {
16067            return Err(PendingFlashblockPollError::Integrity(
16068                SubscriberError::Provider(
16069                    "OP pending block does not extend its exact certified parent".into(),
16070                ),
16071            ));
16072        }
16073        Ok(parent_header.clone())
16074    }
16075
16076    async fn fetch_op_pending_block(
16077        &mut self,
16078    ) -> Result<Option<N::BlockResponse>, PendingFlashblockPollError> {
16079        self.record_rpc(
16080            SubscriberRpcCause::PendingStateSample,
16081            SubscriberRpcMethod::EthGetBlockByNumber,
16082        );
16083        let state_provider = self
16084            .flashblocks_state_provider
16085            .as_ref()
16086            .unwrap_or(&self.provider);
16087        let value: Option<serde_json::Value> = state_provider
16088            .raw_request(
16089                Cow::Borrowed("eth_getBlockByNumber"),
16090                (BlockNumberOrTag::Pending, true),
16091            )
16092            .await
16093            .map_err(pending_flashblock_request_error)?;
16094        value
16095            .map(normalize_op_pending_block::<N>)
16096            .transpose()
16097            .map_err(PendingFlashblockPollError::Integrity)
16098    }
16099
16100    /// Resolve the provider's chain identity once. The assignment happens only
16101    /// after a complete RPC response, so cancelling the future leaves the
16102    /// subscriber cleanly retryable.
16103    async fn ensure_chain_id(&mut self) -> Result<u64, SubscriberError> {
16104        if let Some(chain_id) = self.chain_id {
16105            return Ok(chain_id);
16106        }
16107        self.record_rpc(
16108            SubscriberRpcCause::ChainIdentity,
16109            SubscriberRpcMethod::EthChainId,
16110        );
16111        let chain_id = self.provider.get_chain_id().await.map_err(provider_error)?;
16112        self.chain_id = Some(chain_id);
16113        Ok(chain_id)
16114    }
16115
16116    fn validate_flashblocks_setup(&self) -> Result<(), SubscriberError> {
16117        if self.config.preconfirmations == PreconfirmationMode::Disabled {
16118            if self.uses_external_flashblock_updates() {
16119                return Err(SubscriberError::InvalidConfig(
16120                    "external Flashblock updates require preconfirmations to be preferred or required",
16121                ));
16122            }
16123            return Ok(());
16124        }
16125        if self.uses_external_flashblock_updates() {
16126            return Ok(());
16127        }
16128        if self.provider_ref.is_none() {
16129            return Err(SubscriberError::InvalidConfig(
16130                "Flashblocks require a stable provider ref from a pinned provider lease",
16131            ));
16132        }
16133        let Some(chain_id) = self.chain_id else {
16134            return Ok(());
16135        };
16136        match flashblocks_adapter(chain_id) {
16137            Some(FlashblocksAdapter::NativeSubscriptions)
16138                if resolve_subscriber_transport(self.mode)? != SubscriberTransport::PubSub
16139                    && self.config.preconfirmations == PreconfirmationMode::Required =>
16140            {
16141                return Err(SubscriberError::Unsupported(
16142                    "Base Flashblocks require pubsub for newFlashblocks and pendingLogs",
16143                ));
16144            }
16145            Some(FlashblocksAdapter::NativeSubscriptions) => {}
16146            Some(_) => {}
16147            None if self.config.preconfirmations == PreconfirmationMode::Required => {
16148                return Err(SubscriberError::Unsupported(
16149                    "Flashblocks are currently implemented for Base and OP chains",
16150                ));
16151            }
16152            None => {}
16153        }
16154        Ok(())
16155    }
16156
16157    /// Subscribe first, then catch an exact staged owner up through a verified
16158    /// canonical block.
16159    ///
16160    /// This compatibility wrapper delegates to
16161    /// [`reconcile_interest_owners`](Self::reconcile_interest_owners), so a
16162    /// driver adopting several owners should call the bulk API once rather than
16163    /// invoking this method in a loop.
16164    ///
16165    /// # Errors
16166    ///
16167    /// Returns [`SubscriberOwnerError`] when the epoch is not staged, lacks a
16168    /// baseline, conflicts/regresses, provider certification or transport
16169    /// fails, returned logs are invalid, or subscriber resources are exhausted.
16170    pub async fn reconcile_interest_owner(
16171        &mut self,
16172        epoch: &SubscriberOwnerEpoch,
16173        through: BlockRef,
16174    ) -> Result<SubscriberOwnerProgress, SubscriberOwnerError>
16175    where
16176        P: Clone,
16177    {
16178        self.reconcile_interest_owners(std::slice::from_ref(epoch), through)
16179            .await?
16180            .pop()
16181            .ok_or(SubscriberOwnerError::NotStaged)
16182    }
16183
16184    /// Subscribe first, then atomically catch staged owners up through one
16185    /// verified canonical block.
16186    ///
16187    /// All epochs are preflighted before provider I/O. Live streams are
16188    /// reconciled once, compatible provider filters are merged into bounded
16189    /// chunks, and every historical request shares one double target-header
16190    /// certification. Provider-filter supersets are routed back through each
16191    /// owner's exact interests, retaining owner-scoped delivery provenance.
16192    /// Duplicate epoch tokens in `epochs` are coalesced in first-seen order.
16193    ///
16194    /// Live events are continuously drained while an independent provider
16195    /// clone performs catch-up. Fetched owner records and progress become
16196    /// visible only after every request and the final certification succeed. A
16197    /// failure leaves every target staged with its prior progress unchanged;
16198    /// live canonical delivery consumed during the attempt is preserved while
16199    /// excluding the failed target epochs from its staged-owner audience.
16200    ///
16201    /// # Errors
16202    ///
16203    /// Returns [`SubscriberOwnerError`] when an epoch is not staged, lacks a
16204    /// baseline, conflicts/regresses, provider certification or transport
16205    /// fails, returned logs are invalid, or subscriber resources are exhausted.
16206    /// Target progress remains unchanged on error.
16207    pub async fn reconcile_interest_owners(
16208        &mut self,
16209        epochs: &[SubscriberOwnerEpoch],
16210        through: BlockRef,
16211    ) -> Result<Vec<SubscriberOwnerProgress>, SubscriberOwnerError>
16212    where
16213        P: Clone,
16214    {
16215        if epochs.is_empty() {
16216            return Ok(Vec::new());
16217        }
16218
16219        self.ensure_chain_id().await?;
16220
16221        let mut seen = HashSet::new();
16222        let mut plans = Vec::with_capacity(epochs.len());
16223        for epoch in epochs {
16224            if !seen.insert(epoch.clone()) {
16225                continue;
16226            }
16227            let entry = self
16228                .owned_interests
16229                .iter()
16230                .find(|entry| {
16231                    entry.epoch.as_ref() == Some(epoch)
16232                        && entry.state == SubscriberOwnerState::Staged
16233                })
16234                .ok_or(SubscriberOwnerError::NotStaged)?;
16235            let position = entry
16236                .progress
16237                .as_ref()
16238                .map(|progress| &progress.through)
16239                .or(entry.baseline.as_ref())
16240                .ok_or(SubscriberOwnerError::MissingBaseline)?;
16241            let baseline = position.number;
16242            if through.number < baseline {
16243                return Err(SubscriberOwnerError::ProgressRegression {
16244                    current: baseline,
16245                    target: through.number,
16246                });
16247            }
16248            let from_block = baseline
16249                .checked_add(1)
16250                .ok_or(SubscriberOwnerError::PostBlockOverflow(baseline))?;
16251            if through.number == baseline && through.hash != position.hash {
16252                return Err(SubscriberOwnerError::ProgressConflict {
16253                    number: baseline,
16254                    current_hash: position.hash,
16255                    target_hash: through.hash,
16256                });
16257            }
16258            if through.number == from_block
16259                && through
16260                    .parent_hash
16261                    .is_some_and(|parent| parent != position.hash)
16262            {
16263                return Err(SubscriberOwnerError::ProgressConflict {
16264                    number: baseline,
16265                    current_hash: position.hash,
16266                    target_hash: through.parent_hash.expect("checked as present above"),
16267                });
16268            }
16269            if entry
16270                .interests
16271                .iter()
16272                .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
16273            {
16274                return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
16275            }
16276            plans.push(SubscriberOwnerReconcilePlan {
16277                epoch: epoch.clone(),
16278                interests: entry.interests.clone(),
16279                retained: *position,
16280                from_block,
16281            });
16282        }
16283
16284        // The ordering is intentional and part of the public continuity
16285        // contract: connect first, then fetch the bounded historical window.
16286        self.ensure_streams().await?;
16287        let provider = self.provider.clone();
16288        let filters = merged_owner_reconcile_filters(&plans, through.number);
16289        let retained = plans.iter().map(|plan| plan.retained).collect();
16290        let target_epochs: HashSet<_> = plans.iter().map(|plan| plan.epoch.clone()).collect();
16291        let fetch = fetch_owner_catchup::<P, N>(
16292            provider,
16293            filters,
16294            retained,
16295            through,
16296            SubscriberOwnerCatchupOptions {
16297                target_preverified: false,
16298                max_logs: self.config.max_pending_records,
16299                max_log_bytes: self.config.max_backfill_log_bytes,
16300                max_requests_in_flight: self.config.max_reconcile_requests_in_flight,
16301                cause: SubscriberRpcCause::OwnerReconcile,
16302            },
16303            Arc::clone(&self.rpc_counters),
16304        );
16305        let SubscriberOwnerCatchup { logs, certified } =
16306            self.drive_reconcile_fetch(fetch, &target_epochs).await?;
16307
16308        let records = logs
16309            .into_iter()
16310            .map(|log| log_input_record(log, InputSource::Backfill))
16311            .collect();
16312        let mut routed_records = Vec::new();
16313        for record in dedupe_records(sort_records(records)).map_err(|error| {
16314            SubscriberError::InvalidBackfill(format!(
16315                "conflicting duplicate owner catch-up record: {error}"
16316            ))
16317        })? {
16318            let block_number = match &record.input {
16319                ReactiveInput::Log(log) => log
16320                    .block_number
16321                    .expect("bulk catch-up logs were validated before commit"),
16322                _ => unreachable!("bulk owner catch-up contains log records only"),
16323            };
16324            let owners: Vec<SubscriberOwnerEpoch> = plans
16325                .iter()
16326                .filter(|plan| block_number >= plan.from_block)
16327                .filter(|plan| {
16328                    plan.interests
16329                        .iter()
16330                        .any(|interest| interest_matches(interest, &record.input))
16331                })
16332                .map(|plan| plan.epoch.clone())
16333                .collect();
16334            if !owners.is_empty() {
16335                routed_records.push((record, owners));
16336            }
16337        }
16338        self.ensure_pending_record_capacity(
16339            routed_records.len(),
16340            "owner reconciliation historical records",
16341        )?;
16342
16343        // Nothing provider-derived becomes authoritative until every record is
16344        // known to fit. In particular, preserve queued retry state and owner
16345        // progress when the bounded delivery queue cannot accept the catch-up.
16346        self.pending_backfills.retain(|queued| {
16347            queued
16348                .epoch
16349                .as_ref()
16350                .is_none_or(|epoch| !target_epochs.contains(epoch))
16351        });
16352        for (record, owners) in routed_records {
16353            self.enqueue_owner_record_for_owners_unmerged(record, owners);
16354        }
16355        self.promote_reconcile_owner_records(&target_epochs);
16356        self.seed_reconciled_filter_anchors(&plans, certified.number);
16357
16358        let stream_revision = self.stream_revision;
16359        let mut progress = Vec::with_capacity(plans.len());
16360        for plan in plans {
16361            let item = SubscriberOwnerProgress {
16362                owner: plan.epoch.clone(),
16363                through: certified,
16364            };
16365            let entry = self
16366                .owned_interests
16367                .iter_mut()
16368                .find(|entry| entry.epoch.as_ref() == Some(&plan.epoch))
16369                .expect("bulk reconcile holds exclusive access after epoch preflight");
16370            entry.progress = Some(item.clone());
16371            entry.progress_stream_revision = Some(stream_revision);
16372            progress.push(item);
16373        }
16374        Ok(progress)
16375    }
16376
16377    async fn drive_reconcile_fetch<T, F>(
16378        &mut self,
16379        fetch: F,
16380        target_epochs: &HashSet<SubscriberOwnerEpoch>,
16381    ) -> Result<T, SubscriberOwnerError>
16382    where
16383        F: Future<Output = Result<T, SubscriberOwnerError>>,
16384    {
16385        if !matches!(&self.state, AlloySubscriberState::Active(_)) {
16386            return fetch.await;
16387        }
16388        let mut fetch = Box::pin(fetch);
16389        loop {
16390            let event = {
16391                let live = Box::pin(self.next_event());
16392                match select(fetch, live).await {
16393                    Either::Left((result, pending_live)) => {
16394                        drop(pending_live);
16395                        return result;
16396                    }
16397                    Either::Right((event, pending_fetch)) => {
16398                        fetch = pending_fetch;
16399                        event
16400                    }
16401                }
16402            };
16403            let event = event?.ok_or_else(|| {
16404                SubscriberError::Provider(
16405                    "Alloy subscriber streams ended during owner reconcile".to_owned(),
16406                )
16407            })?;
16408            self.buffer_reconcile_event_for_owners(&event, target_epochs);
16409            self.enqueue_event_excluding_owners(event, target_epochs);
16410            self.check_resource_error()?;
16411        }
16412    }
16413
16414    /// Poll one driver control future with priority over the next scoped batch.
16415    ///
16416    /// This is the supported control-interleaving primitive for a subscriber
16417    /// driver. `control` is borrowed rather than consumed, so a batch win leaves
16418    /// the caller's pending control future alive. When control wins, the
16419    /// in-progress subscriber poll is cancelled at a documented safe boundary:
16420    /// queued records are removed only when a complete batch is returned,
16421    /// successful backfill steps are committed before the next await, provider
16422    /// streams created but not installed are dropped, and installed streams
16423    /// remain owned by the subscriber for the next call.
16424    ///
16425    /// The control future is polled first. Therefore a ready shutdown/removal
16426    /// command cannot starve behind a continuously ready subscriber queue.
16427    ///
16428    /// # Errors
16429    ///
16430    /// Returns [`SubscriberError`] when the subscriber poll encounters a
16431    /// transport, continuity, decoding, configuration, or resource failure.
16432    pub async fn next_scoped_batch_or<C, F>(
16433        &mut self,
16434        control: Pin<&mut F>,
16435    ) -> Result<SubscriberDriverPoll<C, N>, SubscriberError>
16436    where
16437        C: Send,
16438        F: Future<Output = C> + Send,
16439    {
16440        let batch = self.next_scoped_batch();
16441        match select(control, batch).await {
16442            Either::Left((control, pending_batch)) => {
16443                drop(pending_batch);
16444                Ok(SubscriberDriverPoll::Control(control))
16445            }
16446            Either::Right((batch, _pending_control)) => batch.map(SubscriberDriverPoll::Batch),
16447        }
16448    }
16449
16450    /// Return the next subscriber batch while retaining staged-owner delivery
16451    /// provenance captured at enqueue time.
16452    ///
16453    /// Transaction-aware drivers must use this method. The compatibility
16454    /// [`EventSubscriber::next_batch`] method flattens the same queue and keeps
16455    /// its historical behavior for existing callers.
16456    ///
16457    /// For command interleaving, prefer
16458    /// [`next_scoped_batch_or`](Self::next_scoped_batch_or), which preserves the
16459    /// cancellation-safety invariants of this poll and prioritizes ready control.
16460    pub fn next_scoped_batch(&mut self) -> SubscriberNextScopedBatch<'_, N> {
16461        Box::pin(async {
16462            self.check_resource_error()?;
16463            if self.chain_id.is_none()
16464                && (!self.pending_records.is_empty()
16465                    || !self.pending_chain_controls.is_empty()
16466                    || !self.pending_backfills.is_empty()
16467                    || !self.interests.is_empty())
16468            {
16469                self.ensure_chain_id().await?;
16470            }
16471            if let Some(batch) = self.drain_next_scoped_batch() {
16472                return Ok(Some(batch));
16473            }
16474
16475            // Subscribe/adopt the complete desired topology before resolving
16476            // any queued historical upper bound. Live streams therefore own
16477            // every event that can arrive while the bounded backfill is in
16478            // flight, including the coordinated registration window.
16479            self.ensure_streams().await?;
16480            self.check_resource_error()?;
16481            if let Some(batch) = self.drain_next_scoped_batch() {
16482                return Ok(Some(batch));
16483            }
16484
16485            self.drain_pending_backfills().await?;
16486            self.check_resource_error()?;
16487            if let Some(batch) = self.drain_next_scoped_batch() {
16488                return Ok(Some(batch));
16489            }
16490
16491            if self.interests.is_empty() {
16492                return Ok(None);
16493            }
16494
16495            loop {
16496                let Some(event) = self.next_event().await? else {
16497                    return Ok(None);
16498                };
16499
16500                self.enqueue_event(event);
16501                self.check_resource_error()?;
16502                if let Some(batch) = self.drain_next_scoped_batch() {
16503                    return Ok(Some(batch));
16504                }
16505            }
16506        })
16507    }
16508
16509    /// Bring live streams in line with the current interest set.
16510    ///
16511    /// Runs incrementally: the desired-vs-live diff only happens when interest
16512    /// bookkeeping changed since the last successful pass (`sources_dirty`), so
16513    /// steady-state polling costs nothing here. Missing sources are connected,
16514    /// sources for retired filters are dropped (dropping an Alloy subscription
16515    /// unsubscribes provider-side), and unrelated live streams — with their
16516    /// delivery and anchor state — are left untouched.
16517    ///
16518    /// A newly connected log source whose filter already has a delivery anchor
16519    /// is caught up from that anchor immediately after subscribing (the same
16520    /// subscribe-then-backfill order the reconnect path uses). Together with
16521    /// anchor seeding in [`Self::drain_pending_backfills`], that closes the
16522    /// window between an adoption backfill and live stream start.
16523    async fn ensure_streams(&mut self) -> Result<(), SubscriberError> {
16524        if !self.sources_dirty {
16525            return Ok(());
16526        }
16527        // An interest-less subscriber never touches the provider
16528        // ([`EventSubscriber::next_batch`] returns `Ok(None)`). Still certify
16529        // the empty desired topology as clean so a deliberately empty staged
16530        // epoch can reconcile and activate instead of remaining dirty forever.
16531        if matches!(self.state, AlloySubscriberState::Uninitialized) && self.interests.is_empty() {
16532            self.bump_stream_revision();
16533            self.sources_dirty = false;
16534            return Ok(());
16535        }
16536
16537        let desired = self.stream_sources()?;
16538        let missing: Vec<SubscriberStreamSource> = match &self.state {
16539            AlloySubscriberState::Active(streams) => desired
16540                .iter()
16541                .filter(|source| !streams.contains_source(source))
16542                .cloned()
16543                .collect(),
16544            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => desired.clone(),
16545        };
16546
16547        for source in missing {
16548            let stream = match self.connect_source_stream(source.clone()).await {
16549                Ok(stream) => stream,
16550                Err(error)
16551                    if source.is_flashblocks()
16552                        && self.config.preconfirmations == PreconfirmationMode::Preferred =>
16553                {
16554                    tracing::warn!(
16555                        stream = source.label(),
16556                        error = %error,
16557                        "Flashblocks source unavailable; canonical delivery remains active"
16558                    );
16559                    if self.config.reconnect.enabled {
16560                        self.schedule_flashblock_reconnect(
16561                            source,
16562                            self.config.reconnect.retry_delay,
16563                        );
16564                    }
16565                    continue;
16566                }
16567                Err(error) => return Err(error),
16568            };
16569            // Publish each successful connection before any later await. If a
16570            // second connection or anchored catch-up fails/cancels, this stream
16571            // remains live and the next reconcile skips reconnecting it.
16572            self.install_source_stream(source.clone(), stream);
16573            if self.source_requires_backfill(&source) {
16574                self.queue_source_backfill(source);
16575            }
16576        }
16577
16578        while let Some(source) = self.pending_source_backfills.front().cloned() {
16579            let desired_and_live = desired.iter().any(|item| item.same_key(&source))
16580                && matches!(
16581                    &self.state,
16582                    AlloySubscriberState::Active(streams) if streams.contains_source(&source)
16583                );
16584            if !desired_and_live {
16585                self.pending_source_backfills.pop_front();
16586                continue;
16587            }
16588
16589            // Anchored catch-up for a source with a known delivery watermark
16590            // (seeded by a drained adoption backfill, or inherited from a
16591            // filter shape that was live before): subscribe first, then fetch
16592            // the gap, so nothing lands between the two. Pop only after the
16593            // request succeeds; errors and cancellation retain retry intent.
16594            let event = self.backfill_reconnected_source(&source).await?;
16595            self.pending_source_backfills.pop_front();
16596            if let Some(event) = event {
16597                self.enqueue_event(event);
16598            }
16599        }
16600
16601        if let AlloySubscriberState::Active(streams) = &mut self.state {
16602            streams.retain_sources(&desired);
16603            if streams.is_empty() {
16604                self.state = AlloySubscriberState::Empty;
16605            }
16606        }
16607
16608        self.bump_stream_revision();
16609        self.sources_dirty = false;
16610        self.retire_unreferenced_filters();
16611        Ok(())
16612    }
16613
16614    fn install_source_stream(
16615        &mut self,
16616        source: SubscriberStreamSource,
16617        stream: BoxStream<'static, SubscriberEvent<N>>,
16618    ) {
16619        match &mut self.state {
16620            AlloySubscriberState::Active(streams) => {
16621                if streams.contains_source(&source) {
16622                    return;
16623                }
16624                streams.push(source, stream);
16625            }
16626            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
16627                let mut streams = SubscriberStreams::new();
16628                streams.push(source, stream);
16629                self.state = AlloySubscriberState::Active(streams);
16630            }
16631        }
16632        // A partially completed reconcile is still a topology change. Advance
16633        // the revision now rather than only at the final clean boundary.
16634        self.bump_stream_revision();
16635    }
16636
16637    fn schedule_flashblock_reconnect(
16638        &mut self,
16639        source: SubscriberStreamSource,
16640        first_delay: Duration,
16641    ) {
16642        if self
16643            .pending_flashblock_reconnect_sources
16644            .iter()
16645            .any(|pending| pending.same_key(&source))
16646        {
16647            return;
16648        }
16649        self.pending_flashblock_reconnect_sources
16650            .push(source.clone());
16651        self.pending_flashblock_reconnects
16652            .push(flashblock_reconnect_future(
16653                self.provider.root().clone(),
16654                source,
16655                self.config.max_batch_size,
16656                self.config.reconnect.clone(),
16657                first_delay,
16658                self.config.flashblock_poll_interval,
16659                Arc::clone(&self.rpc_counters),
16660            ));
16661    }
16662
16663    fn reschedule_preferred_flashblock(&mut self, source: SubscriberStreamSource) {
16664        if !self.config.reconnect.enabled {
16665            return;
16666        }
16667        self.schedule_flashblock_reconnect(source, self.config.reconnect.max_delay);
16668    }
16669
16670    fn source_requires_backfill(&self, source: &SubscriberStreamSource) -> bool {
16671        matches!(source, SubscriberStreamSource::PubSubLog { id, .. }
16672            if self.last_seen_log_blocks.contains_key(id))
16673    }
16674
16675    fn queue_source_backfill(&mut self, source: SubscriberStreamSource) {
16676        if !self
16677            .pending_source_backfills
16678            .iter()
16679            .any(|pending| pending.same_key(&source))
16680        {
16681            self.pending_source_backfills.push_back(source);
16682        }
16683    }
16684
16685    /// Fetch queued adoption/continuity backfills, oldest first.
16686    ///
16687    /// An entry is consumed only after its `get_logs` fetch succeeds — a
16688    /// transient RPC failure surfaces the error and leaves the entry queued for
16689    /// the next poll, so a flaky request cannot silently discard the missed
16690    /// window the backfill exists to close. Open-ended backfills resolve their
16691    /// upper bound to the provider's current head before fetching, and every
16692    /// drained backfill advances the filter's delivery anchor to that bound —
16693    /// even a zero-log window — so the filter is reconnect-protected from then
16694    /// on. Draining pauses as soon as records are ready for delivery; remaining
16695    /// entries stay queued.
16696    async fn drain_pending_backfills(&mut self) -> Result<(), SubscriberError> {
16697        while let Some(queued) = self.pending_backfills.front() {
16698            // Owner was removed while its backfill was queued.
16699            let epoch = queued.epoch.clone();
16700            let owner = queued.owner.clone();
16701            let owner_exists = match (&epoch, &owner) {
16702                (Some(epoch), _) => self.interest_owner_state(epoch).is_some(),
16703                (None, Some(owner)) => self.owner_interests(owner).is_some(),
16704                (None, None) => true,
16705            };
16706            if !owner_exists {
16707                self.pending_backfills.pop_front();
16708                continue;
16709            }
16710            let filters = queued.filters.clone();
16711            let backfill = queued.backfill;
16712
16713            let to_block = match backfill.end_block() {
16714                Some(to_block) => to_block,
16715                None => {
16716                    self.record_rpc(
16717                        SubscriberRpcCause::LazyBackfill,
16718                        SubscriberRpcMethod::EthBlockNumber,
16719                    );
16720                    self.provider
16721                        .get_block_number()
16722                        .await
16723                        .map_err(provider_error)?
16724                }
16725            };
16726            if to_block < backfill.start_block() {
16727                // An exclusive post-baseline range can be empty when the
16728                // provider is still exactly at the retained head. Consume the
16729                // work only after validating that head and seed the filter at
16730                // the proven baseline so reconnect catch-up starts at C + 1.
16731                let certified = if let Some(retained) = backfill.retained_anchor() {
16732                    let actual = fetch_provider_block_ref::<P, N>(
16733                        &self.provider,
16734                        retained.number,
16735                        &self.rpc_counters,
16736                        SubscriberRpcCause::LazyBackfill,
16737                    )
16738                    .await?;
16739                    if !block_ref_satisfies_expected(&actual, retained) {
16740                        return Err(SubscriberError::InvalidBackfill(format!(
16741                            "retained anchor {}:{:?} conflicts with provider block {}:{:?}",
16742                            retained.number, retained.hash, actual.number, actual.hash
16743                        )));
16744                    }
16745                    if to_block < retained.number {
16746                        return Err(SubscriberError::InvalidBackfill(format!(
16747                            "backfill upper bound {to_block} precedes retained anchor {}",
16748                            retained.number
16749                        )));
16750                    }
16751                    Some(actual)
16752                } else {
16753                    None
16754                };
16755                self.pending_backfills.pop_front();
16756                for filter in &filters {
16757                    let source_id = self.log_source_id(filter);
16758                    if let Some(certified) = certified {
16759                        self.last_seen_log_blocks
16760                            .entry(source_id)
16761                            .and_modify(|anchor| *anchor = (*anchor).max(certified.number))
16762                            .or_insert(certified.number);
16763                    }
16764                }
16765                if owner.is_none()
16766                    && let Some(certified) = certified
16767                {
16768                    self.pending_chain_controls
16769                        .push_back(global_backfill_barrier(backfill, certified));
16770                }
16771                if !self.pending_chain_controls.is_empty() {
16772                    break;
16773                }
16774                continue;
16775            }
16776
16777            let through = fetch_provider_block_ref::<P, N>(
16778                &self.provider,
16779                to_block,
16780                &self.rpc_counters,
16781                SubscriberRpcCause::LazyBackfill,
16782            )
16783            .await?;
16784            let request_filters =
16785                merged_lazy_backfill_filters(&filters, backfill.start_block(), through.number);
16786            let retained = backfill.retained_anchor().copied().into_iter().collect();
16787            let SubscriberOwnerCatchup {
16788                mut logs,
16789                certified,
16790            } = fetch_owner_catchup::<&P, N>(
16791                &self.provider,
16792                request_filters,
16793                retained,
16794                through,
16795                SubscriberOwnerCatchupOptions {
16796                    target_preverified: true,
16797                    max_logs: self.config.max_pending_records,
16798                    max_log_bytes: self.config.max_backfill_log_bytes,
16799                    max_requests_in_flight: self.config.max_reconcile_requests_in_flight,
16800                    cause: SubscriberRpcCause::LazyBackfill,
16801                },
16802                Arc::clone(&self.rpc_counters),
16803            )
16804            .await
16805            .map_err(lazy_backfill_error)?;
16806            logs.sort_by_key(|log| {
16807                (
16808                    log.block_number.unwrap_or_default(),
16809                    log.transaction_index.unwrap_or_default(),
16810                    log.log_index.unwrap_or_default(),
16811                )
16812            });
16813            logs.dedup();
16814            self.ensure_pending_record_capacity(logs.len(), "lazy subscriber backfill records")?;
16815
16816            // Fetch succeeded: consume the entry, deliver, and advance the
16817            // complete filter group through one globally ordered window.
16818            self.pending_backfills.pop_front();
16819            if let Some(epoch) = epoch.as_ref() {
16820                self.enqueue_backfilled_logs(logs, None, Some(epoch), Some(backfill));
16821            } else if let Some(owner) = owner.as_ref() {
16822                self.enqueue_compat_owner_backfilled_logs(logs, owner, backfill);
16823            } else {
16824                self.enqueue_backfilled_logs(logs, None, None, Some(backfill));
16825                self.pending_chain_controls
16826                    .push_back(global_backfill_barrier(backfill, certified));
16827            }
16828            for filter in &filters {
16829                let source_id = self.log_source_id(filter);
16830                let anchor = self
16831                    .last_seen_log_blocks
16832                    .entry(source_id)
16833                    .or_insert(certified.number);
16834                *anchor = (*anchor).max(certified.number);
16835            }
16836
16837            if !self.pending_records.is_empty() || !self.pending_chain_controls.is_empty() {
16838                break;
16839            }
16840        }
16841        Ok(())
16842    }
16843
16844    fn stream_sources(&mut self) -> Result<Vec<SubscriberStreamSource>, SubscriberError> {
16845        let sources = match resolve_subscriber_transport(self.mode)? {
16846            SubscriberTransport::PubSub => self.pubsub_stream_sources(),
16847            SubscriberTransport::Polling => self.polling_stream_sources(),
16848        };
16849        #[cfg(feature = "raw-flashblocks-json")]
16850        let sources = {
16851            let mut sources = sources;
16852            if self.external_flashblock_update_channel_opened {
16853                sources.push(SubscriberStreamSource::ExternalFlashblockUpdates);
16854            }
16855            sources
16856        };
16857        Ok(sources)
16858    }
16859
16860    fn pubsub_stream_sources(&mut self) -> Vec<SubscriberStreamSource> {
16861        let mut sources = Vec::new();
16862        let inherited_anchor = self.last_seen_log_blocks.values().copied().min();
16863
16864        for filter in self.log_stream_filters() {
16865            let id = self.log_source_id(&filter);
16866            if let Some(anchor) = inherited_anchor {
16867                self.last_seen_log_blocks.entry(id).or_insert(anchor);
16868            }
16869            sources.push(SubscriberStreamSource::PubSubLog { id, filter });
16870        }
16871
16872        if needs_pending_hash_stream(&self.interests) {
16873            sources.push(SubscriberStreamSource::PubSubPendingHashes);
16874        }
16875
16876        if needs_header_block_stream(&self.interests) {
16877            if !self.uses_external_flashblock_updates()
16878                && self.config.preconfirmations != PreconfirmationMode::Disabled
16879                && self.chain_id.and_then(flashblocks_adapter).is_some()
16880            {
16881                sources.push(SubscriberStreamSource::CanonicalHeadPolling);
16882            } else {
16883                sources.push(SubscriberStreamSource::PubSubBlockHeaders);
16884            }
16885        }
16886
16887        if self.config.preconfirmations != PreconfirmationMode::Disabled
16888            && !self.uses_external_flashblock_updates()
16889        {
16890            match self.chain_id.and_then(flashblocks_adapter) {
16891                Some(FlashblocksAdapter::NativeSubscriptions) => {
16892                    sources.push(SubscriberStreamSource::BaseFlashblocks);
16893                    for filter in self.log_stream_filters() {
16894                        let id = self.log_source_id(&filter);
16895                        sources.push(SubscriberStreamSource::BasePendingLog { id, filter });
16896                    }
16897                }
16898                Some(FlashblocksAdapter::PendingStatePolling) => {
16899                    sources.push(SubscriberStreamSource::OpPendingFlashblocks);
16900                }
16901                None => {}
16902            }
16903        }
16904
16905        sources
16906    }
16907
16908    fn polling_stream_sources(&self) -> Vec<SubscriberStreamSource> {
16909        let mut sources = Vec::new();
16910
16911        for filter in self.log_stream_filters() {
16912            sources.push(SubscriberStreamSource::PollingLog { filter });
16913        }
16914
16915        if needs_pending_hash_stream(&self.interests) {
16916            sources.push(SubscriberStreamSource::PollingPendingHashes);
16917        }
16918
16919        if self.config.preconfirmations != PreconfirmationMode::Disabled
16920            && !self.uses_external_flashblock_updates()
16921            && self.chain_id.and_then(flashblocks_adapter)
16922                == Some(FlashblocksAdapter::PendingStatePolling)
16923        {
16924            sources.push(SubscriberStreamSource::OpPendingFlashblocks);
16925        }
16926
16927        sources
16928    }
16929
16930    fn log_source_id(&mut self, filter: &Filter) -> usize {
16931        if let Some(id) = self.log_source_ids.get(filter) {
16932            return *id;
16933        }
16934
16935        let id = self.next_log_source_id;
16936        self.next_log_source_id = self.next_log_source_id.saturating_add(1);
16937        self.log_source_ids.insert(filter.clone(), id);
16938        id
16939    }
16940
16941    async fn connect_source_stream(
16942        &mut self,
16943        source: SubscriberStreamSource,
16944    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
16945        match source {
16946            SubscriberStreamSource::PubSubLog { id, filter } => {
16947                self.connect_pubsub_log_stream(id, filter).await
16948            }
16949            SubscriberStreamSource::BasePendingLog { id, filter } => {
16950                self.connect_base_pending_log_stream(id, filter).await
16951            }
16952            SubscriberStreamSource::BaseFlashblocks => self.connect_base_flashblock_stream().await,
16953            SubscriberStreamSource::OpPendingFlashblocks => {
16954                self.connect_op_flashblock_tick_stream()
16955            }
16956            SubscriberStreamSource::CanonicalHeadPolling => {
16957                self.connect_canonical_head_tick_stream()
16958            }
16959            SubscriberStreamSource::PubSubPendingHashes => {
16960                self.connect_pubsub_pending_hash_stream().await
16961            }
16962            SubscriberStreamSource::PubSubBlockHeaders => {
16963                self.connect_pubsub_block_header_stream().await
16964            }
16965            SubscriberStreamSource::PollingLog { filter } => {
16966                self.connect_polling_log_stream(filter).await
16967            }
16968            SubscriberStreamSource::PollingPendingHashes => {
16969                self.connect_polling_pending_hash_stream().await
16970            }
16971            #[cfg(feature = "raw-flashblocks-json")]
16972            SubscriberStreamSource::ExternalFlashblockUpdates => {
16973                let receiver = self.external_flashblock_updates.take().ok_or_else(|| {
16974                    SubscriberError::Provider(
16975                        "external Flashblock update channel receiver is unavailable".into(),
16976                    )
16977                })?;
16978                let updates = stream::unfold(receiver, |mut receiver| async move {
16979                    receiver
16980                        .recv()
16981                        .await
16982                        .map(|update| (SubscriberEvent::ExternalFlashblockUpdate(update), receiver))
16983                });
16984                Ok(stream_with_termination(
16985                    updates,
16986                    SubscriberStreamSource::ExternalFlashblockUpdates,
16987                ))
16988            }
16989        }
16990    }
16991
16992    async fn connect_pubsub_log_stream(
16993        &mut self,
16994        id: usize,
16995        filter: Filter,
16996    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
16997        #[cfg(feature = "reactive-ws")]
16998        {
16999            let source = SubscriberStreamSource::PubSubLog {
17000                id,
17001                filter: filter.clone(),
17002            };
17003            self.record_rpc(
17004                SubscriberRpcCause::StreamSubscription,
17005                SubscriberRpcMethod::EthSubscribe,
17006            );
17007            let subscription = self
17008                .provider
17009                .subscribe_logs(&filter)
17010                .channel_size(self.log_channel_size())
17011                .await
17012                .map_err(provider_error)?;
17013            Ok(gap_observing_stream(
17014                subscription,
17015                source,
17016                Arc::clone(&self.gap_counters),
17017                #[cfg(feature = "ingress-timing")]
17018                self.transport_receipts.clone(),
17019                move |log, #[cfg(feature = "ingress-timing")] timing| SubscriberEvent::Log {
17020                    source_id: id,
17021                    log,
17022                    #[cfg(feature = "ingress-timing")]
17023                    timing,
17024                },
17025            ))
17026        }
17027
17028        #[cfg(not(feature = "reactive-ws"))]
17029        {
17030            let _ = (id, filter);
17031            Err(SubscriberError::Unsupported(
17032                "AlloySubscriber pubsub mode requires the reactive-ws feature",
17033            ))
17034        }
17035    }
17036
17037    async fn connect_base_pending_log_stream(
17038        &mut self,
17039        id: usize,
17040        filter: Filter,
17041    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
17042        #[cfg(feature = "reactive-ws")]
17043        {
17044            let source = SubscriberStreamSource::BasePendingLog {
17045                id,
17046                filter: filter.clone(),
17047            };
17048            let params = base_pending_log_filter(&filter)?;
17049            self.record_rpc(
17050                SubscriberRpcCause::StreamSubscription,
17051                SubscriberRpcMethod::EthSubscribe,
17052            );
17053            let subscription = self
17054                .provider
17055                .subscribe::<_, Log>(("pendingLogs", params))
17056                .channel_size(self.log_channel_size())
17057                .await
17058                .map_err(provider_error)?;
17059            Ok(gap_observing_stream(
17060                subscription,
17061                source,
17062                Arc::clone(&self.gap_counters),
17063                #[cfg(feature = "ingress-timing")]
17064                None,
17065                move |log, #[cfg(feature = "ingress-timing")] _| {
17066                    SubscriberEvent::BasePendingLogTimed {
17067                        source_id: id,
17068                        log,
17069                        timing: FlashblockIngressTiming::new(Instant::now()),
17070                    }
17071                },
17072            ))
17073        }
17074
17075        #[cfg(not(feature = "reactive-ws"))]
17076        {
17077            let _ = (id, filter);
17078            Err(SubscriberError::Unsupported(
17079                "Base Flashblocks require the reactive-ws feature",
17080            ))
17081        }
17082    }
17083
17084    async fn connect_base_flashblock_stream(
17085        &mut self,
17086    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
17087        #[cfg(feature = "reactive-ws")]
17088        {
17089            self.record_rpc(
17090                SubscriberRpcCause::StreamSubscription,
17091                SubscriberRpcMethod::EthSubscribe,
17092            );
17093            let stream = self
17094                .provider
17095                .subscribe::<_, BaseFlashblockWirePayload>(("newFlashblocks",))
17096                .channel_size(self.config.max_batch_size.max(1))
17097                .await
17098                .map_err(provider_error)?
17099                .into_stream()
17100                .map(|payload| SubscriberEvent::BaseFlashblockTimed {
17101                    payload,
17102                    timing: FlashblockIngressTiming::new(Instant::now()),
17103                });
17104            Ok(stream_with_termination(
17105                stream,
17106                SubscriberStreamSource::BaseFlashblocks,
17107            ))
17108        }
17109
17110        #[cfg(not(feature = "reactive-ws"))]
17111        {
17112            Err(SubscriberError::Unsupported(
17113                "Base Flashblocks require the reactive-ws feature",
17114            ))
17115        }
17116    }
17117
17118    fn connect_canonical_head_tick_stream(
17119        &self,
17120    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
17121        let mut interval = tokio::time::interval(self.config.canonical_head_poll_interval);
17122        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
17123        let stream = stream::unfold(interval, |mut interval| async move {
17124            interval.tick().await;
17125            Some((SubscriberEvent::CanonicalHeadTick, interval))
17126        });
17127        Ok(stream_with_termination(
17128            stream,
17129            SubscriberStreamSource::CanonicalHeadPolling,
17130        ))
17131    }
17132
17133    fn connect_op_flashblock_tick_stream(
17134        &self,
17135    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
17136        let first_tick = tokio::time::Instant::now() + self.config.flashblock_poll_interval;
17137        let mut interval =
17138            tokio::time::interval_at(first_tick, self.config.flashblock_poll_interval);
17139        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
17140        let stream = stream::unfold(interval, |mut interval| async move {
17141            interval.tick().await;
17142            Some((
17143                SubscriberEvent::OpFlashblockTickTimed(
17144                    FlashblockIngressTiming::new(Instant::now()),
17145                ),
17146                interval,
17147            ))
17148        });
17149        Ok(stream_with_termination(
17150            stream,
17151            SubscriberStreamSource::OpPendingFlashblocks,
17152        ))
17153    }
17154
17155    async fn connect_pubsub_pending_hash_stream(
17156        &mut self,
17157    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
17158        #[cfg(feature = "reactive-ws")]
17159        {
17160            self.record_rpc(
17161                SubscriberRpcCause::StreamSubscription,
17162                SubscriberRpcMethod::EthSubscribe,
17163            );
17164            let stream = self
17165                .provider
17166                .subscribe_pending_transactions()
17167                .channel_size(self.config.max_batch_size.max(1))
17168                .await
17169                .map_err(provider_error)?
17170                .into_stream()
17171                .map(SubscriberEvent::PendingHash);
17172            Ok(stream_with_termination(
17173                stream,
17174                SubscriberStreamSource::PubSubPendingHashes,
17175            ))
17176        }
17177
17178        #[cfg(not(feature = "reactive-ws"))]
17179        {
17180            Err(SubscriberError::Unsupported(
17181                "AlloySubscriber pubsub mode requires the reactive-ws feature",
17182            ))
17183        }
17184    }
17185
17186    async fn connect_pubsub_block_header_stream(
17187        &mut self,
17188    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
17189        #[cfg(feature = "reactive-ws")]
17190        {
17191            self.record_rpc(
17192                SubscriberRpcCause::StreamSubscription,
17193                SubscriberRpcMethod::EthSubscribe,
17194            );
17195            let subscription = self
17196                .provider
17197                .subscribe_blocks()
17198                .channel_size(self.config.max_batch_size.max(1))
17199                .await
17200                .map_err(provider_error)?;
17201            Ok(gap_observing_stream(
17202                subscription,
17203                SubscriberStreamSource::PubSubBlockHeaders,
17204                Arc::clone(&self.gap_counters),
17205                #[cfg(feature = "ingress-timing")]
17206                self.transport_receipts.clone(),
17207                |header, #[cfg(feature = "ingress-timing")] timing| SubscriberEvent::BlockHeader {
17208                    header,
17209                    #[cfg(feature = "ingress-timing")]
17210                    timing,
17211                },
17212            ))
17213        }
17214
17215        #[cfg(not(feature = "reactive-ws"))]
17216        {
17217            Err(SubscriberError::Unsupported(
17218                "AlloySubscriber pubsub mode requires the reactive-ws feature",
17219            ))
17220        }
17221    }
17222
17223    async fn connect_polling_log_stream(
17224        &mut self,
17225        filter: Filter,
17226    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
17227        #[cfg(feature = "reactive-polling")]
17228        {
17229            let source = SubscriberStreamSource::PollingLog {
17230                filter: filter.clone(),
17231            };
17232            self.record_rpc(
17233                SubscriberRpcCause::StreamSubscription,
17234                SubscriberRpcMethod::EthSubscribe,
17235            );
17236            let stream = self
17237                .provider
17238                .watch_logs(&filter)
17239                .await
17240                .map_err(provider_error)?
17241                .with_channel_size(self.config.max_batch_size.max(1))
17242                .into_stream()
17243                .map(SubscriberEvent::Logs);
17244            Ok(stream_with_termination(stream, source))
17245        }
17246
17247        #[cfg(not(feature = "reactive-polling"))]
17248        {
17249            let _ = filter;
17250            Err(SubscriberError::Unsupported(
17251                "AlloySubscriber polling mode requires the reactive-polling feature",
17252            ))
17253        }
17254    }
17255
17256    async fn connect_polling_pending_hash_stream(
17257        &mut self,
17258    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
17259        #[cfg(feature = "reactive-polling")]
17260        {
17261            self.record_rpc(
17262                SubscriberRpcCause::StreamSubscription,
17263                SubscriberRpcMethod::EthSubscribe,
17264            );
17265            let stream = self
17266                .provider
17267                .watch_pending_transactions()
17268                .await
17269                .map_err(provider_error)?
17270                .with_channel_size(self.config.max_batch_size.max(1))
17271                .into_stream()
17272                .map(SubscriberEvent::PendingHashes);
17273            Ok(stream_with_termination(
17274                stream,
17275                SubscriberStreamSource::PollingPendingHashes,
17276            ))
17277        }
17278
17279        #[cfg(not(feature = "reactive-polling"))]
17280        {
17281            Err(SubscriberError::Unsupported(
17282                "AlloySubscriber polling mode requires the reactive-polling feature",
17283            ))
17284        }
17285    }
17286
17287    async fn next_event(&mut self) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
17288        loop {
17289            let ready = match &mut self.state {
17290                AlloySubscriberState::Active(streams)
17291                    if !self.pending_flashblock_reconnects.is_empty() =>
17292                {
17293                    let stream_event = Box::pin(streams.next());
17294                    let reconnect = Box::pin(self.pending_flashblock_reconnects.next());
17295                    match select(reconnect, stream_event).await {
17296                        Either::Left((reconnect, pending_event)) => {
17297                            drop(pending_event);
17298                            let Some((source, result)) = reconnect else {
17299                                continue;
17300                            };
17301                            SubscriberReady::FlashblockReconnect(source, result)
17302                        }
17303                        Either::Right((event, pending_reconnect)) => {
17304                            drop(pending_reconnect);
17305                            SubscriberReady::Event(event)
17306                        }
17307                    }
17308                }
17309                AlloySubscriberState::Active(streams) => {
17310                    SubscriberReady::Event(streams.next().await)
17311                }
17312                AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty
17313                    if !self.pending_flashblock_reconnects.is_empty() =>
17314                {
17315                    let Some((source, result)) = self.pending_flashblock_reconnects.next().await
17316                    else {
17317                        continue;
17318                    };
17319                    SubscriberReady::FlashblockReconnect(source, result)
17320                }
17321                AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
17322                    return Ok(None);
17323                }
17324            };
17325
17326            let event = match ready {
17327                SubscriberReady::Event(event) => event,
17328                SubscriberReady::FlashblockReconnect(source, result) => {
17329                    self.pending_flashblock_reconnect_sources
17330                        .retain(|pending| !pending.same_key(&source));
17331                    match result {
17332                        Ok(stream) => {
17333                            self.install_source_stream(source, stream);
17334                        }
17335                        Err(error)
17336                            if self.config.preconfirmations == PreconfirmationMode::Preferred =>
17337                        {
17338                            tracing::warn!(
17339                                stream = source.label(),
17340                                error = %error,
17341                                "Flashblocks reconnect window exhausted; canonical delivery remains active"
17342                            );
17343                            self.reschedule_preferred_flashblock(source);
17344                        }
17345                        Err(error) => return Err(error),
17346                    }
17347                    continue;
17348                }
17349            };
17350
17351            let Some(event) = event else {
17352                return Err(SubscriberError::Provider(
17353                    "Alloy subscriber streams terminated before the subscriber was stopped"
17354                        .to_owned(),
17355                ));
17356            };
17357
17358            match event {
17359                SubscriberEvent::StreamTerminated(source) => {
17360                    if source.is_external_flashblocks() {
17361                        #[cfg(feature = "raw-flashblocks-json")]
17362                        {
17363                            self.external_flashblock_update_channel_opened = false;
17364                        }
17365                        if let AlloySubscriberState::Active(streams) = &mut self.state {
17366                            streams
17367                                .entries
17368                                .retain(|entry| !entry.source.is_external_flashblocks());
17369                            streams.normalize_next_index();
17370                        }
17371                        self.invalidate_preconfirmation_snapshot();
17372                        if self.config.preconfirmations == PreconfirmationMode::Required {
17373                            return Err(SubscriberError::Provider(
17374                                "required external Flashblock update channel closed".into(),
17375                            ));
17376                        }
17377                        return Ok(Some(SubscriberEvent::FlashblockInvalidated));
17378                    }
17379                    // Persist the missing-source intent before the first await.
17380                    // If a control command cancels this poll during reconnect,
17381                    // the next poll will reconcile the desired/live diff.
17382                    if source.is_flashblocks() {
17383                        self.invalidate_flashblock_generation();
17384                        return Ok(Some(SubscriberEvent::FlashblockInvalidated));
17385                    }
17386                    self.sources_dirty = true;
17387                    self.bump_stream_revision();
17388                    if let Some(backfill_event) = self.reconnect_source_stream(source).await? {
17389                        self.sources_dirty = false;
17390                        if let Some(backfill_event) =
17391                            self.normalize_flashblock_event(backfill_event).await?
17392                        {
17393                            self.verify_event_log_blocks(&backfill_event).await?;
17394                            return Ok(Some(backfill_event));
17395                        }
17396                    }
17397                    self.sources_dirty = false;
17398                }
17399                SubscriberEvent::StreamGap { source, gap } => {
17400                    if let Some(backfill_event) = self.recover_stream_gap(&source, gap).await? {
17401                        self.verify_event_log_blocks(&backfill_event).await?;
17402                        return Ok(Some(backfill_event));
17403                    }
17404                }
17405                event => {
17406                    let Some(event) = self.normalize_flashblock_event(event).await? else {
17407                        continue;
17408                    };
17409                    self.verify_event_log_blocks(&event).await?;
17410                    return Ok(Some(event));
17411                }
17412            }
17413        }
17414    }
17415
17416    fn invalidate_flashblock_generation(&mut self) {
17417        self.pending_records
17418            .retain(|record| record.scope != SubscriberInputScope::Preconfirmed);
17419        self.pending_preconfirmation_invalidation = true;
17420        self.reset_flashblock_tracking();
17421        if let Some(provider) = self.provider_ref.as_mut() {
17422            provider.generation = provider.generation.saturating_add(1);
17423        }
17424        if let AlloySubscriberState::Active(streams) = &mut self.state {
17425            streams
17426                .entries
17427                .retain(|entry| !entry.source.is_flashblocks());
17428            streams.normalize_next_index();
17429        }
17430        let reconnect_sources = self
17431            .stream_sources()
17432            .unwrap_or_default()
17433            .into_iter()
17434            .filter(SubscriberStreamSource::is_flashblocks)
17435            .collect::<Vec<_>>();
17436        self.pending_flashblock_reconnects.clear();
17437        self.pending_flashblock_reconnect_sources.clear();
17438        if self.config.preconfirmations == PreconfirmationMode::Required
17439            || self.config.reconnect.enabled
17440        {
17441            for source in reconnect_sources {
17442                self.schedule_flashblock_reconnect(source, self.config.reconnect.initial_delay);
17443            }
17444        }
17445        self.sources_dirty = false;
17446        self.bump_stream_revision();
17447    }
17448
17449    async fn normalize_flashblock_event(
17450        &mut self,
17451        event: SubscriberEvent<N>,
17452    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
17453        let event = match event {
17454            SubscriberEvent::BasePendingLog { source_id, log } => {
17455                SubscriberEvent::BasePendingLogTimed {
17456                    source_id,
17457                    log,
17458                    timing: FlashblockIngressTiming::new(Instant::now()),
17459                }
17460            }
17461            SubscriberEvent::BaseFlashblock(payload) => SubscriberEvent::BaseFlashblockTimed {
17462                payload,
17463                timing: FlashblockIngressTiming::new(Instant::now()),
17464            },
17465            SubscriberEvent::OpFlashblockTick => {
17466                SubscriberEvent::OpFlashblockTickTimed(FlashblockIngressTiming::new(Instant::now()))
17467            }
17468            event => event,
17469        };
17470        match event {
17471            #[cfg(feature = "raw-flashblocks-json")]
17472            SubscriberEvent::ExternalFlashblockUpdate(queued) => {
17473                let provider = queued.update.provider().clone();
17474                match self.ingest_flashblock_update_with_ingress(queued.update, queued.timing) {
17475                    Ok(()) => {
17476                        let _ = queued.acknowledgement.send(Ok(()));
17477                        Ok(Some(SubscriberEvent::FlashblockObserved))
17478                    }
17479                    Err(error)
17480                        if self.config.preconfirmations == PreconfirmationMode::Preferred =>
17481                    {
17482                        let recoverable_capacity =
17483                            matches!(error, SubscriberError::ResourceExhausted(_));
17484                        if !recoverable_capacity
17485                            && let Some(configured) = self.external_flashblocks_provider.as_mut()
17486                            && configured.endpoint == provider.endpoint
17487                        {
17488                            self.rejected_external_flashblock_generation = Some(
17489                                self.rejected_external_flashblock_generation
17490                                    .map_or(provider.generation, |rejected| {
17491                                        rejected.max(provider.generation)
17492                                    }),
17493                            );
17494                            configured.generation = configured
17495                                .generation
17496                                .max(provider.generation.saturating_add(1));
17497                        }
17498                        self.invalidate_preconfirmation_snapshot();
17499                        self.last_external_flashblock_snapshot = None;
17500                        let _ = queued
17501                            .acknowledgement
17502                            .send(Err(FlashblockUpdateChannelError::Rejected));
17503                        tracing::warn!(
17504                            provider = %provider.endpoint,
17505                            generation = provider.generation,
17506                            error = %error,
17507                            "external Flashblock update rejected; canonical delivery remains active"
17508                        );
17509                        Ok(Some(SubscriberEvent::FlashblockInvalidated))
17510                    }
17511                    Err(error) => {
17512                        let _ = queued
17513                            .acknowledgement
17514                            .send(Err(FlashblockUpdateChannelError::Rejected));
17515                        Err(error)
17516                    }
17517                }
17518            }
17519            SubscriberEvent::BasePendingLogTimed {
17520                source_id,
17521                log,
17522                timing,
17523            } => {
17524                let block_number = log.block_number.ok_or_else(|| {
17525                    SubscriberError::Provider(
17526                        "pendingLogs item is missing its pending block number".into(),
17527                    )
17528                })?;
17529                let transaction_hash = log.transaction_hash.ok_or_else(|| {
17530                    SubscriberError::Provider(
17531                        "pendingLogs item is missing its transaction hash".into(),
17532                    )
17533                })?;
17534                let matching = self.latest_preconfirmation.as_ref().filter(|flashblock| {
17535                    flashblock.block_number == block_number
17536                        && flashblock.contains_transaction(&transaction_hash)
17537                });
17538                let Some(flashblock) = matching.cloned() else {
17539                    if self
17540                        .latest_preconfirmation
17541                        .as_ref()
17542                        .is_some_and(|latest| block_number < latest.block_number)
17543                    {
17544                        return Ok(None);
17545                    }
17546                    if self.unmatched_pending_logs.len() >= self.config.max_pending_records {
17547                        return Err(SubscriberError::ResourceExhausted(
17548                            "unmatched pendingLogs exceeded max_pending_records".into(),
17549                        ));
17550                    }
17551                    self.unmatched_pending_logs
17552                        .push_back((source_id, log, timing));
17553                    return Ok(None);
17554                };
17555                let logs = self.filter_preconfirmed_logs(&flashblock, vec![log])?;
17556                Ok(Some(if logs.is_empty() {
17557                    SubscriberEvent::FlashblockObserved
17558                } else {
17559                    SubscriberEvent::PreconfirmedLogs {
17560                        flashblock,
17561                        logs,
17562                        timing,
17563                    }
17564                }))
17565            }
17566            SubscriberEvent::BaseFlashblockTimed {
17567                payload,
17568                timing: source_timing,
17569            } => {
17570                let (flashblock, recover_pending_snapshot) =
17571                    self.accept_base_flashblock(payload)?;
17572                if std::mem::take(&mut self.sealed_block_pending_certification)
17573                    && let Some(header_event) =
17574                        self.certify_canonical_head_on_sealed_block().await?
17575                {
17576                    // Queued rather than returned: the flashblock event this
17577                    // arm is normalizing still has to reach the consumer.
17578                    self.enqueue_event(header_event);
17579                }
17580                let mut logs = Vec::new();
17581                let mut retained = VecDeque::new();
17582                let mut timing = source_timing;
17583                while let Some((source_id, log, log_timing)) =
17584                    self.unmatched_pending_logs.pop_front()
17585                {
17586                    let transaction_hash = log.transaction_hash;
17587                    if log.block_number == Some(flashblock.block_number)
17588                        && transaction_hash
17589                            .as_ref()
17590                            .is_some_and(|hash| flashblock.contains_transaction(hash))
17591                    {
17592                        let _ = source_id;
17593                        timing = timing.earliest(log_timing);
17594                        logs.push(log);
17595                    } else if log
17596                        .block_number
17597                        .is_some_and(|number| number >= flashblock.block_number)
17598                    {
17599                        retained.push_back((source_id, log, log_timing));
17600                    } else {
17601                        // A late log for an older speculative block can no
17602                        // longer be applied to the active cumulative branch.
17603                    }
17604                }
17605                self.unmatched_pending_logs = retained;
17606
17607                let indexed_recovery = recover_pending_snapshot.then(|| {
17608                    let payload_id = flashblock
17609                        .payload_id
17610                        .expect("indexed recovery carries a payload id");
17611                    let index = flashblock.index.expect("indexed recovery carries an index");
17612                    let last_diff = self
17613                        .base_flashblock_transactions
17614                        .as_ref()
17615                        .filter(|(known_payload, known_index, _, _)| {
17616                            *known_payload == payload_id && *known_index == index
17617                        })
17618                        .map(|(_, _, _, last_diff)| last_diff.clone())
17619                        .unwrap_or_default();
17620                    (payload_id, index, last_diff)
17621                });
17622                if recover_pending_snapshot {
17623                    if let Some(event) = self
17624                        .fetch_pending_flashblock_with_timing(indexed_recovery, timing)
17625                        .await
17626                        .map_err(PendingFlashblockPollError::into_subscriber)?
17627                    {
17628                        return Ok(Some(event));
17629                    }
17630                    self.invalidate_flashblock_generation();
17631                    return Ok(Some(SubscriberEvent::FlashblockInvalidated));
17632                }
17633                let logs = self.filter_preconfirmed_logs(&flashblock, logs)?;
17634                Ok(Some(if logs.is_empty() {
17635                    SubscriberEvent::FlashblockObserved
17636                } else {
17637                    SubscriberEvent::PreconfirmedLogs {
17638                        flashblock,
17639                        logs,
17640                        timing,
17641                    }
17642                }))
17643            }
17644            SubscriberEvent::OpFlashblockTickTimed(timing) => {
17645                self.poll_op_pending_flashblock(timing).await
17646            }
17647            SubscriberEvent::CanonicalHeadTick => {
17648                if self.canonical_head_certification_is_current() {
17649                    // The flashblock stream already drove a certification inside
17650                    // this window; polling again would buy nothing.
17651                    self.flashblocks_rpc_metrics.suppressed_canonical_head_polls = self
17652                        .flashblocks_rpc_metrics
17653                        .suppressed_canonical_head_polls
17654                        .saturating_add(1);
17655                    Ok(None)
17656                } else {
17657                    self.fetch_certified_canonical_head().await
17658                }
17659            }
17660            SubscriberEvent::PreconfirmedLogs {
17661                flashblock,
17662                logs,
17663                timing,
17664            } => {
17665                let logs = self.filter_preconfirmed_logs(&flashblock, logs)?;
17666                Ok(Some(if logs.is_empty() {
17667                    SubscriberEvent::FlashblockObserved
17668                } else {
17669                    SubscriberEvent::PreconfirmedLogs {
17670                        flashblock,
17671                        logs,
17672                        timing,
17673                    }
17674                }))
17675            }
17676            SubscriberEvent::FlashblockObserved => Ok(None),
17677            SubscriberEvent::BasePendingLog { .. }
17678            | SubscriberEvent::BaseFlashblock(_)
17679            | SubscriberEvent::OpFlashblockTick => unreachable!("normalized above"),
17680            event => Ok(Some(event)),
17681        }
17682    }
17683
17684    /// Whether a certification already happened inside the current poll window.
17685    fn canonical_head_certification_is_current(&self) -> bool {
17686        self.last_canonical_head_certification
17687            .is_some_and(|at| at.elapsed() < self.config.canonical_head_poll_interval)
17688    }
17689
17690    /// Certify the sealed canonical head because a new block just started.
17691    ///
17692    /// A `newFlashblocks` payload at index zero opens a block, which means the
17693    /// previous one sealed — the exact moment a certification is worth
17694    /// spending. Driving it from that signal instead of a blind timer costs one
17695    /// request per block rather than one per interval, and detects the head
17696    /// sooner.
17697    async fn certify_canonical_head_on_sealed_block(
17698        &mut self,
17699    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
17700        if !needs_header_block_stream(&self.interests) {
17701            return Ok(None);
17702        }
17703        if self.canonical_head_certification_is_current() {
17704            return Ok(None);
17705        }
17706        self.fetch_certified_canonical_head().await
17707    }
17708
17709    async fn fetch_certified_canonical_head(
17710        &mut self,
17711    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
17712        self.last_canonical_head_certification = Some(Instant::now());
17713        tokio::time::timeout(
17714            self.config.canonical_head_request_timeout,
17715            self.fetch_certified_canonical_head_inner(),
17716        )
17717        .await
17718        .map_err(|_| {
17719            SubscriberError::Provider(format!(
17720                "canonical head certification timed out after {:?}",
17721                self.config.canonical_head_request_timeout
17722            ))
17723        })?
17724    }
17725
17726    async fn fetch_certified_canonical_head_inner(
17727        &mut self,
17728    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
17729        if self.chain_id.and_then(flashblocks_adapter)
17730            == Some(FlashblocksAdapter::PendingStatePolling)
17731        {
17732            if !self.reserve_flashblock_rpc_methods(2) {
17733                return Ok(None);
17734            }
17735            self.flashblocks_rpc_metrics.pending_block_requests = self
17736                .flashblocks_rpc_metrics
17737                .pending_block_requests
17738                .saturating_add(1);
17739            let pending = self
17740                .fetch_op_pending_block()
17741                .await
17742                .map_err(PendingFlashblockPollError::into_subscriber)?
17743                .ok_or_else(|| {
17744                    SubscriberError::Provider(
17745                        "provider returned no OP pending block while certifying its parent".into(),
17746                    )
17747                })?;
17748            let header = self
17749                .certify_op_pending_parent(&pending)
17750                .await
17751                .map_err(PendingFlashblockPollError::into_subscriber)?;
17752            let certified = BlockRef {
17753                number: header.number(),
17754                hash: header.hash(),
17755                parent_hash: Some(header.parent_hash()),
17756                timestamp: Some(header.timestamp()),
17757            };
17758            if self.last_certified_canonical_head.as_ref() == Some(&certified) {
17759                return Ok(None);
17760            }
17761            self.last_certified_canonical_head = Some(certified);
17762            return Ok(Some(SubscriberEvent::BlockHeader {
17763                header,
17764                #[cfg(feature = "ingress-timing")]
17765                timing: None,
17766            }));
17767        }
17768        self.record_rpc(
17769            SubscriberRpcCause::CanonicalHeadCertification,
17770            SubscriberRpcMethod::EthGetBlockByNumber,
17771        );
17772        self.flashblocks_rpc_metrics.canonical_head_requests = self
17773            .flashblocks_rpc_metrics
17774            .canonical_head_requests
17775            .saturating_add(1);
17776        let block = self
17777            .provider
17778            .get_block_by_number(BlockNumberOrTag::Latest)
17779            .await
17780            .map_err(provider_error)?
17781            .ok_or_else(|| {
17782                SubscriberError::Provider(
17783                    "provider returned no latest block while certifying canonical head".into(),
17784                )
17785            })?;
17786        let header = block.header();
17787        if header.hash().is_zero() {
17788            return Err(SubscriberError::Provider(
17789                "provider returned a placeholder hash for the latest canonical head".into(),
17790            ));
17791        }
17792        let certified = BlockRef {
17793            number: header.number(),
17794            hash: header.hash(),
17795            parent_hash: Some(header.parent_hash()),
17796            timestamp: Some(header.timestamp()),
17797        };
17798        if self.last_certified_canonical_head.as_ref() == Some(&certified) {
17799            return Ok(None);
17800        }
17801        self.last_certified_canonical_head = Some(certified);
17802        Ok(Some(SubscriberEvent::BlockHeader {
17803            header: header.clone(),
17804            #[cfg(feature = "ingress-timing")]
17805            timing: None,
17806        }))
17807    }
17808
17809    fn accept_base_flashblock(
17810        &mut self,
17811        payload: BaseFlashblockWirePayload,
17812    ) -> Result<(FlashblockRef, bool), SubscriberError> {
17813        let provider = self.provider_ref.clone().ok_or({
17814            SubscriberError::InvalidConfig(
17815                "Flashblocks require a stable provider ref from a pinned provider lease",
17816            )
17817        })?;
17818
17819        let (flashblock, recover_pending_snapshot) = match payload {
17820            BaseFlashblockWirePayload::Indexed(payload) => {
17821                if payload.index == 0 {
17822                    let base = payload.base.clone().ok_or_else(|| {
17823                        SubscriberError::Provider(
17824                            "indexed newFlashblocks item zero omitted its base header".into(),
17825                        )
17826                    })?;
17827                    // A new payload id at index zero opens a block, so the
17828                    // previous one just sealed. Certifying on that signal is
17829                    // what lets the interval timer stop guessing.
17830                    if self
17831                        .base_flashblock_header
17832                        .as_ref()
17833                        .is_none_or(|(known, _)| *known != payload.payload_id)
17834                    {
17835                        self.sealed_block_pending_certification = true;
17836                    }
17837                    self.base_flashblock_header = Some((payload.payload_id, base));
17838                }
17839
17840                let base = self
17841                    .base_flashblock_header
17842                    .as_ref()
17843                    .filter(|(payload_id, _)| *payload_id == payload.payload_id)
17844                    .map(|(_, base)| base);
17845                let block_number = base.map(|base| base.block_number).or_else(|| {
17846                    payload
17847                        .metadata
17848                        .as_ref()
17849                        .map(|metadata| metadata.block_number)
17850                });
17851                let block_number = block_number.ok_or_else(|| {
17852                    SubscriberError::Provider(
17853                        "indexed newFlashblocks payload omitted both base and metadata block number"
17854                            .into(),
17855                    )
17856                })?;
17857                let diff_transactions = flashblock_transaction_hashes(&payload.diff.transactions)?;
17858                let transaction_hashes = match self.base_flashblock_transactions.as_mut() {
17859                    Some((known_payload, known_index, transactions, last_diff))
17860                        if *known_payload == payload.payload_id =>
17861                    {
17862                        if payload.index < *known_index {
17863                            return self
17864                                .latest_preconfirmation
17865                                .clone()
17866                                .map(|flashblock| (flashblock, false))
17867                                .ok_or_else(|| {
17868                                    SubscriberError::Provider(
17869                                        "regressive indexed Flashblock arrived without an active snapshot"
17870                                            .into(),
17871                                    )
17872                                });
17873                        }
17874                        if payload.index == *known_index {
17875                            if *last_diff != diff_transactions {
17876                                return Err(SubscriberError::Provider(
17877                                    "conflicting duplicate indexed Flashblock payload".into(),
17878                                ));
17879                            }
17880                        } else {
17881                            if diff_transactions
17882                                .iter()
17883                                .any(|hash| transactions.contains(hash))
17884                            {
17885                                return Err(SubscriberError::Provider(
17886                                    "indexed Flashblock repeated a transaction from an earlier diff"
17887                                        .into(),
17888                                ));
17889                            }
17890                            transactions.extend(diff_transactions.iter().copied());
17891                            *known_index = payload.index;
17892                            *last_diff = diff_transactions;
17893                        }
17894                        transactions.clone()
17895                    }
17896                    _ => {
17897                        self.base_flashblock_transactions = Some((
17898                            payload.payload_id,
17899                            payload.index,
17900                            diff_transactions.clone(),
17901                            diff_transactions.clone(),
17902                        ));
17903                        diff_transactions
17904                    }
17905                };
17906                let partial_block_hash = non_placeholder_hash(payload.diff.block_hash);
17907                let transactions_root = payload
17908                    .diff
17909                    .transactions_root
17910                    .and_then(non_placeholder_hash);
17911                let parent_hash = base.and_then(|base| non_placeholder_hash(base.parent_hash));
17912                let state_root = non_placeholder_hash(payload.diff.state_root);
17913                let timestamp = base.map(|base| base.timestamp);
17914                let base_fee_per_gas = base.and_then(|base| base.base_fee_per_gas);
17915                let beneficiary = base.and_then(|base| base.beneficiary);
17916                let prevrandao = base
17917                    .and_then(|base| base.prevrandao)
17918                    .and_then(non_placeholder_hash);
17919                let gas_limit = base.and_then(|base| base.gas_limit);
17920                let content_hash = flashblock_content_hash(FlashblockContentCommitment {
17921                    provider: &provider,
17922                    payload_id: Some(payload.payload_id),
17923                    index: Some(payload.index),
17924                    block_number,
17925                    partial_block_hash,
17926                    parent_hash,
17927                    state_root,
17928                    transactions_root,
17929                    transaction_hashes: &transaction_hashes,
17930                    timestamp,
17931                    base_fee_per_gas,
17932                    beneficiary,
17933                    prevrandao,
17934                    gas_limit,
17935                });
17936                let flashblock = FlashblockRef {
17937                    provider,
17938                    payload_id: Some(payload.payload_id),
17939                    index: Some(payload.index),
17940                    block_number,
17941                    content_hash,
17942                    partial_block_hash,
17943                    parent_hash,
17944                    state_root,
17945                    transactions_root,
17946                    transaction_hashes,
17947                    timestamp,
17948                    base_fee_per_gas,
17949                    beneficiary,
17950                    prevrandao,
17951                    gas_limit,
17952                };
17953                if let Some(previous) = self.latest_preconfirmation.as_ref()
17954                    && previous.same_payload(&flashblock)
17955                    && previous.index == flashblock.index
17956                    && previous.content_hash != flashblock.content_hash
17957                {
17958                    return Err(SubscriberError::Provider(
17959                        "conflicting duplicate indexed Flashblock content".into(),
17960                    ));
17961                }
17962                let recover = match self.latest_preconfirmation.as_ref() {
17963                    Some(previous) if previous.same_payload(&flashblock) => {
17964                        if let (Some(previous), Some(current)) = (previous.index, flashblock.index)
17965                        {
17966                            if current < previous {
17967                                return Ok((flashblock, false));
17968                            }
17969                            current > previous.saturating_add(1)
17970                        } else {
17971                            false
17972                        }
17973                    }
17974                    Some(_) => payload.index != 0,
17975                    None => payload.index != 0,
17976                };
17977                (flashblock, recover)
17978            }
17979            BaseFlashblockWirePayload::Block(payload) => {
17980                let transaction_hashes = flashblock_transaction_hashes(&payload.transactions)?;
17981                let parent_hash = non_placeholder_hash(payload.parent_hash);
17982                let state_root = non_placeholder_hash(payload.state_root);
17983                let transactions_root = payload.transactions_root.and_then(non_placeholder_hash);
17984                let partial_block_hash = non_placeholder_hash(payload.hash);
17985                let prevrandao = payload.mix_hash.and_then(non_placeholder_hash);
17986                let content_hash = flashblock_content_hash(FlashblockContentCommitment {
17987                    provider: &provider,
17988                    payload_id: None,
17989                    index: None,
17990                    block_number: payload.number,
17991                    partial_block_hash,
17992                    parent_hash,
17993                    state_root,
17994                    transactions_root,
17995                    transaction_hashes: &transaction_hashes,
17996                    timestamp: Some(payload.timestamp),
17997                    base_fee_per_gas: payload.base_fee_per_gas,
17998                    beneficiary: payload.miner,
17999                    prevrandao,
18000                    gas_limit: payload.gas_limit,
18001                });
18002                let flashblock = FlashblockRef {
18003                    provider,
18004                    payload_id: None,
18005                    index: None,
18006                    block_number: payload.number,
18007                    content_hash,
18008                    partial_block_hash,
18009                    parent_hash,
18010                    state_root,
18011                    transactions_root,
18012                    transaction_hashes,
18013                    timestamp: Some(payload.timestamp),
18014                    base_fee_per_gas: payload.base_fee_per_gas,
18015                    beneficiary: payload.miner,
18016                    prevrandao,
18017                    gas_limit: payload.gas_limit,
18018                };
18019                if let Some(previous) = self.latest_preconfirmation.as_ref()
18020                    && flashblock.same_payload(previous)
18021                    && flashblock.content_hash != previous.content_hash
18022                    && !flashblock.is_cumulative_successor_of(previous)
18023                {
18024                    return Err(SubscriberError::Provider(
18025                        "cumulative Flashblock transaction membership is non-monotonic".into(),
18026                    ));
18027                }
18028                (flashblock, false)
18029            }
18030        };
18031        Ok((flashblock, recover_pending_snapshot))
18032    }
18033
18034    async fn poll_op_pending_flashblock(
18035        &mut self,
18036        timing: FlashblockIngressTiming,
18037    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
18038        match self
18039            .fetch_pending_flashblock_with_timing(None, timing)
18040            .await
18041        {
18042            Ok(event) => {
18043                self.consecutive_flashblock_poll_failures = 0;
18044                Ok(event)
18045            }
18046            Err(PendingFlashblockPollError::Request(error)) => {
18047                self.flashblocks_rpc_metrics.failed_requests = self
18048                    .flashblocks_rpc_metrics
18049                    .failed_requests
18050                    .saturating_add(1);
18051                self.consecutive_flashblock_poll_failures =
18052                    self.consecutive_flashblock_poll_failures.saturating_add(1);
18053                if self.consecutive_flashblock_poll_failures
18054                    >= self.config.max_consecutive_flashblock_poll_failures
18055                {
18056                    return Err(error);
18057                }
18058                tracing::warn!(
18059                    consecutive_failures = self.consecutive_flashblock_poll_failures,
18060                    failure_limit = self.config.max_consecutive_flashblock_poll_failures,
18061                    error = %error,
18062                    "Optimism pending-state Flashblocks request failed; retrying on the next tick"
18063                );
18064                Ok(None)
18065            }
18066            Err(PendingFlashblockPollError::Integrity(error)) => Err(error),
18067        }
18068    }
18069
18070    #[cfg(test)]
18071    async fn fetch_pending_flashblock(
18072        &mut self,
18073        indexed_recovery: Option<(FixedBytes<8>, u64, Vec<B256>)>,
18074    ) -> Result<Option<SubscriberEvent<N>>, PendingFlashblockPollError> {
18075        self.fetch_pending_flashblock_with_timing(
18076            indexed_recovery,
18077            FlashblockIngressTiming::new(Instant::now()),
18078        )
18079        .await
18080    }
18081
18082    async fn fetch_pending_flashblock_with_timing(
18083        &mut self,
18084        indexed_recovery: Option<(FixedBytes<8>, u64, Vec<B256>)>,
18085        timing: FlashblockIngressTiming,
18086    ) -> Result<Option<SubscriberEvent<N>>, PendingFlashblockPollError> {
18087        let samples_pending_range = self.chain_id.and_then(flashblocks_adapter)
18088            == Some(FlashblocksAdapter::PendingStatePolling);
18089        if samples_pending_range {
18090            let fixed_methods = 2_usize.saturating_add(self.log_stream_filters().len());
18091            if !self.reserve_flashblock_rpc_methods(fixed_methods) {
18092                return Ok(None);
18093            }
18094        }
18095        let state_provider = if samples_pending_range {
18096            self.flashblocks_state_provider
18097                .as_ref()
18098                .unwrap_or(&self.provider)
18099        } else {
18100            &self.provider
18101        };
18102        let latest = if samples_pending_range {
18103            None
18104        } else {
18105            self.record_rpc(
18106                SubscriberRpcCause::CanonicalHeadCertification,
18107                SubscriberRpcMethod::EthBlockNumber,
18108            );
18109            self.flashblocks_rpc_metrics.canonical_head_requests = self
18110                .flashblocks_rpc_metrics
18111                .canonical_head_requests
18112                .saturating_add(1);
18113            Some(
18114                state_provider
18115                    .get_block_number()
18116                    .await
18117                    .map_err(pending_flashblock_request_error)?,
18118            )
18119        };
18120        self.flashblocks_rpc_metrics.pending_block_requests = self
18121            .flashblocks_rpc_metrics
18122            .pending_block_requests
18123            .saturating_add(1);
18124        let pending_block = if samples_pending_range {
18125            self.fetch_op_pending_block().await?
18126        } else {
18127            self.record_rpc(
18128                SubscriberRpcCause::PendingStateSample,
18129                SubscriberRpcMethod::EthGetBlockByNumber,
18130            );
18131            self.provider
18132                .get_block_by_number(BlockNumberOrTag::Pending)
18133                .await
18134                .map_err(pending_flashblock_request_error)?
18135        };
18136        let Some(block) = pending_block else {
18137            if self.config.preconfirmations == PreconfirmationMode::Required {
18138                return Err(PendingFlashblockPollError::Request(
18139                    SubscriberError::Provider(
18140                        "Flashblocks provider returned no pending block".into(),
18141                    ),
18142                ));
18143            }
18144            return Ok(None);
18145        };
18146        let latest = if samples_pending_range {
18147            self.certify_op_pending_parent(&block).await?.number()
18148        } else {
18149            latest.expect("non-OP pending recovery fetched a canonical height")
18150        };
18151        let header = block.header();
18152        if header.number() <= latest {
18153            return Ok(None);
18154        }
18155
18156        let provider = self.provider_ref.clone().ok_or({
18157            PendingFlashblockPollError::Integrity(SubscriberError::InvalidConfig(
18158                "Flashblocks require a stable provider ref from a pinned provider lease",
18159            ))
18160        })?;
18161        let parent_hash = Some(header.parent_hash());
18162        let transaction_hashes = if let Some(hashes) = block.transactions().as_hashes() {
18163            hashes.to_vec()
18164        } else if let Some(transactions) = block.transactions().as_transactions() {
18165            transactions
18166                .iter()
18167                .map(|transaction| transaction.tx_hash())
18168                .collect()
18169        } else {
18170            Vec::new()
18171        };
18172        let state_root = non_placeholder_hash(header.state_root());
18173        let transactions_root = non_placeholder_hash(header.transactions_root());
18174        let partial_block_hash = non_placeholder_hash(header.hash());
18175        let prevrandao = header.mix_hash().and_then(non_placeholder_hash);
18176        let content_hash = flashblock_content_hash(FlashblockContentCommitment {
18177            provider: &provider,
18178            payload_id: None,
18179            index: None,
18180            block_number: header.number(),
18181            partial_block_hash,
18182            parent_hash,
18183            state_root,
18184            transactions_root,
18185            transaction_hashes: &transaction_hashes,
18186            timestamp: Some(header.timestamp()),
18187            base_fee_per_gas: header.base_fee_per_gas(),
18188            beneficiary: Some(header.beneficiary()),
18189            prevrandao,
18190            gas_limit: Some(header.gas_limit()),
18191        });
18192        let flashblock = FlashblockRef {
18193            provider,
18194            payload_id: None,
18195            index: None,
18196            block_number: header.number(),
18197            content_hash,
18198            partial_block_hash,
18199            parent_hash,
18200            state_root,
18201            transactions_root,
18202            transaction_hashes,
18203            timestamp: Some(header.timestamp()),
18204            base_fee_per_gas: header.base_fee_per_gas(),
18205            beneficiary: Some(header.beneficiary()),
18206            prevrandao,
18207            gas_limit: Some(header.gas_limit()),
18208        };
18209        if samples_pending_range
18210            && self
18211                .latest_preconfirmation
18212                .as_ref()
18213                .is_some_and(|previous| !previous.same_payload(&flashblock))
18214        {
18215            // Revoke as soon as the sampled payload changes, before any
18216            // follow-up receipt await can fail or be cancelled.
18217            self.invalidate_preconfirmation_snapshot();
18218        }
18219        if let Some((payload_id, index, last_diff)) = indexed_recovery {
18220            self.base_flashblock_transactions = Some((
18221                payload_id,
18222                index,
18223                flashblock.transaction_hashes.clone(),
18224                last_diff,
18225            ));
18226        }
18227        let repeats_pending_snapshot = self
18228            .latest_preconfirmation
18229            .as_ref()
18230            .is_some_and(|previous| previous == &flashblock);
18231        if repeats_pending_snapshot && !samples_pending_range {
18232            return Ok(None);
18233        }
18234
18235        if let Some(previous) = self.latest_preconfirmation.as_ref()
18236            && flashblock.same_payload(previous)
18237            && !flashblock.is_cumulative_successor_of(previous)
18238        {
18239            if samples_pending_range {
18240                // OP pending-state reads are not atomic and paid endpoints can
18241                // briefly expose a shorter backend view. Never publish the
18242                // regression. Revoke the active overlay and require a fresh,
18243                // internally coherent sample on a later tick instead.
18244                self.invalidate_preconfirmation_snapshot();
18245                return Ok(Some(SubscriberEvent::FlashblockInvalidated));
18246            }
18247            return Err(PendingFlashblockPollError::Integrity(
18248                SubscriberError::Provider(
18249                    "sampled cumulative Flashblock transaction membership is non-monotonic".into(),
18250                ),
18251            ));
18252        }
18253
18254        let mut logs = self.fetch_pending_logs(flashblock.block_number).await?;
18255        if samples_pending_range {
18256            let (mut receipt_logs, completed_receipts, unavailable_receipts) =
18257                self.fetch_pending_transaction_receipts(&flashblock).await?;
18258            logs.append(&mut receipt_logs);
18259            logs.retain(|log| log.block_number == Some(flashblock.block_number));
18260            for log in &logs {
18261                let transaction_hash = log.transaction_hash.ok_or_else(|| {
18262                    PendingFlashblockPollError::Integrity(SubscriberError::Provider(
18263                        "pre-confirmed log is missing its transaction hash".into(),
18264                    ))
18265                })?;
18266                if !flashblock.contains_transaction(&transaction_hash) {
18267                    self.flashblocks_rpc_metrics.raced_samples =
18268                        self.flashblocks_rpc_metrics.raced_samples.saturating_add(1);
18269                    return Ok(None);
18270                }
18271            }
18272            let logs = self
18273                .filter_preconfirmed_logs(&flashblock, logs)
18274                .map_err(PendingFlashblockPollError::Integrity)?;
18275            self.preconfirmed_unavailable_receipts
18276                .extend(unavailable_receipts);
18277            for transaction_hash in &completed_receipts {
18278                self.preconfirmed_unavailable_receipts
18279                    .remove(transaction_hash);
18280            }
18281            self.preconfirmed_receipted_transactions
18282                .extend(completed_receipts);
18283            if repeats_pending_snapshot && logs.is_empty() {
18284                return Ok(None);
18285            }
18286            return Ok(Some(if logs.is_empty() {
18287                SubscriberEvent::FlashblockObserved
18288            } else {
18289                SubscriberEvent::PreconfirmedLogs {
18290                    flashblock,
18291                    logs,
18292                    timing,
18293                }
18294            }));
18295        }
18296        let logs = self
18297            .filter_preconfirmed_logs(&flashblock, logs)
18298            .map_err(PendingFlashblockPollError::Integrity)?;
18299        Ok(Some(if logs.is_empty() {
18300            SubscriberEvent::FlashblockObserved
18301        } else {
18302            SubscriberEvent::PreconfirmedLogs {
18303                flashblock,
18304                logs,
18305                timing,
18306            }
18307        }))
18308    }
18309
18310    async fn fetch_pending_logs(
18311        &mut self,
18312        pending_block_number: u64,
18313    ) -> Result<Vec<Log>, PendingFlashblockPollError> {
18314        let mut logs = Vec::new();
18315        let samples_pending_range = self.chain_id.and_then(flashblocks_adapter)
18316            == Some(FlashblocksAdapter::PendingStatePolling);
18317        let state_provider = if samples_pending_range {
18318            self.flashblocks_state_provider
18319                .as_ref()
18320                .unwrap_or(&self.provider)
18321        } else {
18322            &self.provider
18323        };
18324        for filter in self.log_stream_filters() {
18325            self.record_rpc(
18326                SubscriberRpcCause::PendingStateSample,
18327                SubscriberRpcMethod::EthGetLogs,
18328            );
18329            self.flashblocks_rpc_metrics.pending_log_requests = self
18330                .flashblocks_rpc_metrics
18331                .pending_log_requests
18332                .saturating_add(1);
18333            let filter = if samples_pending_range {
18334                filter
18335                    .from_block(pending_block_number)
18336                    .to_block(BlockNumberOrTag::Pending)
18337            } else {
18338                filter
18339                    .from_block(BlockNumberOrTag::Pending)
18340                    .to_block(BlockNumberOrTag::Pending)
18341            };
18342            logs.extend(
18343                state_provider
18344                    .get_logs(&filter)
18345                    .await
18346                    .map_err(pending_flashblock_request_error)?,
18347            );
18348        }
18349        if samples_pending_range {
18350            logs.retain(|log| log.block_number == Some(pending_block_number));
18351        }
18352        Ok(logs)
18353    }
18354
18355    async fn fetch_pending_transaction_receipts(
18356        &mut self,
18357        flashblock: &FlashblockRef,
18358    ) -> Result<(Vec<Log>, Vec<B256>, Vec<B256>), PendingFlashblockPollError> {
18359        let receipt_allowance = self.pending_receipt_request_allowance();
18360        let receipt_limit = self
18361            .config
18362            .max_pending_transaction_receipts_per_tick
18363            .min(receipt_allowance);
18364        if receipt_limit == 0 {
18365            return Ok((Vec::new(), Vec::new(), Vec::new()));
18366        }
18367        let mut transaction_hashes = Vec::with_capacity(receipt_limit);
18368        for transaction_hash in &flashblock.transaction_hashes {
18369            if !self
18370                .preconfirmed_receipted_transactions
18371                .contains(transaction_hash)
18372                && !self
18373                    .preconfirmed_unavailable_receipts
18374                    .contains(transaction_hash)
18375            {
18376                transaction_hashes.push(*transaction_hash);
18377                if transaction_hashes.len() == receipt_limit {
18378                    break;
18379                }
18380            }
18381        }
18382        if transaction_hashes.len() < receipt_limit {
18383            for transaction_hash in &flashblock.transaction_hashes {
18384                if self
18385                    .preconfirmed_unavailable_receipts
18386                    .contains(transaction_hash)
18387                {
18388                    transaction_hashes.push(*transaction_hash);
18389                    if transaction_hashes.len() == receipt_limit {
18390                        break;
18391                    }
18392                }
18393            }
18394        }
18395        if transaction_hashes.is_empty() {
18396            return Ok((Vec::new(), Vec::new(), Vec::new()));
18397        }
18398        let reserved = self.reserve_flashblock_rpc_methods(transaction_hashes.len());
18399        debug_assert!(reserved, "receipt allowance must remain reserved until use");
18400        if !reserved {
18401            return Ok((Vec::new(), Vec::new(), Vec::new()));
18402        }
18403        self.rpc_counters.record_many(
18404            SubscriberRpcCause::PendingStateSample,
18405            SubscriberRpcMethod::EthGetTransactionReceipt,
18406            transaction_hashes.len() as u64,
18407        );
18408        self.flashblocks_rpc_metrics.pending_receipt_requests = self
18409            .flashblocks_rpc_metrics
18410            .pending_receipt_requests
18411            .saturating_add(transaction_hashes.len() as u64);
18412        let state_provider = self
18413            .flashblocks_state_provider
18414            .as_ref()
18415            .unwrap_or(&self.provider);
18416        let client = state_provider.client();
18417        let mut batch = BatchRequest::new(client);
18418        let mut waiters = Vec::with_capacity(transaction_hashes.len());
18419        for transaction_hash in transaction_hashes {
18420            let waiter = batch
18421                .add_call::<_, serde_json::Value>("eth_getTransactionReceipt", &(transaction_hash,))
18422                .map_err(pending_flashblock_request_error)?;
18423            waiters.push((transaction_hash, waiter));
18424        }
18425        batch
18426            .send()
18427            .await
18428            .map_err(pending_flashblock_request_error)?;
18429        let mut logs = Vec::new();
18430        let mut completed = Vec::new();
18431        let mut unavailable = Vec::new();
18432        for (transaction_hash, waiter) in waiters {
18433            let value = waiter.await.map_err(pending_flashblock_request_error)?;
18434            if let Some(mut receipt_logs) =
18435                normalize_pending_transaction_receipt(transaction_hash, value)
18436                    .map_err(PendingFlashblockPollError::Integrity)?
18437            {
18438                self.flashblocks_rpc_metrics.pending_receipts_completed = self
18439                    .flashblocks_rpc_metrics
18440                    .pending_receipts_completed
18441                    .saturating_add(1);
18442                logs.append(&mut receipt_logs);
18443                completed.push(transaction_hash);
18444            } else {
18445                self.flashblocks_rpc_metrics.pending_receipts_unavailable = self
18446                    .flashblocks_rpc_metrics
18447                    .pending_receipts_unavailable
18448                    .saturating_add(1);
18449                unavailable.push(transaction_hash);
18450            }
18451        }
18452        Ok((logs, completed, unavailable))
18453    }
18454
18455    fn pending_receipt_request_allowance(&mut self) -> usize {
18456        self.prune_flashblock_rpc_request_times();
18457        let rolling_capacity = self
18458            .config
18459            .max_flashblock_rpc_requests_per_second
18460            .saturating_sub(self.flashblock_rpc_request_times.len());
18461        rolling_capacity.min(self.pending_receipt_requests_per_tick_capacity())
18462    }
18463
18464    fn pending_receipt_requests_per_tick_capacity(&self) -> usize {
18465        let interval_nanos = self.config.flashblock_poll_interval.as_nanos().max(1);
18466        let ticks_per_second = Duration::from_secs(1).as_nanos().div_ceil(interval_nanos);
18467        let ticks_per_second = usize::try_from(ticks_per_second).unwrap_or(usize::MAX);
18468        self.pending_receipt_requests_per_second_capacity()
18469            .checked_div(ticks_per_second)
18470            .unwrap_or(0)
18471    }
18472
18473    fn pending_receipt_requests_per_second_capacity(&self) -> usize {
18474        let interval_nanos = self.config.flashblock_poll_interval.as_nanos().max(1);
18475        let ticks_per_second = Duration::from_secs(1).as_nanos().div_ceil(interval_nanos);
18476        let ticks_per_second = usize::try_from(ticks_per_second).unwrap_or(usize::MAX);
18477        let fixed_methods_per_tick = 2_usize.saturating_add(self.log_stream_filters().len());
18478        let mut reserved_methods = ticks_per_second.saturating_mul(fixed_methods_per_tick);
18479        if needs_header_block_stream(&self.interests) {
18480            let canonical_interval_nanos =
18481                self.config.canonical_head_poll_interval.as_nanos().max(1);
18482            let canonical_ticks = Duration::from_secs(1)
18483                .as_nanos()
18484                .div_ceil(canonical_interval_nanos);
18485            let canonical_ticks = usize::try_from(canonical_ticks).unwrap_or(usize::MAX);
18486            reserved_methods = reserved_methods.saturating_add(canonical_ticks.saturating_mul(2));
18487        }
18488        self.config
18489            .max_flashblock_rpc_requests_per_second
18490            .saturating_sub(reserved_methods)
18491    }
18492
18493    fn reserve_flashblock_rpc_methods(&mut self, methods: usize) -> bool {
18494        self.prune_flashblock_rpc_request_times();
18495        if self
18496            .flashblock_rpc_request_times
18497            .len()
18498            .saturating_add(methods)
18499            > self.config.max_flashblock_rpc_requests_per_second
18500        {
18501            return false;
18502        }
18503        let now = Instant::now();
18504        for _ in 0..methods {
18505            self.flashblock_rpc_request_times.push_back(now);
18506        }
18507        true
18508    }
18509
18510    fn prune_flashblock_rpc_request_times(&mut self) {
18511        let now = Instant::now();
18512        while self
18513            .flashblock_rpc_request_times
18514            .front()
18515            .is_some_and(|requested| now.duration_since(*requested) >= Duration::from_secs(1))
18516        {
18517            self.flashblock_rpc_request_times.pop_front();
18518        }
18519    }
18520
18521    fn filter_preconfirmed_logs(
18522        &mut self,
18523        flashblock: &FlashblockRef,
18524        mut logs: Vec<Log>,
18525    ) -> Result<Vec<Log>, SubscriberError> {
18526        let samples_pending_range = self.chain_id.and_then(flashblocks_adapter)
18527            == Some(FlashblocksAdapter::PendingStatePolling);
18528        if self
18529            .latest_preconfirmation
18530            .as_ref()
18531            .is_some_and(|previous| !previous.same_payload(flashblock))
18532        {
18533            // A new payload revokes the previous overlay even when none of the
18534            // caller's log filters matched in the replacement. Otherwise a
18535            // quiet block could leave stale speculative signing authority
18536            // active until an unrelated canonical pool event arrived.
18537            self.invalidate_preconfirmation_snapshot();
18538        }
18539        if self
18540            .latest_preconfirmation
18541            .as_ref()
18542            .is_none_or(|previous| !previous.same_payload(flashblock))
18543        {
18544            self.preconfirmed_seen_logs.clear();
18545        }
18546        if let Some(previous) = self.latest_preconfirmation.as_ref()
18547            && previous.same_payload(flashblock)
18548            && let (Some(previous_index), Some(current_index)) = (previous.index, flashblock.index)
18549            && current_index < previous_index
18550        {
18551            return Ok(Vec::new());
18552        }
18553        self.latest_preconfirmation = Some(flashblock.clone());
18554
18555        logs.sort_by_key(|log| (log.transaction_index.unwrap_or(u64::MAX), log.log_index));
18556        let mut filtered = Vec::new();
18557        for mut log in logs {
18558            if log.removed || log.block_number != Some(flashblock.block_number) {
18559                return Err(SubscriberError::Provider(
18560                    "pre-confirmed log disagrees with its Flashblock snapshot".into(),
18561                ));
18562            }
18563            let transaction_hash = log.transaction_hash.ok_or_else(|| {
18564                SubscriberError::Provider(
18565                    "pre-confirmed log is missing its transaction hash".into(),
18566                )
18567            })?;
18568            let log_index = log.log_index.ok_or_else(|| {
18569                SubscriberError::Provider("pre-confirmed log is missing its log index".into())
18570            })?;
18571            let transaction_index =
18572                flashblock
18573                    .transaction_index(&transaction_hash)
18574                    .ok_or_else(|| {
18575                        SubscriberError::Provider(
18576                        "pre-confirmed log transaction is absent from the cumulative Flashblock"
18577                            .into(),
18578                    )
18579                    })?;
18580            if log
18581                .transaction_index
18582                .is_some_and(|reported| reported != transaction_index)
18583            {
18584                return Err(SubscriberError::Provider(
18585                    "pre-confirmed log transaction index disagrees with cumulative membership"
18586                        .into(),
18587                ));
18588            }
18589            let reported_hash = log.block_hash.and_then(non_placeholder_hash);
18590            if !samples_pending_range
18591                && let Some(reported) = reported_hash
18592                && reported != flashblock.content_hash
18593                && flashblock
18594                    .partial_block_hash
18595                    .is_some_and(|expected| reported != expected)
18596            {
18597                return Err(SubscriberError::Provider(
18598                    "pre-confirmed log partial block hash disagrees with its Flashblock snapshot"
18599                        .into(),
18600                ));
18601            }
18602            log.block_hash = Some(flashblock.content_hash);
18603            log.block_timestamp = flashblock.timestamp.or(log.block_timestamp);
18604            log.transaction_index = Some(transaction_index);
18605            if self
18606                .preconfirmed_seen_logs
18607                .insert((transaction_hash, log_index))
18608                && log_matches_any_interest(&log, &self.interests)
18609            {
18610                filtered.push(log);
18611            }
18612        }
18613        Ok(filtered)
18614    }
18615
18616    async fn verify_event_log_blocks(
18617        &mut self,
18618        event: &SubscriberEvent<N>,
18619    ) -> Result<(), SubscriberError> {
18620        if !self.config.verify_log_block_context {
18621            return Ok(());
18622        }
18623        match event {
18624            SubscriberEvent::Log { log, .. } => self.verify_log_block_context(log).await,
18625            SubscriberEvent::BackfilledLogs { logs, .. } | SubscriberEvent::Logs(logs) => {
18626                for log in logs {
18627                    self.verify_log_block_context(log).await?;
18628                }
18629                Ok(())
18630            }
18631            #[cfg(feature = "raw-flashblocks-json")]
18632            SubscriberEvent::ExternalFlashblockUpdate(_) => Ok(()),
18633            SubscriberEvent::BlockHeader { .. }
18634            | SubscriberEvent::PendingHash(_)
18635            | SubscriberEvent::PendingHashes(_)
18636            | SubscriberEvent::BasePendingLog { .. }
18637            | SubscriberEvent::BasePendingLogTimed { .. }
18638            | SubscriberEvent::BaseFlashblock { .. }
18639            | SubscriberEvent::BaseFlashblockTimed { .. }
18640            | SubscriberEvent::OpFlashblockTick
18641            | SubscriberEvent::OpFlashblockTickTimed(_)
18642            | SubscriberEvent::CanonicalHeadTick
18643            | SubscriberEvent::PreconfirmedLogs { .. }
18644            | SubscriberEvent::FlashblockInvalidated
18645            | SubscriberEvent::FlashblockObserved
18646            | SubscriberEvent::StreamTerminated(_)
18647            | SubscriberEvent::StreamGap { .. } => Ok(()),
18648        }
18649    }
18650
18651    async fn verify_log_block_context(&mut self, log: &Log) -> Result<(), SubscriberError> {
18652        if log.removed {
18653            return Ok(());
18654        }
18655        let number = log.block_number.ok_or_else(|| {
18656            SubscriberError::Provider(
18657                "canonical log is missing its block number during context verification".into(),
18658            )
18659        })?;
18660        let hash = log.block_hash.ok_or_else(|| {
18661            SubscriberError::Provider(
18662                "canonical log is missing its block hash during context verification".into(),
18663            )
18664        })?;
18665        let key = (number, hash);
18666        if self.verified_log_blocks.contains_key(&key) {
18667            return Ok(());
18668        }
18669        let provider = self
18670            .log_verification_provider
18671            .as_ref()
18672            .unwrap_or(&self.provider);
18673        self.rpc_counters.record(
18674            SubscriberRpcCause::LogBlockVerification,
18675            SubscriberRpcMethod::EthGetBlockByNumber,
18676        );
18677        let block = provider
18678            .get_block_by_number(BlockNumberOrTag::Number(number))
18679            .await
18680            .map_err(provider_error)?
18681            .ok_or_else(|| {
18682                SubscriberError::Provider(format!(
18683                    "canonical log block {number} is unavailable during context verification"
18684                ))
18685            })?;
18686        let header = block.header();
18687        let verified = BlockRef {
18688            number: header.number(),
18689            hash: header.hash(),
18690            parent_hash: Some(header.parent_hash()),
18691            timestamp: Some(header.timestamp()),
18692        };
18693        if verified.number != number
18694            || verified.hash != hash
18695            || log
18696                .block_timestamp
18697                .is_some_and(|timestamp| verified.timestamp != Some(timestamp))
18698        {
18699            return Err(SubscriberError::Provider(format!(
18700                "canonical log block {number}:{hash:?} disagrees with the provider's current canonical identity"
18701            )));
18702        }
18703        self.verified_log_blocks.insert(key, verified);
18704        self.verified_log_block_order.push_back(key);
18705        let capacity = self.config.reconnect.dedupe_window.max(1);
18706        while self.verified_log_block_order.len() > capacity {
18707            if let Some(evicted) = self.verified_log_block_order.pop_front() {
18708                self.verified_log_blocks.remove(&evicted);
18709            }
18710        }
18711        Ok(())
18712    }
18713
18714    fn enqueue_event(&mut self, event: SubscriberEvent<N>) {
18715        self.enqueue_event_with_excluded_owners(event, None);
18716    }
18717
18718    fn buffer_reconcile_event_for_owners(
18719        &mut self,
18720        event: &SubscriberEvent<N>,
18721        target_epochs: &HashSet<SubscriberOwnerEpoch>,
18722    ) {
18723        match event {
18724            SubscriberEvent::Log { log, .. } => {
18725                self.buffer_reconcile_log_for_owners(log, InputSource::Subscription, target_epochs)
18726            }
18727            SubscriberEvent::BackfilledLogs { logs, .. } => {
18728                for log in logs {
18729                    self.buffer_reconcile_log_for_owners(log, InputSource::Backfill, target_epochs);
18730                }
18731            }
18732            SubscriberEvent::Logs(logs) => {
18733                for log in logs {
18734                    self.buffer_reconcile_log_for_owners(log, InputSource::Poll, target_epochs);
18735                }
18736            }
18737            #[cfg(feature = "raw-flashblocks-json")]
18738            SubscriberEvent::ExternalFlashblockUpdate(_) => {}
18739            SubscriberEvent::BlockHeader { .. }
18740            | SubscriberEvent::PendingHash(_)
18741            | SubscriberEvent::PendingHashes(_)
18742            | SubscriberEvent::BasePendingLog { .. }
18743            | SubscriberEvent::BasePendingLogTimed { .. }
18744            | SubscriberEvent::BaseFlashblock { .. }
18745            | SubscriberEvent::BaseFlashblockTimed { .. }
18746            | SubscriberEvent::OpFlashblockTick
18747            | SubscriberEvent::OpFlashblockTickTimed(_)
18748            | SubscriberEvent::CanonicalHeadTick
18749            | SubscriberEvent::PreconfirmedLogs { .. }
18750            | SubscriberEvent::FlashblockInvalidated
18751            | SubscriberEvent::FlashblockObserved
18752            | SubscriberEvent::StreamTerminated(_)
18753            | SubscriberEvent::StreamGap { .. } => {}
18754        }
18755    }
18756
18757    fn buffer_reconcile_log_for_owners(
18758        &mut self,
18759        log: &Log,
18760        source: InputSource,
18761        target_epochs: &HashSet<SubscriberOwnerEpoch>,
18762    ) {
18763        let record = self.with_chain_id(log_input_record(log.clone(), source));
18764        let owners = self
18765            .staged_owners_for_record(&record)
18766            .into_iter()
18767            .filter(|owner| target_epochs.contains(owner))
18768            .collect::<Vec<_>>();
18769        if !owners.is_empty() {
18770            self.push_pending_reconcile_record(BufferedSubscriberOwnerRecord { record, owners });
18771        }
18772    }
18773
18774    fn promote_reconcile_owner_records(&mut self, target_epochs: &HashSet<SubscriberOwnerEpoch>) {
18775        let mut retained = VecDeque::new();
18776        while let Some(mut buffered) = self.pending_reconcile_owner_records.pop_front() {
18777            let mut promoted = Vec::new();
18778            buffered.owners.retain(|owner| {
18779                if target_epochs.contains(owner) {
18780                    promoted.push(owner.clone());
18781                    false
18782                } else {
18783                    true
18784                }
18785            });
18786            if promoted.is_empty() {
18787                retained.push_back(buffered);
18788                continue;
18789            }
18790            let promoted_record = if buffered.owners.is_empty() {
18791                buffered.record
18792            } else {
18793                let record = buffered.record.clone();
18794                retained.push_back(buffered);
18795                record
18796            };
18797            self.enqueue_owner_record_for_owners_unmerged(promoted_record, promoted);
18798        }
18799        self.pending_reconcile_owner_records = retained;
18800    }
18801
18802    fn seed_reconciled_filter_anchors(
18803        &mut self,
18804        plans: &[SubscriberOwnerReconcilePlan<N>],
18805        through: u64,
18806    ) {
18807        for filter in plans.iter().flat_map(|plan| log_filters(&plan.interests)) {
18808            let Some(source_id) = self.log_source_ids.get(&filter).copied() else {
18809                continue;
18810            };
18811            let anchor = self
18812                .last_seen_log_blocks
18813                .entry(source_id)
18814                .or_insert(through);
18815            *anchor = (*anchor).max(through);
18816        }
18817    }
18818
18819    fn enqueue_event_excluding_owners(
18820        &mut self,
18821        event: SubscriberEvent<N>,
18822        excluded: &HashSet<SubscriberOwnerEpoch>,
18823    ) {
18824        self.enqueue_event_with_excluded_owners(event, Some(excluded));
18825    }
18826
18827    fn enqueue_event_with_excluded_owners(
18828        &mut self,
18829        event: SubscriberEvent<N>,
18830        excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
18831    ) {
18832        match event {
18833            SubscriberEvent::Log {
18834                source_id,
18835                log,
18836                #[cfg(feature = "ingress-timing")]
18837                timing,
18838            } => {
18839                if log_matches_any_interest(&log, &self.interests) {
18840                    let record = log_input_record(log, InputSource::Subscription);
18841                    self.note_log_block(source_id, &record);
18842                    self.enqueue_timed_record_with_excluded_owners(
18843                        record,
18844                        excluded,
18845                        #[cfg(feature = "ingress-timing")]
18846                        timing,
18847                    );
18848                }
18849            }
18850            SubscriberEvent::BackfilledLogs { source_id, logs } => {
18851                self.enqueue_backfilled_logs_with_excluded_owners(
18852                    logs,
18853                    Some(source_id),
18854                    None,
18855                    None,
18856                    excluded,
18857                );
18858            }
18859            SubscriberEvent::Logs(logs) => {
18860                for log in logs {
18861                    if log_matches_any_interest(&log, &self.interests) {
18862                        self.enqueue_record_with_excluded_owners(
18863                            log_input_record(log, InputSource::Poll),
18864                            excluded,
18865                        );
18866                    }
18867                }
18868            }
18869            SubscriberEvent::BlockHeader {
18870                header,
18871                #[cfg(feature = "ingress-timing")]
18872                timing,
18873            } => {
18874                if needs_header_block_stream(&self.interests) {
18875                    let record = block_header_input_record::<N>(header);
18876                    self.note_attestable_canonical_block(&record);
18877                    self.enqueue_timed_record_with_excluded_owners(
18878                        record,
18879                        excluded,
18880                        #[cfg(feature = "ingress-timing")]
18881                        timing,
18882                    );
18883                }
18884            }
18885            SubscriberEvent::PendingHash(hash) => {
18886                let record = pending_hash_input_record::<N>(hash, InputSource::Subscription);
18887                self.enqueue_record_with_excluded_owners(record, excluded);
18888            }
18889            SubscriberEvent::PendingHashes(hashes) => {
18890                for hash in hashes {
18891                    self.enqueue_record_with_excluded_owners(
18892                        pending_hash_input_record::<N>(hash, InputSource::Poll),
18893                        excluded,
18894                    );
18895                }
18896            }
18897            SubscriberEvent::PreconfirmedLogs {
18898                flashblock,
18899                logs,
18900                timing,
18901            } => {
18902                for log in logs {
18903                    let record = self
18904                        .with_chain_id(preconfirmed_log_input_record::<N>(log, flashblock.clone()));
18905                    self.push_pending_record(SubscriberInputRecord {
18906                        record,
18907                        scope: SubscriberInputScope::Preconfirmed,
18908                        preconfirmation_timing: Some(timing),
18909                        #[cfg(feature = "ingress-timing")]
18910                        canonical_timing: None,
18911                    });
18912                }
18913            }
18914            SubscriberEvent::FlashblockInvalidated => {
18915                self.pending_preconfirmation_invalidation = true;
18916            }
18917            SubscriberEvent::BasePendingLog { .. }
18918            | SubscriberEvent::BasePendingLogTimed { .. }
18919            | SubscriberEvent::BaseFlashblock { .. }
18920            | SubscriberEvent::BaseFlashblockTimed { .. }
18921            | SubscriberEvent::OpFlashblockTick
18922            | SubscriberEvent::OpFlashblockTickTimed(_)
18923            | SubscriberEvent::CanonicalHeadTick
18924            | SubscriberEvent::FlashblockObserved => {}
18925            #[cfg(feature = "raw-flashblocks-json")]
18926            SubscriberEvent::ExternalFlashblockUpdate(_) => {}
18927            // `next_event` intercepts a gap and recovers it before delivery;
18928            // this arm keeps the classification exhaustive.
18929            SubscriberEvent::StreamTerminated(_) | SubscriberEvent::StreamGap { .. } => {}
18930        }
18931    }
18932
18933    fn enqueue_backfilled_logs(
18934        &mut self,
18935        logs: Vec<Log>,
18936        source_id: Option<usize>,
18937        owner: Option<&SubscriberOwnerEpoch>,
18938        range: Option<SubscriberBackfill>,
18939    ) {
18940        self.enqueue_backfilled_logs_with_excluded_owners(logs, source_id, owner, range, None);
18941    }
18942
18943    fn enqueue_backfilled_logs_with_excluded_owners(
18944        &mut self,
18945        logs: Vec<Log>,
18946        source_id: Option<usize>,
18947        owner: Option<&SubscriberOwnerEpoch>,
18948        range: Option<SubscriberBackfill>,
18949        excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
18950    ) {
18951        for log in logs {
18952            if range.as_ref().is_some_and(|range| {
18953                log.block_number.is_some_and(|block| {
18954                    block < range.start_block() || range.end_block().is_some_and(|end| block > end)
18955                })
18956            }) {
18957                continue;
18958            }
18959            let matches = match owner {
18960                Some(epoch) => self
18961                    .owned_interests
18962                    .iter()
18963                    .find(|entry| entry.epoch.as_ref() == Some(epoch))
18964                    .is_some_and(|entry| log_matches_any_interest(&log, &entry.interests)),
18965                None => log_matches_any_interest(&log, &self.interests),
18966            };
18967            if matches {
18968                let record = log_input_record(log, InputSource::Backfill);
18969                if let Some(epoch) = owner {
18970                    self.enqueue_owner_record(record, epoch.clone());
18971                } else {
18972                    if let Some(source_id) = source_id {
18973                        self.note_log_block(source_id, &record);
18974                    }
18975                    self.enqueue_record_with_excluded_owners(record, excluded);
18976                }
18977            }
18978        }
18979    }
18980
18981    fn enqueue_compat_owner_backfilled_logs(
18982        &mut self,
18983        logs: Vec<Log>,
18984        owner: &HandlerId,
18985        range: SubscriberBackfill,
18986    ) {
18987        let interests = self
18988            .owned_interests
18989            .iter()
18990            .find(|entry| {
18991                &entry.owner == owner
18992                    && entry.epoch.is_none()
18993                    && entry.state == SubscriberOwnerState::Active
18994            })
18995            .map(|entry| entry.interests.clone());
18996        let Some(interests) = interests else {
18997            return;
18998        };
18999        for log in logs {
19000            if log.block_number.is_some_and(|block| {
19001                block < range.start_block() || range.end_block().is_some_and(|end| block > end)
19002            }) || !log_matches_any_interest(&log, &interests)
19003            {
19004                continue;
19005            }
19006            let record = log_input_record(log, InputSource::Backfill);
19007            self.enqueue_compat_owner_record(record, owner.clone());
19008        }
19009    }
19010
19011    async fn reconnect_source_stream(
19012        &mut self,
19013        source: SubscriberStreamSource,
19014    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
19015        if !source.is_pubsub() {
19016            return Err(stream_terminated_error(&source));
19017        }
19018
19019        if !self.config.reconnect.enabled {
19020            return Err(SubscriberError::Provider(format!(
19021                "Alloy subscriber {} stream terminated and reconnect is disabled",
19022                source.label()
19023            )));
19024        }
19025
19026        let mut attempts = 0usize;
19027        let mut delay = self.config.reconnect.initial_delay;
19028        let mut retry_delay = self.config.reconnect.retry_delay;
19029
19030        loop {
19031            attempts = attempts.saturating_add(1);
19032            if !delay.is_zero() {
19033                tokio::time::sleep(delay).await;
19034            }
19035
19036            match self.reconnect_source_once(source.clone()).await {
19037                Ok(backfill_event) => return Ok(backfill_event),
19038                Err(error) if reconnect_attempts_exhausted(attempts, &self.config.reconnect) => {
19039                    return Err(SubscriberError::Provider(format!(
19040                        "Alloy subscriber {} stream terminated and reconnect failed after {attempts} attempt(s): {error}",
19041                        source.label()
19042                    )));
19043                }
19044                Err(error) => {
19045                    tracing::warn!(
19046                        stream = source.label(),
19047                        attempts,
19048                        error = %error,
19049                        "Alloy subscriber reconnect attempt failed"
19050                    );
19051                    delay = retry_delay;
19052                    retry_delay =
19053                        next_reconnect_delay(retry_delay, self.config.reconnect.max_delay);
19054                }
19055            }
19056        }
19057    }
19058
19059    async fn reconnect_source_once(
19060        &mut self,
19061        source: SubscriberStreamSource,
19062    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
19063        if matches!(
19064            &self.state,
19065            AlloySubscriberState::Active(streams) if streams.contains_source(&source)
19066        ) {
19067            // A prior attempt installed the stream before its catch-up await
19068            // failed or was cancelled. Retry only the unfinished historical
19069            // window; reconnecting again would create a duplicate live source.
19070            let backfill_event = self.backfill_reconnected_source(&source).await?;
19071            self.pending_source_backfills
19072                .retain(|pending| !pending.same_key(&source));
19073            return Ok(backfill_event);
19074        }
19075        let stream = self.connect_source_stream(source.clone()).await?;
19076        if !matches!(self.state, AlloySubscriberState::Active(_)) {
19077            return Err(SubscriberError::Provider(
19078                "Alloy subscriber state changed before reconnect completed".to_owned(),
19079            ));
19080        }
19081        self.install_source_stream(source.clone(), stream);
19082        if self.source_requires_backfill(&source) {
19083            self.queue_source_backfill(source.clone());
19084        }
19085        let backfill_event = self.backfill_reconnected_source(&source).await?;
19086        self.pending_source_backfills
19087            .retain(|pending| !pending.same_key(&source));
19088
19089        Ok(backfill_event)
19090    }
19091
19092    /// Recover from notification loss on a still-connected stream.
19093    ///
19094    /// The policy differs by stream because what a gap costs differs:
19095    ///
19096    /// - **Canonical logs** are authoritative and unrecoverable downstream, so
19097    ///   the exact missed range is refetched. This is the only case that spends
19098    ///   an RPC, and it spends the minimum: one bounded window per gap.
19099    /// - **Canonical headers** are self-healing at the consumer. A driver that
19100    ///   walks a replacement header's parent lineage back to retained canonical
19101    ///   history recovers the skipped blocks from the next header it receives,
19102    ///   so refetching here would duplicate that work. The gap is counted so the
19103    ///   self-healing is visible rather than assumed.
19104    /// - **Pre-confirmation logs** are speculative by construction. A punctured
19105    ///   preview must not be published, so the snapshot is discarded and the
19106    ///   next complete generation replaces it.
19107    ///
19108    /// # Errors
19109    ///
19110    /// Returns an error when a canonical log gap cannot be bounded because the
19111    /// source has no delivery anchor yet. Silently continuing would mean knowing
19112    /// that logs were lost and doing nothing, which is exactly the failure this
19113    /// machinery exists to eliminate.
19114    async fn recover_stream_gap(
19115        &mut self,
19116        source: &SubscriberStreamSource,
19117        gap: SubscriberStreamGap,
19118    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
19119        match source {
19120            SubscriberStreamSource::PubSubLog { id, .. } => {
19121                self.reset_log_attestation();
19122                if !self.last_seen_log_blocks.contains_key(id) {
19123                    return Err(SubscriberError::Provider(format!(
19124                        "Alloy subscriber {} lost notifications ({gap}) before establishing a \
19125                         delivery anchor, so the missed range cannot be bounded",
19126                        source.label()
19127                    )));
19128                }
19129                let event = self
19130                    .backfill_source_window(source, SubscriberRpcCause::GapBackfill)
19131                    .await?;
19132                self.gap_counters.record_log_gap_healed();
19133                Ok(event)
19134            }
19135            SubscriberStreamSource::PubSubBlockHeaders => {
19136                self.gap_counters.record_header_gap();
19137                Ok(None)
19138            }
19139            SubscriberStreamSource::BasePendingLog { .. } => {
19140                self.gap_counters.record_preconfirmation_gap();
19141                self.invalidate_preconfirmation_snapshot();
19142                Ok(Some(SubscriberEvent::FlashblockInvalidated))
19143            }
19144            _ => Ok(None),
19145        }
19146    }
19147
19148    async fn backfill_reconnected_source(
19149        &mut self,
19150        source: &SubscriberStreamSource,
19151    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
19152        self.backfill_source_window(source, SubscriberRpcCause::ReconnectBackfill)
19153            .await
19154    }
19155
19156    /// Refetch a log source's window from its delivery anchor to the current
19157    /// head.
19158    ///
19159    /// Shared by the two situations that lose a bounded range of canonical logs
19160    /// — a stream that terminated and reconnected, and a stream that stayed
19161    /// connected but dropped notifications. `cause` attributes the requests to
19162    /// whichever of those spent them, so
19163    /// [`rpc_stats`](Self::rpc_stats) can distinguish reconnect churn from
19164    /// backpressure loss.
19165    async fn backfill_source_window(
19166        &mut self,
19167        source: &SubscriberStreamSource,
19168        cause: SubscriberRpcCause,
19169    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
19170        if source.is_flashblocks() {
19171            return Ok(None);
19172        }
19173        let SubscriberStreamSource::PubSubLog { id, filter } = source else {
19174            return Ok(None);
19175        };
19176        let Some(from_block) = self.last_seen_log_blocks.get(id).copied() else {
19177            return Ok(None);
19178        };
19179
19180        self.record_rpc(cause, SubscriberRpcMethod::EthBlockNumber);
19181        let latest = self
19182            .provider
19183            .get_block_number()
19184            .await
19185            .map_err(provider_error)?;
19186        if latest < from_block {
19187            return Ok(None);
19188        }
19189
19190        self.record_rpc(cause, SubscriberRpcMethod::EthGetLogs);
19191        let logs = self
19192            .provider
19193            .get_logs(&filter.clone().from_block(from_block).to_block(latest))
19194            .await
19195            .map_err(provider_error)?;
19196        Ok(Some(SubscriberEvent::BackfilledLogs {
19197            source_id: *id,
19198            logs,
19199        }))
19200    }
19201
19202    fn note_log_block(&mut self, source_id: usize, record: &ReactiveInputRecord<N>) {
19203        if let Some(block) = record.context.block.as_ref() {
19204            self.last_seen_log_blocks.insert(source_id, block.number);
19205        }
19206    }
19207
19208    fn enqueue_record_with_excluded_owners(
19209        &mut self,
19210        record: ReactiveInputRecord<N>,
19211        excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
19212    ) {
19213        self.enqueue_timed_record_with_excluded_owners(
19214            record,
19215            excluded,
19216            #[cfg(feature = "ingress-timing")]
19217            None,
19218        );
19219    }
19220
19221    fn enqueue_timed_record_with_excluded_owners(
19222        &mut self,
19223        record: ReactiveInputRecord<N>,
19224        excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
19225        #[cfg(feature = "ingress-timing")] timing: Option<CanonicalIngressTiming>,
19226    ) {
19227        let record = self.with_chain_id(record);
19228        let mut owners = self.staged_owners_for_record(&record);
19229        if let Some(excluded) = excluded {
19230            owners.retain(|owner| !excluded.contains(owner));
19231        }
19232        let canonical_duplicate = self.should_skip_recent_duplicate(&record);
19233        let owners = self.filter_recent_owner_duplicates(&record, owners);
19234        let compatibility_owners = self.compatibility_owners_for_record(&record);
19235        let (already_served, newly_served): (Vec<_>, Vec<_>) = compatibility_owners
19236            .into_iter()
19237            .partition(|owner| self.compatibility_owner_has_seen(&record, owner));
19238        if canonical_duplicate {
19239            if !owners.is_empty() {
19240                self.push_pending_record(SubscriberInputRecord {
19241                    record: record.clone(),
19242                    scope: SubscriberInputScope::OwnerOnly { owners },
19243                    preconfirmation_timing: None,
19244                    #[cfg(feature = "ingress-timing")]
19245                    canonical_timing: timing,
19246                });
19247            }
19248            if !newly_served.is_empty() {
19249                for owner in &newly_served {
19250                    self.remember_compatibility_owner_record(&record, owner);
19251                }
19252                self.push_pending_record(SubscriberInputRecord {
19253                    record,
19254                    scope: SubscriberInputScope::OwnerOnlyHandlers {
19255                        owners: newly_served,
19256                    },
19257                    preconfirmation_timing: None,
19258                    #[cfg(feature = "ingress-timing")]
19259                    canonical_timing: timing,
19260                });
19261            }
19262            return;
19263        }
19264        self.remember_record(&record);
19265        for owner in already_served.iter().chain(&newly_served) {
19266            self.remember_compatibility_owner_record(&record, owner);
19267        }
19268        self.push_pending_record(SubscriberInputRecord {
19269            record,
19270            scope: if already_served.is_empty() {
19271                SubscriberInputScope::Canonical { owners }
19272            } else {
19273                SubscriberInputScope::CanonicalResidual {
19274                    owners,
19275                    excluded: already_served,
19276                }
19277            },
19278            preconfirmation_timing: None,
19279            #[cfg(feature = "ingress-timing")]
19280            canonical_timing: timing,
19281        });
19282    }
19283
19284    fn enqueue_compat_owner_record(&mut self, record: ReactiveInputRecord<N>, owner: HandlerId) {
19285        let record = self.with_chain_id(record);
19286        if self.compatibility_owner_has_seen(&record, &owner) {
19287            return;
19288        }
19289        self.remember_compatibility_owner_record(&record, &owner);
19290        self.push_pending_record(SubscriberInputRecord {
19291            record,
19292            scope: SubscriberInputScope::OwnerOnlyHandlers {
19293                owners: vec![owner],
19294            },
19295            preconfirmation_timing: None,
19296            #[cfg(feature = "ingress-timing")]
19297            canonical_timing: None,
19298        });
19299    }
19300
19301    fn compatibility_owners_for_record(&self, record: &ReactiveInputRecord<N>) -> Vec<HandlerId> {
19302        self.owned_interests
19303            .iter()
19304            .filter(|entry| entry.epoch.is_none() && entry.state == SubscriberOwnerState::Active)
19305            .filter(|entry| {
19306                entry
19307                    .interests
19308                    .iter()
19309                    .any(|interest| interest_matches(interest, &record.input))
19310            })
19311            .map(|entry| entry.owner.clone())
19312            .collect()
19313    }
19314
19315    fn compatibility_owner_has_seen(
19316        &self,
19317        record: &ReactiveInputRecord<N>,
19318        owner: &HandlerId,
19319    ) -> bool {
19320        should_dedupe_record(record)
19321            && self
19322                .recent_compat_owner_input_ref_sets
19323                .get(owner)
19324                .is_some_and(|seen| seen.contains(&record.input_ref()))
19325    }
19326
19327    fn remember_compatibility_owner_record(
19328        &mut self,
19329        record: &ReactiveInputRecord<N>,
19330        owner: &HandlerId,
19331    ) {
19332        if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
19333            return;
19334        }
19335        let input_ref = record.input_ref();
19336        let seen = self
19337            .recent_compat_owner_input_ref_sets
19338            .entry(owner.clone())
19339            .or_default();
19340        if !seen.insert(input_ref) {
19341            return;
19342        }
19343        let recent = self
19344            .recent_compat_owner_input_refs
19345            .entry(owner.clone())
19346            .or_default();
19347        recent.push_back(input_ref);
19348        while recent.len() > self.config.reconnect.dedupe_window {
19349            if let Some(evicted) = recent.pop_front() {
19350                seen.remove(&evicted);
19351            }
19352        }
19353    }
19354
19355    fn enqueue_owner_record(
19356        &mut self,
19357        record: ReactiveInputRecord<N>,
19358        owner: SubscriberOwnerEpoch,
19359    ) {
19360        self.enqueue_owner_record_for_owners(record, vec![owner]);
19361    }
19362
19363    fn enqueue_owner_record_for_owners(
19364        &mut self,
19365        record: ReactiveInputRecord<N>,
19366        owners: Vec<SubscriberOwnerEpoch>,
19367    ) {
19368        self.enqueue_owner_record_for_owners_inner(record, owners, true);
19369    }
19370
19371    fn enqueue_owner_record_for_owners_unmerged(
19372        &mut self,
19373        record: ReactiveInputRecord<N>,
19374        owners: Vec<SubscriberOwnerEpoch>,
19375    ) {
19376        self.enqueue_owner_record_for_owners_inner(record, owners, false);
19377    }
19378
19379    fn enqueue_owner_record_for_owners_inner(
19380        &mut self,
19381        record: ReactiveInputRecord<N>,
19382        owners: Vec<SubscriberOwnerEpoch>,
19383        merge_pending: bool,
19384    ) {
19385        let record = self.with_chain_id(record);
19386        let owners = self.filter_recent_owner_duplicates(&record, owners);
19387        if owners.is_empty() {
19388            return;
19389        }
19390        if merge_pending
19391            && should_dedupe_record(&record)
19392            && self.config.reconnect.dedupe_window != 0
19393        {
19394            let input_ref = record.input_ref();
19395            if let Some(pending) = self
19396                .pending_records
19397                .iter_mut()
19398                .rev()
19399                .find(|pending| pending.record.input_ref() == input_ref)
19400            {
19401                let pending_owners = match &mut pending.scope {
19402                    SubscriberInputScope::Canonical { owners }
19403                    | SubscriberInputScope::CanonicalResidual { owners, .. }
19404                    | SubscriberInputScope::OwnerOnly { owners } => Some(owners),
19405                    SubscriberInputScope::OwnerOnlyHandlers { .. }
19406                    | SubscriberInputScope::Preconfirmed => None,
19407                };
19408                if let Some(pending_owners) = pending_owners {
19409                    for owner in owners {
19410                        if !pending_owners.contains(&owner) {
19411                            pending_owners.push(owner);
19412                        }
19413                    }
19414                    return;
19415                }
19416            }
19417        }
19418        self.push_pending_record(SubscriberInputRecord {
19419            record,
19420            scope: SubscriberInputScope::OwnerOnly { owners },
19421            preconfirmation_timing: None,
19422            #[cfg(feature = "ingress-timing")]
19423            canonical_timing: None,
19424        });
19425    }
19426
19427    fn push_pending_record(&mut self, record: SubscriberInputRecord<N>) {
19428        if self.pending_record_count() >= self.config.max_pending_records {
19429            self.note_resource_error(format!(
19430                "pending record queues reached the configured limit of {}",
19431                self.config.max_pending_records
19432            ));
19433            return;
19434        }
19435        self.pending_records.push_back(record);
19436    }
19437
19438    fn ensure_pending_record_capacity(
19439        &mut self,
19440        additional: usize,
19441        operation: &str,
19442    ) -> Result<(), SubscriberError> {
19443        let required = self.pending_record_count().saturating_add(additional);
19444        if required > self.config.max_pending_records {
19445            self.note_resource_error(format!(
19446                "{operation} require {required} pending records, above the configured limit of {}",
19447                self.config.max_pending_records
19448            ));
19449            return self.check_resource_error();
19450        }
19451        Ok(())
19452    }
19453
19454    fn push_pending_reconcile_record(&mut self, record: BufferedSubscriberOwnerRecord<N>) {
19455        if self.pending_record_count() >= self.config.max_pending_records {
19456            self.note_resource_error(format!(
19457                "pending record queues reached the configured limit of {}",
19458                self.config.max_pending_records
19459            ));
19460            return;
19461        }
19462        self.pending_reconcile_owner_records.push_back(record);
19463    }
19464
19465    fn pending_record_count(&self) -> usize {
19466        self.pending_records
19467            .len()
19468            .saturating_add(self.pending_reconcile_owner_records.len())
19469    }
19470
19471    fn note_resource_error(&mut self, message: String) {
19472        if self.resource_error.is_none() {
19473            self.resource_error = Some(message);
19474        }
19475    }
19476
19477    fn check_resource_error(&self) -> Result<(), SubscriberError> {
19478        match &self.resource_error {
19479            Some(message) => Err(SubscriberError::ResourceExhausted(message.clone())),
19480            None => Ok(()),
19481        }
19482    }
19483
19484    fn with_chain_id(&self, mut record: ReactiveInputRecord<N>) -> ReactiveInputRecord<N> {
19485        record.context.chain_id = self.chain_id;
19486        if self.config.verify_log_block_context
19487            && let ReactiveInput::Log(log) = &record.input
19488            && !log.removed
19489            && let (Some(number), Some(hash)) = (log.block_number, log.block_hash)
19490            && let Some(verified) = self.verified_log_blocks.get(&(number, hash)).copied()
19491        {
19492            record.context.block = Some(verified);
19493            record.context.chain_status = ChainStatus::Included {
19494                block: verified,
19495                confirmations: 0,
19496            };
19497        }
19498        record
19499    }
19500
19501    fn staged_owners_for_record(
19502        &self,
19503        record: &ReactiveInputRecord<N>,
19504    ) -> Vec<SubscriberOwnerEpoch> {
19505        self.owned_interests
19506            .iter()
19507            .filter(|entry| entry.state == SubscriberOwnerState::Staged)
19508            .filter(|entry| {
19509                entry
19510                    .interests
19511                    .iter()
19512                    .any(|interest| interest_matches(interest, &record.input))
19513            })
19514            .filter_map(|entry| entry.epoch.clone())
19515            .collect()
19516    }
19517
19518    fn filter_recent_owner_duplicates(
19519        &mut self,
19520        record: &ReactiveInputRecord<N>,
19521        owners: Vec<SubscriberOwnerEpoch>,
19522    ) -> Vec<SubscriberOwnerEpoch> {
19523        if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
19524            return owners;
19525        }
19526        let input_ref = record.input_ref();
19527        let window = self.config.reconnect.dedupe_window;
19528        owners
19529            .into_iter()
19530            .filter(|owner| {
19531                let seen = self
19532                    .recent_owner_input_ref_sets
19533                    .entry(owner.clone())
19534                    .or_default();
19535                if !seen.insert(input_ref) {
19536                    return false;
19537                }
19538                let recent = self
19539                    .recent_owner_input_refs
19540                    .entry(owner.clone())
19541                    .or_default();
19542                recent.push_back(input_ref);
19543                while recent.len() > window {
19544                    if let Some(evicted) = recent.pop_front() {
19545                        seen.remove(&evicted);
19546                    }
19547                }
19548                true
19549            })
19550            .collect()
19551    }
19552
19553    fn should_skip_recent_duplicate(&self, record: &ReactiveInputRecord<N>) -> bool {
19554        if !should_dedupe_record(record) {
19555            return false;
19556        }
19557        self.recent_input_ref_set.contains(&record.input_ref())
19558    }
19559
19560    fn remember_record(&mut self, record: &ReactiveInputRecord<N>) {
19561        if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
19562            return;
19563        }
19564
19565        let input_ref = record.input_ref();
19566        if !self.recent_input_ref_set.insert(input_ref) {
19567            return;
19568        }
19569        self.recent_input_refs.push_back(input_ref);
19570
19571        while self.recent_input_refs.len() > self.config.reconnect.dedupe_window {
19572            if let Some(evicted) = self.recent_input_refs.pop_front() {
19573                self.recent_input_ref_set.remove(&evicted);
19574            }
19575        }
19576    }
19577}
19578
19579/// Turn a pubsub subscription into a stream that reports dropped notifications
19580/// instead of hiding them.
19581///
19582/// [`Subscription::into_stream`] is deliberately not used: it treats both a
19583/// lagged receiver and an undecodable payload as `continue`, logging at `debug`
19584/// and moving on, so a consumer cannot distinguish a complete stream from a
19585/// punctured one. Consuming the raw subscription makes a broadcast `Lagged` a
19586/// first-class [`SubscriberEvent::StreamGap`], while `Closed` still ends the
19587/// stream so the existing reconnect path handles a genuine disconnect unchanged.
19588///
19589/// [`Subscription::into_stream`]: alloy_pubsub::Subscription::into_stream
19590#[cfg(feature = "reactive-ws")]
19591fn gap_observing_stream<
19592    N,
19593    T,
19594    #[cfg(feature = "ingress-timing")] F: FnMut(T, Option<CanonicalIngressTiming>) -> SubscriberEvent<N> + Send + 'static,
19595    #[cfg(not(feature = "ingress-timing"))] F: FnMut(T) -> SubscriberEvent<N> + Send + 'static,
19596>(
19597    subscription: alloy_pubsub::Subscription<T>,
19598    source: SubscriberStreamSource,
19599    gap_counters: Arc<SubscriberStreamGapCounters>,
19600    #[cfg(feature = "ingress-timing")] receipts: Option<TransportReceiptLookup>,
19601    to_event: F,
19602) -> BoxStream<'static, SubscriberEvent<N>>
19603where
19604    N: Network + 'static,
19605    T: serde::de::DeserializeOwned + Send + 'static,
19606{
19607    // The decode closure and the receiver both live in the unfold state, so no
19608    // borrow is held across an await point.
19609    struct GapState<T, F> {
19610        raw: alloy_pubsub::RawSubscription,
19611        to_event: F,
19612        source: SubscriberStreamSource,
19613        gap_counters: Arc<SubscriberStreamGapCounters>,
19614        #[cfg(feature = "ingress-timing")]
19615        receipts: Option<TransportReceiptLookup>,
19616        _item: PhantomData<fn() -> T>,
19617    }
19618
19619    let state = GapState {
19620        raw: subscription.into_raw(),
19621        to_event,
19622        source: source.clone(),
19623        gap_counters,
19624        #[cfg(feature = "ingress-timing")]
19625        receipts,
19626        _item: PhantomData::<fn() -> T>,
19627    };
19628
19629    let stream = stream::unfold(state, |mut state| async move {
19630        let gap = match state.raw.recv().await {
19631            Ok(value) => {
19632                #[cfg(feature = "ingress-timing")]
19633                let dequeued = state.receipts.as_ref().map(|_| Instant::now());
19634                #[cfg(feature = "ingress-timing")]
19635                let receipt = state
19636                    .receipts
19637                    .as_ref()
19638                    .and_then(|lookup| lookup(value.get()));
19639                match serde_json::from_str::<T>(value.get()) {
19640                    Ok(item) => {
19641                        #[cfg(feature = "ingress-timing")]
19642                        let timing = receipt.zip(dequeued).and_then(|(receipt, dequeued)| {
19643                            CanonicalIngressTiming::new(receipt, dequeued, Instant::now())
19644                        });
19645                        let event = (state.to_event)(
19646                            item,
19647                            #[cfg(feature = "ingress-timing")]
19648                            timing,
19649                        );
19650                        return Some((event, state));
19651                    }
19652                    Err(error) => {
19653                        tracing::warn!(
19654                            stream = state.source.label(),
19655                            error = %error,
19656                            "pubsub notification did not decode; treating it as lost data"
19657                        );
19658                        SubscriberStreamGap::Undecodable
19659                    }
19660                }
19661            }
19662            Err(broadcast::error::RecvError::Lagged(skipped)) => {
19663                tracing::warn!(
19664                    stream = state.source.label(),
19665                    skipped,
19666                    "pubsub notification channel overflowed; the missed window will be recovered"
19667                );
19668                SubscriberStreamGap::Lagged { skipped }
19669            }
19670            // A closed channel is a disconnect, not a gap. Ending the stream
19671            // lets `stream_with_termination` drive the existing reconnect.
19672            Err(broadcast::error::RecvError::Closed) => return None,
19673        };
19674        state.gap_counters.record_gap(gap);
19675        let event = SubscriberEvent::StreamGap {
19676            source: state.source.clone(),
19677            gap,
19678        };
19679        Some((event, state))
19680    });
19681
19682    stream_with_termination(stream, source)
19683}
19684
19685fn stream_with_termination<N, S>(
19686    stream: S,
19687    source: SubscriberStreamSource,
19688) -> BoxStream<'static, SubscriberEvent<N>>
19689where
19690    N: Network + 'static,
19691    S: futures::Stream<Item = SubscriberEvent<N>> + Send + 'static,
19692{
19693    stream
19694        .chain(stream::once(async move {
19695            SubscriberEvent::StreamTerminated(source)
19696        }))
19697        .boxed()
19698}
19699
19700fn flashblock_reconnect_future<N>(
19701    provider: RootProvider<N>,
19702    source: SubscriberStreamSource,
19703    channel_size: usize,
19704    reconnect: SubscriberReconnectConfig,
19705    first_delay: Duration,
19706    flashblock_poll_interval: Duration,
19707    counters: Arc<SubscriberRpcCounters>,
19708) -> FlashblockReconnectFuture<N>
19709where
19710    N: Network + 'static,
19711{
19712    Box::pin(async move {
19713        if !reconnect.enabled {
19714            let error = SubscriberError::Provider(format!(
19715                "Alloy subscriber {} stream terminated and reconnect is disabled",
19716                source.label()
19717            ));
19718            return (source, Err(error));
19719        }
19720
19721        let mut attempts = 0_usize;
19722        let mut delay = first_delay;
19723        let mut retry_delay = reconnect.retry_delay;
19724        loop {
19725            attempts = attempts.saturating_add(1);
19726            if !delay.is_zero() {
19727                tokio::time::sleep(delay).await;
19728            }
19729            match connect_flashblock_source_once(
19730                &provider,
19731                source.clone(),
19732                channel_size,
19733                flashblock_poll_interval,
19734                counters.as_ref(),
19735            )
19736            .await
19737            {
19738                Ok(stream) => return (source, Ok(stream)),
19739                Err(error) if reconnect_attempts_exhausted(attempts, &reconnect) => {
19740                    return (
19741                        source.clone(),
19742                        Err(SubscriberError::Provider(format!(
19743                            "Alloy subscriber {} stream reconnect failed after {attempts} attempt(s): {error}",
19744                            source.label()
19745                        ))),
19746                    );
19747                }
19748                Err(error) => {
19749                    tracing::warn!(
19750                        stream = source.label(),
19751                        attempts,
19752                        error = %error,
19753                        "Flashblocks reconnect attempt failed"
19754                    );
19755                    delay = retry_delay;
19756                    retry_delay = next_reconnect_delay(retry_delay, reconnect.max_delay);
19757                }
19758            }
19759        }
19760    })
19761}
19762
19763async fn connect_flashblock_source_once<N>(
19764    provider: &RootProvider<N>,
19765    source: SubscriberStreamSource,
19766    channel_size: usize,
19767    flashblock_poll_interval: Duration,
19768    counters: &SubscriberRpcCounters,
19769) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError>
19770where
19771    N: Network + 'static,
19772{
19773    #[cfg(not(feature = "reactive-ws"))]
19774    let _ = (provider, counters);
19775
19776    match source {
19777        SubscriberStreamSource::BasePendingLog { id, filter } => {
19778            #[cfg(feature = "reactive-ws")]
19779            {
19780                let source = SubscriberStreamSource::BasePendingLog {
19781                    id,
19782                    filter: filter.clone(),
19783                };
19784                let params = base_pending_log_filter(&filter)?;
19785                counters.record(
19786                    SubscriberRpcCause::StreamSubscription,
19787                    SubscriberRpcMethod::EthSubscribe,
19788                );
19789                let stream = provider
19790                    .subscribe::<_, Log>(("pendingLogs", params))
19791                    .channel_size(channel_size.max(1))
19792                    .await
19793                    .map_err(provider_error)?
19794                    .into_stream()
19795                    .map(move |log| SubscriberEvent::BasePendingLogTimed {
19796                        source_id: id,
19797                        log,
19798                        timing: FlashblockIngressTiming::new(Instant::now()),
19799                    });
19800                Ok(stream_with_termination(stream, source))
19801            }
19802            #[cfg(not(feature = "reactive-ws"))]
19803            {
19804                let _ = (id, filter, channel_size);
19805                Err(SubscriberError::Unsupported(
19806                    "Base Flashblocks require the reactive-ws feature",
19807                ))
19808            }
19809        }
19810        SubscriberStreamSource::BaseFlashblocks => {
19811            #[cfg(feature = "reactive-ws")]
19812            {
19813                counters.record(
19814                    SubscriberRpcCause::StreamSubscription,
19815                    SubscriberRpcMethod::EthSubscribe,
19816                );
19817                let stream = provider
19818                    .subscribe::<_, BaseFlashblockWirePayload>(("newFlashblocks",))
19819                    .channel_size(channel_size.max(1))
19820                    .await
19821                    .map_err(provider_error)?
19822                    .into_stream()
19823                    .map(|payload| SubscriberEvent::BaseFlashblockTimed {
19824                        payload,
19825                        timing: FlashblockIngressTiming::new(Instant::now()),
19826                    });
19827                Ok(stream_with_termination(
19828                    stream,
19829                    SubscriberStreamSource::BaseFlashblocks,
19830                ))
19831            }
19832            #[cfg(not(feature = "reactive-ws"))]
19833            {
19834                let _ = channel_size;
19835                Err(SubscriberError::Unsupported(
19836                    "Base Flashblocks require the reactive-ws feature",
19837                ))
19838            }
19839        }
19840        SubscriberStreamSource::OpPendingFlashblocks => {
19841            let first_tick = tokio::time::Instant::now();
19842            let mut interval = tokio::time::interval_at(first_tick, flashblock_poll_interval);
19843            interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
19844            let stream = stream::unfold(interval, |mut interval| async move {
19845                interval.tick().await;
19846                Some((
19847                    SubscriberEvent::OpFlashblockTickTimed(FlashblockIngressTiming::new(
19848                        Instant::now(),
19849                    )),
19850                    interval,
19851                ))
19852            });
19853            Ok(stream_with_termination(
19854                stream,
19855                SubscriberStreamSource::OpPendingFlashblocks,
19856            ))
19857        }
19858        source => Err(SubscriberError::InvalidConfig(match source {
19859            SubscriberStreamSource::PubSubLog { .. }
19860            | SubscriberStreamSource::CanonicalHeadPolling
19861            | SubscriberStreamSource::PubSubPendingHashes
19862            | SubscriberStreamSource::PubSubBlockHeaders
19863            | SubscriberStreamSource::PollingLog { .. }
19864            | SubscriberStreamSource::PollingPendingHashes => {
19865                "Flashblocks reconnect received a canonical source"
19866            }
19867            SubscriberStreamSource::BasePendingLog { .. }
19868            | SubscriberStreamSource::BaseFlashblocks
19869            | SubscriberStreamSource::OpPendingFlashblocks => unreachable!(),
19870            #[cfg(feature = "raw-flashblocks-json")]
19871            SubscriberStreamSource::ExternalFlashblockUpdates => {
19872                "Flashblocks reconnect cannot own an application-managed source"
19873            }
19874        })),
19875    }
19876}
19877
19878fn aggregate_interests<N: Network>(
19879    base: &[ReactiveInterest<N>],
19880    owned: &[OwnedSubscriberInterests<N>],
19881) -> Vec<ReactiveInterest<N>> {
19882    base.iter()
19883        .cloned()
19884        .chain(
19885            owned
19886                .iter()
19887                .flat_map(|entry| entry.interests.iter().cloned()),
19888        )
19889        .collect()
19890}
19891
19892fn stream_terminated_error(source: &SubscriberStreamSource) -> SubscriberError {
19893    SubscriberError::Provider(format!(
19894        "Alloy subscriber {} stream terminated before the subscriber was stopped",
19895        source.label()
19896    ))
19897}
19898
19899fn reconnect_attempts_exhausted(attempts: usize, config: &SubscriberReconnectConfig) -> bool {
19900    config
19901        .max_attempts
19902        .is_some_and(|max_attempts| attempts >= max_attempts)
19903}
19904
19905fn next_reconnect_delay(current: Duration, max: Duration) -> Duration {
19906    if current.is_zero() {
19907        return current;
19908    }
19909    current.checked_mul(2).unwrap_or(max).min(max)
19910}
19911
19912fn should_dedupe_record<N: Network>(record: &ReactiveInputRecord<N>) -> bool {
19913    match &record.input {
19914        ReactiveInput::Log(log) => {
19915            is_canonical_status(&record.context.chain_status) && !log.removed
19916        }
19917        ReactiveInput::BlockHeader(_) | ReactiveInput::PendingTxHash(_) => true,
19918        ReactiveInput::FullBlock(_) | ReactiveInput::PendingTx(_) => false,
19919    }
19920}
19921
19922#[cfg(test)]
19923mod subscriber_helper_tests {
19924    use super::*;
19925    use alloy_json_rpc::{RequestPacket, ResponsePacket};
19926    use alloy_provider::ProviderBuilder;
19927    use alloy_rpc_client::RpcClient;
19928    use alloy_transport::{TransportError, TransportFut, mock::Asserter};
19929    use std::task::{Context, Poll};
19930    use tower::Service;
19931
19932    #[derive(Clone, Debug)]
19933    struct NeverRespondingTransport;
19934
19935    impl Service<RequestPacket> for NeverRespondingTransport {
19936        type Response = ResponsePacket;
19937        type Error = TransportError;
19938        type Future = TransportFut<'static>;
19939
19940        fn poll_ready(&mut self, _context: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
19941            Poll::Ready(Ok(()))
19942        }
19943
19944        fn call(&mut self, _request: RequestPacket) -> Self::Future {
19945            Box::pin(futures::future::pending())
19946        }
19947    }
19948
19949    fn indexed_flashblock(transaction_hash: B256, state_root: B256) -> BaseFlashblockWirePayload {
19950        BaseFlashblockWirePayload::Indexed(BaseFlashblockPayload {
19951            payload_id: FixedBytes::repeat_byte(0x11),
19952            index: 0,
19953            base: Some(BaseFlashblockBase {
19954                parent_hash: B256::repeat_byte(100),
19955                block_number: 101,
19956                timestamp: 1_700_000_101,
19957                gas_limit: Some(30_000_000),
19958                base_fee_per_gas: Some(7),
19959                beneficiary: Some(Address::repeat_byte(0xcb)),
19960                prevrandao: Some(B256::repeat_byte(0x77)),
19961            }),
19962            diff: BaseFlashblockDiff {
19963                state_root,
19964                block_hash: B256::ZERO,
19965                transactions: vec![serde_json::Value::String(format!("{transaction_hash:#x}"))],
19966                transactions_root: None,
19967            },
19968            metadata: None,
19969        })
19970    }
19971
19972    fn base_flashblock_event(payload: BaseFlashblockWirePayload) -> SubscriberEvent<Ethereum> {
19973        SubscriberEvent::BaseFlashblockTimed {
19974            payload,
19975            timing: FlashblockIngressTiming::new(Instant::now()),
19976        }
19977    }
19978
19979    #[test]
19980    fn duplicate_flashblock_transaction_membership_is_rejected() {
19981        let transaction = format!("{:#x}", B256::repeat_byte(0x41));
19982        let transactions = vec![
19983            serde_json::Value::String(transaction.clone()),
19984            serde_json::Value::String(transaction),
19985        ];
19986        assert!(matches!(
19987            flashblock_transaction_hashes(&transactions),
19988            Err(SubscriberError::Provider(ref message)) if message.contains("duplicate")
19989        ));
19990    }
19991
19992    #[test]
19993    fn conflicting_duplicate_indexed_flashblock_is_rejected() {
19994        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
19995        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19996            provider,
19997            SubscriberMode::PubSub,
19998            SubscriberConfig::default(),
19999        )
20000        .with_provider_ref(ProviderRef::new("base-paid", 7));
20001        subscriber.chain_id = Some(8_453);
20002
20003        subscriber
20004            .accept_base_flashblock(indexed_flashblock(
20005                B256::repeat_byte(0x41),
20006                B256::repeat_byte(0xa1),
20007            ))
20008            .expect("first indexed preview");
20009        assert!(matches!(
20010            subscriber.accept_base_flashblock(indexed_flashblock(
20011                B256::repeat_byte(0x42),
20012                B256::repeat_byte(0xa2),
20013            )),
20014            Err(SubscriberError::Provider(ref message))
20015                if message.contains("conflicting duplicate")
20016        ));
20017    }
20018
20019    #[tokio::test]
20020    async fn duplicate_index_with_changed_commitment_is_rejected() {
20021        let transaction = B256::repeat_byte(0x41);
20022        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20023        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20024            provider,
20025            SubscriberMode::PubSub,
20026            SubscriberConfig::default(),
20027        )
20028        .with_provider_ref(ProviderRef::new("base-paid", 7));
20029        subscriber.chain_id = Some(8_453);
20030
20031        subscriber
20032            .normalize_flashblock_event(base_flashblock_event(indexed_flashblock(
20033                transaction,
20034                B256::repeat_byte(0xa1),
20035            )))
20036            .await
20037            .expect("first indexed preview");
20038
20039        let BaseFlashblockWirePayload::Indexed(mut conflicting) =
20040            indexed_flashblock(transaction, B256::repeat_byte(0xa1))
20041        else {
20042            unreachable!()
20043        };
20044        conflicting.diff.state_root = B256::repeat_byte(0xbb);
20045        assert!(matches!(
20046            subscriber
20047                .normalize_flashblock_event(base_flashblock_event(
20048                    BaseFlashblockWirePayload::Indexed(conflicting),
20049                ))
20050                .await,
20051            Err(SubscriberError::Provider(ref message))
20052                if message.contains("conflicting duplicate indexed Flashblock content")
20053        ));
20054    }
20055
20056    #[tokio::test]
20057    async fn indexed_gap_recovery_seeds_later_cumulative_membership() {
20058        let transaction_a = B256::repeat_byte(0x41);
20059        let transaction_b = B256::repeat_byte(0x42);
20060        let transaction_c = B256::repeat_byte(0x43);
20061        let transaction_d = B256::repeat_byte(0x44);
20062        let asserter = Asserter::new();
20063        asserter.push_success(&100_u64);
20064        let pending = rpc_block(101, B256::ZERO).with_transactions(
20065            alloy_network::primitives::BlockTransactions::Hashes(vec![
20066                transaction_a,
20067                transaction_b,
20068                transaction_c,
20069            ]),
20070        );
20071        asserter.push_success(&Some(pending));
20072        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
20073        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20074            provider,
20075            SubscriberMode::PubSub,
20076            SubscriberConfig::default(),
20077        )
20078        .with_provider_ref(ProviderRef::new("base-paid", 7));
20079        subscriber.chain_id = Some(8_453);
20080
20081        subscriber
20082            .normalize_flashblock_event(base_flashblock_event(indexed_flashblock(
20083                transaction_a,
20084                B256::repeat_byte(0xa1),
20085            )))
20086            .await
20087            .expect("index zero preview");
20088        let BaseFlashblockWirePayload::Indexed(mut gap) =
20089            indexed_flashblock(transaction_c, B256::repeat_byte(0xa3))
20090        else {
20091            unreachable!()
20092        };
20093        gap.index = 2;
20094        gap.base = None;
20095        gap.metadata = Some(BaseFlashblockMetadata { block_number: 101 });
20096        subscriber
20097            .normalize_flashblock_event(base_flashblock_event(BaseFlashblockWirePayload::Indexed(
20098                gap,
20099            )))
20100            .await
20101            .expect("the missing index is recovered from pending state");
20102
20103        let BaseFlashblockWirePayload::Indexed(mut next) =
20104            indexed_flashblock(transaction_d, B256::repeat_byte(0xa4))
20105        else {
20106            unreachable!()
20107        };
20108        next.index = 3;
20109        next.base = None;
20110        next.metadata = Some(BaseFlashblockMetadata { block_number: 101 });
20111        let (next, recover) = subscriber
20112            .accept_base_flashblock(BaseFlashblockWirePayload::Indexed(next))
20113            .expect("the next diff extends the recovered cumulative set");
20114        assert!(!recover);
20115        assert_eq!(
20116            next.transaction_hashes,
20117            vec![transaction_a, transaction_b, transaction_c, transaction_d]
20118        );
20119    }
20120
20121    #[tokio::test]
20122    async fn unrecoverable_indexed_gap_revokes_the_generation() {
20123        let asserter = Asserter::new();
20124        asserter.push_success(&100_u64);
20125        asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(0x64))));
20126        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
20127        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20128            provider,
20129            SubscriberMode::PubSub,
20130            SubscriberConfig {
20131                preconfirmations: PreconfirmationMode::Preferred,
20132                ..SubscriberConfig::default()
20133            },
20134        )
20135        .with_provider_ref(ProviderRef::new("base-paid", 7));
20136        subscriber.chain_id = Some(8_453);
20137
20138        subscriber
20139            .normalize_flashblock_event(base_flashblock_event(indexed_flashblock(
20140                B256::repeat_byte(0x41),
20141                B256::repeat_byte(0xa1),
20142            )))
20143            .await
20144            .expect("index zero preview");
20145        let BaseFlashblockWirePayload::Indexed(mut gap) =
20146            indexed_flashblock(B256::repeat_byte(0x43), B256::repeat_byte(0xa3))
20147        else {
20148            unreachable!()
20149        };
20150        gap.index = 2;
20151        gap.base = None;
20152        gap.metadata = Some(BaseFlashblockMetadata { block_number: 101 });
20153        let event = subscriber
20154            .normalize_flashblock_event(base_flashblock_event(BaseFlashblockWirePayload::Indexed(
20155                gap,
20156            )))
20157            .await
20158            .expect("preferred mode fails closed without pending recovery")
20159            .expect("generation invalidation is observable");
20160        assert!(matches!(event, SubscriberEvent::FlashblockInvalidated));
20161        assert!(subscriber.latest_preconfirmation.is_none());
20162        assert_eq!(subscriber.provider_ref.as_ref().unwrap().generation, 8);
20163    }
20164
20165    #[test]
20166    fn base_flashblock_wire_decodes_cumulative_block_shape() {
20167        let payload: BaseFlashblockWirePayload = serde_json::from_str(
20168            r#"{
20169                "hash":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
20170                "number":"0x2ef403b",
20171                "parentHash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
20172                "stateRoot":"0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
20173                "timestamp":"0x6a68dd59",
20174                "transactions":[]
20175            }"#,
20176        )
20177        .expect("decode current Base newFlashblocks shape");
20178        let BaseFlashblockWirePayload::Block(payload) = payload else {
20179            panic!("expected cumulative block-shaped payload")
20180        };
20181        assert_eq!(payload.number, 49_233_979);
20182        assert_eq!(payload.timestamp, 1_785_257_305);
20183        assert_eq!(payload.hash, B256::repeat_byte(0xaa));
20184        assert_eq!(payload.parent_hash, B256::repeat_byte(0xbb));
20185        assert_eq!(payload.state_root, B256::repeat_byte(0xcc));
20186    }
20187
20188    #[tokio::test]
20189    async fn zero_hash_pending_log_waits_for_the_preview_containing_its_transaction() {
20190        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20191        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20192            provider,
20193            SubscriberMode::PubSub,
20194            SubscriberConfig::default(),
20195        )
20196        .with_provider_ref(ProviderRef::new("base-paid", 7));
20197        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
20198            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
20199            local_matcher: None,
20200            route_key: None,
20201        })];
20202        subscriber.interests = subscriber.base_interests.clone();
20203
20204        let first: BaseFlashblockWirePayload = serde_json::from_str(
20205            r#"{
20206                "hash":"0x0000000000000000000000000000000000000000000000000000000000000000",
20207                "number":"0x65",
20208                "parentHash":"0x6464646464646464646464646464646464646464646464646464646464646464",
20209                "stateRoot":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
20210                "transactionsRoot":"0x1111111111111111111111111111111111111111111111111111111111111111",
20211                "timestamp":"0x6553f165",
20212                "transactions":["0x4141414141414141414141414141414141414141414141414141414141414141"]
20213            }"#,
20214        )
20215        .expect("decode first cumulative preview");
20216        subscriber
20217            .normalize_flashblock_event(base_flashblock_event(first))
20218            .await
20219            .expect("first preview is accepted");
20220
20221        let mut second_log = rpc_log(false);
20222        second_log.block_hash = Some(B256::ZERO);
20223        second_log.block_number = Some(102);
20224        second_log.block_timestamp = Some(1_700_000_102);
20225        second_log.transaction_hash = Some(B256::repeat_byte(0x42));
20226        second_log.transaction_index = Some(0);
20227        second_log.log_index = Some(0);
20228
20229        let pending_log_ingress = Instant::now() - Duration::from_millis(25);
20230        let before_preview = subscriber
20231            .normalize_flashblock_event(SubscriberEvent::BasePendingLogTimed {
20232                source_id: 0,
20233                log: second_log,
20234                timing: FlashblockIngressTiming::new(pending_log_ingress),
20235            })
20236            .await
20237            .expect("a zero-hash log for the next block must be buffered");
20238        assert!(before_preview.is_none());
20239
20240        let second: BaseFlashblockWirePayload = serde_json::from_str(
20241            r#"{
20242                "hash":"0x0000000000000000000000000000000000000000000000000000000000000000",
20243                "number":"0x66",
20244                "parentHash":"0x6565656565656565656565656565656565656565656565656565656565656565",
20245                "stateRoot":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
20246                "transactionsRoot":"0x2222222222222222222222222222222222222222222222222222222222222222",
20247                "timestamp":"0x6553f166",
20248                "transactions":["0x4242424242424242424242424242424242424242424242424242424242424242"]
20249            }"#,
20250        )
20251        .expect("decode second cumulative preview");
20252        let event = subscriber
20253            .normalize_flashblock_event(base_flashblock_event(second))
20254            .await
20255            .expect("second preview is accepted")
20256            .expect("the matching buffered log is released");
20257        let SubscriberEvent::PreconfirmedLogs {
20258            flashblock,
20259            logs,
20260            timing,
20261        } = event
20262        else {
20263            panic!("expected a preconfirmed log batch")
20264        };
20265        assert_eq!(timing.source_ingress(), pending_log_ingress);
20266        assert_eq!(flashblock.block_number, 102);
20267        assert_ne!(flashblock.content_hash, B256::ZERO);
20268        assert_eq!(flashblock.partial_block_hash, None);
20269        assert_eq!(logs.len(), 1);
20270        assert_eq!(logs[0].transaction_hash, Some(B256::repeat_byte(0x42)));
20271        assert_eq!(logs[0].block_hash, Some(flashblock.content_hash));
20272    }
20273
20274    #[test]
20275    fn flashblock_endpoints_certify_canonical_heads_instead_of_trusting_newheads() {
20276        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20277        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20278            provider,
20279            SubscriberMode::PubSub,
20280            SubscriberConfig {
20281                preconfirmations: PreconfirmationMode::Required,
20282                ..SubscriberConfig::default()
20283            },
20284        )
20285        .with_provider_ref(ProviderRef::new("base-paid", 7));
20286        subscriber.chain_id = Some(8_453);
20287        subscriber.interests = vec![ReactiveInterest::Blocks(BlockInterest::default())];
20288
20289        let sources = subscriber.pubsub_stream_sources();
20290        assert!(
20291            sources
20292                .iter()
20293                .any(|source| matches!(source, SubscriberStreamSource::CanonicalHeadPolling))
20294        );
20295        assert!(
20296            !sources
20297                .iter()
20298                .any(|source| matches!(source, SubscriberStreamSource::PubSubBlockHeaders))
20299        );
20300    }
20301
20302    #[test]
20303    #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
20304    fn external_flashblocks_keep_normal_canonical_pubsub_sources_on_any_chain() {
20305        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20306        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20307            provider,
20308            SubscriberMode::PubSub,
20309            SubscriberConfig {
20310                preconfirmations: PreconfirmationMode::Required,
20311                ..SubscriberConfig::default()
20312            },
20313        );
20314        subscriber
20315            .configure_external_flashblock_updates(ProviderRef::new("raw-json", 4))
20316            .expect("configure external source");
20317        subscriber.chain_id = Some(1);
20318        subscriber.base_interests = vec![
20319            ReactiveInterest::Blocks(BlockInterest::default()),
20320            log_interest_matching_rpc_log(),
20321        ];
20322        subscriber.interests = subscriber.base_interests.clone();
20323
20324        let pubsub = subscriber.pubsub_stream_sources();
20325        assert!(
20326            pubsub
20327                .iter()
20328                .any(|source| matches!(source, SubscriberStreamSource::PubSubBlockHeaders))
20329        );
20330        assert!(
20331            pubsub
20332                .iter()
20333                .any(|source| matches!(source, SubscriberStreamSource::PubSubLog { .. }))
20334        );
20335        assert!(pubsub.iter().all(|source| !matches!(
20336            source,
20337            SubscriberStreamSource::BaseFlashblocks
20338                | SubscriberStreamSource::BasePendingLog { .. }
20339                | SubscriberStreamSource::OpPendingFlashblocks
20340                | SubscriberStreamSource::CanonicalHeadPolling
20341        )));
20342        assert!(
20343            subscriber
20344                .polling_stream_sources()
20345                .iter()
20346                .all(|source| { !matches!(source, SubscriberStreamSource::OpPendingFlashblocks) })
20347        );
20348        assert!(
20349            subscriber
20350                .capabilities()
20351                .supports(SubscriberCapability::Preconfirmations)
20352        );
20353    }
20354
20355    #[test]
20356    #[cfg(feature = "raw-flashblocks-json")]
20357    fn external_flashblocks_are_rejected_when_preconfirmations_are_disabled() {
20358        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20359        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20360            provider,
20361            SubscriberMode::PubSub,
20362            SubscriberConfig::default(),
20363        );
20364        subscriber
20365            .configure_external_flashblock_updates(ProviderRef::new("raw-json", 4))
20366            .expect("configure external source");
20367        subscriber.chain_id = Some(1);
20368
20369        assert!(matches!(
20370            subscriber.validate_flashblocks_setup(),
20371            Err(SubscriberError::InvalidConfig(message))
20372                if message.contains("require preconfirmations")
20373        ));
20374    }
20375
20376    #[tokio::test]
20377    #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
20378    async fn external_flashblocks_configuration_is_rejected_after_registration_starts() {
20379        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20380        let mut fresh = AlloySubscriber::<_, Ethereum>::new(
20381            provider,
20382            SubscriberMode::PubSub,
20383            SubscriberConfig {
20384                preconfirmations: PreconfirmationMode::Preferred,
20385                ..SubscriberConfig::default()
20386            },
20387        );
20388        fresh
20389            .configure_external_flashblock_updates(ProviderRef::new("raw-json", 4))
20390            .expect("construction-time external source");
20391
20392        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20393        let mut started = AlloySubscriber::<_, Ethereum>::new(
20394            provider,
20395            SubscriberMode::PubSub,
20396            SubscriberConfig {
20397                preconfirmations: PreconfirmationMode::Preferred,
20398                ..SubscriberConfig::default()
20399            },
20400        )
20401        .with_provider_ref(ProviderRef::new("canonical", 3));
20402        started.chain_id = Some(8_453);
20403        started
20404            .register_interests(&[log_interest_matching_rpc_log()])
20405            .await
20406            .expect("register canonical topology");
20407
20408        assert!(matches!(
20409            started.configure_external_flashblock_updates(ProviderRef::new("raw-json", 4)),
20410            Err(SubscriberError::InvalidConfig(message))
20411                if message.contains("before subscriber registration")
20412        ));
20413    }
20414
20415    #[tokio::test]
20416    #[cfg(feature = "raw-flashblocks-json")]
20417    async fn external_flashblocks_preflight_performs_no_flashblocks_rpc() {
20418        let asserter = Asserter::new();
20419        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20420        let source = ProviderRef::new("raw-json", 4);
20421        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20422            provider,
20423            SubscriberMode::PubSub,
20424            SubscriberConfig {
20425                preconfirmations: PreconfirmationMode::Required,
20426                ..SubscriberConfig::default()
20427            },
20428        );
20429        subscriber
20430            .configure_external_flashblock_updates(source.clone())
20431            .expect("configure external source");
20432        subscriber.chain_id = Some(1);
20433        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20434        subscriber.interests = subscriber.base_interests.clone();
20435        let desired = subscriber.pubsub_stream_sources();
20436        let mut streams = SubscriberStreams::new();
20437        for source in desired {
20438            streams.push(source, stream::pending().boxed());
20439        }
20440        subscriber.state = AlloySubscriberState::Active(streams);
20441        subscriber.sources_dirty = false;
20442
20443        let preflight = subscriber
20444            .establish_flashblocks_preflight(1)
20445            .await
20446            .expect("external source preflight");
20447        assert_eq!(preflight.provider(), &source);
20448        assert_eq!(preflight.delivery(), FlashblocksDelivery::ExternalUpdates);
20449        assert_eq!(preflight.pending_log_subscriptions(), 0);
20450        assert_eq!(subscriber.flashblocks_rpc_metrics().total_requests(), 0);
20451        assert!(asserter.read_q().is_empty());
20452    }
20453
20454    #[tokio::test]
20455    #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
20456    async fn bounded_external_channel_survives_subscriber_move_and_closure_keeps_canonical_stream()
20457    {
20458        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20459        let source = ProviderRef::new("raw-json", 4);
20460        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20461            provider,
20462            SubscriberMode::PubSub,
20463            SubscriberConfig {
20464                preconfirmations: PreconfirmationMode::Preferred,
20465                ..SubscriberConfig::default()
20466            },
20467        );
20468        subscriber
20469            .configure_external_flashblock_updates(source.clone())
20470            .expect("configure external source");
20471        subscriber.chain_id = Some(1);
20472        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20473        subscriber.interests = subscriber.base_interests.clone();
20474        let filter = subscriber.log_stream_filters().remove(0);
20475        let source_id = subscriber.log_source_id(&filter);
20476        let mut streams = SubscriberStreams::new();
20477        streams.push(
20478            SubscriberStreamSource::PubSubLog {
20479                id: source_id,
20480                filter,
20481            },
20482            stream::pending().boxed(),
20483        );
20484        subscriber.state = AlloySubscriberState::Active(streams);
20485        subscriber.sources_dirty = false;
20486
20487        let sender = subscriber
20488            .open_external_flashblock_update_channel(2)
20489            .expect("bounded external queue");
20490        let external = SubscriberStreamSource::ExternalFlashblockUpdates;
20491        let update_stream = subscriber
20492            .connect_source_stream(external.clone())
20493            .await
20494            .expect("attach receiver as subscriber source");
20495        subscriber.install_source_stream(external, update_stream);
20496        subscriber.sources_dirty = false;
20497
20498        let mut adapter = RawJsonFlashblocksAdapter::new(source);
20499        let frame = br#"{
20500            "payload_id":"0x1111111111111111",
20501            "index":0,
20502            "base":{
20503                "parent_hash":"0x0606060606060606060606060606060606060606060606060606060606060606",
20504                "block_number":"0x7",
20505                "timestamp":"0x6553f107"
20506            },
20507            "diff":{
20508                "state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
20509                "block_hash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
20510                "transactions":["0x01"]
20511            },
20512            "metadata":{
20513                "block_number":7,
20514                "receipts":{
20515                    "0x5fe7f977e71dba2ea1a68e21057beebb9be2ac30c6410aa38d4f3fbe41dcffd2":{
20516                        "logs":[{
20517                            "address":"0x4242424242424242424242424242424242424242",
20518                            "topics":["0x0101010101010101010101010101010101010101010101010101010101010101"],
20519                            "data":"0x"
20520                        }]
20521                    }
20522                }
20523            }
20524        }"#;
20525        let update = adapter
20526            .ingest_json(frame)
20527            .expect("valid raw update")
20528            .expect("snapshot update");
20529        let valid_update = update.clone();
20530        let sending = {
20531            let sender = sender.clone();
20532            tokio::spawn(async move { sender.send(update).await })
20533        };
20534
20535        let preview = subscriber
20536            .next_scoped_batch()
20537            .await
20538            .expect("poll preview")
20539            .expect("preview batch");
20540        assert_eq!(preview.records().len(), 1);
20541        assert!(preview.records()[0].scope().is_preconfirmed());
20542        assert_eq!(
20543            preview.records()[0].context.source,
20544            InputSource::Flashblocks
20545        );
20546        assert!(subscriber.latest_preconfirmation.is_some());
20547        assert_eq!(sending.await.expect("sender task"), Ok(()));
20548
20549        let mut invalid_update = valid_update.clone();
20550        let FlashblockUpdate::Snapshot(snapshot) = &mut invalid_update else {
20551            unreachable!("fixture is a snapshot")
20552        };
20553        snapshot.logs[0].block_hash = Some(B256::repeat_byte(0xee));
20554        let rejecting = {
20555            let sender = sender.clone();
20556            tokio::spawn(async move { sender.send(invalid_update).await })
20557        };
20558        let rejected = subscriber
20559            .next_scoped_batch()
20560            .await
20561            .expect("preferred mode keeps polling")
20562            .expect("rejected update invalidation");
20563        assert!(rejected.preconfirmation_invalidated());
20564        assert!(rejected.records().is_empty());
20565        assert!(subscriber.latest_preconfirmation.is_none());
20566        assert_eq!(
20567            rejecting.await.expect("sender task"),
20568            Err(FlashblockUpdateChannelError::Rejected)
20569        );
20570        subscriber
20571            .ingest_flashblock_update(valid_update)
20572            .expect("rejected generation is ignored thereafter");
20573        assert!(subscriber.latest_preconfirmation.is_none());
20574
20575        let _reset = adapter
20576            .reset(ProviderRef::new("raw-json", 5))
20577            .expect("advance rejected source generation");
20578        let recovered_update = adapter
20579            .ingest_json(frame)
20580            .expect("valid replacement generation")
20581            .expect("replacement snapshot update");
20582        let recovering = {
20583            let sender = sender.clone();
20584            tokio::spawn(async move { sender.send(recovered_update).await })
20585        };
20586        let recovered = subscriber
20587            .next_scoped_batch()
20588            .await
20589            .expect("poll replacement generation")
20590            .expect("replacement preview batch");
20591        assert_eq!(recovered.records().len(), 1);
20592        assert!(matches!(
20593            &recovered.records()[0].context.chain_status,
20594            ChainStatus::Preconfirmed { flashblock }
20595                if flashblock.provider == ProviderRef::new("raw-json", 5)
20596        ));
20597        assert_eq!(recovering.await.expect("sender task"), Ok(()));
20598
20599        drop(sender);
20600        let invalidation = subscriber
20601            .next_scoped_batch()
20602            .await
20603            .expect("poll channel closure")
20604            .expect("closure invalidation");
20605        assert!(invalidation.preconfirmation_invalidated());
20606        assert!(invalidation.records().is_empty());
20607        assert!(subscriber.latest_preconfirmation.is_none());
20608        assert!(matches!(
20609            &subscriber.state,
20610            AlloySubscriberState::Active(streams)
20611                if streams.entries.iter().any(|entry| matches!(
20612                    entry.source,
20613                    SubscriberStreamSource::PubSubLog { id, .. } if id == source_id
20614                ))
20615        ));
20616    }
20617
20618    #[tokio::test]
20619    #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
20620    async fn required_external_channel_closure_fails_the_subscriber_closed() {
20621        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20622        let source = ProviderRef::new("raw-json", 4);
20623        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20624            provider,
20625            SubscriberMode::PubSub,
20626            SubscriberConfig {
20627                preconfirmations: PreconfirmationMode::Required,
20628                ..SubscriberConfig::default()
20629            },
20630        );
20631        subscriber
20632            .configure_external_flashblock_updates(source)
20633            .expect("configure external source");
20634        subscriber.chain_id = Some(1);
20635        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20636        subscriber.interests = subscriber.base_interests.clone();
20637        subscriber.state = AlloySubscriberState::Active(SubscriberStreams::new());
20638        subscriber.sources_dirty = false;
20639
20640        let sender = subscriber
20641            .open_external_flashblock_update_channel(1)
20642            .expect("bounded external queue");
20643        let external = SubscriberStreamSource::ExternalFlashblockUpdates;
20644        let update_stream = subscriber
20645            .connect_source_stream(external.clone())
20646            .await
20647            .expect("attach receiver as subscriber source");
20648        subscriber.install_source_stream(external, update_stream);
20649        subscriber.sources_dirty = false;
20650        drop(sender);
20651
20652        assert!(matches!(
20653            subscriber.next_scoped_batch().await,
20654            Err(SubscriberError::Provider(ref message))
20655                if message.contains("required external Flashblock update channel closed")
20656        ));
20657    }
20658
20659    #[tokio::test]
20660    #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
20661    async fn required_external_channel_rejects_a_queued_malformed_update() {
20662        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20663        let source = ProviderRef::new("raw-json", 4);
20664        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20665            provider,
20666            SubscriberMode::PubSub,
20667            SubscriberConfig {
20668                preconfirmations: PreconfirmationMode::Required,
20669                ..SubscriberConfig::default()
20670            },
20671        );
20672        subscriber
20673            .configure_external_flashblock_updates(source.clone())
20674            .expect("configure external source");
20675        subscriber.chain_id = Some(1);
20676        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20677        subscriber.interests = subscriber.base_interests.clone();
20678        subscriber.state = AlloySubscriberState::Active(SubscriberStreams::new());
20679        subscriber.sources_dirty = false;
20680
20681        let sender = subscriber
20682            .open_external_flashblock_update_channel(1)
20683            .expect("bounded external queue");
20684        let external = SubscriberStreamSource::ExternalFlashblockUpdates;
20685        let update_stream = subscriber
20686            .connect_source_stream(external.clone())
20687            .await
20688            .expect("attach receiver as subscriber source");
20689        subscriber.install_source_stream(external, update_stream);
20690        subscriber.sources_dirty = false;
20691
20692        let mut adapter = RawJsonFlashblocksAdapter::new(source);
20693        let mut update = adapter
20694            .ingest_json(
20695                br#"{
20696                    "payload_id":"0x1111111111111111",
20697                    "index":0,
20698                    "base":{
20699                        "parent_hash":"0x0606060606060606060606060606060606060606060606060606060606060606",
20700                        "block_number":"0x7",
20701                        "timestamp":"0x6553f107"
20702                    },
20703                    "diff":{
20704                        "state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
20705                        "block_hash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
20706                        "transactions":[]
20707                    },
20708                    "metadata":{"block_number":7,"receipts":{}}
20709                }"#,
20710            )
20711            .expect("valid raw frame")
20712            .expect("snapshot update");
20713        let FlashblockUpdate::Snapshot(snapshot) = &mut update else {
20714            unreachable!("fixture is a snapshot")
20715        };
20716        snapshot.flashblock.content_hash = B256::ZERO;
20717        let sending = tokio::spawn(async move { sender.send(update).await });
20718
20719        assert!(matches!(
20720            subscriber.next_scoped_batch().await,
20721            Err(SubscriberError::Provider(ref message))
20722                if message.contains("content commitment is invalid")
20723        ));
20724        assert_eq!(
20725            sending.await.expect("sender task"),
20726            Err(FlashblockUpdateChannelError::Rejected)
20727        );
20728    }
20729
20730    #[tokio::test]
20731    #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
20732    async fn bounded_external_channel_reports_capacity_rejection_and_accepts_a_new_generation() {
20733        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20734        let source = ProviderRef::new("raw-json", 4);
20735        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20736            provider,
20737            SubscriberMode::PubSub,
20738            SubscriberConfig {
20739                preconfirmations: PreconfirmationMode::Preferred,
20740                max_pending_records: 1,
20741                ..SubscriberConfig::default()
20742            },
20743        );
20744        subscriber
20745            .configure_external_flashblock_updates(source.clone())
20746            .expect("configure external source");
20747        subscriber.chain_id = Some(1);
20748        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20749        subscriber.interests = subscriber.base_interests.clone();
20750        subscriber.state = AlloySubscriberState::Active(SubscriberStreams::new());
20751        subscriber.sources_dirty = false;
20752
20753        let sender = subscriber
20754            .open_external_flashblock_update_channel(1)
20755            .expect("bounded external queue");
20756        let external = SubscriberStreamSource::ExternalFlashblockUpdates;
20757        let update_stream = subscriber
20758            .connect_source_stream(external.clone())
20759            .await
20760            .expect("attach receiver as subscriber source");
20761        subscriber.install_source_stream(external, update_stream);
20762        subscriber.sources_dirty = false;
20763
20764        let first_frame = br#"{
20765            "payload_id":"0x1111111111111111",
20766            "index":0,
20767            "base":{
20768                "parent_hash":"0x0606060606060606060606060606060606060606060606060606060606060606",
20769                "block_number":"0x7",
20770                "timestamp":"0x6553f107"
20771            },
20772            "diff":{
20773                "state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
20774                "block_hash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
20775                "transactions":["0x01"]
20776            },
20777            "metadata":{
20778                "block_number":7,
20779                "receipts":{
20780                    "0x5fe7f977e71dba2ea1a68e21057beebb9be2ac30c6410aa38d4f3fbe41dcffd2":{
20781                        "logs":[{
20782                            "address":"0x4242424242424242424242424242424242424242",
20783                            "topics":["0x0101010101010101010101010101010101010101010101010101010101010101"],
20784                            "data":"0x"
20785                        }]
20786                    }
20787                }
20788            }
20789        }"#;
20790        let second_frame = br#"{
20791            "payload_id":"0x1111111111111111",
20792            "index":1,
20793            "diff":{
20794                "state_root":"0xabababababababababababababababababababababababababababababababab",
20795                "block_hash":"0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
20796                "transactions":["0x02"]
20797            },
20798            "metadata":{
20799                "block_number":7,
20800                "receipts":{
20801                    "0xf2ee15ea639b73fa3db9b34a245bdfa015c260c598b211bf05a1ecc4b3e3b4f2":{
20802                        "logs":[
20803                            {"address":"0x4444444444444444444444444444444444444444","topics":[],"data":"0x"},
20804                            {"address":"0x4545454545454545454545454545454545454545","topics":[],"data":"0x"}
20805                        ]
20806                    }
20807                }
20808            }
20809        }"#;
20810        let mut adapter = RawJsonFlashblocksAdapter::new(source);
20811        let first = adapter
20812            .ingest_json(first_frame)
20813            .expect("valid first frame")
20814            .expect("first snapshot");
20815        let first_send = {
20816            let sender = sender.clone();
20817            tokio::spawn(async move { sender.send(first).await })
20818        };
20819        let first_batch = subscriber
20820            .next_scoped_batch()
20821            .await
20822            .expect("poll first preview")
20823            .expect("first preview batch");
20824        assert_eq!(first_batch.records().len(), 1);
20825        assert_eq!(first_send.await.expect("sender task"), Ok(()));
20826
20827        let oversized = adapter
20828            .ingest_json(second_frame)
20829            .expect("valid oversized delta")
20830            .expect("oversized standardized snapshot");
20831        let rejected_send = {
20832            let sender = sender.clone();
20833            tokio::spawn(async move { sender.send(oversized).await })
20834        };
20835        let invalidation = subscriber
20836            .next_scoped_batch()
20837            .await
20838            .expect("poll capacity rejection")
20839            .expect("capacity invalidation batch");
20840        assert!(invalidation.preconfirmation_invalidated());
20841        assert_eq!(
20842            rejected_send.await.expect("sender task"),
20843            Err(FlashblockUpdateChannelError::Rejected)
20844        );
20845        assert_eq!(subscriber.rejected_external_flashblock_generation, None);
20846
20847        let _ = adapter
20848            .reset(ProviderRef::new("raw-json", 5))
20849            .expect("advance after local capacity rejection");
20850        let recovered = adapter
20851            .ingest_json(first_frame)
20852            .expect("valid recovered frame")
20853            .expect("recovered snapshot");
20854        let recovered_send = {
20855            let sender = sender.clone();
20856            tokio::spawn(async move { sender.send(recovered).await })
20857        };
20858        let recovered_batch = subscriber
20859            .next_scoped_batch()
20860            .await
20861            .expect("poll recovered generation")
20862            .expect("recovered preview batch");
20863        assert_eq!(recovered_batch.records().len(), 1);
20864        assert!(matches!(
20865            &recovered_batch.records()[0].context.chain_status,
20866            ChainStatus::Preconfirmed { flashblock }
20867                if flashblock.provider == ProviderRef::new("raw-json", 5)
20868        ));
20869        assert_eq!(recovered_send.await.expect("sender task"), Ok(()));
20870    }
20871
20872    #[tokio::test]
20873    async fn certified_canonical_heads_are_deduplicated_and_reject_placeholder_hashes() {
20874        let asserter = Asserter::new();
20875        let certified = rpc_block(101, B256::repeat_byte(0x65));
20876        asserter.push_success(&Some(certified.clone()));
20877        asserter.push_success(&Some(certified));
20878        asserter.push_success(&Some(rpc_block(102, B256::ZERO)));
20879        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
20880        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20881            provider,
20882            SubscriberMode::PubSub,
20883            SubscriberConfig::default(),
20884        );
20885
20886        assert!(matches!(
20887            subscriber
20888                .fetch_certified_canonical_head()
20889                .await
20890                .expect("first certified head"),
20891            Some(SubscriberEvent::BlockHeader { .. })
20892        ));
20893        assert!(
20894            subscriber
20895                .fetch_certified_canonical_head()
20896                .await
20897                .expect("duplicate certified head")
20898                .is_none()
20899        );
20900        assert!(matches!(
20901            subscriber.fetch_certified_canonical_head().await,
20902            Err(SubscriberError::Provider(ref message))
20903                if message.contains("placeholder hash")
20904        ));
20905    }
20906
20907    #[tokio::test]
20908    async fn canonical_head_certification_times_out_a_silent_provider() {
20909        let provider =
20910            ProviderBuilder::new().connect_client(RpcClient::new(NeverRespondingTransport, true));
20911        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20912            provider,
20913            SubscriberMode::PubSub,
20914            SubscriberConfig {
20915                preconfirmations: PreconfirmationMode::Required,
20916                canonical_head_request_timeout: Duration::from_millis(10),
20917                ..SubscriberConfig::default()
20918            },
20919        );
20920        subscriber.chain_id = Some(8_453);
20921
20922        let result = tokio::time::timeout(
20923            Duration::from_millis(100),
20924            subscriber.fetch_certified_canonical_head(),
20925        )
20926        .await
20927        .expect("subscriber must bound a silent provider request");
20928        assert!(matches!(
20929            result,
20930            Err(SubscriberError::Provider(ref message))
20931                if message.contains("canonical head certification timed out")
20932        ));
20933    }
20934
20935    #[tokio::test]
20936    async fn optimism_canonical_head_is_the_exact_parent_of_pending() {
20937        let asserter = Asserter::new();
20938        queue_op_pending(&asserter, rpc_block(101, B256::ZERO));
20939        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20940        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20941            provider,
20942            SubscriberMode::PubSub,
20943            SubscriberConfig {
20944                preconfirmations: PreconfirmationMode::Required,
20945                ..SubscriberConfig::default()
20946            },
20947        );
20948        subscriber.chain_id = Some(10);
20949        subscriber.interests = vec![ReactiveInterest::Blocks(BlockInterest::default())];
20950
20951        let event = subscriber
20952            .fetch_certified_canonical_head()
20953            .await
20954            .expect("OP pending parent can be certified")
20955            .expect("the first certified parent is emitted");
20956        let SubscriberEvent::BlockHeader { header, .. } = event else {
20957            panic!("expected a certified canonical block header")
20958        };
20959        assert_eq!(header.number(), 100);
20960        assert_eq!(header.hash, B256::repeat_byte(0x64));
20961        assert_eq!(
20962            subscriber
20963                .flashblocks_rpc_metrics()
20964                .pending_block_requests(),
20965            1
20966        );
20967        assert_eq!(
20968            subscriber
20969                .flashblocks_rpc_metrics()
20970                .canonical_head_requests(),
20971            1
20972        );
20973        assert!(asserter.read_q().is_empty());
20974    }
20975
20976    #[test]
20977    fn optimism_uses_one_bounded_pending_state_stream() {
20978        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20979        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20980            provider,
20981            SubscriberMode::PubSub,
20982            SubscriberConfig {
20983                preconfirmations: PreconfirmationMode::Required,
20984                ..SubscriberConfig::default()
20985            },
20986        )
20987        .with_provider_ref(ProviderRef::new("op-paid", 11));
20988        subscriber.chain_id = Some(10);
20989        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
20990            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
20991            local_matcher: None,
20992            route_key: None,
20993        })];
20994        subscriber.interests = subscriber.base_interests.clone();
20995
20996        let sources = subscriber.pubsub_stream_sources();
20997        assert_eq!(
20998            sources
20999                .iter()
21000                .filter(|source| matches!(source, SubscriberStreamSource::OpPendingFlashblocks))
21001                .count(),
21002            1
21003        );
21004        assert!(sources.iter().all(|source| !matches!(
21005            source,
21006            SubscriberStreamSource::BaseFlashblocks | SubscriberStreamSource::BasePendingLog { .. }
21007        )));
21008    }
21009
21010    #[test]
21011    fn optimism_default_receipt_budget_reserves_every_fixed_sampler_method() {
21012        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21013        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21014            provider,
21015            SubscriberMode::PubSub,
21016            SubscriberConfig {
21017                preconfirmations: PreconfirmationMode::Required,
21018                ..SubscriberConfig::default()
21019            },
21020        );
21021        subscriber.chain_id = Some(10);
21022        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21023        subscriber.interests = subscriber.base_interests.clone();
21024
21025        // At 250 ms, the sampler reserves 4 * (exact parent + pending block +
21026        // one filtered log request) = 12 methods. The remaining 28 exact
21027        // receipt methods stay below the configured 40-method ceiling.
21028        assert_eq!(
21029            subscriber.pending_receipt_requests_per_second_capacity(),
21030            28
21031        );
21032        assert_eq!(subscriber.pending_receipt_requests_per_tick_capacity(), 7);
21033
21034        subscriber
21035            .interests
21036            .push(ReactiveInterest::Blocks(BlockInterest::default()));
21037        assert_eq!(
21038            subscriber.pending_receipt_requests_per_second_capacity(),
21039            24
21040        );
21041        assert_eq!(subscriber.pending_receipt_requests_per_tick_capacity(), 6);
21042        subscriber.interests.pop();
21043
21044        for _ in 0..4 {
21045            assert!(subscriber.reserve_flashblock_rpc_methods(3));
21046            assert_eq!(subscriber.pending_receipt_request_allowance(), 7);
21047            assert!(subscriber.reserve_flashblock_rpc_methods(7));
21048        }
21049        assert!(!subscriber.reserve_flashblock_rpc_methods(1));
21050        subscriber.reset_flashblock_tracking();
21051        assert!(
21052            !subscriber.reserve_flashblock_rpc_methods(1),
21053            "a reconnect must not reset an endpoint's rolling quota window"
21054        );
21055    }
21056
21057    #[test]
21058    fn flashblocks_config_rejects_a_zero_rpc_budget() {
21059        let config = SubscriberConfig {
21060            preconfirmations: PreconfirmationMode::Required,
21061            max_flashblock_rpc_requests_per_second: 0,
21062            ..SubscriberConfig::default()
21063        };
21064
21065        assert!(matches!(
21066            validate_subscriber_config(&config),
21067            Err(SubscriberError::InvalidConfig(
21068                "SubscriberConfig::max_flashblock_rpc_requests_per_second must be greater than zero"
21069            ))
21070        ));
21071    }
21072
21073    #[test]
21074    fn flashblocks_config_rejects_a_zero_canonical_head_request_timeout() {
21075        let config = SubscriberConfig {
21076            preconfirmations: PreconfirmationMode::Required,
21077            canonical_head_request_timeout: Duration::ZERO,
21078            ..SubscriberConfig::default()
21079        };
21080
21081        assert!(matches!(
21082            validate_subscriber_config(&config),
21083            Err(SubscriberError::InvalidConfig(
21084                "SubscriberConfig::canonical_head_request_timeout must be greater than zero"
21085            ))
21086        ));
21087    }
21088
21089    #[tokio::test]
21090    async fn optimism_preflight_rejects_a_budget_without_receipt_capacity() {
21091        let asserter = Asserter::new();
21092        asserter.push_success(&serde_json::json!(["flashblocksv1"]));
21093        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21094        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21095            provider,
21096            SubscriberMode::PubSub,
21097            SubscriberConfig {
21098                preconfirmations: PreconfirmationMode::Required,
21099                // Four ticks reserve three fixed methods each. Three remaining
21100                // methods cannot fund even one receipt on every tick.
21101                max_flashblock_rpc_requests_per_second: 15,
21102                ..SubscriberConfig::default()
21103            },
21104        )
21105        .with_provider_ref(ProviderRef::new("op-paid", 12));
21106        subscriber.chain_id = Some(10);
21107        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21108        subscriber.interests = subscriber.base_interests.clone();
21109        let desired = subscriber.pubsub_stream_sources();
21110        let mut streams = SubscriberStreams::new();
21111        for source in desired {
21112            streams.push(source, stream::pending().boxed());
21113        }
21114        subscriber.state = AlloySubscriberState::Active(streams);
21115        subscriber.sources_dirty = false;
21116        assert!(matches!(
21117            subscriber.establish_flashblocks_preflight(10).await,
21118            Err(SubscriberError::InvalidConfig(message))
21119                if message.contains("leaves no capacity for OP transaction receipts")
21120        ));
21121        assert!(asserter.read_q().is_empty());
21122    }
21123
21124    #[cfg(feature = "reactive-ws")]
21125    #[tokio::test]
21126    async fn flashblocks_preflight_proves_chain_and_both_subscription_lanes() {
21127        let asserter = Asserter::new();
21128        asserter.push_success(&serde_json::json!({"flashblocks": true}));
21129        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
21130        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21131            provider,
21132            SubscriberMode::PubSub,
21133            SubscriberConfig {
21134                preconfirmations: PreconfirmationMode::Required,
21135                ..SubscriberConfig::default()
21136            },
21137        )
21138        .with_provider_ref(ProviderRef::new("base-paid", 7));
21139        subscriber.chain_id = Some(8_453);
21140        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21141        subscriber.interests = subscriber.base_interests.clone();
21142        let desired = subscriber.pubsub_stream_sources();
21143        let mut streams = SubscriberStreams::new();
21144        for source in desired {
21145            streams.push(source, stream::pending().boxed());
21146        }
21147        subscriber.state = AlloySubscriberState::Active(streams);
21148        subscriber.sources_dirty = false;
21149
21150        let preflight = subscriber
21151            .establish_flashblocks_preflight(8_453)
21152            .await
21153            .expect("preflight succeeds");
21154
21155        assert_eq!(preflight.chain_id(), 8_453);
21156        assert_eq!(preflight.provider(), &ProviderRef::new("base-paid", 7));
21157        assert_eq!(
21158            preflight.delivery(),
21159            FlashblocksDelivery::NativeSubscriptions
21160        );
21161        assert_eq!(preflight.pending_log_subscriptions(), 1);
21162        assert_eq!(preflight.pending_log_filters(), 1);
21163        assert_eq!(
21164            preflight.advertised_capabilities(),
21165            Some(&serde_json::json!({"flashblocks": true}))
21166        );
21167    }
21168
21169    #[tokio::test]
21170    async fn optimism_preflight_probes_pending_state_without_native_subscriptions() {
21171        let asserter = Asserter::new();
21172        asserter.push_success(&serde_json::json!(["flashblocksv1"]));
21173        queue_op_pending(&asserter, rpc_block(101, B256::ZERO));
21174        asserter.push_success(&Vec::<Log>::new());
21175        asserter.push_success(&serde_json::json!([]));
21176        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21177        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21178            provider,
21179            SubscriberMode::PubSub,
21180            SubscriberConfig {
21181                preconfirmations: PreconfirmationMode::Required,
21182                ..SubscriberConfig::default()
21183            },
21184        )
21185        .with_provider_ref(ProviderRef::new("op-paid", 12));
21186        subscriber.chain_id = Some(10);
21187        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21188        subscriber.interests = subscriber.base_interests.clone();
21189        let desired = subscriber.pubsub_stream_sources();
21190        let mut streams = SubscriberStreams::new();
21191        for source in desired {
21192            streams.push(source, stream::pending().boxed());
21193        }
21194        subscriber.state = AlloySubscriberState::Active(streams);
21195        subscriber.sources_dirty = false;
21196
21197        let preflight = subscriber
21198            .establish_flashblocks_preflight(10)
21199            .await
21200            .expect("Optimism pending-state preflight succeeds");
21201
21202        assert_eq!(preflight.chain_id(), 10);
21203        assert_eq!(preflight.provider(), &ProviderRef::new("op-paid", 12));
21204        assert_eq!(
21205            preflight.delivery(),
21206            FlashblocksDelivery::PendingStatePolling
21207        );
21208        assert_eq!(preflight.pending_log_subscriptions(), 0);
21209        assert_eq!(preflight.pending_log_filters(), 1);
21210        assert_eq!(
21211            preflight.advertised_capabilities(),
21212            Some(&serde_json::json!(["flashblocksv1"]))
21213        );
21214        assert!(asserter.read_q().is_empty());
21215    }
21216
21217    #[test]
21218    fn optimism_full_pending_block_normalizes_op_transaction_types_to_hashes() {
21219        let transaction_hash = B256::repeat_byte(0x7e);
21220        let mut value = serde_json::to_value(rpc_block(101, B256::ZERO))
21221            .expect("serialize pending block fixture");
21222        value["transactions"] = serde_json::json!([{
21223            "type": "0x7e",
21224            "hash": transaction_hash,
21225            "sourceHash": B256::repeat_byte(0x11),
21226            "from": Address::repeat_byte(0x22),
21227            "to": Address::repeat_byte(0x33)
21228        }]);
21229
21230        let block = normalize_op_pending_block::<Ethereum>(value)
21231            .expect("OP-specific transaction bodies are reduced to hashes");
21232
21233        assert_eq!(
21234            block.transactions().as_hashes(),
21235            Some(&[transaction_hash][..])
21236        );
21237    }
21238
21239    #[tokio::test]
21240    async fn optimism_sampler_does_not_retry_malformed_pending_content() {
21241        let asserter = Asserter::new();
21242        let mut pending = serde_json::to_value(rpc_block(101, B256::ZERO))
21243            .expect("serialize pending block fixture");
21244        pending["transactions"] = serde_json::json!([{"type": "0x7e"}]);
21245        asserter.push_success(&Some(pending));
21246        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21247        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21248            provider,
21249            SubscriberMode::PubSub,
21250            SubscriberConfig {
21251                preconfirmations: PreconfirmationMode::Required,
21252                ..SubscriberConfig::default()
21253            },
21254        )
21255        .with_provider_ref(ProviderRef::new("op-paid", 12));
21256        subscriber.chain_id = Some(10);
21257
21258        let error = match subscriber
21259            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21260            .await
21261        {
21262            Err(error) => error,
21263            Ok(_) => panic!("malformed provider content must fail immediately"),
21264        };
21265        assert!(
21266            error
21267                .to_string()
21268                .contains("transaction is missing its hash")
21269        );
21270        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 0);
21271        assert!(asserter.read_q().is_empty());
21272    }
21273
21274    #[tokio::test]
21275    async fn optimism_sampler_certifies_the_pending_block_by_exact_parent_hash() {
21276        let asserter = Asserter::new();
21277        queue_op_pending(&asserter, rpc_block(101, B256::ZERO));
21278        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
21279        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21280            provider,
21281            SubscriberMode::PubSub,
21282            SubscriberConfig {
21283                preconfirmations: PreconfirmationMode::Required,
21284                ..SubscriberConfig::default()
21285            },
21286        )
21287        .with_provider_ref(ProviderRef::new("op-paid", 12));
21288        subscriber.chain_id = Some(10);
21289
21290        assert!(
21291            subscriber
21292                .fetch_pending_flashblock(None)
21293                .await
21294                .expect("the exact parent certifies the pending payload")
21295                .is_some()
21296        );
21297    }
21298
21299    #[tokio::test]
21300    async fn optimism_sampler_rejects_a_nonconsecutive_pending_parent() {
21301        let asserter = Asserter::new();
21302        asserter.push_success(&Some(rpc_block(101, B256::ZERO)));
21303        asserter.push_success(&Some(rpc_block(99, B256::repeat_byte(0x64))));
21304        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21305        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21306            provider,
21307            SubscriberMode::PubSub,
21308            SubscriberConfig {
21309                preconfirmations: PreconfirmationMode::Required,
21310                ..SubscriberConfig::default()
21311            },
21312        )
21313        .with_provider_ref(ProviderRef::new("op-paid", 12));
21314        subscriber.chain_id = Some(10);
21315
21316        assert!(matches!(
21317            subscriber.fetch_pending_flashblock(None).await,
21318            Err(PendingFlashblockPollError::Integrity(SubscriberError::Provider(
21319                ref message
21320            ))) if message.contains("does not extend its exact certified parent")
21321        ));
21322        assert!(asserter.read_q().is_empty());
21323    }
21324
21325    #[tokio::test]
21326    async fn optimism_sampler_rechecks_unchanged_content_without_republishing_logs() {
21327        let asserter = Asserter::new();
21328        let pending = rpc_block(101, B256::ZERO);
21329        queue_op_pending(&asserter, pending.clone());
21330        asserter.push_success(&Vec::<Log>::new());
21331        queue_op_pending(&asserter, pending);
21332        asserter.push_success(&Vec::<Log>::new());
21333        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21334        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21335            provider,
21336            SubscriberMode::PubSub,
21337            SubscriberConfig {
21338                preconfirmations: PreconfirmationMode::Required,
21339                ..SubscriberConfig::default()
21340            },
21341        )
21342        .with_provider_ref(ProviderRef::new("op-paid", 12));
21343        subscriber.chain_id = Some(10);
21344        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21345        subscriber.interests = subscriber.base_interests.clone();
21346
21347        assert!(
21348            subscriber
21349                .fetch_pending_flashblock(None)
21350                .await
21351                .expect("first cumulative pending view")
21352                .is_some()
21353        );
21354        assert!(
21355            subscriber
21356                .fetch_pending_flashblock(None)
21357                .await
21358                .expect("duplicate cumulative pending view")
21359                .is_none()
21360        );
21361
21362        assert_eq!(
21363            subscriber.flashblocks_rpc_metrics(),
21364            FlashblocksRpcMetrics {
21365                capability_requests: 0,
21366                provider_pair_chain_requests: 0,
21367                canonical_head_requests: 2,
21368                pending_block_requests: 2,
21369                pending_log_requests: 2,
21370                pending_receipt_requests: 0,
21371                pending_receipts_completed: 0,
21372                pending_receipts_unavailable: 0,
21373                failed_requests: 0,
21374                raced_samples: 0,
21375                suppressed_canonical_head_polls: 0,
21376            }
21377        );
21378        assert!(asserter.read_q().is_empty());
21379    }
21380
21381    #[tokio::test]
21382    async fn optimism_sampler_rechecks_logs_for_an_unchanged_pending_view() {
21383        let asserter = Asserter::new();
21384        let transaction = B256::repeat_byte(0x42);
21385        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
21386            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction]),
21387        );
21388        let mut log = rpc_log(false);
21389        log.block_number = Some(101);
21390        log.block_hash = Some(B256::repeat_byte(0xa2));
21391        log.transaction_hash = Some(transaction);
21392        log.transaction_index = Some(0);
21393        log.log_index = Some(0);
21394        queue_op_pending(&asserter, pending.clone());
21395        asserter.push_success(&Vec::<Log>::new());
21396        asserter.push_success(&serde_json::Value::Null);
21397        queue_op_pending(&asserter, pending);
21398        asserter.push_success(&vec![log]);
21399        asserter.push_success(&serde_json::Value::Null);
21400        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21401        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21402            provider,
21403            SubscriberMode::PubSub,
21404            SubscriberConfig {
21405                preconfirmations: PreconfirmationMode::Required,
21406                ..SubscriberConfig::default()
21407            },
21408        )
21409        .with_provider_ref(ProviderRef::new("op-paid", 12));
21410        subscriber.chain_id = Some(10);
21411        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21412        subscriber.interests = subscriber.base_interests.clone();
21413
21414        assert!(matches!(
21415            subscriber
21416                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21417                .await
21418                .expect("first pending view is coherent"),
21419            Some(SubscriberEvent::FlashblockObserved)
21420        ));
21421        assert!(matches!(
21422            subscriber
21423                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21424                .await
21425                .expect("the unchanged view is checked again for lagging logs"),
21426            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
21427        ));
21428        assert!(asserter.read_q().is_empty());
21429    }
21430
21431    #[tokio::test]
21432    async fn optimism_sampler_hydrates_exact_receipts_when_filtered_logs_are_empty() {
21433        let asserter = Asserter::new();
21434        let transaction = B256::repeat_byte(0x42);
21435        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
21436            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction]),
21437        );
21438        let mut log = rpc_log(false);
21439        log.block_number = Some(101);
21440        log.block_hash = Some(B256::repeat_byte(0xa2));
21441        log.transaction_hash = Some(transaction);
21442        log.transaction_index = Some(0);
21443        log.log_index = Some(0);
21444        queue_op_pending(&asserter, pending);
21445        asserter.push_success(&Vec::<Log>::new());
21446        asserter.push_success(&serde_json::json!({
21447            "transactionHash": transaction,
21448            "logs": [log]
21449        }));
21450        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21451        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21452            provider,
21453            SubscriberMode::PubSub,
21454            SubscriberConfig {
21455                preconfirmations: PreconfirmationMode::Required,
21456                ..SubscriberConfig::default()
21457            },
21458        )
21459        .with_provider_ref(ProviderRef::new("op-paid", 12));
21460        subscriber.chain_id = Some(10);
21461        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21462        subscriber.interests = subscriber.base_interests.clone();
21463
21464        let event = subscriber
21465            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21466            .await
21467            .expect("pending receipt fallback succeeds");
21468        assert!(matches!(
21469            event,
21470            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
21471        ));
21472        assert_eq!(
21473            subscriber
21474                .flashblocks_rpc_metrics()
21475                .pending_receipt_requests(),
21476            1
21477        );
21478        assert!(asserter.read_q().is_empty());
21479    }
21480
21481    #[tokio::test]
21482    async fn optimism_receipt_hydration_is_bounded_and_resumes_on_the_next_tick() {
21483        let asserter = Asserter::new();
21484        let transaction_a = B256::repeat_byte(0x41);
21485        let transaction_b = B256::repeat_byte(0x42);
21486        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
21487            alloy_network::primitives::BlockTransactions::Hashes(vec![
21488                transaction_a,
21489                transaction_b,
21490            ]),
21491        );
21492        let mut log = rpc_log(false);
21493        log.block_number = Some(101);
21494        log.transaction_hash = Some(transaction_b);
21495        log.transaction_index = Some(1);
21496        queue_op_pending(&asserter, pending.clone());
21497        asserter.push_success(&Vec::<Log>::new());
21498        asserter.push_success(&serde_json::json!({
21499            "transactionHash": transaction_a,
21500            "logs": []
21501        }));
21502        queue_op_pending(&asserter, pending);
21503        asserter.push_success(&Vec::<Log>::new());
21504        asserter.push_success(&serde_json::json!({
21505            "transactionHash": transaction_b,
21506            "logs": [log]
21507        }));
21508        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21509        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21510            provider,
21511            SubscriberMode::PubSub,
21512            SubscriberConfig {
21513                preconfirmations: PreconfirmationMode::Required,
21514                max_pending_transaction_receipts_per_tick: 1,
21515                ..SubscriberConfig::default()
21516            },
21517        )
21518        .with_provider_ref(ProviderRef::new("op-paid", 12));
21519        subscriber.chain_id = Some(10);
21520        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21521        subscriber.interests = subscriber.base_interests.clone();
21522
21523        assert!(matches!(
21524            subscriber
21525                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21526                .await
21527                .expect("the first bounded receipt is hydrated"),
21528            Some(SubscriberEvent::FlashblockObserved)
21529        ));
21530        assert!(matches!(
21531            subscriber
21532                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21533                .await
21534                .expect("the remaining receipt is hydrated on the next tick"),
21535            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
21536        ));
21537        assert_eq!(
21538            subscriber
21539                .flashblocks_rpc_metrics()
21540                .pending_receipt_requests(),
21541            2
21542        );
21543        assert_eq!(subscriber.preconfirmed_receipted_transactions.len(), 2);
21544        assert!(asserter.read_q().is_empty());
21545    }
21546
21547    #[tokio::test]
21548    async fn optimism_receipt_hydration_prioritizes_unattempted_hashes_over_null_retries() {
21549        let asserter = Asserter::new();
21550        let transaction_a = B256::repeat_byte(0x41);
21551        let transaction_b = B256::repeat_byte(0x42);
21552        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
21553            alloy_network::primitives::BlockTransactions::Hashes(vec![
21554                transaction_a,
21555                transaction_b,
21556            ]),
21557        );
21558        let mut log = rpc_log(false);
21559        log.block_number = Some(101);
21560        log.transaction_hash = Some(transaction_b);
21561        log.transaction_index = Some(1);
21562        queue_op_pending(&asserter, pending.clone());
21563        asserter.push_success(&Vec::<Log>::new());
21564        asserter.push_success(&serde_json::Value::Null);
21565        queue_op_pending(&asserter, pending);
21566        asserter.push_success(&Vec::<Log>::new());
21567        asserter.push_success(&serde_json::json!({
21568            "transactionHash": transaction_b,
21569            "logs": [log]
21570        }));
21571        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21572        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21573            provider,
21574            SubscriberMode::PubSub,
21575            SubscriberConfig {
21576                preconfirmations: PreconfirmationMode::Required,
21577                max_pending_transaction_receipts_per_tick: 1,
21578                ..SubscriberConfig::default()
21579            },
21580        )
21581        .with_provider_ref(ProviderRef::new("op-paid", 12));
21582        subscriber.chain_id = Some(10);
21583        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21584        subscriber.interests = subscriber.base_interests.clone();
21585
21586        assert!(matches!(
21587            subscriber
21588                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21589                .await
21590                .expect("the first null receipt remains retryable"),
21591            Some(SubscriberEvent::FlashblockObserved)
21592        ));
21593        assert!(matches!(
21594            subscriber
21595                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21596                .await
21597                .expect("the next unattempted receipt is not starved"),
21598            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
21599        ));
21600        assert!(
21601            subscriber
21602                .preconfirmed_unavailable_receipts
21603                .contains(&transaction_a)
21604        );
21605        assert!(
21606            subscriber
21607                .preconfirmed_receipted_transactions
21608                .contains(&transaction_b)
21609        );
21610        assert!(asserter.read_q().is_empty());
21611    }
21612
21613    #[tokio::test]
21614    async fn optimism_receipt_batch_commits_dedupe_only_after_every_response_succeeds() {
21615        let asserter = Asserter::new();
21616        let transaction_a = B256::repeat_byte(0x41);
21617        let transaction_b = B256::repeat_byte(0x42);
21618        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
21619            alloy_network::primitives::BlockTransactions::Hashes(vec![
21620                transaction_a,
21621                transaction_b,
21622            ]),
21623        );
21624        let mut log = rpc_log(false);
21625        log.block_number = Some(101);
21626        log.transaction_hash = Some(transaction_b);
21627        log.transaction_index = Some(1);
21628        queue_op_pending(&asserter, pending.clone());
21629        asserter.push_success(&Vec::<Log>::new());
21630        asserter.push_success(&serde_json::json!({
21631            "transactionHash": transaction_a,
21632            "logs": []
21633        }));
21634        asserter.push_failure_msg("receipt temporarily unavailable");
21635        queue_op_pending(&asserter, pending);
21636        asserter.push_success(&Vec::<Log>::new());
21637        asserter.push_success(&serde_json::json!({
21638            "transactionHash": transaction_a,
21639            "logs": []
21640        }));
21641        asserter.push_success(&serde_json::json!({
21642            "transactionHash": transaction_b,
21643            "logs": [log]
21644        }));
21645        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21646        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21647            provider,
21648            SubscriberMode::PubSub,
21649            SubscriberConfig {
21650                preconfirmations: PreconfirmationMode::Required,
21651                max_pending_transaction_receipts_per_tick: 2,
21652                max_consecutive_flashblock_poll_failures: 2,
21653                ..SubscriberConfig::default()
21654            },
21655        )
21656        .with_provider_ref(ProviderRef::new("op-paid", 12));
21657        subscriber.chain_id = Some(10);
21658        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21659        subscriber.interests = subscriber.base_interests.clone();
21660
21661        assert!(
21662            subscriber
21663                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21664                .await
21665                .expect("one failed receipt response remains retryable")
21666                .is_none()
21667        );
21668        assert!(subscriber.preconfirmed_receipted_transactions.is_empty());
21669        assert!(matches!(
21670            subscriber
21671                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21672                .await
21673                .expect("the complete batch is retried transactionally"),
21674            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
21675        ));
21676        assert_eq!(subscriber.preconfirmed_receipted_transactions.len(), 2);
21677        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 1);
21678        assert_eq!(
21679            subscriber
21680                .flashblocks_rpc_metrics()
21681                .pending_receipt_requests(),
21682            4
21683        );
21684        assert!(asserter.read_q().is_empty());
21685    }
21686
21687    #[tokio::test]
21688    async fn optimism_sampler_rejects_a_receipt_for_a_different_transaction() {
21689        let asserter = Asserter::new();
21690        let sampled_transaction = B256::repeat_byte(0x41);
21691        let advanced_transaction = B256::repeat_byte(0x42);
21692        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
21693            alloy_network::primitives::BlockTransactions::Hashes(vec![sampled_transaction]),
21694        );
21695        let mut log = rpc_log(false);
21696        log.block_number = Some(101);
21697        log.transaction_hash = Some(advanced_transaction);
21698        queue_op_pending(&asserter, pending);
21699        asserter.push_success(&Vec::<Log>::new());
21700        asserter.push_success(&serde_json::json!({
21701            "transactionHash": advanced_transaction,
21702            "logs": [log]
21703        }));
21704        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21705        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21706            provider,
21707            SubscriberMode::PubSub,
21708            SubscriberConfig {
21709                preconfirmations: PreconfirmationMode::Required,
21710                ..SubscriberConfig::default()
21711            },
21712        )
21713        .with_provider_ref(ProviderRef::new("op-paid", 12));
21714        subscriber.chain_id = Some(10);
21715        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21716        subscriber.interests = subscriber.base_interests.clone();
21717
21718        let error = match subscriber
21719            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21720            .await
21721        {
21722            Err(error) => error,
21723            Ok(_) => panic!("a receipt for another transaction must fail closed"),
21724        };
21725        assert!(
21726            error
21727                .to_string()
21728                .contains("hash disagrees with its request")
21729        );
21730        assert!(asserter.read_q().is_empty());
21731    }
21732
21733    #[tokio::test]
21734    async fn optimism_sampler_revokes_then_recovers_from_a_regressive_pending_view() {
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![
21740                transaction_a,
21741                transaction_b,
21742            ]),
21743        );
21744        let regressive = rpc_block(101, B256::repeat_byte(0xa2)).with_transactions(
21745            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction_a]),
21746        );
21747        queue_op_pending(&asserter, first);
21748        asserter.push_success(&Vec::<Log>::new());
21749        asserter.push_success(&serde_json::Value::Null);
21750        asserter.push_success(&serde_json::Value::Null);
21751        queue_op_pending(&asserter, regressive.clone());
21752        queue_op_pending(&asserter, regressive);
21753        asserter.push_success(&Vec::<Log>::new());
21754        asserter.push_success(&serde_json::Value::Null);
21755        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21756        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21757            provider,
21758            SubscriberMode::PubSub,
21759            SubscriberConfig {
21760                preconfirmations: PreconfirmationMode::Required,
21761                ..SubscriberConfig::default()
21762            },
21763        )
21764        .with_provider_ref(ProviderRef::new("op-paid", 12));
21765        subscriber.chain_id = Some(10);
21766        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21767        subscriber.interests = subscriber.base_interests.clone();
21768
21769        assert!(
21770            subscriber
21771                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21772                .await
21773                .expect("first pending view is coherent")
21774                .is_some()
21775        );
21776        assert!(matches!(
21777            subscriber
21778                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21779                .await
21780                .expect("regression revokes instead of terminating the stream"),
21781            Some(SubscriberEvent::FlashblockInvalidated)
21782        ));
21783        assert!(subscriber.latest_preconfirmation.is_none());
21784        assert!(subscriber.pending_preconfirmation_invalidation);
21785        assert!(
21786            subscriber
21787                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21788                .await
21789                .expect("a later coherent view establishes a fresh snapshot")
21790                .is_some()
21791        );
21792        assert!(subscriber.latest_preconfirmation.is_some());
21793        assert_eq!(subscriber.provider_ref.as_ref().unwrap().generation, 12);
21794        assert!(asserter.read_q().is_empty());
21795    }
21796
21797    #[tokio::test]
21798    async fn optimism_new_quiet_payload_revokes_the_previous_snapshot() {
21799        let asserter = Asserter::new();
21800        let first = rpc_block(101, B256::repeat_byte(0xa1));
21801        let second = rpc_block(102, B256::repeat_byte(0xa2));
21802        queue_op_pending(&asserter, first);
21803        asserter.push_success(&Vec::<Log>::new());
21804        queue_op_pending(&asserter, second);
21805        asserter.push_success(&Vec::<Log>::new());
21806        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21807        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21808            provider,
21809            SubscriberMode::PubSub,
21810            SubscriberConfig {
21811                preconfirmations: PreconfirmationMode::Required,
21812                ..SubscriberConfig::default()
21813            },
21814        )
21815        .with_provider_ref(ProviderRef::new("op-paid", 12));
21816        subscriber.chain_id = Some(10);
21817        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21818        subscriber.interests = subscriber.base_interests.clone();
21819
21820        subscriber
21821            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21822            .await
21823            .expect("first quiet payload is observed");
21824        assert!(!subscriber.pending_preconfirmation_invalidation);
21825        subscriber
21826            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21827            .await
21828            .expect("replacement quiet payload is observed");
21829        assert!(subscriber.pending_preconfirmation_invalidation);
21830        assert_eq!(
21831            subscriber
21832                .latest_preconfirmation
21833                .as_ref()
21834                .map(|flashblock| flashblock.block_number),
21835            Some(102)
21836        );
21837        assert!(asserter.read_q().is_empty());
21838    }
21839
21840    #[tokio::test]
21841    async fn optimism_sampler_rejects_malformed_pending_receipts() {
21842        let asserter = Asserter::new();
21843        let transaction = B256::repeat_byte(0x42);
21844        let pending = rpc_block(101, B256::ZERO).with_transactions(
21845            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction]),
21846        );
21847        queue_op_pending(&asserter, pending);
21848        asserter.push_success(&Vec::<Log>::new());
21849        asserter.push_success(&serde_json::json!({"transactionHash": transaction}));
21850        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21851        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21852            provider,
21853            SubscriberMode::PubSub,
21854            SubscriberConfig {
21855                preconfirmations: PreconfirmationMode::Required,
21856                ..SubscriberConfig::default()
21857            },
21858        )
21859        .with_provider_ref(ProviderRef::new("op-paid", 12));
21860        subscriber.chain_id = Some(10);
21861        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21862        subscriber.interests = subscriber.base_interests.clone();
21863
21864        let error = match subscriber
21865            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21866            .await
21867        {
21868            Err(error) => error,
21869            Ok(_) => panic!("malformed receipt content must fail closed"),
21870        };
21871        assert!(error.to_string().contains("missing its log array"));
21872        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 0);
21873        assert!(asserter.read_q().is_empty());
21874    }
21875
21876    #[tokio::test]
21877    async fn optimism_sampler_retries_when_logs_advance_past_the_sampled_block() {
21878        let asserter = Asserter::new();
21879        let transaction_a = B256::repeat_byte(0x41);
21880        let transaction_b = B256::repeat_byte(0x42);
21881        let first = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
21882            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction_a]),
21883        );
21884        let second = rpc_block(101, B256::repeat_byte(0xa2)).with_transactions(
21885            alloy_network::primitives::BlockTransactions::Hashes(vec![
21886                transaction_a,
21887                transaction_b,
21888            ]),
21889        );
21890        let mut log = rpc_log(false);
21891        log.block_number = Some(101);
21892        log.block_hash = Some(B256::repeat_byte(0xa2));
21893        log.transaction_hash = Some(transaction_b);
21894        log.transaction_index = Some(1);
21895        log.log_index = Some(0);
21896        queue_op_pending(&asserter, first);
21897        asserter.push_success(&vec![log.clone()]);
21898        asserter.push_success(&serde_json::Value::Null);
21899        queue_op_pending(&asserter, second);
21900        asserter.push_success(&vec![log.clone()]);
21901        asserter.push_success(&serde_json::Value::Null);
21902        asserter.push_success(&serde_json::json!({
21903            "transactionHash": transaction_b,
21904            "logs": [log]
21905        }));
21906        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21907        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21908            provider,
21909            SubscriberMode::PubSub,
21910            SubscriberConfig {
21911                preconfirmations: PreconfirmationMode::Required,
21912                ..SubscriberConfig::default()
21913            },
21914        )
21915        .with_provider_ref(ProviderRef::new("op-paid", 12));
21916        subscriber.chain_id = Some(10);
21917        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21918        subscriber.interests = subscriber.base_interests.clone();
21919
21920        assert!(
21921            subscriber
21922                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21923                .await
21924                .expect("a cross-request race remains retryable")
21925                .is_none()
21926        );
21927        assert!(
21928            subscriber
21929                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21930                .await
21931                .expect("the next coherent cumulative view is delivered")
21932                .is_some()
21933        );
21934        assert_eq!(subscriber.flashblocks_rpc_metrics().raced_samples(), 1);
21935        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 0);
21936        assert!(asserter.read_q().is_empty());
21937    }
21938
21939    #[tokio::test]
21940    async fn optimism_sampler_uses_the_paired_pending_state_provider() {
21941        let stream_asserter = Asserter::new();
21942        let stream_provider = ProviderBuilder::new().connect_mocked_client(stream_asserter.clone());
21943        let state_asserter = Asserter::new();
21944        queue_op_pending(&state_asserter, rpc_block(101, B256::ZERO));
21945        state_asserter.push_success(&Vec::<Log>::new());
21946        let state_provider = ProviderBuilder::new().connect_mocked_client(state_asserter.clone());
21947        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21948            stream_provider,
21949            SubscriberMode::PubSub,
21950            SubscriberConfig {
21951                preconfirmations: PreconfirmationMode::Required,
21952                ..SubscriberConfig::default()
21953            },
21954        )
21955        .with_provider_ref(ProviderRef::new("op-paid", 12))
21956        .with_flashblocks_state_provider(state_provider);
21957        subscriber.chain_id = Some(10);
21958        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21959        subscriber.interests = subscriber.base_interests.clone();
21960
21961        assert!(
21962            subscriber
21963                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21964                .await
21965                .expect("paired pending-state reads succeed")
21966                .is_some()
21967        );
21968        assert!(state_asserter.read_q().is_empty());
21969        assert!(stream_asserter.read_q().is_empty());
21970    }
21971
21972    #[tokio::test]
21973    async fn optimism_sampler_retries_an_isolated_provider_request_failure() {
21974        let asserter = Asserter::new();
21975        let pending = rpc_block(101, B256::ZERO);
21976        queue_op_pending(&asserter, pending.clone());
21977        asserter.push_failure_msg("temporarily unavailable");
21978        queue_op_pending(&asserter, pending);
21979        asserter.push_success(&Vec::<Log>::new());
21980        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21981        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21982            provider,
21983            SubscriberMode::PubSub,
21984            SubscriberConfig {
21985                preconfirmations: PreconfirmationMode::Required,
21986                max_consecutive_flashblock_poll_failures: 2,
21987                ..SubscriberConfig::default()
21988            },
21989        )
21990        .with_provider_ref(ProviderRef::new("op-paid", 12));
21991        subscriber.chain_id = Some(10);
21992        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
21993        subscriber.interests = subscriber.base_interests.clone();
21994
21995        assert!(
21996            subscriber
21997                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
21998                .await
21999                .expect("one request failure stays retryable")
22000                .is_none()
22001        );
22002        assert!(
22003            subscriber
22004                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
22005                .await
22006                .expect("the next cumulative view retries the missing logs")
22007                .is_some()
22008        );
22009        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 1);
22010        assert!(asserter.read_q().is_empty());
22011    }
22012
22013    #[tokio::test]
22014    async fn optimism_sampler_surfaces_sustained_provider_request_failures() {
22015        let asserter = Asserter::new();
22016        asserter.push_failure_msg("temporarily unavailable");
22017        asserter.push_failure_msg("still unavailable");
22018        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
22019        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22020            provider,
22021            SubscriberMode::PubSub,
22022            SubscriberConfig {
22023                preconfirmations: PreconfirmationMode::Required,
22024                max_consecutive_flashblock_poll_failures: 2,
22025                ..SubscriberConfig::default()
22026            },
22027        )
22028        .with_provider_ref(ProviderRef::new("op-paid", 12));
22029        subscriber.chain_id = Some(10);
22030
22031        assert!(
22032            subscriber
22033                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
22034                .await
22035                .expect("the first request failure stays retryable")
22036                .is_none()
22037        );
22038        let error = match subscriber
22039            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
22040            .await
22041        {
22042            Err(error) => error,
22043            Ok(_) => panic!("the configured consecutive-failure limit must fail closed"),
22044        };
22045        assert!(error.to_string().contains("still unavailable"));
22046        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 2);
22047        assert!(asserter.read_q().is_empty());
22048    }
22049
22050    #[tokio::test]
22051    async fn flashblocks_preflight_rejects_a_mismatched_chain_before_subscribing() {
22052        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22053        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22054            provider,
22055            SubscriberMode::PubSub,
22056            SubscriberConfig {
22057                preconfirmations: PreconfirmationMode::Required,
22058                ..SubscriberConfig::default()
22059            },
22060        )
22061        .with_provider_ref(ProviderRef::new("wrong-chain", 1));
22062        subscriber.chain_id = Some(10);
22063        subscriber.interests = vec![log_interest_matching_rpc_log()];
22064
22065        assert!(matches!(
22066            subscriber.establish_flashblocks_preflight(8_453).await,
22067            Err(SubscriberError::ChainMismatch {
22068                expected: 8_453,
22069                actual: 10
22070            })
22071        ));
22072    }
22073
22074    #[tokio::test]
22075    async fn optimism_preflight_rejects_a_mismatched_paired_provider() {
22076        let stream_asserter = Asserter::new();
22077        let stream_provider = ProviderBuilder::new().connect_mocked_client(stream_asserter.clone());
22078        let state_asserter = Asserter::new();
22079        state_asserter.push_success(&serde_json::json!(["flashblocksv1"]));
22080        state_asserter.push_success(&8_453_u64);
22081        let state_provider = ProviderBuilder::new().connect_mocked_client(state_asserter.clone());
22082        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22083            stream_provider,
22084            SubscriberMode::PubSub,
22085            SubscriberConfig {
22086                preconfirmations: PreconfirmationMode::Required,
22087                ..SubscriberConfig::default()
22088            },
22089        )
22090        .with_provider_ref(ProviderRef::new("op-paid", 12))
22091        .with_flashblocks_state_provider(state_provider);
22092        subscriber.chain_id = Some(10);
22093        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
22094        subscriber.interests = subscriber.base_interests.clone();
22095        let desired = subscriber.pubsub_stream_sources();
22096        let mut streams = SubscriberStreams::new();
22097        for source in desired {
22098            streams.push(source, stream::pending().boxed());
22099        }
22100        subscriber.state = AlloySubscriberState::Active(streams);
22101        subscriber.sources_dirty = false;
22102        assert!(matches!(
22103            subscriber.establish_flashblocks_preflight(10).await,
22104            Err(SubscriberError::ChainMismatch {
22105                expected: 10,
22106                actual: 8_453
22107            })
22108        ));
22109        assert!(state_asserter.read_q().is_empty());
22110        assert!(stream_asserter.read_q().is_empty());
22111    }
22112
22113    #[test]
22114    fn unproven_parent_replacement_rewind_discards_every_unauthenticated_identity() {
22115        let parent = BlockRef {
22116            number: 79,
22117            hash: B256::repeat_byte(0x79),
22118            parent_hash: Some(B256::repeat_byte(0x78)),
22119            timestamp: Some(1_700_000_079),
22120        };
22121        let old_tip = BlockRef {
22122            number: 80,
22123            hash: B256::repeat_byte(0x80),
22124            parent_hash: Some(parent.hash),
22125            timestamp: Some(1_700_000_080),
22126        };
22127        let replacement = BlockRef {
22128            hash: B256::repeat_byte(0xe0),
22129            parent_hash: Some(B256::repeat_byte(0xdf)),
22130            ..old_tip
22131        };
22132        let mut state =
22133            CanonicalSequenceState::new(vec![parent, old_tip], Some(old_tip), Some(parent), None);
22134
22135        let rewind = apply_sequence_canonical_block(&mut state, &replacement, false)
22136            .expect("replacement metadata is structurally valid")
22137            .expect("unknown parent is an observable rewind");
22138
22139        assert_eq!(rewind.common_ancestor, None);
22140        assert_eq!(rewind.dropped, vec![parent, old_tip]);
22141        assert_eq!(state.retained_canonical_history(), &[replacement]);
22142        assert_eq!(state.coverage_head(), Some(&replacement));
22143        assert_eq!(state.safe_head(), None);
22144        assert_eq!(state.finalized_head(), None);
22145    }
22146
22147    #[test]
22148    fn handler_ids_are_non_empty_across_construction_and_deserialization() {
22149        assert_eq!(HandlerId::try_new("").unwrap_err(), HandlerIdError);
22150        let valid = HandlerId::try_new("owner-1").expect("non-empty id");
22151        let encoded = serde_json::to_string(&valid).expect("serialize id");
22152        assert_eq!(
22153            serde_json::from_str::<HandlerId>(&encoded).expect("deserialize valid id"),
22154            valid
22155        );
22156        assert!(serde_json::from_str::<HandlerId>(r#"""#).is_err());
22157    }
22158
22159    fn rpc_log(removed: bool) -> Log {
22160        Log {
22161            inner: alloy_primitives::Log::new_unchecked(
22162                Address::repeat_byte(0x42),
22163                vec![B256::repeat_byte(0x01)],
22164                Bytes::new(),
22165            ),
22166            block_hash: Some(B256::repeat_byte(0x02)),
22167            block_number: Some(7),
22168            block_timestamp: Some(1_700_000_000),
22169            transaction_hash: Some(B256::repeat_byte(0x03)),
22170            transaction_index: Some(4),
22171            log_index: Some(5),
22172            removed,
22173        }
22174    }
22175
22176    fn rpc_transaction(chain_id: Option<u64>) -> alloy_rpc_types_eth::Transaction {
22177        use alloy_consensus::SignableTransaction as _;
22178
22179        let envelope: alloy_consensus::TxEnvelope = alloy_consensus::TxLegacy {
22180            chain_id,
22181            ..Default::default()
22182        }
22183        .into_signed(alloy_primitives::Signature::test_signature())
22184        .into();
22185        alloy_rpc_types_eth::Transaction {
22186            inner: alloy_consensus::transaction::Recovered::new_unchecked(envelope, Address::ZERO),
22187            block_hash: None,
22188            block_number: None,
22189            transaction_index: None,
22190            effective_gas_price: None,
22191        }
22192    }
22193
22194    #[cfg(feature = "reactive-ws")]
22195    fn rpc_log_at(block_number: u64, transaction_index: u64, log_index: u64) -> Log {
22196        Log {
22197            inner: alloy_primitives::Log::new_unchecked(
22198                Address::repeat_byte(0x42),
22199                vec![B256::repeat_byte(0x01)],
22200                Bytes::new(),
22201            ),
22202            block_hash: Some(B256::repeat_byte(block_number as u8)),
22203            block_number: Some(block_number),
22204            block_timestamp: Some(1_700_000_000 + block_number),
22205            transaction_hash: Some(B256::repeat_byte(0x20 + transaction_index as u8)),
22206            transaction_index: Some(transaction_index),
22207            log_index: Some(log_index),
22208            removed: false,
22209        }
22210    }
22211
22212    fn rpc_block(number: u64, hash: B256) -> alloy_rpc_types_eth::Block {
22213        alloy_rpc_types_eth::Block::empty(alloy_rpc_types_eth::Header {
22214            hash,
22215            inner: alloy_consensus::Header {
22216                number,
22217                parent_hash: B256::repeat_byte(number.saturating_sub(1) as u8),
22218                timestamp: 1_700_000_000 + number,
22219                ..Default::default()
22220            },
22221            total_difficulty: None,
22222            size: None,
22223        })
22224    }
22225
22226    fn queue_op_pending(asserter: &Asserter, pending: alloy_rpc_types_eth::Block) {
22227        let parent = rpc_block(
22228            pending.header().number().saturating_sub(1),
22229            pending.header().parent_hash(),
22230        );
22231        asserter.push_success(&Some(pending));
22232        asserter.push_success(&Some(parent));
22233    }
22234
22235    #[tokio::test(flavor = "multi_thread")]
22236    #[cfg(feature = "reactive-ws")]
22237    async fn verified_log_context_fetches_and_caches_exact_parent_identity() {
22238        let stream_asserter = Asserter::new();
22239        let provider = ProviderBuilder::new().connect_mocked_client(stream_asserter.clone());
22240        let verification_asserter = Asserter::new();
22241        verification_asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(7))));
22242        let verification_provider =
22243            ProviderBuilder::new().connect_mocked_client(verification_asserter.clone());
22244        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22245            provider,
22246            SubscriberMode::PubSub,
22247            SubscriberConfig {
22248                verify_log_block_context: true,
22249                ..SubscriberConfig::default()
22250            },
22251        )
22252        .with_log_verification_provider(verification_provider);
22253        let log = rpc_log_at(7, 0, 0);
22254
22255        subscriber
22256            .verify_log_block_context(&log)
22257            .await
22258            .expect("verify live log block");
22259        subscriber
22260            .verify_log_block_context(&log)
22261            .await
22262            .expect("reuse verified block cache");
22263        let record = subscriber.with_chain_id(log_input_record(log, InputSource::Subscription));
22264
22265        assert_eq!(
22266            record.context.block.expect("verified block").parent_hash,
22267            Some(B256::repeat_byte(6))
22268        );
22269        assert!(
22270            verification_asserter.read_q().is_empty(),
22271            "one provider lookup should verify every log in the same block"
22272        );
22273        assert!(
22274            stream_asserter.read_q().is_empty(),
22275            "verification must not use the high-volume stream provider"
22276        );
22277    }
22278
22279    #[tokio::test(flavor = "multi_thread")]
22280    async fn stream_with_termination_yields_terminal_source_marker() {
22281        let mut stream = stream_with_termination::<Ethereum, _>(
22282            stream::iter([SubscriberEvent::<Ethereum>::PendingHash(B256::repeat_byte(
22283                0xaa,
22284            ))]),
22285            SubscriberStreamSource::PubSubPendingHashes,
22286        );
22287
22288        assert!(matches!(
22289            stream.next().await,
22290            Some(SubscriberEvent::PendingHash(hash)) if hash == B256::repeat_byte(0xaa)
22291        ));
22292        assert!(matches!(
22293            stream.next().await,
22294            Some(SubscriberEvent::StreamTerminated(source)) if source.is_pubsub()
22295        ));
22296        assert!(stream.next().await.is_none());
22297    }
22298
22299    #[test]
22300    fn reconnect_delay_doubles_until_capped() {
22301        assert_eq!(
22302            next_reconnect_delay(Duration::from_millis(250), Duration::from_secs(1)),
22303            Duration::from_millis(500)
22304        );
22305        assert_eq!(
22306            next_reconnect_delay(Duration::from_millis(750), Duration::from_secs(1)),
22307            Duration::from_secs(1)
22308        );
22309        assert_eq!(
22310            next_reconnect_delay(Duration::ZERO, Duration::from_secs(1)),
22311            Duration::ZERO
22312        );
22313    }
22314
22315    #[test]
22316    fn canonical_logs_are_deduped_but_removed_logs_are_not() {
22317        let included = log_input_record::<Ethereum>(rpc_log(false), InputSource::Subscription);
22318        let removed = log_input_record::<Ethereum>(rpc_log(true), InputSource::Subscription);
22319
22320        assert!(should_dedupe_record(&included));
22321        assert!(!should_dedupe_record(&removed));
22322    }
22323
22324    #[test]
22325    fn owner_reconcile_dedupe_rejects_conflicts_and_preserves_compatible_enrichment() {
22326        let set_context_timestamp = |record: &mut ReactiveInputRecord<Ethereum>,
22327                                     timestamp: Option<u64>| {
22328            record.context.block.as_mut().expect("block").timestamp = timestamp;
22329            match &mut record.context.chain_status {
22330                ChainStatus::Included { block, .. }
22331                | ChainStatus::Safe { block }
22332                | ChainStatus::Finalized { block }
22333                | ChainStatus::Reorged {
22334                    dropped_from: block,
22335                } => block.timestamp = timestamp,
22336                ChainStatus::Pending | ChainStatus::Preconfirmed { .. } => {
22337                    panic!("log record is canonical")
22338                }
22339            }
22340        };
22341
22342        let mut payload_only = log_input_record::<Ethereum>(rpc_log(false), InputSource::Backfill);
22343        let payload_timestamp = match &payload_only.input {
22344            ReactiveInput::Log(log) => log.block_timestamp.expect("timestamp"),
22345            _ => unreachable!(),
22346        };
22347        set_context_timestamp(&mut payload_only, None);
22348        let mut context_only = payload_only.clone();
22349        if let ReactiveInput::Log(log) = &mut context_only.input {
22350            log.block_timestamp = None;
22351        }
22352        set_context_timestamp(&mut context_only, Some(payload_timestamp + 1));
22353        assert!(matches!(
22354            dedupe_records(vec![payload_only, context_only]),
22355            Err(ReactiveError::InvalidInputRecord { .. })
22356        ));
22357
22358        let mut partial = log_input_record::<Ethereum>(rpc_log(false), InputSource::Backfill);
22359        if let ReactiveInput::Log(log) = &mut partial.input {
22360            log.block_timestamp = None;
22361        }
22362        set_context_timestamp(&mut partial, None);
22363        let complete = log_input_record::<Ethereum>(rpc_log(false), InputSource::Subscription);
22364        let deduped =
22365            dedupe_records(vec![partial, complete]).expect("compatible metadata enriches");
22366        assert_eq!(deduped.len(), 1);
22367        deduped[0]
22368            .validated_identity()
22369            .expect("merged record remains coherent");
22370        let resolved = resolve_record_block_payload_metadata(
22371            &deduped[0],
22372            *canonical_record_block(&deduped[0]).expect("canonical"),
22373        )
22374        .expect("effective block");
22375        assert_eq!(resolved.timestamp, Some(payload_timestamp));
22376    }
22377
22378    #[test]
22379    fn full_block_bodies_are_never_suppressed_from_header_hash_alone() {
22380        use alloy_rpc_types_eth::{Block, Header};
22381
22382        let block_ref = BlockRef {
22383            number: 7,
22384            hash: B256::repeat_byte(0x77),
22385            parent_hash: Some(B256::repeat_byte(0x66)),
22386            timestamp: Some(1_700_000_007),
22387        };
22388        let block = Block::empty(Header {
22389            hash: block_ref.hash,
22390            inner: alloy_consensus::Header {
22391                number: block_ref.number,
22392                parent_hash: block_ref.parent_hash.expect("parent"),
22393                timestamp: block_ref.timestamp.expect("timestamp"),
22394                ..Default::default()
22395            },
22396            total_difficulty: None,
22397            size: None,
22398        });
22399        let record = ReactiveInputRecord::<Ethereum>::new(
22400            ReactiveInput::FullBlock(block),
22401            ReactiveContext {
22402                chain_id: Some(1),
22403                source: InputSource::Subscription,
22404                chain_status: ChainStatus::Included {
22405                    block: block_ref,
22406                    confirmations: 0,
22407                },
22408                block: Some(block_ref),
22409                transaction_index: None,
22410                log_index: None,
22411            },
22412        );
22413
22414        assert!(!record.is_payload_deduplicable());
22415        assert!(!record.same_deduplicable_payload(&record));
22416        let retained = dedupe_scoped_records(vec![
22417            (
22418                record.clone(),
22419                DeliveryAudience::All,
22420                DeliveryScope::Canonical,
22421            ),
22422            (record, DeliveryAudience::All, DeliveryScope::Canonical),
22423        ])
22424        .expect("non-deduplicable bodies are preserved, not treated as conflicts");
22425        assert_eq!(retained.len(), 2);
22426    }
22427
22428    #[test]
22429    fn hydrated_transaction_wrappers_reject_inclusion_and_chain_identity_conflicts() {
22430        let pending_context = ReactiveContext {
22431            chain_id: Some(1),
22432            source: InputSource::Batch,
22433            chain_status: ChainStatus::Pending,
22434            block: None,
22435            transaction_index: None,
22436            log_index: None,
22437        };
22438        let mut included_pending = rpc_transaction(Some(1));
22439        included_pending.block_hash = Some(B256::repeat_byte(0xaa));
22440        assert!(matches!(
22441            ReactiveInputRecord::<Ethereum>::new(
22442                ReactiveInput::PendingTx(included_pending),
22443                pending_context.clone(),
22444            )
22445            .validated_identity(),
22446            Err(ReactiveError::InvalidInputRecord { .. })
22447        ));
22448        assert!(matches!(
22449            ReactiveInputRecord::<Ethereum>::new(
22450                ReactiveInput::PendingTx(rpc_transaction(Some(2))),
22451                pending_context,
22452            )
22453            .validated_identity(),
22454            Err(ReactiveError::InvalidInputRecord { .. })
22455        ));
22456
22457        let block_ref = BlockRef {
22458            number: 8,
22459            hash: B256::repeat_byte(0x88),
22460            parent_hash: Some(B256::repeat_byte(0x77)),
22461            timestamp: Some(1_700_000_008),
22462        };
22463        let header = alloy_rpc_types_eth::Header {
22464            hash: block_ref.hash,
22465            inner: alloy_consensus::Header {
22466                number: block_ref.number,
22467                parent_hash: block_ref.parent_hash.expect("parent"),
22468                timestamp: block_ref.timestamp.expect("timestamp"),
22469                ..Default::default()
22470            },
22471            total_difficulty: None,
22472            size: None,
22473        };
22474        let context = ReactiveContext {
22475            chain_id: Some(1),
22476            source: InputSource::Batch,
22477            chain_status: ChainStatus::Included {
22478                block: block_ref,
22479                confirmations: 0,
22480            },
22481            block: Some(block_ref),
22482            transaction_index: None,
22483            log_index: None,
22484        };
22485        for transaction in [
22486            alloy_rpc_types_eth::Transaction {
22487                block_hash: Some(B256::repeat_byte(0xff)),
22488                ..rpc_transaction(Some(1))
22489            },
22490            alloy_rpc_types_eth::Transaction {
22491                block_hash: Some(block_ref.hash),
22492                block_number: Some(block_ref.number),
22493                transaction_index: Some(1),
22494                ..rpc_transaction(Some(1))
22495            },
22496            rpc_transaction(Some(2)),
22497        ] {
22498            let block = alloy_rpc_types_eth::Block::new(
22499                header.clone(),
22500                alloy_network::primitives::BlockTransactions::Full(vec![transaction]),
22501            );
22502            assert!(matches!(
22503                ReactiveInputRecord::<Ethereum>::new(
22504                    ReactiveInput::FullBlock(block),
22505                    context.clone(),
22506                )
22507                .validated_identity(),
22508                Err(ReactiveError::InvalidInputRecord { .. })
22509            ));
22510        }
22511    }
22512
22513    #[test]
22514    #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))]
22515    fn compatibility_owner_backfill_and_live_overlap_split_exact_audiences() {
22516        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22517        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22518            provider,
22519            SubscriberMode::Auto,
22520            SubscriberConfig::default(),
22521        );
22522        let owner = HandlerId::new("compat-owner");
22523        subscriber
22524            .add_interest_owner(
22525                owner.clone(),
22526                &[ReactiveInterest::Logs(LogInterest {
22527                    provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
22528                    local_matcher: None,
22529                    route_key: None,
22530                })],
22531            )
22532            .unwrap();
22533        let log = rpc_log(false);
22534
22535        subscriber.enqueue_compat_owner_record(
22536            log_input_record(log.clone(), InputSource::Backfill),
22537            owner.clone(),
22538        );
22539        subscriber.enqueue_event(SubscriberEvent::Log {
22540            source_id: 0,
22541            log,
22542            #[cfg(feature = "ingress-timing")]
22543            timing: None,
22544        });
22545
22546        let batch = subscriber
22547            .drain_next_scoped_batch()
22548            .expect("owner catch-up and residual live copies");
22549        assert_eq!(batch.records.len(), 2);
22550        assert_eq!(
22551            batch.records[0].scope,
22552            SubscriberInputScope::OwnerOnlyHandlers {
22553                owners: vec![owner.clone()]
22554            }
22555        );
22556        assert_eq!(
22557            batch.records[1].scope,
22558            SubscriberInputScope::CanonicalResidual {
22559                owners: Vec::new(),
22560                excluded: vec![owner.clone()]
22561            }
22562        );
22563
22564        let reactive = batch.into_reactive_batch();
22565        assert_eq!(
22566            reactive.record_audience(0),
22567            Some(&DeliveryAudience::Owners(vec![owner.clone()]))
22568        );
22569        assert_eq!(
22570            reactive.record_delivery_scope(0),
22571            Some(DeliveryScope::OwnerCatchup)
22572        );
22573        assert_eq!(
22574            reactive.record_audience(1),
22575            Some(&DeliveryAudience::AllExcept(vec![owner]))
22576        );
22577        assert_eq!(
22578            reactive.record_delivery_scope(1),
22579            Some(DeliveryScope::Canonical)
22580        );
22581    }
22582
22583    #[test]
22584    #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))]
22585    fn active_owner_replacement_commits_atomically_to_one_new_epoch() {
22586        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22587        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22588            provider,
22589            SubscriberMode::Auto,
22590            SubscriberConfig::default(),
22591        );
22592        let owner = HandlerId::new("replace-owner");
22593        let original = ReactiveInterest::Logs(LogInterest {
22594            provider_filter: Filter::new().address(Address::repeat_byte(0x41)),
22595            local_matcher: None,
22596            route_key: None,
22597        });
22598        let replacement_interest = ReactiveInterest::Logs(LogInterest {
22599            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
22600            local_matcher: None,
22601            route_key: None,
22602        });
22603        let active = subscriber
22604            .stage_interest_owner(owner.clone(), &[original], SubscriberOwnerStart::Live)
22605            .unwrap();
22606        assert!(subscriber.activate_interest_owner(&active));
22607        let replacement = subscriber
22608            .stage_interest_owner_replacement(
22609                owner,
22610                &[replacement_interest],
22611                SubscriberOwnerStart::Live,
22612            )
22613            .unwrap();
22614
22615        assert!(subscriber.commit_interest_owner_replacement(&active, &replacement));
22616        assert_eq!(subscriber.interest_owner_state(&active), None);
22617        assert_eq!(
22618            subscriber.interest_owner_state(&replacement),
22619            Some(SubscriberOwnerState::Active)
22620        );
22621        assert_eq!(subscriber.registered_interests().len(), 1);
22622    }
22623
22624    #[test]
22625    #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))]
22626    fn compatibility_and_epoch_owner_lifecycles_cannot_mix() {
22627        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22628        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22629            provider,
22630            SubscriberMode::Auto,
22631            SubscriberConfig::default(),
22632        );
22633        let owner = HandlerId::new("one-lifecycle");
22634        let interest = ReactiveInterest::Logs(LogInterest {
22635            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
22636            local_matcher: None,
22637            route_key: None,
22638        });
22639        let epoch = subscriber
22640            .stage_interest_owner(
22641                owner.clone(),
22642                std::slice::from_ref(&interest),
22643                SubscriberOwnerStart::Live,
22644            )
22645            .expect("stage epoch owner");
22646
22647        assert!(matches!(
22648            subscriber.add_interest_owner(owner.clone(), std::slice::from_ref(&interest)),
22649            Err(SubscriberError::InvalidConfig(_))
22650        ));
22651        assert_eq!(
22652            subscriber.interest_owner_state(&epoch),
22653            Some(SubscriberOwnerState::Staged)
22654        );
22655        assert!(subscriber.abort_interest_owner(&epoch));
22656        subscriber
22657            .add_interest_owner(owner.clone(), std::slice::from_ref(&interest))
22658            .expect("compatibility owner after epoch abort");
22659        assert!(matches!(
22660            subscriber.stage_interest_owner_replacement(
22661                owner,
22662                std::slice::from_ref(&interest),
22663                SubscriberOwnerStart::Live,
22664            ),
22665            Err(SubscriberOwnerError::AlreadyRegistered(_))
22666        ));
22667    }
22668
22669    #[test]
22670    fn pending_record_overflow_is_sticky_and_fail_closed() {
22671        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22672        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22673            provider,
22674            SubscriberMode::Polling,
22675            SubscriberConfig {
22676                max_pending_records: 1,
22677                ..SubscriberConfig::default()
22678            },
22679        );
22680        subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
22681            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
22682            local_matcher: None,
22683            route_key: None,
22684        })];
22685        subscriber.enqueue_event(SubscriberEvent::Log {
22686            source_id: 0,
22687            log: rpc_log(false),
22688            #[cfg(feature = "ingress-timing")]
22689            timing: None,
22690        });
22691        let mut second = rpc_log(false);
22692        second.log_index = Some(6);
22693        second.transaction_hash = Some(B256::repeat_byte(0x04));
22694        subscriber.enqueue_event(SubscriberEvent::Log {
22695            source_id: 0,
22696            log: second,
22697            #[cfg(feature = "ingress-timing")]
22698            timing: None,
22699        });
22700
22701        assert_eq!(subscriber.pending_records.len(), 1);
22702        assert!(matches!(
22703            subscriber.check_resource_error(),
22704            Err(SubscriberError::ResourceExhausted(_))
22705        ));
22706        subscriber.reset_delivery_state();
22707        assert!(subscriber.check_resource_error().is_ok());
22708    }
22709
22710    #[test]
22711    fn historical_log_payload_bytes_are_bounded_independently_of_log_count() {
22712        let baseline = rpc_log(false);
22713        let fixed_bytes =
22714            validate_backfill_resource_limits(std::slice::from_ref(&baseline), 1, usize::MAX)
22715                .expect("measure fixed log accounting");
22716        let mut large = baseline;
22717        large.inner = alloy_primitives::Log::new_unchecked(
22718            Address::repeat_byte(0x42),
22719            vec![B256::repeat_byte(0x01)],
22720            Bytes::from(vec![0u8; 256]),
22721        );
22722
22723        assert!(matches!(
22724            validate_backfill_resource_limits(&[large], 1, fixed_bytes + 255),
22725            Err(SubscriberError::ResourceExhausted(_))
22726        ));
22727    }
22728
22729    #[tokio::test(flavor = "multi_thread")]
22730    #[cfg(feature = "reactive-polling")]
22731    async fn reconcile_capacity_failure_does_not_publish_progress_or_partial_history() {
22732        use alloy_rpc_types_eth::{Block, Header};
22733
22734        let asserter = Asserter::new();
22735        let baseline = BlockRef {
22736            number: 6,
22737            hash: B256::repeat_byte(6),
22738            parent_hash: Some(B256::repeat_byte(5)),
22739            timestamp: Some(1_700_000_006),
22740        };
22741        let through = BlockRef {
22742            number: 7,
22743            hash: B256::repeat_byte(7),
22744            parent_hash: Some(baseline.hash),
22745            timestamp: Some(1_700_000_007),
22746        };
22747        let rpc_block = || -> Block {
22748            Block::empty(Header {
22749                hash: through.hash,
22750                inner: alloy_consensus::Header {
22751                    number: through.number,
22752                    parent_hash: through.parent_hash.expect("parent"),
22753                    timestamp: through.timestamp.expect("timestamp"),
22754                    ..Default::default()
22755                },
22756                total_difficulty: None,
22757                size: None,
22758            })
22759        };
22760        let mut historical = rpc_log(false);
22761        historical.block_hash = Some(through.hash);
22762        historical.block_timestamp = through.timestamp;
22763        asserter.push_success(&Some(rpc_block()));
22764        asserter.push_success(&vec![historical]);
22765        asserter.push_success(&Some(rpc_block()));
22766        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
22767        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22768            provider,
22769            SubscriberMode::Polling,
22770            SubscriberConfig {
22771                max_pending_records: 1,
22772                ..SubscriberConfig::default()
22773            },
22774        );
22775        subscriber.chain_id = Some(1);
22776        let interest = ReactiveInterest::Logs(LogInterest {
22777            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
22778            local_matcher: None,
22779            route_key: None,
22780        });
22781        let epoch = subscriber
22782            .stage_interest_owner(
22783                HandlerId::new("capacity-owner"),
22784                std::slice::from_ref(&interest),
22785                SubscriberOwnerStart::PostBlock(baseline),
22786            )
22787            .expect("stage owner");
22788        // Isolate the commit-side capacity edge: the live queue acquired one
22789        // canonical record while the historical request was in flight.
22790        subscriber.sources_dirty = false;
22791        subscriber.state = AlloySubscriberState::Empty;
22792        subscriber.push_pending_record(SubscriberInputRecord {
22793            record: log_input_record(rpc_log(false), InputSource::Poll),
22794            scope: SubscriberInputScope::Canonical { owners: Vec::new() },
22795            preconfirmation_timing: None,
22796            #[cfg(feature = "ingress-timing")]
22797            canonical_timing: None,
22798        });
22799
22800        let error = subscriber
22801            .reconcile_interest_owner(&epoch, through)
22802            .await
22803            .expect_err("historical delivery cannot displace the queued live record");
22804        assert!(matches!(
22805            error,
22806            SubscriberOwnerError::Subscriber(SubscriberError::ResourceExhausted(_))
22807        ));
22808        assert!(subscriber.interest_owner_progress(&epoch).is_none());
22809        assert_eq!(subscriber.pending_records.len(), 1);
22810        assert!(matches!(
22811            subscriber.pending_records[0].scope,
22812            SubscriberInputScope::Canonical { .. }
22813        ));
22814    }
22815
22816    #[test]
22817    #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))]
22818    fn lazy_backfill_queue_capacity_failure_is_atomic() {
22819        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22820        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22821            provider,
22822            SubscriberMode::Auto,
22823            SubscriberConfig {
22824                max_pending_backfills: 1,
22825                ..SubscriberConfig::default()
22826            },
22827        );
22828        let interest = |address| {
22829            ReactiveInterest::Logs(LogInterest {
22830                provider_filter: Filter::new().address(address),
22831                local_matcher: None,
22832                route_key: None,
22833            })
22834        };
22835        subscriber
22836            .add_interest_owner_with_backfill(
22837                HandlerId::new("owner-a"),
22838                &[interest(Address::repeat_byte(0x41))],
22839                SubscriberBackfill::from_block(10),
22840            )
22841            .expect("first queued backfill");
22842
22843        let error = subscriber
22844            .add_interest_owner_with_backfill(
22845                HandlerId::new("owner-b"),
22846                &[interest(Address::repeat_byte(0x42))],
22847                SubscriberBackfill::from_block(10),
22848            )
22849            .expect_err("second backfill must exceed capacity");
22850
22851        assert!(matches!(error, SubscriberError::ResourceExhausted(_)));
22852        assert!(
22853            subscriber
22854                .owner_interests(&HandlerId::new("owner-b"))
22855                .is_none()
22856        );
22857        assert_eq!(subscriber.pending_backfills.len(), 1);
22858    }
22859
22860    #[test]
22861    #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))]
22862    fn exact_owner_replacement_is_atomic_and_removes_crash_stale_owners() {
22863        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22864        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22865            provider,
22866            SubscriberMode::Auto,
22867            SubscriberConfig {
22868                max_pending_backfills: 1,
22869                ..SubscriberConfig::default()
22870            },
22871        );
22872        let interest = |address| {
22873            ReactiveInterest::Logs(LogInterest {
22874                provider_filter: Filter::new().address(address),
22875                local_matcher: None,
22876                route_key: None,
22877            })
22878        };
22879        subscriber
22880            .add_interest_owner(
22881                HandlerId::new("crash-stale"),
22882                &[interest(Address::repeat_byte(0xee))],
22883            )
22884            .expect("seed stale owner");
22885        subscriber.base_interests = vec![interest(Address::repeat_byte(0xdd))];
22886        subscriber.rebuild_registered_interests();
22887        subscriber.push_pending_record(SubscriberInputRecord {
22888            record: log_input_record(rpc_log(false), InputSource::Poll),
22889            scope: SubscriberInputScope::Canonical { owners: Vec::new() },
22890            preconfirmation_timing: None,
22891            #[cfg(feature = "ingress-timing")]
22892            canonical_timing: None,
22893        });
22894        let baseline = BlockRef {
22895            number: 100,
22896            hash: B256::repeat_byte(100),
22897            parent_hash: Some(B256::repeat_byte(99)),
22898            timestamp: Some(1_700_000_100),
22899        };
22900        let backfill = SubscriberBackfill::after_canonical_block(baseline).expect("C + 1");
22901
22902        let error = subscriber
22903            .replace_interest_owners_with_global_backfill(
22904                vec![
22905                    (
22906                        HandlerId::new("pool-a"),
22907                        vec![interest(Address::repeat_byte(0xa1))],
22908                    ),
22909                    (
22910                        HandlerId::new("pool-b"),
22911                        vec![ReactiveInterest::Logs(LogInterest {
22912                            // A distinct block option prevents provider-filter
22913                            // fan-in, exercising the two-unit capacity edge.
22914                            provider_filter: Filter::new()
22915                                .address(Address::repeat_byte(0xb2))
22916                                .from_block(7),
22917                            local_matcher: None,
22918                            route_key: None,
22919                        })],
22920                    ),
22921                ],
22922                backfill,
22923            )
22924            .expect_err("two backfills exceed atomic capacity");
22925        assert!(matches!(error, SubscriberError::ResourceExhausted(_)));
22926        assert!(
22927            subscriber
22928                .owner_interests(&HandlerId::new("crash-stale"))
22929                .is_some(),
22930            "failed replacement must preserve the prior topology"
22931        );
22932        assert!(
22933            subscriber
22934                .owner_interests(&HandlerId::new("pool-a"))
22935                .is_none()
22936        );
22937        assert_eq!(subscriber.base_interests.len(), 1);
22938        assert_eq!(subscriber.pending_records.len(), 1);
22939
22940        subscriber
22941            .replace_interest_owners_with_global_backfill(
22942                vec![(
22943                    HandlerId::new("pool-a"),
22944                    vec![interest(Address::repeat_byte(0xa1))],
22945                )],
22946                backfill,
22947            )
22948            .expect("replacement within capacity");
22949        assert!(
22950            subscriber
22951                .owner_interests(&HandlerId::new("crash-stale"))
22952                .is_none(),
22953            "successful exact replacement removes stale owners"
22954        );
22955        assert!(
22956            subscriber.base_interests.is_empty(),
22957            "successful exact replacement removes stale unowned interests"
22958        );
22959        assert!(
22960            subscriber.drain_next_scoped_batch().is_none(),
22961            "stale canonical delivery must not escape before C + 1 recovery"
22962        );
22963        assert!(
22964            subscriber
22965                .owner_interests(&HandlerId::new("pool-a"))
22966                .is_some()
22967        );
22968        assert_eq!(subscriber.pending_backfills.len(), 1);
22969        assert_eq!(subscriber.pending_backfills[0].backfill, backfill);
22970        assert!(
22971            subscriber.pending_backfills[0].owner.is_none(),
22972            "startup history must be global canonical catch-up, not owner-only"
22973        );
22974    }
22975
22976    #[test]
22977    fn exclusive_canonical_backfill_rejects_block_number_overflow() {
22978        let baseline = BlockRef {
22979            number: u64::MAX,
22980            hash: B256::repeat_byte(0xff),
22981            parent_hash: None,
22982            timestamp: None,
22983        };
22984        assert!(matches!(
22985            SubscriberBackfill::after_canonical_block(baseline),
22986            Err(SubscriberError::InvalidConfig(_))
22987        ));
22988    }
22989
22990    #[tokio::test(flavor = "multi_thread")]
22991    #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))]
22992    async fn exclusive_canonical_backfill_validates_the_retained_baseline_hash() {
22993        let asserter = Asserter::new();
22994        asserter.push_success(&101u64);
22995        asserter.push_success(&Some(rpc_block(101, B256::repeat_byte(101))));
22996        asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(0xee))));
22997        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
22998        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22999            provider,
23000            SubscriberMode::Auto,
23001            SubscriberConfig::default(),
23002        );
23003        let baseline = BlockRef {
23004            number: 100,
23005            hash: B256::repeat_byte(0xaa),
23006            parent_hash: None,
23007            timestamp: None,
23008        };
23009        let backfill = SubscriberBackfill::after_canonical_block(baseline).expect("C + 1");
23010        subscriber
23011            .add_interest_owner_with_backfill(
23012                HandlerId::new("pool"),
23013                &[ReactiveInterest::Logs(LogInterest {
23014                    provider_filter: Filter::new().address(Address::repeat_byte(0xa1)),
23015                    local_matcher: None,
23016                    route_key: None,
23017                })],
23018                backfill,
23019            )
23020            .expect("queue post-baseline backfill");
23021
23022        let error = subscriber
23023            .drain_pending_backfills()
23024            .await
23025            .expect_err("provider branch differs at retained baseline");
23026        assert!(matches!(error, SubscriberError::InvalidBackfill(_)));
23027        assert_eq!(subscriber.pending_backfills.len(), 1);
23028        assert_eq!(subscriber.pending_backfills[0].backfill.start_block(), 101);
23029        assert!(subscriber.pending_records.is_empty());
23030    }
23031
23032    #[tokio::test(flavor = "multi_thread")]
23033    #[cfg(feature = "reactive-ws")]
23034    async fn coordinated_multifilter_windows_are_globally_sorted_for_owner_and_canonical_delivery()
23035    {
23036        let asserter = Asserter::new();
23037        let retained = BlockRef {
23038            number: 10,
23039            hash: B256::repeat_byte(10),
23040            parent_hash: Some(B256::repeat_byte(9)),
23041            timestamp: Some(1_700_000_010),
23042        };
23043        let activation = BlockRef {
23044            number: 12,
23045            hash: B256::repeat_byte(12),
23046            parent_hash: Some(B256::repeat_byte(11)),
23047            timestamp: Some(1_700_000_012),
23048        };
23049
23050        // 257 distinct logical block options cross the 256-filter request
23051        // chunk boundary. Each window therefore makes two concurrent log
23052        // requests whose responses deliberately arrive in reverse order.
23053        asserter.push_success(&Some(rpc_block(retained.number, retained.hash)));
23054        asserter.push_success(&vec![rpc_log_at(10, 2, 2)]);
23055        asserter.push_success(&vec![rpc_log_at(10, 1, 1)]);
23056        asserter.push_success(&Some(rpc_block(retained.number, retained.hash)));
23057        asserter.push_success(&activation.number);
23058        asserter.push_success(&Some(rpc_block(activation.number, activation.hash)));
23059        asserter.push_success(&Some(rpc_block(retained.number, retained.hash)));
23060        asserter.push_success(&vec![rpc_log_at(12, 2, 2)]);
23061        asserter.push_success(&vec![rpc_log_at(11, 1, 1)]);
23062        asserter.push_success(&Some(rpc_block(activation.number, activation.hash)));
23063        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
23064        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23065            provider,
23066            SubscriberMode::Auto,
23067            SubscriberConfig::default(),
23068        );
23069        let interests = (0..257)
23070            .map(|start| {
23071                ReactiveInterest::Logs(LogInterest {
23072                    provider_filter: Filter::new()
23073                        .address(Address::repeat_byte(0x42))
23074                        .event_signature(B256::repeat_byte(0x01))
23075                        .from_block(start),
23076                    local_matcher: None,
23077                    route_key: None,
23078                })
23079            })
23080            .collect::<Vec<_>>();
23081        subscriber
23082            .add_interest_owner_with_canonical_catchup(
23083                HandlerId::new("many-filters"),
23084                &interests,
23085                retained,
23086            )
23087            .expect("queue coordinated windows");
23088        assert_eq!(subscriber.pending_backfills.len(), 2);
23089        assert_eq!(subscriber.pending_backfills[0].filters.len(), 257);
23090        assert_eq!(subscriber.pending_backfills[1].filters.len(), 257);
23091
23092        subscriber
23093            .drain_pending_backfills()
23094            .await
23095            .expect("owner filter group");
23096        let owner = subscriber
23097            .drain_next_scoped_batch()
23098            .expect("owner ordered batch");
23099        assert_eq!(owner.records.len(), 2);
23100        assert_eq!(owner.records[0].record.context.transaction_index, Some(1));
23101        assert_eq!(owner.records[1].record.context.transaction_index, Some(2));
23102        assert!(
23103            owner.records.iter().all(|record| matches!(
23104                record.scope,
23105                SubscriberInputScope::OwnerOnlyHandlers { .. }
23106            ))
23107        );
23108
23109        subscriber
23110            .drain_pending_backfills()
23111            .await
23112            .expect("global filter group");
23113        let global = subscriber
23114            .drain_next_scoped_batch()
23115            .expect("global ordered batch");
23116        assert_eq!(global.records.len(), 2);
23117        assert_eq!(
23118            global.records[0].record.context.block.map(|b| b.number),
23119            Some(11)
23120        );
23121        assert_eq!(
23122            global.records[1].record.context.block.map(|b| b.number),
23123            Some(12)
23124        );
23125        assert!(
23126            global
23127                .records
23128                .iter()
23129                .all(|record| record.scope.is_canonical())
23130        );
23131        assert!(matches!(
23132            global.chain_controls.as_slice(),
23133            [ChainControl::Barrier {
23134                block: Some(block),
23135                ..
23136            }] if block == &activation
23137        ));
23138    }
23139
23140    #[tokio::test(flavor = "multi_thread")]
23141    #[cfg(feature = "reactive-ws")]
23142    async fn aborting_staged_epoch_purges_only_its_buffered_delivery() {
23143        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23144        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23145            provider,
23146            SubscriberMode::PubSub,
23147            SubscriberConfig::default(),
23148        );
23149        subscriber.chain_id = Some(1);
23150        let interest = ReactiveInterest::Logs(LogInterest {
23151            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
23152            local_matcher: None,
23153            route_key: None,
23154        });
23155        let owner_a = subscriber
23156            .stage_interest_owner(
23157                HandlerId::new("owner-a"),
23158                std::slice::from_ref(&interest),
23159                SubscriberOwnerStart::Live,
23160            )
23161            .unwrap();
23162        let owner_b = subscriber
23163            .stage_interest_owner(
23164                HandlerId::new("owner-b"),
23165                &[interest],
23166                SubscriberOwnerStart::Live,
23167            )
23168            .unwrap();
23169
23170        subscriber.enqueue_event(SubscriberEvent::Log {
23171            source_id: 0,
23172            log: rpc_log(false),
23173            #[cfg(feature = "ingress-timing")]
23174            timing: None,
23175        });
23176        assert!(subscriber.abort_interest_owner(&owner_a));
23177
23178        let batch = subscriber
23179            .next_scoped_batch()
23180            .await
23181            .unwrap()
23182            .expect("shared canonical delivery remains queued");
23183        assert_eq!(batch.records.len(), 1);
23184        assert_eq!(
23185            batch.records[0].scope,
23186            SubscriberInputScope::Canonical {
23187                owners: vec![owner_b]
23188            }
23189        );
23190    }
23191
23192    #[tokio::test(flavor = "multi_thread")]
23193    #[cfg(feature = "reactive-ws")]
23194    async fn owner_backfill_dedupe_never_suppresses_canonical_delivery() {
23195        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23196        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23197            provider,
23198            SubscriberMode::PubSub,
23199            SubscriberConfig::default(),
23200        );
23201        subscriber.chain_id = Some(1);
23202        let epoch = subscriber
23203            .stage_interest_owner(
23204                HandlerId::new("owner"),
23205                &[ReactiveInterest::Logs(LogInterest {
23206                    provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
23207                    local_matcher: None,
23208                    route_key: None,
23209                })],
23210                SubscriberOwnerStart::Live,
23211            )
23212            .unwrap();
23213        let log = rpc_log(false);
23214
23215        subscriber.enqueue_owner_record(
23216            log_input_record(log.clone(), InputSource::Backfill),
23217            epoch.clone(),
23218        );
23219        subscriber.enqueue_event(SubscriberEvent::Log {
23220            source_id: 0,
23221            log,
23222            #[cfg(feature = "ingress-timing")]
23223            timing: None,
23224        });
23225
23226        let batch = subscriber
23227            .next_scoped_batch()
23228            .await
23229            .unwrap()
23230            .expect("owner backfill and canonical live delivery");
23231        assert_eq!(batch.records.len(), 2);
23232        assert_eq!(
23233            batch.records[0].scope,
23234            SubscriberInputScope::OwnerOnly {
23235                owners: vec![epoch]
23236            }
23237        );
23238        assert_eq!(
23239            batch.records[1].scope,
23240            SubscriberInputScope::Canonical { owners: Vec::new() },
23241            "owner replay dedupe must not suppress the global live record"
23242        );
23243    }
23244
23245    #[tokio::test(flavor = "multi_thread")]
23246    #[cfg(feature = "reactive-polling")]
23247    async fn reconcile_fetch_drains_live_burst_beyond_output_batch_capacity() {
23248        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23249        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23250            provider,
23251            SubscriberMode::Polling,
23252            SubscriberConfig {
23253                max_batch_size: 2,
23254                ..SubscriberConfig::default()
23255            },
23256        );
23257        let interest = ReactiveInterest::Logs(LogInterest {
23258            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
23259            local_matcher: None,
23260            route_key: None,
23261        });
23262        let epoch = subscriber
23263            .stage_interest_owner(
23264                HandlerId::new("owner"),
23265                std::slice::from_ref(&interest),
23266                SubscriberOwnerStart::Live,
23267            )
23268            .unwrap();
23269        subscriber.sources_dirty = false;
23270
23271        let mut duplicate = rpc_log(false);
23272        duplicate.transaction_hash = Some(B256::repeat_byte(1));
23273        duplicate.log_index = Some(0);
23274        let events = (0u8..10).map(|index| {
23275            let mut log = rpc_log(false);
23276            log.transaction_hash = Some(B256::repeat_byte(index.saturating_add(1)));
23277            log.log_index = Some(index as u64);
23278            SubscriberEvent::Log {
23279                source_id: 0,
23280                log,
23281                #[cfg(feature = "ingress-timing")]
23282                timing: None,
23283            }
23284        });
23285        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
23286        let mut streams = SubscriberStreams::new();
23287        streams.push(
23288            SubscriberStreamSource::PollingLog { filter },
23289            stream::iter(events).boxed(),
23290        );
23291        subscriber.state = AlloySubscriberState::Active(streams);
23292
23293        let mut polls = 0usize;
23294        let fetched_duplicate = duplicate.clone();
23295        let fetch = poll_fn(move |cx| {
23296            polls += 1;
23297            if polls > 10 {
23298                std::task::Poll::Ready(Ok::<_, SubscriberOwnerError>(fetched_duplicate.clone()))
23299            } else {
23300                cx.waker().wake_by_ref();
23301                std::task::Poll::Pending
23302            }
23303        });
23304        let target_epochs = HashSet::from([epoch.clone()]);
23305        let fetched_duplicate = subscriber
23306            .drive_reconcile_fetch(fetch, &target_epochs)
23307            .await
23308            .unwrap();
23309        subscriber.enqueue_owner_record_for_owners_unmerged(
23310            log_input_record(fetched_duplicate, InputSource::Backfill),
23311            vec![epoch.clone()],
23312        );
23313        subscriber.promote_reconcile_owner_records(&target_epochs);
23314
23315        assert_eq!(subscriber.pending_records.len(), 20);
23316        assert!(subscriber.pending_records.iter().take(10).all(|record| {
23317            record.scope == SubscriberInputScope::Canonical { owners: Vec::new() }
23318        }));
23319        assert!(subscriber.pending_records.iter().skip(10).all(|record| {
23320            record.scope
23321                == SubscriberInputScope::OwnerOnly {
23322                    owners: vec![epoch.clone()],
23323                }
23324        }));
23325    }
23326
23327    #[tokio::test(flavor = "multi_thread")]
23328    #[cfg(feature = "reactive-polling")]
23329    async fn reconcile_fetch_waits_for_provider_when_live_topology_is_empty() {
23330        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23331        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23332            provider,
23333            SubscriberMode::Polling,
23334            SubscriberConfig::default(),
23335        );
23336        subscriber.chain_id = Some(1);
23337        subscriber.sources_dirty = false;
23338        let mut first_poll = true;
23339        let fetch = poll_fn(move |cx| {
23340            if first_poll {
23341                first_poll = false;
23342                cx.waker().wake_by_ref();
23343                std::task::Poll::Pending
23344            } else {
23345                std::task::Poll::Ready(Ok::<_, SubscriberOwnerError>("certified"))
23346            }
23347        });
23348
23349        let result = subscriber
23350            .drive_reconcile_fetch(fetch, &HashSet::new())
23351            .await
23352            .expect("an empty live topology must not be mistaken for termination");
23353        assert_eq!(result, "certified");
23354    }
23355
23356    #[tokio::test(flavor = "multi_thread")]
23357    #[cfg(all(feature = "reactive-polling", feature = "reactive-ws"))]
23358    async fn successful_owner_reconcile_seeds_its_live_filter_reconnect_anchor() {
23359        use alloy_rpc_types_eth::{Block, Header};
23360
23361        let asserter = Asserter::new();
23362        let baseline = BlockRef {
23363            number: 100,
23364            hash: B256::repeat_byte(0x64),
23365            parent_hash: Some(B256::repeat_byte(0x63)),
23366            timestamp: Some(1_700_000_100),
23367        };
23368        let through = BlockRef {
23369            number: 101,
23370            hash: B256::repeat_byte(0x65),
23371            parent_hash: Some(baseline.hash),
23372            timestamp: Some(1_700_000_101),
23373        };
23374        let rpc_block = || -> Block {
23375            Block::empty(Header {
23376                hash: through.hash,
23377                inner: alloy_consensus::Header {
23378                    number: through.number,
23379                    parent_hash: through.parent_hash.unwrap(),
23380                    timestamp: through.timestamp.unwrap(),
23381                    ..Default::default()
23382                },
23383                total_difficulty: None,
23384                size: None,
23385            })
23386        };
23387        asserter.push_success(&Some(rpc_block()));
23388        asserter.push_success(&Vec::<Log>::new());
23389        asserter.push_success(&Some(rpc_block()));
23390        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
23391        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23392            provider,
23393            SubscriberMode::PubSub,
23394            SubscriberConfig::default(),
23395        );
23396        subscriber.chain_id = Some(1);
23397        let interest = ReactiveInterest::Logs(LogInterest {
23398            provider_filter: Filter::new().address(Address::repeat_byte(0xac)),
23399            local_matcher: None,
23400            route_key: None,
23401        });
23402        let epoch = subscriber
23403            .stage_interest_owner(
23404                HandlerId::new("reconnect-anchor"),
23405                std::slice::from_ref(&interest),
23406                SubscriberOwnerStart::PostBlock(baseline),
23407            )
23408            .unwrap();
23409        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
23410        let source = SubscriberStreamSource::PubSubLog {
23411            id: subscriber.log_source_id(&filter),
23412            filter: filter.clone(),
23413        };
23414        let mut streams = SubscriberStreams::new();
23415        streams.push(source, stream::pending().boxed());
23416        subscriber.state = AlloySubscriberState::Active(streams);
23417        subscriber.sources_dirty = false;
23418
23419        subscriber
23420            .reconcile_interest_owner(&epoch, through)
23421            .await
23422            .unwrap();
23423        assert_eq!(subscriber.log_anchor(&filter), Some(through.number));
23424        assert!(asserter.read_q().is_empty());
23425    }
23426
23427    #[tokio::test(flavor = "multi_thread")]
23428    #[cfg(feature = "reactive-polling")]
23429    async fn cancelled_reconcile_retains_hidden_owner_live_delivery_for_retry() {
23430        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23431        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23432            provider,
23433            SubscriberMode::Polling,
23434            SubscriberConfig::default(),
23435        );
23436        let interest = ReactiveInterest::Logs(LogInterest {
23437            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
23438            local_matcher: None,
23439            route_key: None,
23440        });
23441        let epoch = subscriber
23442            .stage_interest_owner(
23443                HandlerId::new("owner"),
23444                std::slice::from_ref(&interest),
23445                SubscriberOwnerStart::PostBlock(BlockRef {
23446                    number: 100,
23447                    hash: B256::repeat_byte(0x64),
23448                    parent_hash: None,
23449                    timestamp: None,
23450                }),
23451            )
23452            .unwrap();
23453        subscriber.sources_dirty = false;
23454
23455        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
23456        let event = SubscriberEvent::Log {
23457            source_id: 0,
23458            log: rpc_log(false),
23459            #[cfg(feature = "ingress-timing")]
23460            timing: None,
23461        };
23462        let mut streams = SubscriberStreams::new();
23463        streams.push(
23464            SubscriberStreamSource::PollingLog { filter },
23465            stream::once(async move { event })
23466                .chain(stream::pending())
23467                .boxed(),
23468        );
23469        subscriber.state = AlloySubscriberState::Active(streams);
23470
23471        let targets = HashSet::from([epoch.clone()]);
23472        {
23473            let fetch = futures::future::pending::<Result<(), SubscriberOwnerError>>();
23474            let drive = subscriber.drive_reconcile_fetch(fetch, &targets);
23475            futures::pin_mut!(drive);
23476            poll_fn(|cx| {
23477                assert!(drive.as_mut().poll(cx).is_pending());
23478                std::task::Poll::Ready(())
23479            })
23480            .await;
23481        }
23482
23483        assert_eq!(subscriber.pending_records.len(), 1);
23484        assert_eq!(
23485            subscriber.pending_records[0].scope,
23486            SubscriberInputScope::Canonical { owners: Vec::new() },
23487            "canonical delivery commits immediately at a cancellation-safe boundary"
23488        );
23489        assert_eq!(subscriber.pending_reconcile_owner_records.len(), 1);
23490
23491        subscriber
23492            .drive_reconcile_fetch(futures::future::ready(Ok(())), &targets)
23493            .await
23494            .unwrap();
23495        subscriber.promote_reconcile_owner_records(&targets);
23496        assert!(subscriber.pending_reconcile_owner_records.is_empty());
23497        assert_eq!(subscriber.pending_records.len(), 2);
23498        assert_eq!(
23499            subscriber.pending_records[0].scope,
23500            SubscriberInputScope::Canonical { owners: Vec::new() },
23501            "canonical delivery remains target-excluded"
23502        );
23503        assert_eq!(
23504            subscriber.pending_records[1].scope,
23505            SubscriberInputScope::OwnerOnly {
23506                owners: vec![epoch]
23507            },
23508            "retry commit appends hidden owner delivery after historical catch-up"
23509        );
23510    }
23511
23512    #[tokio::test(flavor = "multi_thread")]
23513    #[cfg(feature = "reactive-ws")]
23514    async fn control_cancellation_preserves_terminated_source_reconcile_intent() {
23515        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23516        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23517            provider,
23518            SubscriberMode::PubSub,
23519            SubscriberConfig {
23520                reconnect: SubscriberReconnectConfig {
23521                    initial_delay: Duration::from_secs(60),
23522                    ..SubscriberReconnectConfig::default()
23523                },
23524                ..SubscriberConfig::default()
23525            },
23526        );
23527        subscriber.chain_id = Some(1);
23528        let interest = ReactiveInterest::Logs(LogInterest {
23529            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
23530            local_matcher: None,
23531            route_key: None,
23532        });
23533        let epoch = subscriber
23534            .stage_interest_owner(
23535                HandlerId::new("owner"),
23536                std::slice::from_ref(&interest),
23537                SubscriberOwnerStart::PostBlock(BlockRef {
23538                    number: 7,
23539                    hash: B256::repeat_byte(0x07),
23540                    parent_hash: Some(B256::repeat_byte(0x06)),
23541                    timestamp: Some(1_700_000_007),
23542                }),
23543            )
23544            .unwrap();
23545        subscriber.sources_dirty = false;
23546        subscriber.stream_revision = 1;
23547        let entry = subscriber
23548            .owned_interests
23549            .iter_mut()
23550            .find(|entry| entry.epoch.as_ref() == Some(&epoch))
23551            .unwrap();
23552        entry.progress = Some(SubscriberOwnerProgress {
23553            owner: epoch.clone(),
23554            through: entry.baseline.unwrap(),
23555        });
23556        entry.progress_stream_revision = Some(1);
23557
23558        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
23559        let source = SubscriberStreamSource::PubSubLog {
23560            id: subscriber.log_source_id(&filter),
23561            filter,
23562        };
23563        let mut streams = SubscriberStreams::new();
23564        streams.push(
23565            source.clone(),
23566            stream::iter([SubscriberEvent::StreamTerminated(source)]).boxed(),
23567        );
23568        subscriber.state = AlloySubscriberState::Active(streams);
23569        let prior_revision = subscriber.stream_revision;
23570
23571        let mut first_poll = true;
23572        let control = poll_fn(move |cx| {
23573            if first_poll {
23574                first_poll = false;
23575                cx.waker().wake_by_ref();
23576                std::task::Poll::Pending
23577            } else {
23578                std::task::Poll::Ready("stop")
23579            }
23580        });
23581        futures::pin_mut!(control);
23582        let outcome = subscriber
23583            .next_scoped_batch_or(control.as_mut())
23584            .await
23585            .unwrap();
23586
23587        assert!(matches!(outcome, SubscriberDriverPoll::Control("stop")));
23588        assert!(subscriber.sources_dirty);
23589        assert!(subscriber.stream_revision > prior_revision);
23590        assert!(
23591            !subscriber.activate_interest_owner(&epoch),
23592            "progress certified against the terminated stream revision is stale"
23593        );
23594    }
23595
23596    #[tokio::test]
23597    #[cfg(feature = "reactive-ws")]
23598    async fn pubsub_sources_assign_stable_log_ids_before_shared_streams() {
23599        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23600        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23601            provider,
23602            SubscriberMode::PubSub,
23603            SubscriberConfig::default(),
23604        );
23605        subscriber.chain_id = Some(1);
23606        subscriber
23607            .register_interests(&[
23608                ReactiveInterest::Logs(LogInterest {
23609                    provider_filter: Filter::new().address(Address::repeat_byte(0x01)),
23610                    local_matcher: None,
23611                    route_key: None,
23612                }),
23613                ReactiveInterest::Logs(LogInterest {
23614                    provider_filter: Filter::new().address(Address::repeat_byte(0x02)),
23615                    local_matcher: None,
23616                    route_key: None,
23617                }),
23618                ReactiveInterest::PendingTransactions(PendingTxInterest::default()),
23619            ])
23620            .await
23621            .expect("register base interests");
23622
23623        // The two default-block-option log filters merge into one address
23624        // superset (existing consolidation behavior), so there is one log source
23625        // — assigned id 0, before the pending-hash source.
23626        let sources = subscriber.stream_sources().expect("stream sources");
23627        assert_eq!(sources.len(), 2);
23628        assert!(matches!(
23629            &sources[0],
23630            SubscriberStreamSource::PubSubLog { id: 0, .. }
23631        ));
23632        assert!(matches!(
23633            sources[1],
23634            SubscriberStreamSource::PubSubPendingHashes
23635        ));
23636
23637        // Ids are stable across repeated source construction.
23638        let again = subscriber.stream_sources().expect("stream sources again");
23639        assert!(again[0].same_key(&sources[0]));
23640    }
23641
23642    #[tokio::test(flavor = "multi_thread")]
23643    #[cfg(feature = "reactive-ws")]
23644    async fn pubsub_stream_termination_attempts_reconnect_before_error() {
23645        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23646        let mut subscriber = AlloySubscriber::new(
23647            provider,
23648            SubscriberMode::PubSub,
23649            SubscriberConfig {
23650                reconnect: SubscriberReconnectConfig {
23651                    initial_delay: Duration::ZERO,
23652                    retry_delay: Duration::ZERO,
23653                    max_delay: Duration::ZERO,
23654                    max_attempts: Some(1),
23655                    ..SubscriberReconnectConfig::default()
23656                },
23657                ..SubscriberConfig::default()
23658            },
23659        );
23660        subscriber.chain_id = Some(1);
23661        subscriber.interests = vec![ReactiveInterest::PendingTransactions(
23662            PendingTxInterest::default(),
23663        )];
23664
23665        let mut streams = SubscriberStreams::new();
23666        let source = SubscriberStreamSource::PubSubPendingHashes;
23667        streams.push(
23668            source,
23669            stream::once(async {
23670                SubscriberEvent::<Ethereum>::StreamTerminated(
23671                    SubscriberStreamSource::PubSubPendingHashes,
23672                )
23673            })
23674            .boxed(),
23675        );
23676        subscriber.state = AlloySubscriberState::Active(streams);
23677
23678        let result = subscriber.next_batch().await;
23679        assert!(
23680            matches!(result, Err(SubscriberError::Provider(ref message)) if message.contains("reconnect failed after 1 attempt")),
23681            "terminated pubsub streams should attempt reconnect before surfacing failure: {result:?}"
23682        );
23683    }
23684
23685    #[tokio::test]
23686    #[cfg(feature = "reactive-ws")]
23687    async fn flashblock_stream_termination_invalidates_before_reconnect_io() {
23688        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23689        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23690            provider,
23691            SubscriberMode::PubSub,
23692            SubscriberConfig {
23693                preconfirmations: PreconfirmationMode::Required,
23694                ..SubscriberConfig::default()
23695            },
23696        )
23697        .with_provider_ref(ProviderRef::new("base-paid", 7));
23698        subscriber.chain_id = Some(8_453);
23699        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
23700        subscriber.interests = subscriber.base_interests.clone();
23701        subscriber.sources_dirty = false;
23702
23703        let preview: BaseFlashblockWirePayload = serde_json::from_str(
23704            r#"{
23705                "hash":"0x0000000000000000000000000000000000000000000000000000000000000000",
23706                "number":"0x65",
23707                "parentHash":"0x6464646464646464646464646464646464646464646464646464646464646464",
23708                "stateRoot":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
23709                "transactionsRoot":"0x1111111111111111111111111111111111111111111111111111111111111111",
23710                "timestamp":"0x6553f165",
23711                "transactions":["0x4141414141414141414141414141414141414141414141414141414141414141"]
23712            }"#,
23713        )
23714        .unwrap();
23715        let (preview, _) = subscriber.accept_base_flashblock(preview).unwrap();
23716        subscriber.latest_preconfirmation = Some(preview);
23717
23718        let mut streams = SubscriberStreams::new();
23719        streams.push(
23720            SubscriberStreamSource::BaseFlashblocks,
23721            stream::once(async {
23722                SubscriberEvent::<Ethereum>::StreamTerminated(
23723                    SubscriberStreamSource::BaseFlashblocks,
23724                )
23725            })
23726            .boxed(),
23727        );
23728        subscriber.state = AlloySubscriberState::Active(streams);
23729
23730        let batch = subscriber
23731            .next_scoped_batch()
23732            .await
23733            .expect("termination handling succeeds")
23734            .expect("invalidation is delivered");
23735        assert!(batch.preconfirmation_invalidated());
23736        assert!(subscriber.latest_preconfirmation.is_none());
23737        assert_eq!(subscriber.provider_ref.as_ref().unwrap().generation, 8);
23738        assert_eq!(subscriber.pending_flashblock_reconnects.len(), 2);
23739        assert!(
23740            subscriber
23741                .pending_flashblock_reconnect_sources
23742                .iter()
23743                .any(|source| matches!(source, SubscriberStreamSource::BaseFlashblocks))
23744        );
23745        assert!(
23746            subscriber
23747                .pending_flashblock_reconnect_sources
23748                .iter()
23749                .any(|source| matches!(source, SubscriberStreamSource::BasePendingLog { .. }))
23750        );
23751        let AlloySubscriberState::Active(streams) = &subscriber.state else {
23752            panic!("subscriber remains active while reconnect is pending")
23753        };
23754        assert!(
23755            streams
23756                .entries
23757                .iter()
23758                .all(|entry| !entry.source.is_flashblocks())
23759        );
23760    }
23761
23762    #[tokio::test]
23763    #[cfg(feature = "reactive-ws")]
23764    async fn preferred_initial_flashblock_rejection_retains_canonical_streams() {
23765        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23766        let filter = Filter::new().address(Address::repeat_byte(0x42));
23767        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23768            provider,
23769            SubscriberMode::PubSub,
23770            SubscriberConfig {
23771                preconfirmations: PreconfirmationMode::Preferred,
23772                reconnect: SubscriberReconnectConfig {
23773                    enabled: false,
23774                    ..SubscriberReconnectConfig::default()
23775                },
23776                ..SubscriberConfig::default()
23777            },
23778        )
23779        .with_provider_ref(ProviderRef::new("base-paid", 1));
23780        subscriber.chain_id = Some(8_453);
23781        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
23782            provider_filter: filter.clone(),
23783            local_matcher: None,
23784            route_key: None,
23785        })];
23786        subscriber.interests = subscriber.base_interests.clone();
23787        subscriber.log_source_ids.insert(filter.clone(), 0);
23788        subscriber.next_log_source_id = 1;
23789
23790        let canonical_source = SubscriberStreamSource::PubSubLog {
23791            id: 0,
23792            filter: filter.clone(),
23793        };
23794        let mut streams = SubscriberStreams::new();
23795        streams.push(
23796            canonical_source.clone(),
23797            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
23798        );
23799        subscriber.state = AlloySubscriberState::Active(streams);
23800        subscriber.sources_dirty = true;
23801
23802        subscriber
23803            .ensure_streams()
23804            .await
23805            .expect("preferred Flashblocks setup degrades to canonical-only");
23806        let AlloySubscriberState::Active(streams) = &subscriber.state else {
23807            panic!("canonical stream remains active")
23808        };
23809        assert!(streams.contains_source(&canonical_source));
23810        assert!(
23811            streams
23812                .entries
23813                .iter()
23814                .all(|entry| !entry.source.is_flashblocks())
23815        );
23816        assert!(subscriber.pending_flashblock_reconnects.is_empty());
23817        assert!(!subscriber.sources_dirty);
23818    }
23819
23820    #[tokio::test]
23821    #[cfg(feature = "reactive-ws")]
23822    async fn required_initial_flashblock_rejection_remains_fail_closed() {
23823        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23824        let filter = Filter::new().address(Address::repeat_byte(0x42));
23825        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23826            provider,
23827            SubscriberMode::PubSub,
23828            SubscriberConfig {
23829                preconfirmations: PreconfirmationMode::Required,
23830                reconnect: SubscriberReconnectConfig {
23831                    enabled: false,
23832                    ..SubscriberReconnectConfig::default()
23833                },
23834                ..SubscriberConfig::default()
23835            },
23836        )
23837        .with_provider_ref(ProviderRef::new("base-paid", 1));
23838        subscriber.chain_id = Some(8_453);
23839        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
23840            provider_filter: filter.clone(),
23841            local_matcher: None,
23842            route_key: None,
23843        })];
23844        subscriber.interests = subscriber.base_interests.clone();
23845        subscriber.log_source_ids.insert(filter.clone(), 0);
23846        subscriber.next_log_source_id = 1;
23847
23848        let canonical_source = SubscriberStreamSource::PubSubLog {
23849            id: 0,
23850            filter: filter.clone(),
23851        };
23852        let mut streams = SubscriberStreams::new();
23853        streams.push(
23854            canonical_source.clone(),
23855            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
23856        );
23857        subscriber.state = AlloySubscriberState::Active(streams);
23858        subscriber.sources_dirty = true;
23859
23860        let error = subscriber
23861            .ensure_streams()
23862            .await
23863            .expect_err("required Flashblocks setup must fail closed");
23864        assert!(matches!(error, SubscriberError::Provider(_)));
23865        let AlloySubscriberState::Active(streams) = &subscriber.state else {
23866            panic!("the already-connected canonical stream is retained")
23867        };
23868        assert!(streams.contains_source(&canonical_source));
23869    }
23870
23871    #[tokio::test]
23872    #[cfg(feature = "reactive-ws")]
23873    async fn preferred_flashblock_termination_preserves_canonical_delivery() {
23874        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23875        let filter = Filter::new().address(Address::repeat_byte(0x42));
23876        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23877            provider,
23878            SubscriberMode::PubSub,
23879            SubscriberConfig {
23880                preconfirmations: PreconfirmationMode::Preferred,
23881                reconnect: SubscriberReconnectConfig {
23882                    enabled: false,
23883                    ..SubscriberReconnectConfig::default()
23884                },
23885                ..SubscriberConfig::default()
23886            },
23887        )
23888        .with_provider_ref(ProviderRef::new("base-paid", 1));
23889        subscriber.chain_id = Some(8_453);
23890        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
23891            provider_filter: filter.clone(),
23892            local_matcher: None,
23893            route_key: None,
23894        })];
23895        subscriber.interests = subscriber.base_interests.clone();
23896        subscriber.log_source_ids.insert(filter.clone(), 0);
23897        subscriber.next_log_source_id = 1;
23898        subscriber.sources_dirty = false;
23899
23900        let mut streams = SubscriberStreams::new();
23901        streams.push(
23902            SubscriberStreamSource::BaseFlashblocks,
23903            stream::once(async {
23904                SubscriberEvent::<Ethereum>::StreamTerminated(
23905                    SubscriberStreamSource::BaseFlashblocks,
23906                )
23907            })
23908            .boxed(),
23909        );
23910        streams.push(
23911            SubscriberStreamSource::PubSubLog {
23912                id: 0,
23913                filter: filter.clone(),
23914            },
23915            stream::once(async {
23916                SubscriberEvent::<Ethereum>::Log {
23917                    #[cfg(feature = "ingress-timing")]
23918                    timing: None,
23919                    source_id: 0,
23920                    log: rpc_log(false),
23921                }
23922            })
23923            .boxed(),
23924        );
23925        subscriber.state = AlloySubscriberState::Active(streams);
23926
23927        let invalidation = subscriber
23928            .next_scoped_batch()
23929            .await
23930            .expect("preferred termination does not fail")
23931            .expect("invalidation is delivered");
23932        assert!(invalidation.preconfirmation_invalidated());
23933
23934        let canonical = subscriber
23935            .next_scoped_batch()
23936            .await
23937            .expect("canonical stream remains healthy")
23938            .expect("canonical log is delivered");
23939        assert!(!canonical.preconfirmation_invalidated());
23940        assert_eq!(canonical.records().len(), 1);
23941        assert_eq!(
23942            canonical.records()[0].record.context.source,
23943            InputSource::Subscription
23944        );
23945    }
23946
23947    #[tokio::test]
23948    #[cfg(feature = "reactive-ws")]
23949    async fn preferred_flashblock_reconnect_exhaustion_preserves_canonical_delivery() {
23950        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23951        let filter = Filter::new().address(Address::repeat_byte(0x42));
23952        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23953            provider,
23954            SubscriberMode::PubSub,
23955            SubscriberConfig {
23956                preconfirmations: PreconfirmationMode::Preferred,
23957                reconnect: SubscriberReconnectConfig {
23958                    enabled: false,
23959                    ..SubscriberReconnectConfig::default()
23960                },
23961                ..SubscriberConfig::default()
23962            },
23963        )
23964        .with_provider_ref(ProviderRef::new("base-paid", 1));
23965        subscriber.chain_id = Some(8_453);
23966        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
23967            provider_filter: filter.clone(),
23968            local_matcher: None,
23969            route_key: None,
23970        })];
23971        subscriber.interests = subscriber.base_interests.clone();
23972        subscriber.log_source_ids.insert(filter.clone(), 0);
23973        subscriber.next_log_source_id = 1;
23974        subscriber.sources_dirty = false;
23975
23976        let canonical_source = SubscriberStreamSource::PubSubLog { id: 0, filter };
23977        let mut streams = SubscriberStreams::new();
23978        streams.push(
23979            canonical_source,
23980            stream::once(async {
23981                tokio::time::sleep(Duration::from_millis(1)).await;
23982                SubscriberEvent::<Ethereum>::Log {
23983                    #[cfg(feature = "ingress-timing")]
23984                    timing: None,
23985                    source_id: 0,
23986                    log: rpc_log(false),
23987                }
23988            })
23989            .boxed(),
23990        );
23991        subscriber.state = AlloySubscriberState::Active(streams);
23992
23993        let source = SubscriberStreamSource::BaseFlashblocks;
23994        subscriber
23995            .pending_flashblock_reconnect_sources
23996            .push(source.clone());
23997        subscriber
23998            .pending_flashblock_reconnects
23999            .push(Box::pin(async move {
24000                (
24001                    source,
24002                    Err(SubscriberError::Provider(
24003                        "test reconnect window exhausted".to_owned(),
24004                    )),
24005                )
24006            }));
24007
24008        let canonical = subscriber
24009            .next_scoped_batch()
24010            .await
24011            .expect("preferred reconnect exhaustion does not fail")
24012            .expect("canonical log is delivered");
24013        assert_eq!(canonical.records().len(), 1);
24014        assert!(subscriber.pending_flashblock_reconnects.is_empty());
24015    }
24016
24017    #[test]
24018    fn backfilled_logs_skip_recent_subscription_duplicates() {
24019        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
24020        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24021            provider,
24022            SubscriberMode::PubSub,
24023            SubscriberConfig::default(),
24024        );
24025        subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
24026            provider_filter: Filter::new()
24027                .address(Address::repeat_byte(0x42))
24028                .event_signature(B256::repeat_byte(0x01)),
24029            local_matcher: None,
24030            route_key: None,
24031        })];
24032
24033        let log = rpc_log(false);
24034        subscriber.enqueue_event(SubscriberEvent::Log {
24035            source_id: 0,
24036            log: log.clone(),
24037            #[cfg(feature = "ingress-timing")]
24038            timing: None,
24039        });
24040        subscriber.enqueue_event(SubscriberEvent::BackfilledLogs {
24041            source_id: 0,
24042            logs: vec![log],
24043        });
24044
24045        assert_eq!(subscriber.pending_records.len(), 1);
24046        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
24047        assert_eq!(
24048            subscriber.pending_records[0].context.source,
24049            InputSource::Subscription
24050        );
24051    }
24052
24053    #[test]
24054    fn backfilled_logs_surface_with_backfill_source() {
24055        // A backfilled log with no prior subscription duplicate is delivered as
24056        // an `InputSource::Backfill` record (the positive side of the dedup test,
24057        // pinning the README's "marking recovered records as InputSource::Backfill"
24058        // claim — the only place that source is produced).
24059        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
24060        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24061            provider,
24062            SubscriberMode::PubSub,
24063            SubscriberConfig::default(),
24064        );
24065        subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
24066            provider_filter: Filter::new()
24067                .address(Address::repeat_byte(0x42))
24068                .event_signature(B256::repeat_byte(0x01)),
24069            local_matcher: None,
24070            route_key: None,
24071        })];
24072
24073        subscriber.enqueue_event(SubscriberEvent::BackfilledLogs {
24074            source_id: 0,
24075            logs: vec![rpc_log(false)],
24076        });
24077
24078        assert_eq!(subscriber.pending_records.len(), 1);
24079        assert_eq!(
24080            subscriber.pending_records[0].context.source,
24081            InputSource::Backfill
24082        );
24083        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
24084    }
24085
24086    #[test]
24087    #[cfg(feature = "reactive-ws")]
24088    fn owner_removal_preserves_delivery_and_dedupe_state() {
24089        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
24090        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24091            provider,
24092            SubscriberMode::PubSub,
24093            SubscriberConfig::default(),
24094        );
24095        subscriber
24096            .add_interest_owner(
24097                HandlerId::new("pool-a"),
24098                &[ReactiveInterest::Logs(LogInterest {
24099                    provider_filter: Filter::new()
24100                        .address(Address::repeat_byte(0x42))
24101                        .event_signature(B256::repeat_byte(0x01)),
24102                    local_matcher: None,
24103                    route_key: None,
24104                })],
24105            )
24106            .expect("register pool-a owner");
24107        subscriber
24108            .add_interest_owner(
24109                HandlerId::new("pool-b"),
24110                &[ReactiveInterest::Logs(LogInterest {
24111                    provider_filter: Filter::new()
24112                        .address(Address::repeat_byte(0x24))
24113                        .event_signature(B256::repeat_byte(0x02)),
24114                    local_matcher: None,
24115                    route_key: None,
24116                })],
24117            )
24118            .expect("register pool-b owner");
24119
24120        // Allocate source ids the way live stream setup would (pool-a -> id 0),
24121        // so the injected delivery anchor hangs off a referenced filter.
24122        let sources = subscriber.stream_sources().expect("stream sources");
24123        subscriber.enqueue_event(SubscriberEvent::Log {
24124            source_id: 0,
24125            log: rpc_log(false),
24126            #[cfg(feature = "ingress-timing")]
24127            timing: None,
24128        });
24129        let mut streams = SubscriberStreams::new();
24130        streams.push(
24131            sources[0].clone(),
24132            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
24133        );
24134        subscriber.state = AlloySubscriberState::Active(streams);
24135        assert_eq!(subscriber.pending_records.len(), 1);
24136        assert_eq!(subscriber.recent_input_refs.len(), 1);
24137        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
24138
24139        let removed = subscriber
24140            .remove_interest_owner(&HandlerId::new("pool-b"))
24141            .expect("pool-b should be removed");
24142
24143        assert_eq!(removed.len(), 1);
24144        assert_eq!(subscriber.pending_records.len(), 1);
24145        assert_eq!(subscriber.recent_input_refs.len(), 1);
24146        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
24147        assert!(
24148            subscriber
24149                .owner_interests(&HandlerId::new("pool-a"))
24150                .is_some()
24151        );
24152        assert!(
24153            subscriber
24154                .owner_interests(&HandlerId::new("pool-b"))
24155                .is_none()
24156        );
24157        assert_eq!(subscriber.registered_interests().len(), 1);
24158    }
24159
24160    #[test]
24161    #[cfg(feature = "reactive-ws")]
24162    fn owner_log_sources_fan_in_across_owners() {
24163        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
24164        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24165            provider,
24166            SubscriberMode::PubSub,
24167            SubscriberConfig::default(),
24168        );
24169        subscriber
24170            .add_interest_owner(
24171                HandlerId::new("pool-a"),
24172                &[ReactiveInterest::Logs(LogInterest {
24173                    provider_filter: Filter::new().address(Address::repeat_byte(0xa1)),
24174                    local_matcher: None,
24175                    route_key: None,
24176                })],
24177            )
24178            .expect("register pool-a owner");
24179
24180        let initial_sources = subscriber.stream_sources().expect("initial sources");
24181        assert_eq!(initial_sources.len(), 1);
24182        let pool_a_source = initial_sources[0].clone();
24183        assert!(matches!(
24184            &pool_a_source,
24185            SubscriberStreamSource::PubSubLog { id: 0, .. }
24186        ));
24187
24188        subscriber
24189            .add_interest_owner(
24190                HandlerId::new("pool-b"),
24191                &[ReactiveInterest::Logs(LogInterest {
24192                    provider_filter: Filter::new().address(Address::repeat_byte(0xb2)),
24193                    local_matcher: None,
24194                    route_key: None,
24195                })],
24196            )
24197            .expect("register pool-b owner");
24198
24199        let expanded_sources = subscriber.stream_sources().expect("expanded sources");
24200        assert_eq!(
24201            expanded_sources.len(),
24202            1,
24203            "compatible owner filters should share one provider subscription"
24204        );
24205        assert!(
24206            !expanded_sources[0].same_key(&pool_a_source),
24207            "the provider-facing superset changes while owner routing remains exact"
24208        );
24209
24210        subscriber
24211            .remove_interest_owner(&HandlerId::new("pool-b"))
24212            .expect("pool-b should be removed");
24213        let trimmed_sources = subscriber.stream_sources().expect("trimmed sources");
24214        assert_eq!(trimmed_sources.len(), 1);
24215        assert!(trimmed_sources[0].same_key(&pool_a_source));
24216    }
24217
24218    #[test]
24219    #[cfg(feature = "reactive-ws")]
24220    fn provider_log_fan_in_respects_address_ceiling() {
24221        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
24222        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24223            provider,
24224            SubscriberMode::PubSub,
24225            SubscriberConfig {
24226                max_log_addresses_per_subscription: 2,
24227                ..SubscriberConfig::default()
24228            },
24229        );
24230        for index in 0..5 {
24231            subscriber
24232                .add_interest_owner(
24233                    HandlerId::new(format!("pool-{index}")),
24234                    &[log_interest_for(index + 1)],
24235                )
24236                .expect("register pool owner");
24237        }
24238
24239        let sources = subscriber.stream_sources().expect("stream sources");
24240        assert_eq!(sources.len(), 3);
24241        let mut address_counts: Vec<_> = sources
24242            .iter()
24243            .map(|source| match source {
24244                SubscriberStreamSource::PubSubLog { filter, .. } => filter.address.iter().count(),
24245                _ => panic!("expected log source"),
24246            })
24247            .collect();
24248        address_counts.sort_unstable();
24249        assert_eq!(address_counts, vec![1, 2, 2]);
24250    }
24251
24252    #[tokio::test(flavor = "multi_thread")]
24253    #[cfg(feature = "reactive-ws")]
24254    async fn owner_backfill_seeds_reconnect_anchor_before_live_log() {
24255        let asserter = Asserter::new();
24256        asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
24257        asserter.push_success(&vec![rpc_log(false)]);
24258        asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
24259        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
24260        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24261            provider,
24262            SubscriberMode::PubSub,
24263            SubscriberConfig::default(),
24264        );
24265        subscriber
24266            .add_interest_owner_with_backfill(
24267                HandlerId::new("pool-a"),
24268                &[ReactiveInterest::Logs(LogInterest {
24269                    provider_filter: Filter::new()
24270                        .address(Address::repeat_byte(0x42))
24271                        .event_signature(B256::repeat_byte(0x01)),
24272                    local_matcher: None,
24273                    route_key: None,
24274                })],
24275                SubscriberBackfill::range(1, 7),
24276            )
24277            .expect("register pool-a with backfill");
24278
24279        subscriber
24280            .drain_pending_backfills()
24281            .await
24282            .expect("owner backfill should drain");
24283
24284        assert_eq!(subscriber.pending_records.len(), 1);
24285        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
24286    }
24287
24288    #[tokio::test(flavor = "multi_thread")]
24289    async fn subscriber_streams_poll_ready_sources_round_robin() {
24290        let first_hash = B256::repeat_byte(0x01);
24291        let second_hash = B256::repeat_byte(0x02);
24292        let mut streams = SubscriberStreams::new();
24293        streams.push(
24294            SubscriberStreamSource::PubSubPendingHashes,
24295            stream::iter([
24296                SubscriberEvent::<Ethereum>::PendingHash(first_hash),
24297                SubscriberEvent::<Ethereum>::PendingHash(first_hash),
24298            ])
24299            .boxed(),
24300        );
24301        streams.push(
24302            SubscriberStreamSource::PubSubBlockHeaders,
24303            stream::once(async move { SubscriberEvent::<Ethereum>::PendingHash(second_hash) })
24304                .boxed(),
24305        );
24306
24307        assert!(matches!(
24308            streams.next().await,
24309            Some(SubscriberEvent::PendingHash(hash)) if hash == first_hash
24310        ));
24311        assert!(matches!(
24312            streams.next().await,
24313            Some(SubscriberEvent::PendingHash(hash)) if hash == second_hash
24314        ));
24315    }
24316
24317    #[tokio::test(flavor = "multi_thread")]
24318    #[cfg(feature = "reactive-ws")]
24319    async fn owner_updates_ensure_streams_without_full_reset() {
24320        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
24321        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24322            provider,
24323            SubscriberMode::PubSub,
24324            SubscriberConfig::default(),
24325        );
24326        subscriber.chain_id = Some(1);
24327        subscriber
24328            .register_interests(&[ReactiveInterest::PendingTransactions(
24329                PendingTxInterest::default(),
24330            )])
24331            .await
24332            .expect("register base pending interest");
24333        subscriber
24334            .add_interest_owner(
24335                HandlerId::new("headers"),
24336                &[ReactiveInterest::Blocks(BlockInterest::default())],
24337            )
24338            .expect("register header owner");
24339
24340        let mut streams = SubscriberStreams::new();
24341        streams.push(
24342            SubscriberStreamSource::PubSubPendingHashes,
24343            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
24344        );
24345        streams.push(
24346            SubscriberStreamSource::PubSubBlockHeaders,
24347            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
24348        );
24349        subscriber.state = AlloySubscriberState::Active(streams);
24350
24351        subscriber
24352            .remove_interest_owner(&HandlerId::new("headers"))
24353            .expect("header owner should be removed");
24354        assert!(matches!(
24355            &subscriber.state,
24356            AlloySubscriberState::Active(streams) if streams.len() == 2
24357        ));
24358
24359        subscriber
24360            .ensure_streams()
24361            .await
24362            .expect("pure removal reconciliation should not touch provider");
24363
24364        assert!(matches!(
24365            &subscriber.state,
24366            AlloySubscriberState::Active(streams)
24367                if streams.len() == 1
24368                    && streams.contains_source(&SubscriberStreamSource::PubSubPendingHashes)
24369                    && !streams.contains_source(&SubscriberStreamSource::PubSubBlockHeaders)
24370        ));
24371
24372        subscriber
24373            .add_interest_owner(
24374                HandlerId::new("headers"),
24375                &[ReactiveInterest::Blocks(BlockInterest::default())],
24376            )
24377            .expect("re-add header owner");
24378        assert!(matches!(
24379            &subscriber.state,
24380            AlloySubscriberState::Active(streams) if streams.len() == 1
24381        ));
24382    }
24383
24384    #[tokio::test(flavor = "multi_thread")]
24385    #[cfg(feature = "reactive-polling")]
24386    async fn ensure_streams_retains_each_successful_connection_across_later_failure() {
24387        let asserter = Asserter::new();
24388        asserter.push_success(&U256::from(1));
24389        asserter.push_failure_msg("second filter connection failed");
24390        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
24391        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24392            provider,
24393            SubscriberMode::Polling,
24394            SubscriberConfig {
24395                max_log_addresses_per_subscription: 1,
24396                ..SubscriberConfig::default()
24397            },
24398        );
24399        subscriber.chain_id = Some(1);
24400        subscriber
24401            .register_interests(&[log_interest_for(0x41), log_interest_for(0x42)])
24402            .await
24403            .expect("register two independently connected filters");
24404
24405        let error = subscriber
24406            .ensure_streams()
24407            .await
24408            .expect_err("second provider connection is forced to fail");
24409        assert!(matches!(error, SubscriberError::Provider(_)));
24410        assert!(subscriber.sources_dirty);
24411        let retained_streams = match &subscriber.state {
24412            AlloySubscriberState::Active(streams) => Some(streams.len()),
24413            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => None,
24414        };
24415        assert_eq!(
24416            retained_streams,
24417            Some(1),
24418            "first connection must survive later error {error:?}; revision {}",
24419            subscriber.stream_revision
24420        );
24421
24422        asserter.push_success(&U256::from(2));
24423        subscriber
24424            .ensure_streams()
24425            .await
24426            .expect("retry connects only the missing source");
24427        assert!(!subscriber.sources_dirty);
24428        assert!(matches!(
24429            &subscriber.state,
24430            AlloySubscriberState::Active(streams) if streams.len() == 2
24431        ));
24432        assert!(asserter.read_q().is_empty());
24433    }
24434
24435    #[tokio::test(flavor = "multi_thread")]
24436    #[cfg(feature = "reactive-ws")]
24437    async fn cancelled_post_install_backfill_is_retried_without_reconnecting() {
24438        let asserter = Asserter::new();
24439        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
24440        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24441            provider,
24442            SubscriberMode::PubSub,
24443            SubscriberConfig::default(),
24444        );
24445        subscriber.chain_id = Some(1);
24446        subscriber
24447            .register_interests(&[log_interest_for(0x43)])
24448            .await
24449            .expect("register log source");
24450        let source = subscriber
24451            .stream_sources()
24452            .expect("one desired source")
24453            .pop()
24454            .expect("log source");
24455        let SubscriberStreamSource::PubSubLog { id, .. } = source else {
24456            panic!("expected pubsub log source")
24457        };
24458        subscriber.last_seen_log_blocks.insert(id, 6);
24459
24460        {
24461            let source = SubscriberStreamSource::PubSubLog {
24462                id,
24463                filter: subscriber
24464                    .log_stream_filters()
24465                    .pop()
24466                    .expect("provider filter"),
24467            };
24468            let interrupted = async {
24469                subscriber.install_source_stream(
24470                    source.clone(),
24471                    stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
24472                );
24473                subscriber.queue_source_backfill(source);
24474                subscriber.sources_dirty = true;
24475                futures::future::pending::<()>().await;
24476            };
24477            futures::pin_mut!(interrupted);
24478            poll_fn(|cx| {
24479                assert!(interrupted.as_mut().poll(cx).is_pending());
24480                std::task::Poll::Ready(())
24481            })
24482            .await;
24483        }
24484
24485        assert_eq!(subscriber.pending_source_backfills.len(), 1);
24486        assert!(matches!(
24487            &subscriber.state,
24488            AlloySubscriberState::Active(streams) if streams.len() == 1
24489        ));
24490
24491        asserter.push_success(&7u64);
24492        asserter.push_success(&Vec::<Log>::new());
24493        subscriber
24494            .ensure_streams()
24495            .await
24496            .expect("retry completes only the pending historical window");
24497
24498        assert!(subscriber.pending_source_backfills.is_empty());
24499        assert!(!subscriber.sources_dirty);
24500        assert!(matches!(
24501            &subscriber.state,
24502            AlloySubscriberState::Active(streams) if streams.len() == 1
24503        ));
24504        assert!(asserter.read_q().is_empty());
24505    }
24506
24507    // A log interest matching `rpc_log` (address 0x42, topic0 0x01).
24508    fn log_interest_matching_rpc_log() -> ReactiveInterest<Ethereum> {
24509        ReactiveInterest::Logs(LogInterest {
24510            provider_filter: Filter::new()
24511                .address(Address::repeat_byte(0x42))
24512                .event_signature(B256::repeat_byte(0x01)),
24513            local_matcher: None,
24514            route_key: None,
24515        })
24516    }
24517
24518    #[cfg(any(feature = "reactive-ws", feature = "reactive-polling"))]
24519    fn log_interest_for(address: u8) -> ReactiveInterest<Ethereum> {
24520        ReactiveInterest::Logs(LogInterest {
24521            provider_filter: Filter::new().address(Address::repeat_byte(address)),
24522            local_matcher: None,
24523            route_key: None,
24524        })
24525    }
24526
24527    // B1: a transient provider error must not consume the queued backfill — the
24528    // missed window has to survive for the next poll to retry.
24529    #[tokio::test(flavor = "multi_thread")]
24530    #[cfg(feature = "reactive-ws")]
24531    async fn drain_backfill_retains_queue_entry_on_provider_error() {
24532        let asserter = Asserter::new();
24533        asserter.push_failure_msg("rate limited");
24534        asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
24535        asserter.push_success(&vec![rpc_log(false)]);
24536        asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
24537        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
24538        let mut subscriber = AlloySubscriber::new(
24539            provider,
24540            SubscriberMode::PubSub,
24541            SubscriberConfig::default(),
24542        );
24543        subscriber
24544            .add_interest_owner_with_backfill(
24545                HandlerId::new("pool"),
24546                &[log_interest_matching_rpc_log()],
24547                SubscriberBackfill::range(1, 7),
24548            )
24549            .expect("register owner with backfill");
24550        assert_eq!(subscriber.pending_backfills.len(), 1);
24551
24552        let first = subscriber.drain_pending_backfills().await;
24553        assert!(first.is_err(), "provider failure should surface");
24554        assert_eq!(
24555            subscriber.pending_backfills.len(),
24556            1,
24557            "failed fetch must leave the backfill queued for retry"
24558        );
24559        assert!(subscriber.pending_records.is_empty());
24560
24561        subscriber
24562            .drain_pending_backfills()
24563            .await
24564            .expect("retry should succeed");
24565        assert!(subscriber.pending_backfills.is_empty());
24566        assert_eq!(subscriber.pending_records.len(), 1);
24567    }
24568
24569    // B3: a zero-log backfill window still advances the delivery anchor to its
24570    // upper bound, so a later reconnect catches up from the right block.
24571    #[tokio::test(flavor = "multi_thread")]
24572    #[cfg(feature = "reactive-ws")]
24573    async fn drain_backfill_seeds_anchor_on_empty_window() {
24574        let asserter = Asserter::new();
24575        asserter.push_success(&Some(rpc_block(42, B256::repeat_byte(42))));
24576        asserter.push_success(&Vec::<Log>::new());
24577        asserter.push_success(&Some(rpc_block(42, B256::repeat_byte(42))));
24578        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
24579        let mut subscriber = AlloySubscriber::new(
24580            provider,
24581            SubscriberMode::PubSub,
24582            SubscriberConfig::default(),
24583        );
24584        subscriber
24585            .add_interest_owner_with_backfill(
24586                HandlerId::new("pool"),
24587                &[log_interest_matching_rpc_log()],
24588                SubscriberBackfill::range(1, 42),
24589            )
24590            .expect("register owner with backfill");
24591
24592        subscriber
24593            .drain_pending_backfills()
24594            .await
24595            .expect("empty backfill should drain");
24596
24597        assert!(subscriber.pending_records.is_empty());
24598        let filter = log_filters(subscriber.owner_interests(&HandlerId::new("pool")).unwrap())
24599            .pop()
24600            .unwrap();
24601        assert_eq!(
24602            subscriber.log_anchor(&filter),
24603            Some(42),
24604            "empty window must still seed the anchor at its upper bound"
24605        );
24606    }
24607
24608    // B3 (open-ended): a `from_block`-only backfill resolves its upper bound to
24609    // the provider head and seeds the anchor there.
24610    #[tokio::test(flavor = "multi_thread")]
24611    #[cfg(feature = "reactive-ws")]
24612    async fn drain_backfill_open_ended_resolves_head_and_seeds_anchor() {
24613        let asserter = Asserter::new();
24614        asserter.push_success(&100u64); // get_block_number
24615        asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(100))));
24616        asserter.push_success(&Vec::<Log>::new()); // get_logs
24617        asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(100))));
24618        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
24619        let mut subscriber = AlloySubscriber::new(
24620            provider,
24621            SubscriberMode::PubSub,
24622            SubscriberConfig::default(),
24623        );
24624        subscriber
24625            .add_interest_owner_with_backfill(
24626                HandlerId::new("pool"),
24627                &[log_interest_matching_rpc_log()],
24628                SubscriberBackfill::from_block(10),
24629            )
24630            .expect("register owner with open-ended backfill");
24631
24632        subscriber
24633            .drain_pending_backfills()
24634            .await
24635            .expect("open-ended backfill should drain");
24636
24637        let filter = log_filters(subscriber.owner_interests(&HandlerId::new("pool")).unwrap())
24638            .pop()
24639            .unwrap();
24640        assert_eq!(subscriber.log_anchor(&filter), Some(100));
24641    }
24642
24643    // B2: two owners requesting the same filter shape share exactly one live
24644    // source (and thus one anchor), rather than double-subscribing.
24645    #[test]
24646    #[cfg(feature = "reactive-ws")]
24647    fn duplicate_filters_across_owners_map_to_single_source() {
24648        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
24649        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24650            provider,
24651            SubscriberMode::PubSub,
24652            SubscriberConfig::default(),
24653        );
24654        subscriber
24655            .add_interest_owner(HandlerId::new("pool-a"), &[log_interest_for(0xaa)])
24656            .expect("register pool-a");
24657        subscriber
24658            .add_interest_owner(HandlerId::new("pool-b"), &[log_interest_for(0xaa)])
24659            .expect("register pool-b with identical filter");
24660
24661        assert_eq!(
24662            subscriber.log_stream_filters().len(),
24663            1,
24664            "identical filters across owners must collapse to one"
24665        );
24666        let sources = subscriber.stream_sources().expect("stream sources");
24667        assert_eq!(sources.len(), 1);
24668    }
24669
24670    // B4: removing an owner retires the source-id and anchor bookkeeping for
24671    // filters no other owner references, so long-lived churn cannot leak.
24672    #[test]
24673    #[cfg(feature = "reactive-ws")]
24674    fn owner_removal_prunes_source_ids_and_anchors() {
24675        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
24676        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24677            provider,
24678            SubscriberMode::PubSub,
24679            SubscriberConfig::default(),
24680        );
24681        subscriber
24682            .add_interest_owner(HandlerId::new("pool-a"), &[log_interest_for(0xaa)])
24683            .expect("register pool-a");
24684        subscriber
24685            .add_interest_owner(HandlerId::new("pool-b"), &[log_interest_for(0xbb)])
24686            .expect("register pool-b");
24687
24688        // Allocate ids and simulate delivery anchors on both.
24689        let _ = subscriber.stream_sources().expect("stream sources");
24690        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
24691        let filter_b = log_filters(&[log_interest_for(0xbb)]).pop().unwrap();
24692        let id_a = subscriber.log_source_id(&filter_a);
24693        let id_b = subscriber.log_source_id(&filter_b);
24694        subscriber.last_seen_log_blocks.insert(id_a, 10);
24695        subscriber.last_seen_log_blocks.insert(id_b, 20);
24696        assert_eq!(
24697            subscriber.log_source_ids.len(),
24698            3,
24699            "one provider fan-in id plus two explicitly seeded logical ids"
24700        );
24701
24702        subscriber
24703            .remove_interest_owner(&HandlerId::new("pool-b"))
24704            .expect("remove pool-b");
24705
24706        assert_eq!(
24707            subscriber.log_source_ids.len(),
24708            1,
24709            "pool-b's filter id should be retired"
24710        );
24711        assert!(subscriber.log_source_ids.contains_key(&filter_a));
24712        assert_eq!(subscriber.last_seen_log_blocks.get(&id_a), Some(&10));
24713        assert_eq!(
24714            subscriber.last_seen_log_blocks.get(&id_b),
24715            None,
24716            "pool-b's anchor should be pruned"
24717        );
24718    }
24719
24720    // D1: growing an owner's filter set (a new pool on an existing adapter)
24721    // changes the merged filter shape; the new shape must inherit the old
24722    // anchor via an automatic continuity backfill, or logs between the last
24723    // delivery and the new subscription are silently lost.
24724    #[test]
24725    #[cfg(feature = "reactive-ws")]
24726    fn owner_filter_growth_queues_continuity_backfill_from_prior_anchor() {
24727        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
24728        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24729            provider,
24730            SubscriberMode::PubSub,
24731            SubscriberConfig::default(),
24732        );
24733        subscriber
24734            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
24735            .expect("register amm with pool A");
24736
24737        // Simulate the owner's single merged filter having delivered up to
24738        // block 50.
24739        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
24740        let id_a = subscriber.log_source_id(&filter_a);
24741        subscriber.last_seen_log_blocks.insert(id_a, 50);
24742
24743        // Grow the owner to also watch pool B (same block option -> merges into
24744        // one {A,B} filter, a new shape).
24745        subscriber
24746            .add_interest_owner(
24747                HandlerId::new("amm"),
24748                &[log_interest_for(0xaa), log_interest_for(0xbb)],
24749            )
24750            .expect("grow amm to pools A+B");
24751
24752        assert_eq!(
24753            subscriber.pending_backfills.len(),
24754            1,
24755            "the changed merged filter should queue exactly one continuity backfill"
24756        );
24757        let queued = &subscriber.pending_backfills[0];
24758        assert_eq!(queued.owner, Some(HandlerId::new("amm")));
24759        assert_eq!(queued.backfill.start_block(), 50);
24760        assert_eq!(
24761            queued.backfill.end_block(),
24762            None,
24763            "continuity backfill runs open-ended to the current head"
24764        );
24765    }
24766
24767    // D1 negative: replacing an owner's interests with the identical shape must
24768    // NOT re-fetch — the filter kept its anchor and its live stream.
24769    #[test]
24770    #[cfg(feature = "reactive-ws")]
24771    fn unchanged_owner_filter_does_not_queue_continuity_backfill() {
24772        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
24773        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24774            provider,
24775            SubscriberMode::PubSub,
24776            SubscriberConfig::default(),
24777        );
24778        subscriber
24779            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
24780            .expect("register amm");
24781        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
24782        let id_a = subscriber.log_source_id(&filter_a);
24783        subscriber.last_seen_log_blocks.insert(id_a, 50);
24784
24785        subscriber
24786            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
24787            .expect("re-register identical interests");
24788
24789        assert!(
24790            subscriber.pending_backfills.is_empty(),
24791            "an unchanged filter shape must not queue continuity backfill"
24792        );
24793    }
24794
24795    // D5 interaction: an explicit open-ended backfill starting at or below the
24796    // owner's prior anchor already covers the continuity window, so no extra
24797    // continuity backfill is queued (no redundant double fetch).
24798    #[test]
24799    #[cfg(feature = "reactive-ws")]
24800    fn explicit_open_ended_backfill_below_anchor_suppresses_continuity() {
24801        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
24802        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24803            provider,
24804            SubscriberMode::PubSub,
24805            SubscriberConfig::default(),
24806        );
24807        subscriber
24808            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
24809            .expect("register amm");
24810        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
24811        let id_a = subscriber.log_source_id(&filter_a);
24812        subscriber.last_seen_log_blocks.insert(id_a, 50);
24813
24814        // Grow with an explicit deep backfill from block 10 (< anchor 50).
24815        subscriber
24816            .add_interest_owner_with_backfill(
24817                HandlerId::new("amm"),
24818                &[log_interest_for(0xaa), log_interest_for(0xbb)],
24819                SubscriberBackfill::from_block(10),
24820            )
24821            .expect("grow amm with explicit deep backfill");
24822
24823        assert_eq!(
24824            subscriber.pending_backfills.len(),
24825            1,
24826            "only the explicit backfill should be queued; continuity is subsumed"
24827        );
24828        assert_eq!(subscriber.pending_backfills[0].backfill.start_block(), 10);
24829    }
24830
24831    // The dirty flag gates reconciliation: when nothing changed since the last
24832    // reconcile, `ensure_streams` must not touch the provider or the state.
24833    #[tokio::test(flavor = "multi_thread")]
24834    #[cfg(feature = "reactive-ws")]
24835    async fn ensure_streams_is_noop_when_not_dirty() {
24836        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
24837        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
24838            provider,
24839            SubscriberMode::PubSub,
24840            SubscriberConfig::default(),
24841        );
24842        // An interest that WOULD require a new block-header source...
24843        subscriber
24844            .add_interest_owner(
24845                HandlerId::new("headers"),
24846                &[ReactiveInterest::Blocks(BlockInterest::default())],
24847            )
24848            .expect("register header owner");
24849        // ...but we mark bookkeeping clean and start from Empty.
24850        subscriber.state = AlloySubscriberState::Empty;
24851        subscriber.sources_dirty = false;
24852
24853        subscriber
24854            .ensure_streams()
24855            .await
24856            .expect("clean reconcile must be a no-op");
24857
24858        assert!(
24859            matches!(subscriber.state, AlloySubscriberState::Empty),
24860            "not-dirty ensure_streams must not connect new sources"
24861        );
24862    }
24863}
24864
24865fn resolve_subscriber_transport(
24866    mode: SubscriberMode,
24867) -> Result<SubscriberTransport, SubscriberError> {
24868    match mode {
24869        SubscriberMode::PubSub => {
24870            #[cfg(feature = "reactive-ws")]
24871            {
24872                Ok(SubscriberTransport::PubSub)
24873            }
24874            #[cfg(not(feature = "reactive-ws"))]
24875            {
24876                Err(SubscriberError::Unsupported(
24877                    "AlloySubscriber pubsub mode requires the reactive-ws feature",
24878                ))
24879            }
24880        }
24881        SubscriberMode::Polling => {
24882            #[cfg(feature = "reactive-polling")]
24883            {
24884                Ok(SubscriberTransport::Polling)
24885            }
24886            #[cfg(not(feature = "reactive-polling"))]
24887            {
24888                Err(SubscriberError::Unsupported(
24889                    "AlloySubscriber polling mode requires the reactive-polling feature",
24890                ))
24891            }
24892        }
24893        SubscriberMode::Auto => resolve_auto_subscriber_transport(),
24894    }
24895}
24896
24897fn resolve_auto_subscriber_transport() -> Result<SubscriberTransport, SubscriberError> {
24898    #[cfg(feature = "reactive-ws")]
24899    {
24900        Ok(SubscriberTransport::PubSub)
24901    }
24902
24903    #[cfg(all(not(feature = "reactive-ws"), feature = "reactive-polling"))]
24904    {
24905        Ok(SubscriberTransport::Polling)
24906    }
24907
24908    #[cfg(not(any(feature = "reactive-ws", feature = "reactive-polling")))]
24909    {
24910        Err(SubscriberError::Unsupported(
24911            "AlloySubscriber requires either reactive-ws or reactive-polling",
24912        ))
24913    }
24914}
24915
24916fn validate_subscriber_config(config: &SubscriberConfig) -> Result<(), SubscriberError> {
24917    if config.preconfirmations != PreconfirmationMode::Disabled
24918        && config.canonical_head_poll_interval.is_zero()
24919    {
24920        return Err(SubscriberError::InvalidConfig(
24921            "SubscriberConfig::canonical_head_poll_interval must be greater than zero",
24922        ));
24923    }
24924    if config.preconfirmations != PreconfirmationMode::Disabled
24925        && config.canonical_head_request_timeout.is_zero()
24926    {
24927        return Err(SubscriberError::InvalidConfig(
24928            "SubscriberConfig::canonical_head_request_timeout must be greater than zero",
24929        ));
24930    }
24931    if config.preconfirmations != PreconfirmationMode::Disabled
24932        && config.flashblock_poll_interval.is_zero()
24933    {
24934        return Err(SubscriberError::InvalidConfig(
24935            "SubscriberConfig::flashblock_poll_interval must be greater than zero",
24936        ));
24937    }
24938    if config.preconfirmations != PreconfirmationMode::Disabled
24939        && config.max_consecutive_flashblock_poll_failures == 0
24940    {
24941        return Err(SubscriberError::InvalidConfig(
24942            "SubscriberConfig::max_consecutive_flashblock_poll_failures must be greater than zero",
24943        ));
24944    }
24945    if config.preconfirmations != PreconfirmationMode::Disabled
24946        && config.max_pending_transaction_receipts_per_tick == 0
24947    {
24948        return Err(SubscriberError::InvalidConfig(
24949            "SubscriberConfig::max_pending_transaction_receipts_per_tick must be greater than zero",
24950        ));
24951    }
24952    if config.preconfirmations != PreconfirmationMode::Disabled
24953        && config.max_flashblock_rpc_requests_per_second == 0
24954    {
24955        return Err(SubscriberError::InvalidConfig(
24956            "SubscriberConfig::max_flashblock_rpc_requests_per_second must be greater than zero",
24957        ));
24958    }
24959    if config.max_batch_size == 0 {
24960        return Err(SubscriberError::InvalidConfig(
24961            "SubscriberConfig::max_batch_size must be greater than zero",
24962        ));
24963    }
24964    if config.max_log_addresses_per_subscription == 0 {
24965        return Err(SubscriberError::InvalidConfig(
24966            "SubscriberConfig::max_log_addresses_per_subscription must be greater than zero",
24967        ));
24968    }
24969    if config.max_pending_records == 0 {
24970        return Err(SubscriberError::InvalidConfig(
24971            "SubscriberConfig::max_pending_records must be greater than zero",
24972        ));
24973    }
24974    if config.max_pending_backfills == 0 {
24975        return Err(SubscriberError::InvalidConfig(
24976            "SubscriberConfig::max_pending_backfills must be greater than zero",
24977        ));
24978    }
24979    if config.max_backfill_log_bytes == 0 {
24980        return Err(SubscriberError::InvalidConfig(
24981            "SubscriberConfig::max_backfill_log_bytes must be greater than zero",
24982        ));
24983    }
24984    if config.max_reconcile_requests_in_flight == 0 {
24985        return Err(SubscriberError::InvalidConfig(
24986            "SubscriberConfig::max_reconcile_requests_in_flight must be greater than zero",
24987        ));
24988    }
24989    if config.reconnect.enabled {
24990        if config.reconnect.retry_delay > config.reconnect.max_delay {
24991            return Err(SubscriberError::InvalidConfig(
24992                "SubscriberReconnectConfig::retry_delay must be less than or equal to max_delay",
24993            ));
24994        }
24995        if matches!(config.reconnect.max_attempts, Some(0)) {
24996            return Err(SubscriberError::InvalidConfig(
24997                "SubscriberReconnectConfig::max_attempts must be greater than zero when set",
24998            ));
24999        }
25000    }
25001    Ok(())
25002}
25003
25004fn validate_supported_interests<N: Network>(
25005    mode: SubscriberMode,
25006    config: &SubscriberConfig,
25007    interests: &[ReactiveInterest<N>],
25008) -> Result<(), SubscriberError> {
25009    let transport = resolve_subscriber_transport(mode)?;
25010
25011    for interest in interests {
25012        match interest {
25013            ReactiveInterest::Logs(_) => {}
25014            ReactiveInterest::PendingTransactions(interest)
25015                if !config.hydrate_pending_transactions && interest.matches_hash_only() => {}
25016            ReactiveInterest::PendingTransactions(_) => {
25017                return Err(SubscriberError::Unsupported(
25018                    "AlloySubscriber currently supports pending transaction hash interests only (full pending-tx hydration is unimplemented)",
25019                ));
25020            }
25021            ReactiveInterest::Blocks(interest) => match (transport, interest.mode) {
25022                (SubscriberTransport::PubSub, BlockInterestMode::Header) => {}
25023                (_, BlockInterestMode::FullBlock) => {
25024                    return Err(SubscriberError::Unsupported(
25025                        "AlloySubscriber full block streams are not implemented in this transport slice",
25026                    ));
25027                }
25028                (SubscriberTransport::Polling, BlockInterestMode::Header) => {
25029                    return Err(SubscriberError::Unsupported(
25030                        "AlloySubscriber polling block streams are not implemented in this transport slice",
25031                    ));
25032                }
25033            },
25034        }
25035    }
25036
25037    Ok(())
25038}
25039
25040fn log_filters<N: Network>(interests: &[ReactiveInterest<N>]) -> Vec<Filter> {
25041    let mut filters = Vec::new();
25042    for interest in interests {
25043        if let ReactiveInterest::Logs(interest) = interest {
25044            merge_log_subscription_filter(&mut filters, &interest.provider_filter);
25045        }
25046    }
25047    filters
25048}
25049
25050fn needs_header_block_stream<N: Network>(interests: &[ReactiveInterest<N>]) -> bool {
25051    interests.iter().any(|interest| {
25052        matches!(
25053            interest,
25054            ReactiveInterest::Blocks(BlockInterest {
25055                mode: BlockInterestMode::Header,
25056            })
25057        )
25058    })
25059}
25060
25061fn needs_pending_hash_stream<N: Network>(interests: &[ReactiveInterest<N>]) -> bool {
25062    interests.iter().any(|interest| {
25063        matches!(
25064            interest,
25065            ReactiveInterest::PendingTransactions(interest) if interest.matches_hash_only()
25066        )
25067    })
25068}
25069
25070fn log_matches_any_interest<N: Network>(log: &Log, interests: &[ReactiveInterest<N>]) -> bool {
25071    interests.iter().any(|interest| {
25072        matches!(
25073            interest,
25074            ReactiveInterest::Logs(interest) if interest.matches(log)
25075        )
25076    })
25077}
25078
25079fn validate_owner_backfill_logs(
25080    logs: &[Log],
25081    from_block: u64,
25082    through: &BlockRef,
25083) -> Result<(), SubscriberOwnerError> {
25084    for log in logs {
25085        if log.removed {
25086            return Err(SubscriberOwnerError::InvalidBackfillLog(
25087                "removed log in canonical catch-up",
25088            ));
25089        }
25090        let number = log
25091            .block_number
25092            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
25093                "log missing block number",
25094            ))?;
25095        let hash = log
25096            .block_hash
25097            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
25098                "log missing block hash",
25099            ))?;
25100        log.transaction_hash
25101            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
25102                "log missing transaction hash",
25103            ))?;
25104        log.transaction_index
25105            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
25106                "log missing transaction index",
25107            ))?;
25108        log.log_index
25109            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
25110                "log missing log index",
25111            ))?;
25112        if number < from_block || number > through.number {
25113            return Err(SubscriberOwnerError::InvalidBackfillLog(
25114                "log outside requested block range",
25115            ));
25116        }
25117        if number == through.number && hash != through.hash {
25118            return Err(SubscriberOwnerError::InvalidBackfillLog(
25119                "target-block log hash mismatch",
25120            ));
25121        }
25122    }
25123    Ok(())
25124}
25125
25126fn validate_backfill_resource_limits(
25127    logs: &[Log],
25128    max_logs: usize,
25129    max_log_bytes: usize,
25130) -> Result<usize, SubscriberError> {
25131    if logs.len() > max_logs {
25132        return Err(SubscriberError::ResourceExhausted(format!(
25133            "historical response returned {} logs, above the configured limit of {max_logs}",
25134            logs.len()
25135        )));
25136    }
25137    let bytes = logs.iter().fold(0usize, |total, log| {
25138        // Include fixed address/block/transaction/index fields in addition to
25139        // the variable topic and data payload. This is deliberately a stable
25140        // conservative accounting unit rather than Rust heap-layout size.
25141        let fixed = 20usize + (32 * 3) + (8 * 4) + 1;
25142        total
25143            .saturating_add(fixed)
25144            .saturating_add(log.topics().len().saturating_mul(32))
25145            .saturating_add(log.inner.data.data.len())
25146    });
25147    if bytes > max_log_bytes {
25148        return Err(SubscriberError::ResourceExhausted(format!(
25149            "historical response retained approximately {bytes} log bytes, above the configured limit of {max_log_bytes}"
25150        )));
25151    }
25152    Ok(bytes)
25153}
25154
25155async fn fetch_provider_block_ref<P, N>(
25156    provider: &P,
25157    number: u64,
25158    counters: &SubscriberRpcCounters,
25159    cause: SubscriberRpcCause,
25160) -> Result<BlockRef, SubscriberError>
25161where
25162    P: Provider<N> + Send + Sync,
25163    N: Network,
25164{
25165    counters.record(cause, SubscriberRpcMethod::EthGetBlockByNumber);
25166    let block = provider
25167        .get_block_by_number(BlockNumberOrTag::Number(number))
25168        .await
25169        .map_err(provider_error)?
25170        .ok_or_else(|| {
25171            SubscriberError::InvalidBackfill(format!(
25172                "canonical target block {number} is unavailable"
25173            ))
25174        })?;
25175    let header = block.header();
25176    Ok(BlockRef {
25177        number: header.number(),
25178        hash: header.hash(),
25179        parent_hash: Some(header.parent_hash()),
25180        timestamp: Some(header.timestamp()),
25181    })
25182}
25183
25184fn block_ref_satisfies_expected(actual: &BlockRef, expected: &BlockRef) -> bool {
25185    actual.number == expected.number
25186        && actual.hash == expected.hash
25187        && optional_metadata_compatible(actual.parent_hash.as_ref(), expected.parent_hash.as_ref())
25188        && optional_metadata_compatible(actual.timestamp.as_ref(), expected.timestamp.as_ref())
25189}
25190
25191fn validate_owner_backfill_log_set(logs: &[Log]) -> Result<(), SubscriberOwnerError> {
25192    let mut positions = HashMap::new();
25193    let mut block_hashes = HashMap::new();
25194    let mut transaction_hashes = HashMap::new();
25195    let mut transaction_positions = HashMap::new();
25196    let mut ordering = BTreeMap::<u64, Vec<(u64, u64)>>::new();
25197    for log in logs {
25198        let number = log
25199            .block_number
25200            .expect("individual owner catch-up logs are validated before set validation");
25201        let block_hash = log
25202            .block_hash
25203            .expect("individual owner catch-up logs are validated before set validation");
25204        let transaction_hash = log
25205            .transaction_hash
25206            .expect("individual owner catch-up logs are validated before set validation");
25207        let transaction_index = log
25208            .transaction_index
25209            .expect("individual owner catch-up logs are validated before set validation");
25210        let log_index = log
25211            .log_index
25212            .expect("individual owner catch-up logs are validated before set validation");
25213        if block_hashes
25214            .insert(number, block_hash)
25215            .is_some_and(|prior| prior != block_hash)
25216        {
25217            return Err(SubscriberOwnerError::InvalidBackfillLog(
25218                "conflicting block identity in canonical catch-up",
25219            ));
25220        }
25221        if let Some(previous) = positions.insert((number, log_index), log)
25222            && previous != log
25223        {
25224            return Err(SubscriberOwnerError::InvalidBackfillLog(
25225                "conflicting logs at one canonical block position",
25226            ));
25227        }
25228        let conflicting_transaction = transaction_hashes
25229            .insert((number, transaction_index), transaction_hash)
25230            .is_some_and(|prior| prior != transaction_hash)
25231            || transaction_positions
25232                .insert((number, transaction_hash), transaction_index)
25233                .is_some_and(|prior| prior != transaction_index);
25234        if conflicting_transaction {
25235            return Err(SubscriberOwnerError::InvalidBackfillLog(
25236                "conflicting transaction identity at one canonical block position",
25237            ));
25238        }
25239        ordering
25240            .entry(number)
25241            .or_default()
25242            .push((log_index, transaction_index));
25243    }
25244    for positions in ordering.values_mut() {
25245        positions.sort_unstable();
25246        if positions.windows(2).any(|pair| pair[0].1 > pair[1].1) {
25247            return Err(SubscriberOwnerError::InvalidBackfillLog(
25248                "transaction and log positions disagree on canonical order",
25249            ));
25250        }
25251    }
25252    Ok(())
25253}
25254
25255fn merged_owner_reconcile_filters<N: Network>(
25256    plans: &[SubscriberOwnerReconcilePlan<N>],
25257    through: u64,
25258) -> Vec<SubscriberOwnerReconcileFilter> {
25259    let mut by_start = BTreeMap::<u64, Vec<Filter>>::new();
25260    for plan in plans.iter().filter(|plan| plan.from_block <= through) {
25261        let filters = by_start.entry(plan.from_block).or_default();
25262        filters.extend(
25263            log_filters(&plan.interests)
25264                .into_iter()
25265                .map(|filter| filter.from_block(plan.from_block).to_block(through)),
25266        );
25267    }
25268
25269    let mut chunks = Vec::new();
25270    for (from_block, filters) in by_start {
25271        for filters in filters.chunks(OWNER_RECONCILE_FILTERS_PER_CHUNK) {
25272            let mut merged = Vec::new();
25273            for filter in filters {
25274                merge_log_subscription_filter(&mut merged, filter);
25275            }
25276            chunks.extend(
25277                merged
25278                    .into_iter()
25279                    .map(|filter| SubscriberOwnerReconcileFilter { filter, from_block }),
25280            );
25281        }
25282    }
25283    chunks
25284}
25285
25286fn merged_lazy_backfill_filters(
25287    filters: &[Filter],
25288    from_block: u64,
25289    through: u64,
25290) -> Vec<SubscriberOwnerReconcileFilter> {
25291    let mut requests = Vec::new();
25292    for filters in filters.chunks(OWNER_RECONCILE_FILTERS_PER_CHUNK) {
25293        let mut merged = Vec::new();
25294        for filter in filters {
25295            merge_log_subscription_filter(
25296                &mut merged,
25297                &filter.clone().from_block(from_block).to_block(through),
25298            );
25299        }
25300        requests.extend(
25301            merged
25302                .into_iter()
25303                .map(|filter| SubscriberOwnerReconcileFilter { filter, from_block }),
25304        );
25305    }
25306    requests
25307}
25308
25309fn lazy_backfill_error(error: SubscriberOwnerError) -> SubscriberError {
25310    match error {
25311        SubscriberOwnerError::Subscriber(error) => error,
25312        error => SubscriberError::InvalidBackfill(error.to_string()),
25313    }
25314}
25315
25316fn global_backfill_barrier(backfill: SubscriberBackfill, certified: BlockRef) -> ChainControl {
25317    let mut id = b"alloy-global-backfill-v1".to_vec();
25318    id.extend_from_slice(&backfill.start_block().to_be_bytes());
25319    id.extend_from_slice(&certified.number.to_be_bytes());
25320    id.extend_from_slice(certified.hash.as_slice());
25321    ChainControl::Barrier {
25322        id,
25323        block: Some(certified),
25324    }
25325}
25326
25327async fn fetch_owner_catchup<P, N>(
25328    provider: P,
25329    filters: Vec<SubscriberOwnerReconcileFilter>,
25330    retained: Vec<BlockRef>,
25331    through: BlockRef,
25332    options: SubscriberOwnerCatchupOptions,
25333    counters: Arc<SubscriberRpcCounters>,
25334) -> Result<SubscriberOwnerCatchup, SubscriberOwnerError>
25335where
25336    P: Provider<N> + Send + Sync,
25337    N: Network,
25338{
25339    let counters = counters.as_ref();
25340    if !options.target_preverified {
25341        let _ =
25342            verify_provider_reconcile_target::<P, N>(&provider, &through, counters, options.cause)
25343                .await?;
25344    }
25345    let mut certified_positions = HashSet::new();
25346    for position in retained {
25347        let target_certifies_position = position == through
25348            || (position.number.checked_add(1) == Some(through.number)
25349                && through.parent_hash == Some(position.hash));
25350        if !target_certifies_position && certified_positions.insert(position) {
25351            let _ = verify_provider_reconcile_target::<P, N>(
25352                &provider,
25353                &position,
25354                counters,
25355                options.cause,
25356            )
25357            .await?;
25358        }
25359    }
25360    let mut logs = Vec::new();
25361    let mut total_log_bytes = 0usize;
25362    let requests = stream::iter(filters.into_iter().map(|filter| {
25363        let provider = &provider;
25364        async move {
25365            counters.record(options.cause, SubscriberRpcMethod::EthGetLogs);
25366            let logs = provider
25367                .get_logs(&filter.filter)
25368                .await
25369                .map_err(provider_error)?;
25370            Ok::<_, SubscriberOwnerError>((filter.from_block, logs))
25371        }
25372    }))
25373    .buffer_unordered(options.max_requests_in_flight);
25374    futures::pin_mut!(requests);
25375    while let Some(result) = requests.next().await {
25376        let (from_block, fetched) = result?;
25377        let fetched_bytes =
25378            validate_backfill_resource_limits(&fetched, options.max_logs, options.max_log_bytes)?;
25379        validate_owner_backfill_logs(&fetched, from_block, &through)?;
25380        if logs.len().saturating_add(fetched.len()) > options.max_logs {
25381            return Err(SubscriberError::ResourceExhausted(format!(
25382                "bulk reconcile returned more than {} logs",
25383                options.max_logs
25384            ))
25385            .into());
25386        }
25387        total_log_bytes = total_log_bytes.saturating_add(fetched_bytes);
25388        if total_log_bytes > options.max_log_bytes {
25389            return Err(SubscriberError::ResourceExhausted(format!(
25390                "bulk reconcile retained approximately {total_log_bytes} log bytes, above the configured limit of {}",
25391                options.max_log_bytes
25392            ))
25393            .into());
25394        }
25395        logs.extend(fetched);
25396    }
25397    validate_owner_backfill_log_set(&logs)?;
25398    let certified =
25399        verify_provider_reconcile_target::<P, N>(&provider, &through, counters, options.cause)
25400            .await?;
25401    Ok(SubscriberOwnerCatchup { logs, certified })
25402}
25403
25404async fn verify_provider_reconcile_target<P, N>(
25405    provider: &P,
25406    expected: &BlockRef,
25407    counters: &SubscriberRpcCounters,
25408    cause: SubscriberRpcCause,
25409) -> Result<BlockRef, SubscriberOwnerError>
25410where
25411    P: Provider<N> + Send + Sync,
25412    N: Network,
25413{
25414    counters.record(cause, SubscriberRpcMethod::EthGetBlockByNumber);
25415    let block = provider
25416        .get_block_by_number(BlockNumberOrTag::Number(expected.number))
25417        .await
25418        .map_err(provider_error)?
25419        .ok_or(SubscriberOwnerError::BlockUnavailable(expected.number))?;
25420    let header = block.header();
25421    let actual = BlockRef {
25422        number: header.number(),
25423        hash: header.hash(),
25424        parent_hash: Some(header.parent_hash()),
25425        timestamp: Some(header.timestamp()),
25426    };
25427    let exact_parent = expected
25428        .parent_hash
25429        .is_none_or(|parent| Some(parent) == actual.parent_hash);
25430    let exact_timestamp = expected
25431        .timestamp
25432        .is_none_or(|timestamp| Some(timestamp) == actual.timestamp);
25433    if actual.number != expected.number
25434        || actual.hash != expected.hash
25435        || !exact_parent
25436        || !exact_timestamp
25437    {
25438        return Err(SubscriberOwnerError::BlockMismatch {
25439            expected_number: expected.number,
25440            expected_hash: expected.hash,
25441            actual_number: actual.number,
25442            actual_hash: actual.hash,
25443        });
25444    }
25445    Ok(actual)
25446}
25447
25448fn log_input_record<N: Network>(log: Log, source: InputSource) -> ReactiveInputRecord<N> {
25449    let context = log_reactive_context(&log);
25450    ReactiveInputRecord::new(
25451        ReactiveInput::Log(log),
25452        ReactiveContext { source, ..context },
25453    )
25454}
25455
25456fn preconfirmed_log_input_record<N: Network>(
25457    log: Log,
25458    flashblock: FlashblockRef,
25459) -> ReactiveInputRecord<N> {
25460    let block = flashblock.block_ref();
25461    let provider = flashblock.provider.clone();
25462    ReactiveInputRecord::new(
25463        ReactiveInput::Log(log.clone()),
25464        ReactiveContext {
25465            chain_id: None,
25466            source: InputSource::Flashblocks,
25467            chain_status: ChainStatus::Preconfirmed {
25468                flashblock: Arc::new(flashblock),
25469            },
25470            block: Some(block),
25471            transaction_index: log.transaction_index,
25472            log_index: log.log_index,
25473        },
25474    )
25475    .with_provider(provider)
25476}
25477
25478fn log_reactive_context(log: &Log) -> ReactiveContext {
25479    let block = match (log.block_hash, log.block_number) {
25480        (Some(hash), Some(number)) => Some(BlockRef {
25481            number,
25482            hash,
25483            parent_hash: None,
25484            timestamp: log.block_timestamp,
25485        }),
25486        _ => None,
25487    };
25488
25489    let chain_status = match (&block, log.removed) {
25490        (Some(block), true) => ChainStatus::Reorged {
25491            dropped_from: *block,
25492        },
25493        (Some(block), false) => ChainStatus::Included {
25494            block: *block,
25495            confirmations: 0,
25496        },
25497        (None, _) => ChainStatus::Pending,
25498    };
25499
25500    ReactiveContext {
25501        chain_id: None,
25502        source: InputSource::Poll,
25503        chain_status,
25504        block,
25505        transaction_index: log.transaction_index,
25506        log_index: log.log_index,
25507    }
25508}
25509
25510fn block_header_input_record<N>(header: N::HeaderResponse) -> ReactiveInputRecord<N>
25511where
25512    N: Network,
25513{
25514    let block = BlockRef {
25515        number: header.number(),
25516        hash: HeaderResponseTrait::hash(&header),
25517        parent_hash: Some(header.parent_hash()),
25518        timestamp: Some(header.timestamp()),
25519    };
25520    ReactiveInputRecord::new(
25521        ReactiveInput::BlockHeader(header),
25522        ReactiveContext {
25523            chain_id: None,
25524            source: InputSource::Subscription,
25525            chain_status: ChainStatus::Included {
25526                block,
25527                confirmations: 0,
25528            },
25529            block: Some(block),
25530            transaction_index: None,
25531            log_index: None,
25532        },
25533    )
25534}
25535
25536fn pending_hash_input_record<N: Network>(
25537    hash: B256,
25538    source: InputSource,
25539) -> ReactiveInputRecord<N> {
25540    ReactiveInputRecord::new(
25541        ReactiveInput::PendingTxHash(hash),
25542        ReactiveContext {
25543            chain_id: None,
25544            source,
25545            chain_status: ChainStatus::Pending,
25546            block: None,
25547            transaction_index: None,
25548            log_index: None,
25549        },
25550    )
25551}
25552
25553#[cfg(feature = "reactive-ws")]
25554fn base_pending_log_filter(filter: &Filter) -> Result<serde_json::Value, SubscriberError> {
25555    let encoded = serde_json::to_value(filter)
25556        .map_err(|error| SubscriberError::Provider(error.to_string()))?;
25557    let serde_json::Value::Object(mut fields) = encoded else {
25558        return Err(SubscriberError::Provider(
25559            "Alloy log filter did not serialize as an object".into(),
25560        ));
25561    };
25562    fields.retain(|key, _| key == "address" || key == "topics");
25563    Ok(serde_json::Value::Object(fields))
25564}
25565
25566fn provider_error(error: impl fmt::Display) -> SubscriberError {
25567    SubscriberError::Provider(error.to_string())
25568}
25569
25570/// Subscriber error.
25571#[derive(Debug, thiserror::Error)]
25572#[non_exhaustive]
25573pub enum SubscriberError {
25574    /// Invalid subscriber configuration.
25575    #[error("{0}")]
25576    InvalidConfig(&'static str),
25577    /// Requested subscriber behavior is not implemented.
25578    #[error("{0}")]
25579    Unsupported(&'static str),
25580    /// The pinned provider lease reports a different chain identity.
25581    #[error("subscriber chain mismatch: expected {expected}, got {actual}")]
25582    ChainMismatch {
25583        /// Required chain id.
25584        expected: u64,
25585        /// Observed chain id.
25586        actual: u64,
25587    },
25588    /// Provider or transport error.
25589    #[error("provider error: {0}")]
25590    Provider(String),
25591    /// A provider returned malformed, out-of-range, or non-canonical lazy
25592    /// backfill data.
25593    #[error("invalid canonical backfill: {0}")]
25594    InvalidBackfill(String),
25595    /// A configured subscriber memory/concurrency boundary was exceeded.
25596    #[error("subscriber resource limit exceeded: {0}")]
25597    ResourceExhausted(String),
25598}
25599
25600/// Canonical head certification is the second unrequested request stream in the
25601/// live path: on a Flashblocks endpoint it replaces the `newHeads` subscription
25602/// with a fixed-interval poll, so it bills a request per tick regardless of
25603/// whether the head actually moved.
25604#[cfg(test)]
25605mod canonical_head_rpc_stats_tests {
25606    use super::*;
25607    use alloy_provider::ProviderBuilder;
25608    use alloy_rpc_types_eth::{Block, Header as RpcHeader};
25609    use alloy_transport::mock::Asserter;
25610
25611    fn sealed_head() -> Block {
25612        Block::empty(RpcHeader {
25613            hash: B256::repeat_byte(0x65),
25614            inner: alloy_consensus::Header {
25615                number: 101,
25616                parent_hash: B256::repeat_byte(0x64),
25617                timestamp: 1_700_000_101,
25618                ..alloy_consensus::Header::default()
25619            },
25620            total_difficulty: None,
25621            size: None,
25622        })
25623    }
25624
25625    /// Two ticks, one new head: the poll that observes no change still costs a
25626    /// request, and `rpc_stats` reports both. This is the in-process form of the
25627    /// measured Base head-poll volume — the counter tracks requests issued, not
25628    /// events produced.
25629    #[tokio::test]
25630    async fn unchanged_head_still_counts_its_certification_request() {
25631        let asserter = Asserter::new();
25632        asserter.push_success(&Some(sealed_head()));
25633        asserter.push_success(&Some(sealed_head()));
25634        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
25635        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
25636            provider,
25637            SubscriberMode::PubSub,
25638            SubscriberConfig::default(),
25639        );
25640        subscriber.chain_id = Some(8_453);
25641
25642        let first = subscriber
25643            .fetch_certified_canonical_head()
25644            .await
25645            .expect("first certification succeeds");
25646        assert!(
25647            matches!(first, Some(SubscriberEvent::BlockHeader { .. })),
25648            "a newly certified head is delivered"
25649        );
25650
25651        let second = subscriber
25652            .fetch_certified_canonical_head()
25653            .await
25654            .expect("second certification succeeds");
25655        assert!(
25656            second.is_none(),
25657            "an unchanged head produces no event to deliver"
25658        );
25659
25660        let stats = subscriber.rpc_stats();
25661        assert_eq!(
25662            stats.get(
25663                SubscriberRpcCause::CanonicalHeadCertification,
25664                SubscriberRpcMethod::EthGetBlockByNumber,
25665            ),
25666            2,
25667            "both polls are billed even though only one advanced the head"
25668        );
25669        assert_eq!(stats.total(), 2, "certification is the only request issued");
25670        assert_eq!(
25671            subscriber
25672                .flashblocks_rpc_metrics()
25673                .canonical_head_requests(),
25674            2,
25675            "the attributed counter agrees with the Flashblocks-scoped one"
25676        );
25677        assert!(asserter.read_q().is_empty());
25678    }
25679}
25680
25681/// Notification loss on a live subscription must be observable and recoverable.
25682///
25683/// The premise of sourcing canonical logs from a subscription is that loss can be
25684/// detected; `alloy-pubsub`'s typed stream defeats that by treating a lagged
25685/// receiver and an undecodable payload as `continue`. These tests drive a real
25686/// broadcast channel — overflowing it for real rather than simulating the error —
25687/// and pin both the detection and the bounded recovery it triggers.
25688#[cfg(all(test, feature = "reactive-ws"))]
25689mod stream_gap_tests {
25690    use super::*;
25691    use alloy_provider::ProviderBuilder;
25692    use alloy_transport::mock::Asserter;
25693    use serde_json::value::RawValue;
25694
25695    fn raw_json(value: &serde_json::Value) -> Box<RawValue> {
25696        RawValue::from_string(value.to_string()).expect("valid JSON")
25697    }
25698
25699    fn wire_log(block_number: u64, log_index: u64) -> Box<RawValue> {
25700        raw_json(&serde_json::json!({
25701            "address": "0x0000000000000000000000000000000000000077",
25702            "topics": ["0x1111111111111111111111111111111111111111111111111111111111111111"],
25703            "data": "0x",
25704            "blockHash": format!("0x{:064x}", block_number),
25705            "blockNumber": format!("0x{block_number:x}"),
25706            "transactionHash": format!("0x{:064x}", 0x20 + log_index),
25707            "transactionIndex": format!("0x{log_index:x}"),
25708            "logIndex": format!("0x{log_index:x}"),
25709            "removed": false,
25710        }))
25711    }
25712
25713    fn log_source(id: usize) -> SubscriberStreamSource {
25714        SubscriberStreamSource::PubSubLog {
25715            id,
25716            filter: Filter::new().address(Address::repeat_byte(0x77)),
25717        }
25718    }
25719
25720    /// Build a `Subscription<Log>` over a real broadcast channel so the test can
25721    /// overflow it, feed it garbage, or close it.
25722    fn wired_subscription(
25723        capacity: usize,
25724    ) -> (
25725        tokio::sync::broadcast::Sender<Box<RawValue>>,
25726        alloy_pubsub::Subscription<Log>,
25727    ) {
25728        let (tx, rx) = tokio::sync::broadcast::channel(capacity);
25729        let raw = alloy_pubsub::RawSubscription {
25730            rx,
25731            local_id: B256::repeat_byte(0x5b),
25732        };
25733        (tx, raw.into_typed())
25734    }
25735
25736    fn gap_stream(
25737        subscription: alloy_pubsub::Subscription<Log>,
25738        source: SubscriberStreamSource,
25739        counters: Arc<SubscriberStreamGapCounters>,
25740    ) -> BoxStream<'static, SubscriberEvent<Ethereum>> {
25741        gap_observing_stream(
25742            subscription,
25743            source,
25744            counters,
25745            #[cfg(feature = "ingress-timing")]
25746            None,
25747            |log, #[cfg(feature = "ingress-timing")] timing| SubscriberEvent::Log {
25748                source_id: 0,
25749                log,
25750                #[cfg(feature = "ingress-timing")]
25751                timing,
25752            },
25753        )
25754    }
25755
25756    #[cfg(feature = "ingress-timing")]
25757    #[tokio::test]
25758    async fn receipt_lookup_disabled_preserves_delivery_without_timing() {
25759        let counters = Arc::new(SubscriberStreamGapCounters::default());
25760        let (tx, subscription) = wired_subscription(8);
25761        tx.send(wire_log(100, 0)).expect("receiver alive");
25762        let mut stream = gap_stream(subscription, log_source(0), counters);
25763        let Some(SubscriberEvent::Log { log, timing, .. }) = stream.next().await else {
25764            panic!("canonical log expected");
25765        };
25766        assert_eq!(log.block_number, Some(100));
25767        assert!(timing.is_none());
25768    }
25769
25770    #[cfg(feature = "ingress-timing")]
25771    #[tokio::test]
25772    async fn consumed_raw_subscription_preserves_transport_clock_without_rewriting_payload() {
25773        let counters = Arc::new(SubscriberStreamGapCounters::default());
25774        let (tx, subscription) = wired_subscription(8);
25775        let raw = wire_log(100, 0);
25776        let expected = raw.get().to_owned();
25777        let received = Instant::now() - Duration::from_millis(30);
25778        let receipt = TransportReceipt::new(B256::repeat_byte(7), received, received).unwrap();
25779        let lookup: TransportReceiptLookup = Arc::new(move |raw| {
25780            assert_eq!(raw, expected);
25781            Some(receipt)
25782        });
25783        tx.send(raw).expect("receiver alive");
25784        let mut stream = gap_observing_stream(
25785            subscription,
25786            log_source(0),
25787            counters,
25788            Some(lookup),
25789            |log, timing| SubscriberEvent::<Ethereum>::Log {
25790                source_id: 0,
25791                log,
25792                timing,
25793            },
25794        );
25795        let Some(SubscriberEvent::Log {
25796            #[cfg(feature = "ingress-timing")]
25797                timing: Some(timing),
25798            log,
25799            ..
25800        }) = stream.next().await
25801        else {
25802            panic!("timed canonical log expected");
25803        };
25804        assert_eq!(log.block_number, Some(100));
25805        assert_eq!(timing.receipt(), receipt);
25806        assert!(timing.dequeued().duration_since(received) >= Duration::from_millis(30));
25807        assert!(timing.decoded() >= timing.dequeued());
25808    }
25809
25810    /// A channel that overflows reports the loss. Under
25811    /// `Subscription::into_stream` this same sequence yields only the surviving
25812    /// notification, with the drop visible nowhere.
25813    #[tokio::test]
25814    async fn overflowing_channel_reports_the_gap_instead_of_skipping_it() {
25815        let counters = Arc::new(SubscriberStreamGapCounters::default());
25816        let (tx, subscription) = wired_subscription(2);
25817        // Publish past capacity before the stream is ever polled.
25818        for index in 0..5 {
25819            tx.send(wire_log(100 + index, index))
25820                .expect("receiver alive");
25821        }
25822        let mut stream = gap_stream(subscription, log_source(0), Arc::clone(&counters));
25823
25824        let first = stream.next().await.expect("an event is produced");
25825        let SubscriberEvent::StreamGap { source, gap } = first else {
25826            panic!("expected the dropped notifications to surface as a gap, got a delivery");
25827        };
25828        assert!(
25829            matches!(source, SubscriberStreamSource::PubSubLog { id: 0, .. }),
25830            "the gap must name the source that lost data"
25831        );
25832        assert_eq!(gap, SubscriberStreamGap::Lagged { skipped: 3 });
25833        assert_eq!(gap.skipped(), Some(3));
25834
25835        // The surviving notifications still arrive after the gap is reported.
25836        assert!(matches!(
25837            stream.next().await,
25838            Some(SubscriberEvent::Log { .. })
25839        ));
25840        assert_eq!(counters.snapshot().lagged_notifications(), 3);
25841        assert_eq!(counters.snapshot().undecodable_notifications(), 0);
25842    }
25843
25844    /// An unreadable payload is lost data, not a skippable curiosity: a filter's
25845    /// matched set cannot be called complete while one notification is opaque.
25846    #[tokio::test]
25847    async fn undecodable_notification_fails_closed_as_a_gap() {
25848        let counters = Arc::new(SubscriberStreamGapCounters::default());
25849        let (tx, subscription) = wired_subscription(8);
25850        tx.send(raw_json(&serde_json::json!({"not": "a log"})))
25851            .expect("receiver alive");
25852        tx.send(wire_log(101, 0)).expect("receiver alive");
25853        let mut stream = gap_stream(subscription, log_source(0), Arc::clone(&counters));
25854
25855        assert_eq!(
25856            stream.next().await.map(|event| matches!(
25857                event,
25858                SubscriberEvent::StreamGap {
25859                    gap: SubscriberStreamGap::Undecodable,
25860                    ..
25861                }
25862            )),
25863            Some(true),
25864        );
25865        assert!(matches!(
25866            stream.next().await,
25867            Some(SubscriberEvent::Log { .. })
25868        ));
25869        let stats = counters.snapshot();
25870        assert_eq!(stats.undecodable_notifications(), 1);
25871        assert_eq!(stats.lagged_notifications(), 0);
25872        assert_eq!(stats.total_gaps(), 1);
25873    }
25874
25875    /// A closed channel is a disconnect, not a gap: it must still end the stream
25876    /// so the existing reconnect path runs unchanged.
25877    #[tokio::test]
25878    async fn closed_channel_terminates_the_stream_for_reconnect() {
25879        let counters = Arc::new(SubscriberStreamGapCounters::default());
25880        let (tx, subscription) = wired_subscription(8);
25881        tx.send(wire_log(101, 0)).expect("receiver alive");
25882        drop(tx);
25883        let mut stream = gap_stream(subscription, log_source(0), Arc::clone(&counters));
25884
25885        assert!(matches!(
25886            stream.next().await,
25887            Some(SubscriberEvent::Log { .. })
25888        ));
25889        assert!(
25890            matches!(
25891                stream.next().await,
25892                Some(SubscriberEvent::StreamTerminated(
25893                    SubscriberStreamSource::PubSubLog { id: 0, .. }
25894                ))
25895            ),
25896            "a closed subscription must terminate, not report a gap"
25897        );
25898        assert_eq!(counters.snapshot().total_gaps(), 0);
25899    }
25900
25901    fn mocked_subscriber(
25902        asserter: Asserter,
25903    ) -> AlloySubscriber<impl alloy_provider::Provider<Ethereum> + Clone, Ethereum> {
25904        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
25905        AlloySubscriber::new(
25906            provider,
25907            SubscriberMode::PubSub,
25908            SubscriberConfig::default(),
25909        )
25910    }
25911
25912    /// A canonical log gap refetches the source's window from its delivery
25913    /// anchor to the current head, and charges the requests to `GapBackfill` so
25914    /// backpressure loss is distinguishable from reconnect churn.
25915    #[tokio::test]
25916    async fn log_gap_refetches_the_missed_window_and_attributes_it() {
25917        let asserter = Asserter::new();
25918        asserter.push_success(&U256::from(104)); // eth_blockNumber
25919        asserter.push_success(&vec![
25920            serde_json::from_str::<Log>(wire_log(103, 0).get()).expect("log"),
25921        ]);
25922        let mut subscriber = mocked_subscriber(asserter.clone());
25923        subscriber.chain_id = Some(1);
25924        // The source has delivered through block 102.
25925        subscriber.last_seen_log_blocks.insert(0, 102);
25926
25927        let event = subscriber
25928            .recover_stream_gap(&log_source(0), SubscriberStreamGap::Lagged { skipped: 2 })
25929            .await
25930            .expect("a bounded window is recoverable");
25931
25932        assert!(
25933            matches!(
25934                event,
25935                Some(SubscriberEvent::BackfilledLogs { source_id: 0, ref logs }) if logs.len() == 1
25936            ),
25937            "the missed window is delivered as backfill"
25938        );
25939        let stats = subscriber.rpc_stats();
25940        assert_eq!(
25941            stats.by_cause(SubscriberRpcCause::GapBackfill),
25942            2,
25943            "one head read plus one bounded eth_getLogs"
25944        );
25945        assert_eq!(
25946            stats.by_cause(SubscriberRpcCause::ReconnectBackfill),
25947            0,
25948            "a live-stream gap is not reconnect churn"
25949        );
25950        assert_eq!(subscriber.stream_gap_stats().log_gaps_healed(), 1);
25951        assert!(asserter.read_q().is_empty());
25952    }
25953
25954    /// Without a delivery anchor the missed range has no lower bound. Continuing
25955    /// would mean knowing logs were lost and doing nothing, so this fails closed.
25956    #[tokio::test]
25957    async fn log_gap_without_a_delivery_anchor_fails_closed() {
25958        let mut subscriber = mocked_subscriber(Asserter::new());
25959        subscriber.chain_id = Some(1);
25960
25961        let Err(error) = subscriber
25962            .recover_stream_gap(&log_source(0), SubscriberStreamGap::Lagged { skipped: 9 })
25963            .await
25964        else {
25965            panic!("an unbounded gap must not be silently ignored");
25966        };
25967
25968        assert!(
25969            matches!(&error, SubscriberError::Provider(message)
25970                if message.contains("delivery anchor") && message.contains("lagged(9)")),
25971            "the error must name the cause and the loss: {error}"
25972        );
25973        assert_eq!(subscriber.stream_gap_stats().log_gaps_healed(), 0);
25974    }
25975
25976    /// Header gaps are recovered by the consumer's parent-lineage walk, so they
25977    /// are counted rather than refetched — no provider request is spent.
25978    #[tokio::test]
25979    async fn header_gap_is_counted_without_spending_a_request() {
25980        let asserter = Asserter::new();
25981        let mut subscriber = mocked_subscriber(asserter.clone());
25982        subscriber.chain_id = Some(1);
25983
25984        let event = subscriber
25985            .recover_stream_gap(
25986                &SubscriberStreamSource::PubSubBlockHeaders,
25987                SubscriberStreamGap::Lagged { skipped: 1 },
25988            )
25989            .await
25990            .expect("a header gap is not fatal");
25991
25992        assert!(event.is_none(), "no synthetic header is fabricated");
25993        assert_eq!(subscriber.stream_gap_stats().header_gaps(), 1);
25994        assert_eq!(
25995            subscriber.rpc_stats().total(),
25996            0,
25997            "the lineage walk already covers this; refetching would duplicate it"
25998        );
25999        assert!(asserter.read_q().is_empty());
26000    }
26001
26002    /// A punctured pre-confirmation must never be published: the speculative
26003    /// snapshot is discarded and the next complete generation replaces it.
26004    #[tokio::test]
26005    async fn preconfirmation_gap_discards_the_speculative_snapshot() {
26006        let mut subscriber = mocked_subscriber(Asserter::new());
26007        subscriber.chain_id = Some(8_453);
26008
26009        let event = subscriber
26010            .recover_stream_gap(
26011                &SubscriberStreamSource::BasePendingLog {
26012                    id: 0,
26013                    filter: Filter::new(),
26014                },
26015                SubscriberStreamGap::Undecodable,
26016            )
26017            .await
26018            .expect("a preview gap is recoverable by discarding it");
26019
26020        assert!(
26021            matches!(event, Some(SubscriberEvent::FlashblockInvalidated)),
26022            "the incomplete preview must be invalidated, not delivered"
26023        );
26024        assert_eq!(subscriber.stream_gap_stats().preconfirmation_gaps(), 1);
26025        assert_eq!(subscriber.rpc_stats().total(), 0);
26026    }
26027
26028    /// Gap counters are diagnostics and must not perturb the delivery path.
26029    #[tokio::test]
26030    async fn resetting_gap_stats_opens_a_new_window() {
26031        let counters = Arc::new(SubscriberStreamGapCounters::default());
26032        counters.record_gap(SubscriberStreamGap::Lagged { skipped: 4 });
26033        counters.record_gap(SubscriberStreamGap::Undecodable);
26034        counters.record_header_gap();
26035        assert_eq!(counters.snapshot().total_gaps(), 5);
26036
26037        counters.reset();
26038
26039        assert_eq!(counters.snapshot(), SubscriberStreamGapStats::default());
26040    }
26041
26042    /// Labels are part of the diagnostic contract for a metrics export.
26043    #[test]
26044    fn gap_labels_are_stable() {
26045        assert_eq!(
26046            SubscriberStreamGap::Lagged { skipped: 7 }.as_str(),
26047            "lagged"
26048        );
26049        assert_eq!(SubscriberStreamGap::Undecodable.as_str(), "undecodable");
26050        assert_eq!(
26051            SubscriberStreamGap::Lagged { skipped: 7 }.to_string(),
26052            "lagged(7)"
26053        );
26054        assert_eq!(SubscriberRpcCause::GapBackfill.as_str(), "gap_backfill");
26055        assert_eq!(SubscriberStreamGap::Undecodable.skipped(), None);
26056    }
26057}
26058
26059/// The attestation must never outrun what the subscriber actually observed
26060/// whole. These tests drive the watermark directly, because the interesting
26061/// cases are the ones where it must *refuse* to advance.
26062#[cfg(all(test, feature = "reactive-ws"))]
26063mod log_coverage_attestation_tests {
26064    use super::*;
26065    use alloy_provider::ProviderBuilder;
26066    use alloy_rpc_types_eth::Header as RpcHeader;
26067    use alloy_transport::mock::Asserter;
26068
26069    fn header_record(number: u64) -> ReactiveInputRecord<Ethereum> {
26070        block_header_input_record::<Ethereum>(RpcHeader {
26071            hash: B256::repeat_byte(number as u8),
26072            inner: alloy_consensus::Header {
26073                number,
26074                parent_hash: B256::repeat_byte((number - 1) as u8),
26075                timestamp: 1_700_000_000 + number,
26076                ..alloy_consensus::Header::default()
26077            },
26078            total_difficulty: None,
26079            size: None,
26080        })
26081    }
26082
26083    fn subscriber(
26084        mode: SubscriberMode,
26085        with_log_interest: bool,
26086    ) -> AlloySubscriber<impl alloy_provider::Provider<Ethereum> + Clone, Ethereum> {
26087        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
26088        let mut subscriber = AlloySubscriber::new(provider, mode, SubscriberConfig::default());
26089        if with_log_interest {
26090            subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
26091                provider_filter: Filter::new().address(Address::repeat_byte(0x77)),
26092                local_matcher: None,
26093                route_key: None,
26094            })];
26095        }
26096        subscriber
26097    }
26098
26099    fn queued_coverage(
26100        subscriber: &mut AlloySubscriber<impl alloy_provider::Provider<Ethereum> + Clone, Ethereum>,
26101    ) -> Vec<u64> {
26102        subscriber.queue_log_coverage_attestation();
26103        subscriber
26104            .pending_chain_controls
26105            .drain(..)
26106            .filter_map(|control| match control {
26107                ChainControl::LogCoverage(block) => Some(block.number),
26108                _ => None,
26109            })
26110            .collect()
26111    }
26112
26113    #[test]
26114    fn attestation_advances_with_observed_canonical_headers() {
26115        let mut subscriber = subscriber(SubscriberMode::PubSub, true);
26116
26117        subscriber.note_attestable_canonical_block(&header_record(101));
26118        assert_eq!(queued_coverage(&mut subscriber), vec![101]);
26119
26120        // Re-attesting the same block says nothing new and must not be emitted.
26121        assert!(queued_coverage(&mut subscriber).is_empty());
26122
26123        subscriber.note_attestable_canonical_block(&header_record(102));
26124        assert_eq!(queued_coverage(&mut subscriber), vec![102]);
26125    }
26126
26127    /// The safety property: a gap discovered after a header was observed must
26128    /// withdraw that header's candidacy. Attesting it would tell the consumer a
26129    /// block was whole on the strength of an observation made before the loss
26130    /// was known.
26131    #[test]
26132    fn a_detected_gap_withdraws_the_pending_attestation() {
26133        let mut subscriber = subscriber(SubscriberMode::PubSub, true);
26134        subscriber.note_attestable_canonical_block(&header_record(101));
26135
26136        subscriber.reset_log_attestation();
26137
26138        assert!(
26139            queued_coverage(&mut subscriber).is_empty(),
26140            "a withdrawn candidate must not be attested"
26141        );
26142
26143        // Only a header observed after the gap re-establishes the watermark.
26144        subscriber.note_attestable_canonical_block(&header_record(102));
26145        assert_eq!(queued_coverage(&mut subscriber), vec![102]);
26146    }
26147
26148    /// A withdrawn candidate must not let a *lower* block be attested later
26149    /// either — the watermark is monotonic at the source, not just downstream.
26150    #[test]
26151    fn attestation_never_regresses_after_a_gap() {
26152        let mut subscriber = subscriber(SubscriberMode::PubSub, true);
26153        subscriber.note_attestable_canonical_block(&header_record(105));
26154        assert_eq!(queued_coverage(&mut subscriber), vec![105]);
26155
26156        subscriber.reset_log_attestation();
26157        subscriber.note_attestable_canonical_block(&header_record(103));
26158
26159        assert!(
26160            queued_coverage(&mut subscriber).is_empty(),
26161            "an older block must never be attested after a newer one"
26162        );
26163    }
26164
26165    /// The polling transport's watcher cannot observe a dropped notification, so
26166    /// it must neither claim the capability nor emit the control.
26167    #[test]
26168    #[cfg(feature = "reactive-polling")]
26169    fn polling_transport_neither_claims_nor_emits_the_attestation() {
26170        let mut subscriber = subscriber(SubscriberMode::Polling, true);
26171        assert!(!subscriber.attests_log_coverage());
26172
26173        subscriber.note_attestable_canonical_block(&header_record(101));
26174
26175        assert!(queued_coverage(&mut subscriber).is_empty());
26176        assert!(
26177            !EventSubscriber::capabilities(&subscriber)
26178                .supports(SubscriberCapability::LogCoverageAttestation)
26179        );
26180    }
26181
26182    #[test]
26183    fn pubsub_transport_claims_the_capability() {
26184        let subscriber = subscriber(SubscriberMode::PubSub, true);
26185        assert!(
26186            EventSubscriber::capabilities(&subscriber)
26187                .supports(SubscriberCapability::LogCoverageAttestation)
26188        );
26189    }
26190
26191    /// Nothing to attest about without log interests, so stay silent rather than
26192    /// emit a vacuously true watermark a consumer might lean on.
26193    #[test]
26194    fn subscriber_without_log_interests_stays_silent() {
26195        let mut subscriber = subscriber(SubscriberMode::PubSub, false);
26196        assert!(!subscriber.attests_log_coverage());
26197
26198        subscriber.note_attestable_canonical_block(&header_record(101));
26199
26200        assert!(queued_coverage(&mut subscriber).is_empty());
26201    }
26202}
26203
26204/// The canonical head poll exists because a Flashblocks endpoint's `newHeads`
26205/// may carry partial heads — but a fixed interval spends a request whether or
26206/// not anything sealed. These tests pin that a certification driven by the
26207/// flashblock stream suppresses the redundant tick, and that the timer still
26208/// works unaided when no such signal exists.
26209#[cfg(all(test, feature = "reactive-ws"))]
26210mod canonical_head_suppression_tests {
26211    use super::*;
26212    use alloy_provider::ProviderBuilder;
26213    use alloy_rpc_types_eth::{Block, Header as RpcHeader};
26214    use alloy_transport::mock::Asserter;
26215
26216    fn sealed_head(number: u64) -> Block {
26217        Block::empty(RpcHeader {
26218            hash: B256::repeat_byte(number as u8),
26219            inner: alloy_consensus::Header {
26220                number,
26221                parent_hash: B256::repeat_byte((number - 1) as u8),
26222                timestamp: 1_700_000_000 + number,
26223                ..alloy_consensus::Header::default()
26224            },
26225            total_difficulty: None,
26226            size: None,
26227        })
26228    }
26229
26230    fn subscriber(
26231        asserter: Asserter,
26232    ) -> AlloySubscriber<impl alloy_provider::Provider<Ethereum> + Clone, Ethereum> {
26233        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
26234        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
26235            provider,
26236            SubscriberMode::PubSub,
26237            SubscriberConfig::default(),
26238        );
26239        subscriber.chain_id = Some(8_453);
26240        subscriber.interests = vec![ReactiveInterest::Blocks(BlockInterest::default())];
26241        subscriber
26242    }
26243
26244    /// A tick inside the window after a certification issues no request. This is
26245    /// the whole saving: on a chain whose blocks seal faster than the interval,
26246    /// most ticks cost nothing.
26247    #[tokio::test]
26248    async fn a_tick_inside_the_window_after_a_certification_costs_nothing() {
26249        let asserter = Asserter::new();
26250        asserter.push_success(&Some(sealed_head(101)));
26251        let mut subscriber = subscriber(asserter.clone());
26252
26253        // One real certification, as the flashblock stream would drive.
26254        let certified = subscriber
26255            .certify_canonical_head_on_sealed_block()
26256            .await
26257            .expect("certification succeeds");
26258        assert!(matches!(
26259            certified,
26260            Some(SubscriberEvent::BlockHeader { .. })
26261        ));
26262
26263        // A tick immediately afterwards is inside the poll window.
26264        assert!(subscriber.canonical_head_certification_is_current());
26265        let suppressed = subscriber
26266            .certify_canonical_head_on_sealed_block()
26267            .await
26268            .expect("a suppressed certification is not an error");
26269
26270        assert!(suppressed.is_none());
26271        assert_eq!(
26272            subscriber.rpc_stats().get(
26273                SubscriberRpcCause::CanonicalHeadCertification,
26274                SubscriberRpcMethod::EthGetBlockByNumber,
26275            ),
26276            1,
26277            "only the first certification may spend a request"
26278        );
26279        assert!(
26280            asserter.read_q().is_empty(),
26281            "the suppressed call must not consume a queued response"
26282        );
26283    }
26284
26285    /// Once the window lapses the certification runs again, so a stalled
26286    /// flashblock stream degrades to the previous polling behaviour rather than
26287    /// to silence.
26288    #[tokio::test]
26289    async fn certification_resumes_once_the_window_lapses() {
26290        let asserter = Asserter::new();
26291        asserter.push_success(&Some(sealed_head(101)));
26292        asserter.push_success(&Some(sealed_head(102)));
26293        let mut subscriber = subscriber(asserter.clone());
26294
26295        let _ = subscriber
26296            .certify_canonical_head_on_sealed_block()
26297            .await
26298            .expect("first certification");
26299
26300        // Age the record past the poll interval.
26301        subscriber.last_canonical_head_certification = Some(
26302            Instant::now()
26303                - subscriber.config.canonical_head_poll_interval
26304                - Duration::from_millis(1),
26305        );
26306        assert!(!subscriber.canonical_head_certification_is_current());
26307
26308        let second = subscriber
26309            .certify_canonical_head_on_sealed_block()
26310            .await
26311            .expect("second certification");
26312
26313        assert!(matches!(second, Some(SubscriberEvent::BlockHeader { .. })));
26314        assert_eq!(
26315            subscriber
26316                .rpc_stats()
26317                .by_cause(SubscriberRpcCause::CanonicalHeadCertification),
26318            2
26319        );
26320        assert!(asserter.read_q().is_empty());
26321    }
26322
26323    /// A subscriber with no block-header interest has no head to certify, so the
26324    /// signal must not manufacture a request.
26325    #[tokio::test]
26326    async fn without_a_header_interest_nothing_is_certified() {
26327        let asserter = Asserter::new();
26328        let mut subscriber = subscriber(asserter.clone());
26329        subscriber.interests = Vec::new();
26330
26331        let certified = subscriber
26332            .certify_canonical_head_on_sealed_block()
26333            .await
26334            .expect("no interest is not an error");
26335
26336        assert!(certified.is_none());
26337        assert_eq!(subscriber.rpc_stats().total(), 0);
26338        assert!(asserter.read_q().is_empty());
26339    }
26340}