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::{HashMap, HashSet, VecDeque},
18    fmt,
19    future::Future,
20    hash::Hash,
21    marker::PhantomData,
22    num::NonZeroU64,
23    pin::Pin,
24    sync::{
25        Arc,
26        atomic::{AtomicU64, Ordering},
27    },
28    time::Duration,
29};
30
31use alloy_consensus::{BlockHeader as _, Transaction as _};
32use alloy_eips::BlockId;
33use alloy_network::{
34    Ethereum, Network,
35    primitives::{
36        BlockResponse as _, HeaderResponse as HeaderResponseTrait,
37        TransactionResponse as TransactionResponseTrait,
38    },
39};
40use alloy_primitives::{Address, B256, Bytes, U256};
41use alloy_provider::Provider;
42use alloy_rpc_types_eth::{Filter, FilterSet, Log};
43#[cfg(any(feature = "reactive-ws", feature = "reactive-polling", test))]
44use futures::{StreamExt, stream};
45use futures::{future::poll_fn, stream::BoxStream};
46
47use crate::{
48    cache::{AccountProof, BlockStateDiff, EvmCache},
49    errors::{BlockContextError, StorageFetchResult},
50    events::{EventDecoder, StateView},
51    freshness::FreshnessRegistry,
52    state_update::{AccountPatch, PurgeScope, StateDiff, StateUpdate},
53};
54
55/// Input accepted by the reactive runtime.
56#[derive(Clone, Debug, PartialEq, Eq)]
57pub enum ReactiveInput<N: Network = Ethereum> {
58    /// A canonical or removed EVM log, using Alloy's RPC log type.
59    Log(Log),
60    /// A block header response for header-oriented handlers.
61    BlockHeader(N::HeaderResponse),
62    /// A full block response for block handlers that need transaction bodies.
63    FullBlock(N::BlockResponse),
64    /// A pending transaction hash.
65    PendingTxHash(B256),
66    /// A full pending transaction body.
67    PendingTx(N::TransactionResponse),
68}
69
70/// Context supplied with each [`ReactiveInput`].
71#[derive(Clone, Debug, PartialEq, Eq)]
72pub struct ReactiveContext {
73    /// Chain id, when known.
74    pub chain_id: Option<u64>,
75    /// Where the input came from.
76    pub source: InputSource,
77    /// Lifecycle status of the input.
78    pub chain_status: ChainStatus,
79    /// Block metadata associated with the input, when known.
80    pub block: Option<BlockRef>,
81    /// Transaction index for log or transaction inputs.
82    pub transaction_index: Option<u64>,
83    /// Log index for log inputs.
84    pub log_index: Option<u64>,
85}
86
87/// Minimal block identity carried through reports.
88#[derive(Clone, Debug, PartialEq, Eq, Hash)]
89pub struct BlockRef {
90    /// Block number.
91    pub number: u64,
92    /// Block hash.
93    pub hash: B256,
94    /// Parent hash, when known.
95    pub parent_hash: Option<B256>,
96    /// Block timestamp, when known.
97    pub timestamp: Option<u64>,
98}
99
100/// Lifecycle status for an input.
101#[derive(Clone, Debug, PartialEq, Eq)]
102pub enum ChainStatus {
103    /// The input is mempool-only and must not mutate canonical cache state.
104    Pending,
105    /// The input is included in a block with a confirmation count.
106    Included {
107        /// Included block.
108        block: BlockRef,
109        /// Confirmation count.
110        confirmations: u64,
111    },
112    /// The input is in the chain's safe head.
113    Safe {
114        /// Safe block.
115        block: BlockRef,
116    },
117    /// The input is in the finalized head.
118    Finalized {
119        /// Finalized block.
120        block: BlockRef,
121    },
122    /// The input was dropped by a reorg.
123    Reorged {
124        /// Block the input was dropped from.
125        dropped_from: BlockRef,
126    },
127}
128
129/// Source of an input batch.
130#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
131pub enum InputSource {
132    /// Caller-supplied batch.
133    Batch,
134    /// Live subscription stream.
135    Subscription,
136    /// Polling subscriber.
137    Poll,
138    /// Historical backfill.
139    Backfill,
140    /// Test or synthetic input.
141    Synthetic,
142}
143
144/// Stable identity used for input deduplication and reports.
145#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
146pub enum InputRef {
147    /// Stable log identity.
148    Log {
149        /// Chain id, when known.
150        chain_id: Option<u64>,
151        /// Block hash containing the log.
152        block_hash: B256,
153        /// Transaction hash that emitted the log.
154        transaction_hash: B256,
155        /// Log index within the block.
156        log_index: u64,
157    },
158    /// Stable pending transaction identity.
159    PendingTx {
160        /// Chain id, when known.
161        chain_id: Option<u64>,
162        /// Transaction hash.
163        hash: B256,
164    },
165    /// Stable block identity.
166    Block {
167        /// Chain id, when known.
168        chain_id: Option<u64>,
169        /// Block hash.
170        hash: B256,
171        /// Block number.
172        number: u64,
173    },
174}
175
176/// Reliability of state effects emitted by a handler.
177#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
178pub enum StateEffectQuality {
179    /// Effects are exact from the input alone.
180    ExactFromInput,
181    /// Effects were applied, but follow-up resync is pending.
182    AppliedWithPendingResync,
183    /// Effects came from authoritative resync.
184    ResyncedAuthoritatively,
185    /// State requires repair before it should be trusted.
186    RequiresRepair,
187    /// No canonical state effect was emitted.
188    NoStateEffect,
189}
190
191/// Identifier for a reactive handler.
192#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
193pub struct HandlerId(String);
194
195impl HandlerId {
196    /// Create a handler id.
197    pub fn new(id: impl Into<String>) -> Self {
198        Self(id.into())
199    }
200
201    /// Return the id as a string slice.
202    pub fn as_str(&self) -> &str {
203        &self.0
204    }
205}
206
207impl fmt::Display for HandlerId {
208    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209        self.0.fmt(f)
210    }
211}
212
213/// Lightweight report label.
214#[derive(Clone, Debug, PartialEq, Eq, Hash)]
215pub struct ReportTag {
216    /// Label key.
217    pub key: String,
218    /// Label value.
219    pub value: String,
220}
221
222impl ReportTag {
223    /// Create a report tag.
224    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
225        Self {
226            key: key.into(),
227            value: value.into(),
228        }
229    }
230}
231
232/// Domain-neutral hook signal emitted by a handler.
233#[derive(Clone)]
234pub struct HookSignal {
235    /// Signal namespace owned by the caller.
236    pub namespace: Cow<'static, str>,
237    /// Signal kind within the namespace.
238    pub kind: Cow<'static, str>,
239    /// Additional labels for routing or observability.
240    pub labels: Vec<ReportTag>,
241    /// Optional in-process typed payload.
242    pub payload: Option<Arc<dyn Any + Send + Sync>>,
243}
244
245impl fmt::Debug for HookSignal {
246    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247        f.debug_struct("HookSignal")
248            .field("namespace", &self.namespace)
249            .field("kind", &self.kind)
250            .field("labels", &self.labels)
251            .field("payload", &self.payload.as_ref().map(|_| "<payload>"))
252            .finish()
253    }
254}
255
256/// Effect emitted by a [`ReactiveHandler`].
257#[derive(Clone, Debug)]
258pub enum ReactiveEffect {
259    /// Canonical cache mutation applied through [`EvmCache::apply_updates`].
260    StateUpdate(StateUpdate),
261    /// Request for authoritative state repair.
262    Resync(ResyncRequest),
263    /// Rich invalidation request lowered to [`StateUpdate::Purge`].
264    Invalidate(InvalidationRequest),
265    /// Hook signal dispatched after committed mutation phases.
266    Hook(HookSignal),
267    /// Speculative signal for mempool or downstream work.
268    Speculative(SpeculativeRequest),
269}
270
271/// Handler output for a single input.
272#[derive(Clone, Debug)]
273pub struct HandlerOutcome {
274    /// Effects emitted by the handler.
275    pub effects: Vec<ReactiveEffect>,
276    /// Reliability of emitted state effects.
277    pub quality: StateEffectQuality,
278    /// Labels copied into reports.
279    pub tags: Vec<ReportTag>,
280}
281
282impl HandlerOutcome {
283    /// Construct an empty outcome with the supplied quality.
284    pub fn empty(quality: StateEffectQuality) -> Self {
285        Self {
286            effects: Vec::new(),
287            quality,
288            tags: Vec::new(),
289        }
290    }
291}
292
293/// One input and its execution context.
294#[derive(Clone, Debug)]
295pub struct ReactiveInputRecord<N: Network = Ethereum> {
296    /// Input value.
297    pub input: ReactiveInput<N>,
298    /// Input context.
299    pub context: ReactiveContext,
300}
301
302impl<N: Network> ReactiveInputRecord<N> {
303    /// Create an input record.
304    pub fn new(input: ReactiveInput<N>, context: ReactiveContext) -> Self {
305        Self { input, context }
306    }
307
308    /// Compute the stable input reference used for deduplication.
309    pub fn input_ref(&self) -> InputRef {
310        input_ref(&self.input, &self.context)
311    }
312}
313
314/// Batch of reactive input records.
315#[derive(Clone, Debug)]
316pub struct ReactiveInputBatch<N: Network = Ethereum> {
317    records: Vec<ReactiveInputRecord<N>>,
318}
319
320impl<N: Network> ReactiveInputBatch<N> {
321    /// Create a batch from records.
322    pub fn new(records: Vec<ReactiveInputRecord<N>>) -> Self {
323        Self { records }
324    }
325
326    /// Borrow the records in this batch.
327    pub fn records(&self) -> &[ReactiveInputRecord<N>] {
328        &self.records
329    }
330
331    /// Consume the batch into its records.
332    pub fn into_records(self) -> Vec<ReactiveInputRecord<N>> {
333        self.records
334    }
335}
336
337/// Pure synchronous handler for reactive inputs.
338pub trait ReactiveHandler<N: Network = Ethereum>: Send + Sync {
339    /// Stable handler id.
340    fn id(&self) -> HandlerId;
341
342    /// Interests used by subscribers and the local router.
343    fn interests(&self) -> Vec<ReactiveInterest<N>>;
344
345    /// Handle one input against a read-only cache view.
346    fn handle(
347        &self,
348        ctx: &ReactiveContext,
349        input: &ReactiveInput<N>,
350        state: &dyn StateView,
351    ) -> Result<HandlerOutcome, HandlerError>;
352}
353
354/// Hook invoked after reports are built and cache mutation phases have ended.
355pub trait ReactiveHook<N: Network = Ethereum>: Send + Sync {
356    /// Observe a runtime report.
357    fn on_report(&self, report: Arc<ReactiveReport<N>>);
358}
359
360/// Reactive subscription interest.
361#[allow(clippy::large_enum_variant)]
362#[derive(Clone)]
363pub enum ReactiveInterest<N: Network = Ethereum> {
364    /// Log interest.
365    Logs(LogInterest),
366    /// Block interest.
367    Blocks(BlockInterest),
368    /// Pending transaction interest.
369    PendingTransactions(PendingTxInterest<N>),
370}
371
372impl<N: Network> fmt::Debug for ReactiveInterest<N> {
373    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
374        match self {
375            Self::Logs(interest) => f.debug_tuple("Logs").field(interest).finish(),
376            Self::Blocks(interest) => f.debug_tuple("Blocks").field(interest).finish(),
377            Self::PendingTransactions(interest) => f
378                .debug_tuple("PendingTransactions")
379                .field(interest)
380                .finish(),
381        }
382    }
383}
384
385/// Interest in logs.
386#[derive(Clone)]
387pub struct LogInterest {
388    /// Provider-side filter.
389    pub provider_filter: Filter,
390    /// Optional local matcher for predicates providers cannot express.
391    pub local_matcher: Option<Arc<dyn LogMatcher>>,
392    /// Optional route-key extraction strategy.
393    pub route_key: Option<RouteKeySpec>,
394}
395
396impl LogInterest {
397    /// Return true if the log matches both the provider filter and local matcher.
398    pub fn matches(&self, log: &Log) -> bool {
399        self.provider_filter.rpc_matches(log)
400            && self
401                .local_matcher
402                .as_ref()
403                .is_none_or(|matcher| matcher.matches(log))
404    }
405
406    /// Extract the route key for a matching log, if configured.
407    pub fn route_key(&self, log: &Log) -> Option<RouteKey> {
408        self.route_key.as_ref().and_then(|spec| spec.extract(log))
409    }
410}
411
412impl fmt::Debug for LogInterest {
413    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
414        f.debug_struct("LogInterest")
415            .field("provider_filter", &self.provider_filter)
416            .field(
417                "local_matcher",
418                &self.local_matcher.as_ref().map(|_| "<matcher>"),
419            )
420            .field("route_key", &self.route_key)
421            .finish()
422    }
423}
424
425/// Local log predicate.
426pub trait LogMatcher: Send + Sync {
427    /// Return true when the log should be routed to the handler.
428    fn matches(&self, log: &Log) -> bool;
429}
430
431/// Route-key extraction strategy for logs.
432#[derive(Clone)]
433pub enum RouteKeySpec {
434    /// Route by emitting address.
435    EmitterAddress,
436    /// Route by indexed topic.
437    Topic {
438        /// Topic index.
439        index: usize,
440    },
441    /// Route by a byte slice in log data.
442    DataSlice {
443        /// Byte offset in the data payload.
444        offset: usize,
445        /// Number of bytes to copy.
446        len: usize,
447    },
448    /// Custom extractor.
449    Custom(Arc<dyn RouteKeyExtractor>),
450}
451
452impl RouteKeySpec {
453    /// Extract a route key from a log.
454    pub fn extract(&self, log: &Log) -> Option<RouteKey> {
455        match self {
456            Self::EmitterAddress => Some(RouteKey::Address(log.address())),
457            Self::Topic { index } => log.topics().get(*index).copied().map(RouteKey::Bytes32),
458            Self::DataSlice { offset, len } => {
459                let data = log.inner.data.data.as_ref();
460                let end = offset.checked_add(*len)?;
461                data.get(*offset..end)
462                    .map(|bytes| RouteKey::Bytes(bytes.to_vec()))
463            }
464            Self::Custom(extractor) => extractor.extract(log),
465        }
466    }
467}
468
469impl fmt::Debug for RouteKeySpec {
470    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
471        match self {
472            Self::EmitterAddress => f.write_str("EmitterAddress"),
473            Self::Topic { index } => f.debug_struct("Topic").field("index", index).finish(),
474            Self::DataSlice { offset, len } => f
475                .debug_struct("DataSlice")
476                .field("offset", offset)
477                .field("len", len)
478                .finish(),
479            Self::Custom(_) => f.write_str("Custom(<extractor>)"),
480        }
481    }
482}
483
484/// Extracts custom route keys from logs.
485pub trait RouteKeyExtractor: Send + Sync {
486    /// Extract a route key.
487    fn extract(&self, log: &Log) -> Option<RouteKey>;
488}
489
490/// Extracted route key.
491#[derive(Clone, Debug, PartialEq, Eq, Hash)]
492pub enum RouteKey {
493    /// Address key.
494    Address(Address),
495    /// 32-byte key.
496    Bytes32(B256),
497    /// Arbitrary bytes key.
498    Bytes(Vec<u8>),
499}
500
501/// Exact log route selected by [`ReactiveRegistry::route_log`].
502#[derive(Clone, Debug, PartialEq, Eq)]
503pub struct ReactiveLogRoute {
504    /// Handler whose log interest matched.
505    pub handler_id: HandlerId,
506    /// Optional route key extracted from the matching log interest.
507    pub route_key: Option<RouteKey>,
508}
509
510/// Interest in block inputs.
511#[derive(Clone, Debug, PartialEq, Eq, Hash)]
512pub struct BlockInterest {
513    /// Block input mode.
514    pub mode: BlockInterestMode,
515}
516
517impl Default for BlockInterest {
518    fn default() -> Self {
519        Self {
520            mode: BlockInterestMode::Header,
521        }
522    }
523}
524
525/// Block subscription mode.
526#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
527pub enum BlockInterestMode {
528    /// Header-only block input.
529    Header,
530    /// Full block input.
531    FullBlock,
532}
533
534/// Interest in pending transaction inputs.
535#[derive(Clone)]
536pub struct PendingTxInterest<N: Network = Ethereum> {
537    /// Whether the handler requires full transaction bodies.
538    pub full_transactions: bool,
539    /// Sender matcher.
540    pub from: AddressMatcher,
541    /// Recipient matcher.
542    pub to: AddressMatcher,
543    /// Calldata selector matcher.
544    pub selectors: SelectorMatcher,
545    /// Optional local transaction matcher.
546    pub local_matcher: Option<Arc<dyn PendingTxMatcher<N>>>,
547}
548
549impl<N: Network> Default for PendingTxInterest<N> {
550    fn default() -> Self {
551        Self {
552            full_transactions: false,
553            from: AddressMatcher::Any,
554            to: AddressMatcher::Any,
555            selectors: SelectorMatcher::Any,
556            local_matcher: None,
557        }
558    }
559}
560
561impl<N: Network> PendingTxInterest<N> {
562    fn matches_hash_only(&self) -> bool {
563        !self.full_transactions
564            && self.from.is_any()
565            && self.to.is_any()
566            && self.selectors.is_any()
567            && self.local_matcher.is_none()
568    }
569
570    fn matches_tx(&self, tx: &N::TransactionResponse) -> bool {
571        self.from.matches(tx.from())
572            && self.to.matches_option(tx.to())
573            && self.selectors.matches(tx.input())
574            && self
575                .local_matcher
576                .as_ref()
577                .is_none_or(|matcher| matcher.matches(tx))
578    }
579}
580
581impl<N: Network> fmt::Debug for PendingTxInterest<N> {
582    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
583        f.debug_struct("PendingTxInterest")
584            .field("full_transactions", &self.full_transactions)
585            .field("from", &self.from)
586            .field("to", &self.to)
587            .field("selectors", &self.selectors)
588            .field(
589                "local_matcher",
590                &self.local_matcher.as_ref().map(|_| "<matcher>"),
591            )
592            .finish()
593    }
594}
595
596/// Address matching helper for pending transaction interests.
597#[derive(Clone, Debug, PartialEq, Eq, Hash)]
598pub enum AddressMatcher {
599    /// Match every address.
600    Any,
601    /// Match one address.
602    Exact(Address),
603    /// Match any address in the list.
604    AnyOf(Vec<Address>),
605}
606
607impl AddressMatcher {
608    /// Return true when the matcher is unconstrained.
609    pub fn is_any(&self) -> bool {
610        matches!(self, Self::Any)
611    }
612
613    /// Match a present address.
614    pub fn matches(&self, address: Address) -> bool {
615        match self {
616            Self::Any => true,
617            Self::Exact(expected) => *expected == address,
618            Self::AnyOf(addresses) => addresses.contains(&address),
619        }
620    }
621
622    /// Match an optional address.
623    pub fn matches_option(&self, address: Option<Address>) -> bool {
624        match (self, address) {
625            (Self::Any, _) => true,
626            (_, Some(address)) => self.matches(address),
627            _ => false,
628        }
629    }
630}
631
632/// Calldata selector matching helper.
633#[derive(Clone, Debug, PartialEq, Eq, Hash)]
634pub enum SelectorMatcher {
635    /// Match every selector.
636    Any,
637    /// Match any selector in the list.
638    AnyOf(Vec<[u8; 4]>),
639}
640
641impl SelectorMatcher {
642    /// Return true when the matcher is unconstrained.
643    pub fn is_any(&self) -> bool {
644        matches!(self, Self::Any)
645    }
646
647    /// Match calldata bytes.
648    pub fn matches(&self, input: &Bytes) -> bool {
649        match self {
650            Self::Any => true,
651            Self::AnyOf(selectors) => input
652                .get(..4)
653                .and_then(|bytes| bytes.try_into().ok())
654                .is_some_and(|selector| selectors.contains(&selector)),
655        }
656    }
657}
658
659/// Local predicate over a full pending transaction.
660pub trait PendingTxMatcher<N: Network = Ethereum>: Send + Sync {
661    /// Return true when the transaction should be routed to the handler.
662    fn matches(&self, tx: &N::TransactionResponse) -> bool;
663}
664
665/// How a tracked account is kept live by the per-block root gate (Phase-8 step 4).
666///
667/// The `storageHash` root gate behaves *oppositely* for two contract shapes, so
668/// liveness strategy is per-contract:
669///
670/// - A sparse-interest contract (a few balance slots, e.g. WETH) has its root
671///   churn on nearly every block, so the root is a noisy gate — [`Slots`] opts
672///   out. Its enumerated slots stay fresh via decoders + cadence reconcile.
673/// - A whole-economic-state contract (e.g. a Uniswap-V2 pool) has
674///   `root_moved ≈ my_state_changed`, so [`WholeAccount`] opts in: probe the root
675///   each canonical block; a move a decoder did not cover is a coverage gap.
676///
677/// A false-positive resync is never *incorrect* — it costs one batched read — so
678/// the policy is a **pure cost knob**, not a correctness lever.
679///
680/// [`Slots`]: TrackingPolicy::Slots
681/// [`WholeAccount`]: TrackingPolicy::WholeAccount
682#[derive(Clone, Debug)]
683#[non_exhaustive]
684pub enum TrackingPolicy {
685    /// Sparse interest (e.g. WETH: a few balance slots). The root churns on
686    /// nearly every block, so it is a noisy gate — this policy is **never**
687    /// root-gated (spec Decision 3). Keep the enumerated slots fresh via decoders
688    /// and cadence reconcile.
689    Slots {
690        /// The enumerated storage slots of interest.
691        slots: Vec<U256>,
692    },
693    /// Whole economic state (e.g. a V2 pool). `root_moved ≈ my_state_changed`, so
694    /// the root is a tight, cheap gate: probe each canonical block; on a move no
695    /// decoder covered, emit a [`ReactiveReport::CoverageGap`] and schedule a
696    /// [`ResyncReason::RootMoved`] repair.
697    WholeAccount,
698    /// Balance / nonce / code-hash only — resolved from the same `get_proof`
699    /// response's account fields; no storage interest. Native balance/nonce
700    /// changes do **not** move the storage root, so this policy compares the
701    /// account fields directly across blocks rather than root-gating.
702    Scalars,
703}
704
705/// How often the reactive root gate probes tracked accounts
706/// ([`TrackingPolicy::WholeAccount`] / [`TrackingPolicy::Scalars`]; the
707/// `Scalars` account-fields comparison rides the same firing).
708///
709/// `eth_getProof` is the slowest read this crate issues, so per-block probing
710/// is never the default. Skipping blocks is safe by construction: the gate
711/// diffs `root_now` against its **persisted baseline**, never
712/// block-over-block, so a move in any skipped block is still visible at the
713/// next firing — cadence trades detection lag (at most `n − 1` blocks) for
714/// cost, never eventual detection. The decoder-touched set accumulates across
715/// skipped blocks and drains per firing, so a covered write in a skipped
716/// block never false-positives as a [`ReactiveReport::CoverageGap`].
717#[derive(Clone, Copy, Debug, PartialEq, Eq)]
718pub enum RootGateCadence {
719    /// Probe at most once every `n` canonical blocks (the first canonical
720    /// block ever seen always fires, so baseline adoption does not wait a
721    /// full window). `EveryNBlocks(1)` is per-block probing.
722    EveryNBlocks(NonZeroU64),
723    /// Root gate off: coverage gaps surface only via decoders + freshness.
724    Disabled,
725}
726
727impl RootGateCadence {
728    /// Probe at most once every `n` canonical blocks, clamping `0` to `1`.
729    pub fn every_n_blocks(n: u64) -> Self {
730        Self::EveryNBlocks(NonZeroU64::new(n.max(1)).expect("clamped to at least 1"))
731    }
732}
733
734impl Default for RootGateCadence {
735    /// Every 16 canonical blocks — ~3.2 min worst-case detection lag on
736    /// mainnet for a 16× probe-cost cut. Fast-block chains should *raise*
737    /// `n`, not lower it.
738    fn default() -> Self {
739        Self::every_n_blocks(16)
740    }
741}
742
743/// Per-account baseline held by the root gate: the last observed on-chain root
744/// and account fields, plus the block they were observed at.
745///
746/// The gate diffs the on-chain root **across time** (never local-vs-chain, per
747/// spec §6): it persists the *observed* root as a baseline and compares
748/// `root_now` to it. This is a currency gate, not a completeness gate.
749#[derive(Clone, Debug)]
750struct TrackedRoot {
751    last_root: B256,
752    last_block: u64,
753    balance: U256,
754    nonce: u64,
755    code_hash: B256,
756}
757
758/// Request for authoritative state repair.
759#[derive(Clone, Debug, PartialEq, Eq)]
760pub struct ResyncRequest {
761    /// Resync id.
762    pub id: ResyncId,
763    /// Reason for the request.
764    pub reason: ResyncReason,
765    /// Block selection for the read.
766    pub block: ResyncBlock,
767    /// Targets to resync.
768    pub targets: Vec<ResyncTarget>,
769    /// Scheduling priority.
770    pub priority: ResyncPriority,
771}
772
773/// Resync id.
774#[derive(Clone, Debug, PartialEq, Eq, Hash)]
775pub struct ResyncId(String);
776
777impl ResyncId {
778    /// Create a resync id.
779    pub fn new(id: impl Into<String>) -> Self {
780        Self(id.into())
781    }
782}
783
784/// Reason for a resync request.
785#[derive(Clone, Debug, PartialEq, Eq, Hash)]
786#[non_exhaustive]
787pub enum ResyncReason {
788    /// Handler requested repair.
789    HandlerRequested,
790    /// State effect could not be applied completely.
791    SkippedStateEffect,
792    /// A missed block range was detected; caller-scheduled repair.
793    ///
794    /// The runtime does not fabricate a targetless [`ResyncRequest`] for a missed
795    /// range (there are no known targets to resync). This reason is provided so a
796    /// caller building its own repair in response to a
797    /// [`ReactiveReport::MissedBlockRange`] can attribute it.
798    MissedBlockRange,
799    /// A tracked account's storage root moved with no covering decoder.
800    ///
801    /// Emitted by the per-block root gate (Phase-8 step 4). A
802    /// [`WholeAccount`](TrackingPolicy::WholeAccount)-tracked account's
803    /// `storageHash` moved between the adopted baseline and the current canonical
804    /// block, yet no decoder wrote that account during the block — a coverage gap.
805    /// The gate schedules a resync with this reason to re-read the account
806    /// authoritatively and self-heal the blind spot. Also used for the
807    /// [`Scalars`](TrackingPolicy::Scalars) account-field freshness path.
808    RootMoved,
809    /// Caller-defined reason.
810    Custom(String),
811}
812
813/// Block target for a resync.
814#[derive(Clone, Debug, PartialEq, Eq, Hash)]
815pub enum ResyncBlock {
816    /// Latest block.
817    Latest,
818    /// Safe head.
819    Safe,
820    /// Finalized head.
821    Finalized,
822    /// Block number.
823    Number(u64),
824    /// Block hash and number.
825    Hash {
826        /// Block number.
827        number: u64,
828        /// Block hash.
829        hash: B256,
830        /// Require the hash to still be canonical.
831        require_canonical: bool,
832    },
833}
834
835/// State target for a resync.
836#[derive(Clone, Debug, PartialEq, Eq, Hash)]
837pub enum ResyncTarget {
838    /// One storage slot.
839    StorageSlot {
840        /// Contract address.
841        address: Address,
842        /// Storage slot.
843        slot: U256,
844    },
845    /// Multiple storage slots on one contract.
846    StorageSlots {
847        /// Contract address.
848        address: Address,
849        /// Storage slots.
850        slots: Vec<U256>,
851    },
852    /// Account fields.
853    Account {
854        /// Account address.
855        address: Address,
856        /// Fields to resync.
857        fields: AccountFieldMask,
858    },
859}
860
861/// Account fields requested by a resync.
862#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
863pub struct AccountFieldMask {
864    /// Balance field.
865    pub balance: bool,
866    /// Nonce field.
867    pub nonce: bool,
868    /// Code field.
869    pub code: bool,
870}
871
872/// Resync priority.
873#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
874pub enum ResyncPriority {
875    /// Low priority.
876    Low,
877    /// Normal priority.
878    #[default]
879    Normal,
880    /// High priority.
881    High,
882}
883
884/// Rich invalidation request lowered to [`StateUpdate::Purge`].
885#[derive(Clone, Debug, PartialEq, Eq)]
886pub struct InvalidationRequest {
887    /// Purge scope.
888    pub scope: PurgeScope,
889    /// Address to purge.
890    pub address: Address,
891    /// Reason for reporting.
892    pub reason: InvalidationReason,
893}
894
895/// Invalidation reason.
896#[derive(Clone, Debug, PartialEq, Eq, Hash)]
897pub enum InvalidationReason {
898    /// Handler requested invalidation.
899    HandlerRequested,
900    /// Reorg invalidation.
901    Reorg,
902    /// Caller-defined reason.
903    Custom(String),
904}
905
906/// Speculative signal emitted by handlers.
907#[derive(Clone, Debug, PartialEq, Eq)]
908pub struct SpeculativeRequest {
909    /// Speculative request id.
910    pub id: SpeculativeId,
911    /// Input that triggered the request.
912    pub input_ref: InputRef,
913    /// Labels for downstream routing.
914    pub labels: Vec<ReportTag>,
915}
916
917/// Speculative request id.
918#[derive(Clone, Debug, PartialEq, Eq, Hash)]
919pub struct SpeculativeId(String);
920
921impl SpeculativeId {
922    /// Create a speculative id.
923    pub fn new(id: impl Into<String>) -> Self {
924        Self(id.into())
925    }
926}
927
928/// Configuration for [`ReactiveRuntime`].
929#[derive(Clone, Debug, PartialEq, Eq)]
930pub struct ReactiveConfig {
931    /// Hook backpressure policy. **Reserved — currently has no effect.** Hook
932    /// dispatch is synchronous today (every report is delivered to every hook in
933    /// order), so this field is a no-op placeholder for a future async dispatcher.
934    /// Setting it to anything other than the default does not change behavior.
935    pub hook_backpressure: HookBackpressure,
936    /// Reorg journal depth: the number of recent canonical blocks whose effects
937    /// are journaled for rollback. This is **load-bearing** for reorg recovery:
938    /// only blocks still resident in the journal can be recovered. A reorg deeper
939    /// than `journal_depth` recovers the blocks still in the journal and leaves
940    /// the aged-out blocks' effects in place — they are **neither rolled back nor
941    /// purged**, so the freshness/validation loop is the only backstop for that
942    /// span. `0` disables journaling entirely: no reorg is rolled back or purged.
943    ///
944    /// Set `journal_depth` to exceed the deepest reorg you intend to recover
945    /// precisely. When a reorg references a block that is no longer in the journal,
946    /// the runtime emits a `tracing::warn!` so the under-recovery is observable
947    /// rather than silent.
948    pub journal_depth: usize,
949}
950
951impl Default for ReactiveConfig {
952    fn default() -> Self {
953        Self {
954            hook_backpressure: HookBackpressure::Block,
955            journal_depth: 64,
956        }
957    }
958}
959
960/// Hook backpressure policy.
961#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
962pub enum HookBackpressure {
963    /// Block the producer until hooks are accepted.
964    Block,
965    /// Drop the newest report under pressure.
966    DropNewest,
967    /// Drop the oldest report under pressure.
968    DropOldest,
969    /// Return an error under pressure.
970    Error,
971}
972
973/// Queryable coarse health of the reactive cache.
974///
975/// The runtime starts [`Healthy`](CacheHealth::Healthy) and transitions to a
976/// degraded or unhealthy state when it detects that its recovery guarantees no
977/// longer hold (for example a reorg that runs deeper than the journal, so some
978/// dropped effects are neither rolled back nor purged). Later waves report
979/// missed-range and coverage-gap conditions into the same state machine.
980#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
981#[non_exhaustive]
982pub enum CacheHealth {
983    /// All recovery guarantees hold; the cache is fully self-consistent.
984    #[default]
985    Healthy,
986    /// A recoverable inconsistency was detected (for example under-recovered
987    /// reorg effects); `since_block` records the block that triggered the
988    /// transition.
989    Degraded {
990        /// Block number at which the degradation was first observed.
991        since_block: u64,
992    },
993    /// A more serious inconsistency was detected; `since_block` records the
994    /// block that triggered the transition.
995    Unhealthy {
996        /// Block number at which the unhealthy condition was first observed.
997        since_block: u64,
998    },
999}
1000
1001/// Point-in-time copy of the reactive runtime's observability counters.
1002///
1003/// Returned by [`ReactiveRuntime::metrics`]. Each field is a monotonically
1004/// increasing count over the lifetime of the runtime. Counters wired by later
1005/// waves (missed-range detection, storage-hash coverage gaps, stale-verdict
1006/// tracking) remain zero until those waves land.
1007#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1008#[non_exhaustive]
1009pub struct CacheMetricsSnapshot {
1010    /// Reorgs that ran deeper than the journal, so aged-out effects could not be
1011    /// rolled back or purged.
1012    pub deep_reorgs: u64,
1013    /// Reorgs for which a [`ReorgReport`] recovery ran (including deep reorgs).
1014    pub reorgs_recovered: u64,
1015    /// Storage resync targets considered by the resync execution pass.
1016    pub resync_requests: u64,
1017    /// Storage resync targets that could not be fetched or applied.
1018    pub resync_failures: u64,
1019    /// Ranges of blocks the runtime detected it did not observe (reserved).
1020    pub missed_ranges: u64,
1021    /// Storage-hash coverage gaps detected (reserved).
1022    pub coverage_gaps: u64,
1023    /// Pending-source inputs that attempted a canonical cache effect.
1024    pub pending_contamination: u64,
1025    /// Verdicts served past their freshness horizon (reserved).
1026    pub stale_verdicts: u64,
1027}
1028
1029/// Internal atomic-backed counters mirrored by [`CacheMetricsSnapshot`].
1030///
1031/// Fields are [`AtomicU64`] so counters can be incremented behind a shared
1032/// reference; [`ReactiveRuntime::metrics`] loads each with [`Ordering::Relaxed`]
1033/// into a plain [`CacheMetricsSnapshot`].
1034#[derive(Debug, Default)]
1035struct CacheMetrics {
1036    deep_reorgs: AtomicU64,
1037    reorgs_recovered: AtomicU64,
1038    resync_requests: AtomicU64,
1039    resync_failures: AtomicU64,
1040    missed_ranges: AtomicU64,
1041    coverage_gaps: AtomicU64,
1042    pending_contamination: AtomicU64,
1043    stale_verdicts: AtomicU64,
1044}
1045
1046impl CacheMetrics {
1047    fn snapshot(&self) -> CacheMetricsSnapshot {
1048        CacheMetricsSnapshot {
1049            deep_reorgs: self.deep_reorgs.load(Ordering::Relaxed),
1050            reorgs_recovered: self.reorgs_recovered.load(Ordering::Relaxed),
1051            resync_requests: self.resync_requests.load(Ordering::Relaxed),
1052            resync_failures: self.resync_failures.load(Ordering::Relaxed),
1053            missed_ranges: self.missed_ranges.load(Ordering::Relaxed),
1054            coverage_gaps: self.coverage_gaps.load(Ordering::Relaxed),
1055            pending_contamination: self.pending_contamination.load(Ordering::Relaxed),
1056            stale_verdicts: self.stale_verdicts.load(Ordering::Relaxed),
1057        }
1058    }
1059}
1060
1061/// Runtime report.
1062#[derive(Clone, Debug)]
1063#[non_exhaustive]
1064pub enum ReactiveReport<N: Network = Ethereum> {
1065    /// Input was accepted after deduplication.
1066    Input(InputReport<N>),
1067    /// Handlers produced outcomes.
1068    Decoded(DecodedReport<N>),
1069    /// Direct state effects were applied.
1070    Applied(AppliedReport<N>),
1071    /// Resync request was scheduled or completed.
1072    Resynced(ResyncReport),
1073    /// Block-level processing completed.
1074    BlockCommitted(BlockReport<N>),
1075    /// Reorg processing report.
1076    Reorg(ReorgReport<N>),
1077    /// A forward gap in the canonical block sequence was detected: blocks between
1078    /// the last-seen head and an arriving block were never observed.
1079    MissedBlockRange(MissedRangeReport<N>),
1080    /// Cache health transitioned between states.
1081    Health(HealthReport<N>),
1082    /// A tracked account's storage root moved with no covering decoder — a
1083    /// coverage gap the per-block root gate detected (Phase-8 step 4).
1084    CoverageGap(CoverageGapReport<N>),
1085    /// Runtime or handler error.
1086    Error(ReactiveErrorReport<N>),
1087}
1088
1089/// Input acceptance report.
1090#[derive(Clone, Debug)]
1091pub struct InputReport<N: Network = Ethereum> {
1092    /// Input reference.
1093    pub input_ref: InputRef,
1094    /// Input context.
1095    pub context: ReactiveContext,
1096    /// Network marker.
1097    pub _network: PhantomData<N>,
1098}
1099
1100/// Decoding report.
1101#[derive(Clone, Debug)]
1102pub struct DecodedReport<N: Network = Ethereum> {
1103    /// Input reference.
1104    pub input_ref: InputRef,
1105    /// Handler ids that matched the input.
1106    pub handler_ids: Vec<HandlerId>,
1107    /// Network marker.
1108    pub _network: PhantomData<N>,
1109}
1110
1111/// Applied state report.
1112#[derive(Clone, Debug)]
1113pub struct AppliedReport<N: Network = Ethereum> {
1114    /// Input reference.
1115    pub input_ref: InputRef,
1116    /// Handler that produced the applied effects.
1117    pub handler_id: HandlerId,
1118    /// State effect quality.
1119    pub quality: StateEffectQuality,
1120    /// Labels emitted by the handler.
1121    pub tags: Vec<ReportTag>,
1122    /// Merged state diff from applied updates and invalidations.
1123    pub diff: StateDiff,
1124    /// State updates applied through the cache.
1125    pub state_updates: Vec<StateUpdate>,
1126    /// Invalidation requests lowered to purge updates.
1127    pub invalidations: Vec<InvalidationRequest>,
1128    /// Resync requests surfaced for a scheduler.
1129    pub resyncs: Vec<ResyncRequest>,
1130    /// Speculative requests surfaced for downstream users.
1131    pub speculative: Vec<SpeculativeRequest>,
1132    /// Hook signals emitted by the handler.
1133    pub hook_signals: Vec<HookSignal>,
1134    /// Network marker.
1135    pub _network: PhantomData<N>,
1136}
1137
1138/// Report of the storage resync requests executed during an ingest cycle: the
1139/// requests considered, the authoritative updates built from successful fetches
1140/// (and their applied diff), and any targets that could not be resynced.
1141#[derive(Clone, Debug, Default, PartialEq, Eq)]
1142pub struct ResyncReport {
1143    /// Requests considered by the resync execution pass.
1144    pub requested: Vec<ResyncRequest>,
1145    /// Authoritative state updates built from successful resync fetches.
1146    pub state_updates: Vec<StateUpdate>,
1147    /// Diff returned by applying [`state_updates`](Self::state_updates).
1148    pub diff: StateDiff,
1149    /// Targets that could not be resynced.
1150    pub failed: Vec<ResyncFailure>,
1151}
1152
1153/// One resync target that could not be fetched or applied.
1154#[derive(Clone, Debug, PartialEq, Eq)]
1155pub struct ResyncFailure {
1156    /// Request that produced the failed target.
1157    pub request_id: ResyncId,
1158    /// Block selection used for the failed target.
1159    pub block: ResyncBlock,
1160    /// Target that could not be resynced.
1161    pub target: ResyncTarget,
1162    /// Stable failure classification for retry policy and metrics.
1163    pub kind: ResyncFailureKind,
1164    /// Human-readable failure reason.
1165    pub message: String,
1166}
1167
1168/// Stable classification for a failed resync target.
1169#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1170#[non_exhaustive]
1171pub enum ResyncFailureKind {
1172    /// A storage target could not be fetched because no storage batch fetcher is configured.
1173    MissingStorageFetcher,
1174    /// The storage batch fetcher returned an error for the requested slot.
1175    StorageFetchFailed,
1176    /// The storage batch fetcher did not return a result for the requested slot.
1177    StorageFetchOmitted,
1178    /// An account target could not be fetched because no account proof fetcher is configured.
1179    MissingAccountFetcher,
1180    /// The account proof fetcher returned an error for the requested address.
1181    AccountFetchFailed,
1182    /// The account proof fetcher did not return a result for the requested address.
1183    AccountFetchOmitted,
1184}
1185
1186/// Block processing report.
1187#[derive(Clone, Debug)]
1188pub struct BlockReport<N: Network = Ethereum> {
1189    /// Block reference, when known.
1190    pub block: Option<BlockRef>,
1191    /// Input references committed for the block.
1192    pub inputs: Vec<InputRef>,
1193    /// Network marker.
1194    pub _network: PhantomData<N>,
1195}
1196
1197/// Report of a detected reorg and the recovery it performed: the dropped
1198/// block(s) and inputs, the exact rollback updates applied for reversible dropped
1199/// effects, the conservative purge updates for irreversible ones, the canceled
1200/// hash-pinned resyncs, and why recovery ran.
1201///
1202/// Recovery only covers blocks still resident in the journal. If a reorg runs
1203/// deeper than [`ReactiveConfig::journal_depth`], the aged-out blocks do not
1204/// appear here and their effects are neither rolled back nor purged (the runtime
1205/// logs a `tracing::warn!` in that case); the freshness/validation loop is the
1206/// backstop for that span.
1207#[derive(Clone, Debug)]
1208pub struct ReorgReport<N: Network = Ethereum> {
1209    /// First dropped block, when known.
1210    pub dropped: Option<BlockRef>,
1211    /// Blocks dropped from the journal, in ascending journal order.
1212    pub dropped_blocks: Vec<BlockRef>,
1213    /// Input references that belonged to dropped blocks.
1214    pub dropped_inputs: Vec<InputRef>,
1215    /// Exact rollback updates applied for reversible dropped effects.
1216    pub rollback_updates: Vec<StateUpdate>,
1217    /// Diff returned by applying [`rollback_updates`](Self::rollback_updates).
1218    pub rollback_diff: StateDiff,
1219    /// Conservative purge updates applied for irreversible dropped effects.
1220    pub purge_updates: Vec<StateUpdate>,
1221    /// Diff returned by applying [`purge_updates`](Self::purge_updates).
1222    pub purge_diff: StateDiff,
1223    /// Hash-pinned pending resync requests canceled because their block was dropped.
1224    pub canceled_resyncs: Vec<ResyncRequest>,
1225    /// Reorg trigger.
1226    pub reason: ReorgReason,
1227    /// Network marker.
1228    pub _network: PhantomData<N>,
1229}
1230
1231/// Reason reorg recovery ran.
1232#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1233pub enum ReorgReason {
1234    /// A provider emitted an Alloy removed log.
1235    RemovedLog,
1236    /// The input context explicitly marked an input as reorged.
1237    ReorgedInput,
1238    /// A canonical block did not connect to the journaled head.
1239    ParentMismatch,
1240}
1241
1242/// Report of a forward gap in the canonical block sequence: an arriving block
1243/// whose number is more than one past the last-seen head, so the blocks in
1244/// between were never observed (for example during a subscription disconnect).
1245///
1246/// The arriving block is still accepted and applied — the chain extends — so this
1247/// report only makes the skipped span observable; it does not drop the block. The
1248/// span `from..=to` is inclusive of both endpoints.
1249#[derive(Clone, Debug)]
1250pub struct MissedRangeReport<N: Network = Ethereum> {
1251    /// First skipped block (`last-seen block number + 1`).
1252    pub from: u64,
1253    /// Last skipped block (`arriving block number - 1`).
1254    pub to: u64,
1255    /// The arriving block's number.
1256    pub block: u64,
1257    /// Network marker.
1258    pub _network: PhantomData<N>,
1259}
1260
1261/// Report of a [`CacheHealth`] transition, emitted into the ingest cycle that
1262/// caused it and delivered to hooks through the normal dispatch path.
1263#[derive(Clone, Debug)]
1264pub struct HealthReport<N: Network = Ethereum> {
1265    /// Health state before the transition.
1266    pub from: CacheHealth,
1267    /// Health state after the transition.
1268    pub to: CacheHealth,
1269    /// Block number associated with the transition, when known.
1270    pub block: Option<u64>,
1271    /// Network marker.
1272    pub _network: PhantomData<N>,
1273}
1274
1275/// Report that a tracked account's storage root moved on a canonical block that
1276/// no decoder covered — a coverage gap surfaced by the per-block root gate
1277/// (Phase-8 step 4).
1278///
1279/// An account's `storageHash` is a collision-resistant commitment over all of its
1280/// storage, so a moved root proves *something* under the account changed. When
1281/// that account is [`WholeAccount`](TrackingPolicy::WholeAccount)-tracked and the
1282/// batch's touched-address set does not include it, the change arrived through a
1283/// path no decoder observed. The runtime emits this report (delivered through the
1284/// normal dispatch path so [`ReactiveHook::on_report`] observers see it),
1285/// increments [`CacheMetricsSnapshot::coverage_gaps`], and schedules a
1286/// [`ResyncReason::RootMoved`] repair to re-read the account authoritatively.
1287#[derive(Clone, Debug)]
1288pub struct CoverageGapReport<N: Network = Ethereum> {
1289    /// The tracked account whose root moved with no covering decoder.
1290    pub address: Address,
1291    /// The canonical block number at which the gap was observed.
1292    pub block: u64,
1293    /// Network marker.
1294    pub _network: PhantomData<N>,
1295}
1296
1297/// Report of a non-fatal error surfaced during an ingest cycle, with the
1298/// associated input (when known) and a human-readable message.
1299#[derive(Clone, Debug)]
1300pub struct ReactiveErrorReport<N: Network = Ethereum> {
1301    /// Input associated with the error, when known.
1302    pub input_ref: Option<InputRef>,
1303    /// Error message.
1304    pub message: String,
1305    /// Network marker.
1306    pub _network: PhantomData<N>,
1307}
1308
1309/// Batch report returned by [`ReactiveRuntime::ingest_batch`] and
1310/// [`ReactiveRuntime::ingest_batch_with_resync`].
1311#[derive(Clone, Debug)]
1312pub struct ReactiveBatchReport<N: Network = Ethereum> {
1313    /// Applied reports in commit order.
1314    pub applied: Vec<AppliedReport<N>>,
1315    /// Resync requests surfaced during the batch.
1316    pub resyncs: Vec<ResyncRequest>,
1317    /// Speculative requests surfaced during the batch.
1318    pub speculative: Vec<SpeculativeRequest>,
1319    /// Hook reports dispatched after mutation phases.
1320    pub reports: Vec<Arc<ReactiveReport<N>>>,
1321}
1322
1323impl<N: Network> Default for ReactiveBatchReport<N> {
1324    fn default() -> Self {
1325        Self {
1326            applied: Vec::new(),
1327            resyncs: Vec::new(),
1328            speculative: Vec::new(),
1329            reports: Vec::new(),
1330        }
1331    }
1332}
1333
1334/// Error returned by a handler.
1335#[derive(Clone, Debug, PartialEq, Eq)]
1336pub struct HandlerError {
1337    message: String,
1338}
1339
1340impl HandlerError {
1341    /// Create a handler error from a message.
1342    pub fn new(message: impl Into<String>) -> Self {
1343        Self {
1344            message: message.into(),
1345        }
1346    }
1347}
1348
1349impl fmt::Display for HandlerError {
1350    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1351        self.message.fmt(f)
1352    }
1353}
1354
1355impl std::error::Error for HandlerError {}
1356
1357impl From<String> for HandlerError {
1358    fn from(message: String) -> Self {
1359        Self::new(message)
1360    }
1361}
1362
1363impl From<&str> for HandlerError {
1364    fn from(message: &str) -> Self {
1365        Self::new(message)
1366    }
1367}
1368
1369/// Runtime error.
1370#[derive(Debug, thiserror::Error)]
1371pub enum ReactiveError {
1372    /// Handler returned an error.
1373    #[error("handler `{handler_id}` failed: {source}")]
1374    HandlerFailed {
1375        /// Handler id.
1376        handler_id: HandlerId,
1377        /// Handler error.
1378        source: HandlerError,
1379    },
1380    /// Multiple handlers emitted incompatible absolute writes for one input.
1381    #[error(
1382        "conflicting effects for input {input_ref:?} on target {target:?}: `{first}` vs `{second}`"
1383    )]
1384    ConflictingEffects {
1385        /// Input reference.
1386        input_ref: Box<InputRef>,
1387        /// Conflicting target.
1388        target: Box<EffectTarget>,
1389        /// First handler id.
1390        first: HandlerId,
1391        /// Second handler id.
1392        second: HandlerId,
1393    },
1394    /// Pending inputs attempted to mutate canonical cache state.
1395    #[error(
1396        "pending input {input_ref:?} emitted invalid canonical effect `{effect_kind}` from `{handler_id}`"
1397    )]
1398    InvalidPendingEffect {
1399        /// Input reference.
1400        input_ref: Box<InputRef>,
1401        /// Handler id.
1402        handler_id: HandlerId,
1403        /// Effect kind.
1404        effect_kind: &'static str,
1405    },
1406    /// Registration error.
1407    #[error(transparent)]
1408    Register(#[from] RegisterError),
1409}
1410
1411/// Handler registration error.
1412#[derive(Debug, thiserror::Error)]
1413pub enum RegisterError {
1414    /// Duplicate handler id.
1415    #[error("handler id `{0}` is already registered")]
1416    DuplicateHandler(HandlerId),
1417}
1418
1419/// Error returned when [`ReactiveEngine`] cannot register a handler on both the
1420/// runtime and subscriber sides.
1421#[derive(Debug, thiserror::Error)]
1422pub enum ReactiveEngineRegisterError {
1423    /// Runtime registry rejected the handler.
1424    #[error(transparent)]
1425    Register(#[from] RegisterError),
1426    /// Subscriber rejected the handler's interests.
1427    #[error(transparent)]
1428    Subscriber(#[from] SubscriberError),
1429}
1430
1431/// Error returned by [`ReactiveEngine`] helpers that combine subscriber polling
1432/// and runtime ingestion.
1433#[derive(Debug, thiserror::Error)]
1434pub enum ReactiveEngineError {
1435    /// Subscriber polling failed.
1436    #[error(transparent)]
1437    Subscriber(#[from] SubscriberError),
1438    /// Runtime ingestion failed.
1439    #[error(transparent)]
1440    Runtime(#[from] ReactiveError),
1441}
1442
1443/// Absolute write target used for conflict reports.
1444#[derive(Clone, Debug, PartialEq, Eq, Hash)]
1445pub enum EffectTarget {
1446    /// Storage slot target.
1447    StorageSlot {
1448        /// Contract address.
1449        address: Address,
1450        /// Storage slot.
1451        slot: U256,
1452    },
1453    /// Account balance target.
1454    AccountBalance {
1455        /// Account address.
1456        address: Address,
1457    },
1458    /// Account nonce target.
1459    AccountNonce {
1460        /// Account address.
1461        address: Address,
1462    },
1463    /// Account code target.
1464    AccountCode {
1465        /// Account address.
1466        address: Address,
1467    },
1468    /// Masked storage slot target.
1469    MaskedStorageSlot {
1470        /// Contract address.
1471        address: Address,
1472        /// Storage slot.
1473        slot: U256,
1474        /// Bit mask.
1475        mask: U256,
1476    },
1477}
1478
1479#[derive(Clone, Debug, PartialEq, Eq)]
1480enum AbsoluteValue {
1481    U256(U256),
1482    U64(u64),
1483    Bytes(Bytes),
1484}
1485
1486/// Reactive runtime.
1487pub struct ReactiveRuntime<N: Network = Ethereum> {
1488    registry: ReactiveRegistry<N>,
1489    hooks: Vec<Arc<dyn ReactiveHook<N>>>,
1490    config: ReactiveConfig,
1491    journal: VecDeque<BlockJournal<N>>,
1492    pending_resyncs: Vec<ResyncRequest>,
1493    health: CacheHealth,
1494    metrics: CacheMetrics,
1495    /// Opt-in freshness registry the runtime stamps for canonical event writes.
1496    ///
1497    /// `None` by default (behavior unchanged); populated by
1498    /// [`enable_freshness_stamping`](Self::enable_freshness_stamping). When
1499    /// present, applying a canonical handler storage-slot effect stamps the
1500    /// touched `(address, slot)` as [`Validity::ValidThrough`](crate::freshness::Validity::ValidThrough)`(N)`
1501    /// so event-maintained slots stop being needlessly re-verified while aging to
1502    /// volatile once the clock passes `N`.
1503    freshness: Option<FreshnessRegistry>,
1504    /// Per-account tracking registry consulted by the per-block root gate
1505    /// (Phase-8 step 4). Empty by default; populated by
1506    /// [`track_account`](Self::track_account). When empty the gate is a no-op.
1507    tracking: HashMap<Address, TrackingPolicy>,
1508    /// Per-account root/field baselines the gate diffs against across blocks.
1509    /// Adopted on first probe and re-adopted on every observed move.
1510    tracked_roots: HashMap<Address, TrackedRoot>,
1511    /// How often the root gate fires (§6.2); see [`RootGateCadence`].
1512    root_gate_cadence: RootGateCadence,
1513    /// Canonical block of the last root-gate firing. `None` until the first
1514    /// firing (which happens at the first canonical block ever seen, so
1515    /// baseline adoption never waits a full cadence window).
1516    last_gate_block: Option<u64>,
1517    /// Union of decoder-touched addresses since the last root-gate firing,
1518    /// drained when it fires. Under cadence the gap rule "root moved ∧ addr ∉
1519    /// touched" must judge against every covered write in the window, or a
1520    /// decoder-covered write in a skipped block would false-positive as a
1521    /// [`ReactiveReport::CoverageGap`].
1522    touched_since_gate: HashSet<Address>,
1523}
1524
1525#[derive(Clone, Debug)]
1526struct BlockJournal<N: Network = Ethereum> {
1527    block: BlockRef,
1528    inputs: Vec<InputRef>,
1529    applied: Vec<AppliedReport<N>>,
1530    resynced: Vec<ResyncReport>,
1531}
1532
1533/// Registry and router for provider-neutral reactive handlers.
1534///
1535/// The registry stores pure [`ReactiveHandler`]s in registration order, exposes
1536/// consolidated provider-side log filters for subscription setup, and routes
1537/// provider logs back to the exact matching log interests. Consolidated filters
1538/// may be safe supersets; [`Self::route_log`] always re-checks the original
1539/// [`LogInterest`] and its local matcher before returning a route.
1540pub struct ReactiveRegistry<N: Network = Ethereum> {
1541    handlers: Vec<RegisteredHandler<N>>,
1542}
1543
1544struct RegisteredHandler<N: Network = Ethereum> {
1545    id: HandlerId,
1546    handler: Arc<dyn ReactiveHandler<N>>,
1547    interests: Vec<ReactiveInterest<N>>,
1548}
1549
1550impl<N: Network> Default for ReactiveRegistry<N> {
1551    fn default() -> Self {
1552        Self::new()
1553    }
1554}
1555
1556impl<N: Network> ReactiveRegistry<N> {
1557    /// Create an empty registry.
1558    pub fn new() -> Self {
1559        Self {
1560            handlers: Vec::new(),
1561        }
1562    }
1563
1564    /// Register a handler, preserving registration order.
1565    ///
1566    /// Duplicate handler ids are rejected with
1567    /// [`RegisterError::DuplicateHandler`].
1568    pub fn register_handler(
1569        &mut self,
1570        handler: Arc<dyn ReactiveHandler<N>>,
1571    ) -> Result<(), RegisterError> {
1572        let id = handler.id();
1573        if self.handlers.iter().any(|registered| registered.id == id) {
1574            return Err(RegisterError::DuplicateHandler(id));
1575        }
1576        let interests = handler.interests();
1577        self.handlers.push(RegisteredHandler {
1578            id,
1579            handler,
1580            interests,
1581        });
1582        Ok(())
1583    }
1584
1585    /// Remove one handler by id, leaving all other handlers and interests intact.
1586    ///
1587    /// Returns the removed handler when the id was registered. Cache eviction is
1588    /// intentionally outside this API: unregistering stops future routing and
1589    /// decode for the handler only.
1590    pub fn unregister_handler(&mut self, id: &HandlerId) -> Option<Arc<dyn ReactiveHandler<N>>> {
1591        let index = self
1592            .handlers
1593            .iter()
1594            .position(|registered| &registered.id == id)?;
1595        Some(self.handlers.remove(index).handler)
1596    }
1597
1598    /// Return true when `id` is currently registered.
1599    pub fn contains_handler(&self, id: &HandlerId) -> bool {
1600        self.handlers.iter().any(|registered| &registered.id == id)
1601    }
1602
1603    /// Ids of all registered handlers, in registration (= routing) order.
1604    pub fn handler_ids(&self) -> Vec<HandlerId> {
1605        self.handlers
1606            .iter()
1607            .map(|registered| registered.id.clone())
1608            .collect()
1609    }
1610
1611    /// Borrow the interests owned by one handler.
1612    pub fn handler_interests(&self, id: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
1613        self.handlers
1614            .iter()
1615            .find(|registered| &registered.id == id)
1616            .map(|registered| registered.interests.as_slice())
1617    }
1618
1619    /// Return all registered interests in handler registration order.
1620    pub fn interests(&self) -> Vec<ReactiveInterest<N>> {
1621        self.handlers
1622            .iter()
1623            .flat_map(|handler| handler.interests.clone())
1624            .collect()
1625    }
1626
1627    /// Return consolidated provider-side log filters.
1628    ///
1629    /// Filters are emitted in deterministic first-registration order by
1630    /// compatible block option. Within each returned filter, address and topic
1631    /// sets are unioned independently, which can intentionally overfetch. Use
1632    /// [`Self::route_log`] to enforce the exact original [`LogInterest`]s.
1633    pub fn log_subscription_filters(&self) -> Vec<Filter> {
1634        let mut filters = Vec::new();
1635        for interest in self.log_interests() {
1636            merge_log_subscription_filter(&mut filters, &interest.provider_filter);
1637        }
1638        filters
1639    }
1640
1641    /// Route a log to exact matching handler interests.
1642    ///
1643    /// Routes are returned in handler registration order. Each handler appears
1644    /// at most once for a log, using the first matching log interest declared by
1645    /// that handler.
1646    pub fn route_log(&self, log: &Log) -> Vec<ReactiveLogRoute> {
1647        self.handlers
1648            .iter()
1649            .filter_map(|handler| handler.route_log(log))
1650            .collect()
1651    }
1652
1653    fn handlers(&self) -> &[RegisteredHandler<N>] {
1654        &self.handlers
1655    }
1656
1657    fn log_interests(&self) -> impl Iterator<Item = &LogInterest> {
1658        self.handlers.iter().flat_map(|handler| {
1659            handler
1660                .interests
1661                .iter()
1662                .filter_map(|interest| match interest {
1663                    ReactiveInterest::Logs(interest) => Some(interest),
1664                    ReactiveInterest::Blocks(_) | ReactiveInterest::PendingTransactions(_) => None,
1665                })
1666        })
1667    }
1668}
1669
1670impl<N: Network> ReactiveRuntime<N> {
1671    /// Create an empty runtime.
1672    pub fn new(config: ReactiveConfig) -> Self {
1673        Self {
1674            registry: ReactiveRegistry::new(),
1675            hooks: Vec::new(),
1676            config,
1677            journal: VecDeque::new(),
1678            pending_resyncs: Vec::new(),
1679            health: CacheHealth::Healthy,
1680            metrics: CacheMetrics::default(),
1681            freshness: None,
1682            tracking: HashMap::new(),
1683            tracked_roots: HashMap::new(),
1684            root_gate_cadence: RootGateCadence::default(),
1685            last_gate_block: None,
1686            touched_since_gate: HashSet::new(),
1687        }
1688    }
1689
1690    /// Track `address` under `policy` for the per-block root gate (Phase-8 step 4).
1691    ///
1692    /// Tracking is strictly opt-in: a runtime with no tracked accounts runs the
1693    /// gate as a no-op. Registering an account clears any baseline it held (a
1694    /// policy change re-adopts on the next probe rather than diffing against a
1695    /// baseline captured under the old policy). Each [`RootGateCadence`]
1696    /// firing, the gate
1697    /// probes tracked [`WholeAccount`](TrackingPolicy::WholeAccount) and
1698    /// [`Scalars`](TrackingPolicy::Scalars) accounts' roots/fields via the
1699    /// account-proof seam and, on a move no decoder covered, emits a
1700    /// [`ReactiveReport::CoverageGap`] and schedules a
1701    /// [`ResyncReason::RootMoved`] repair. [`Slots`](TrackingPolicy::Slots)
1702    /// accounts are never root-gated (spec Decision 3).
1703    pub fn track_account(&mut self, address: Address, policy: TrackingPolicy) {
1704        self.tracking.insert(address, policy);
1705        self.tracked_roots.remove(&address);
1706    }
1707
1708    /// Stop tracking `address`, dropping its policy and any adopted baseline.
1709    ///
1710    /// Returns `true` if the account was tracked.
1711    pub fn untrack_account(&mut self, address: Address) -> bool {
1712        self.tracked_roots.remove(&address);
1713        self.tracking.remove(&address).is_some()
1714    }
1715
1716    /// Set how often the root gate probes tracked accounts (default:
1717    /// [`RootGateCadence::default`] — every 16 canonical blocks; see the
1718    /// [`RootGateCadence`] docs for why skipping blocks loses no detection).
1719    ///
1720    /// Reconfiguring resets the gate's window bookkeeping (the touched-address
1721    /// accumulator and the last-fired block), so a stale window never leaks
1722    /// into the new cadence: the next canonical block fires the gate.
1723    pub fn set_root_gate_cadence(&mut self, cadence: RootGateCadence) {
1724        self.root_gate_cadence = cadence;
1725        self.last_gate_block = None;
1726        self.touched_since_gate.clear();
1727    }
1728
1729    /// The configured [`RootGateCadence`].
1730    pub fn root_gate_cadence(&self) -> RootGateCadence {
1731        self.root_gate_cadence
1732    }
1733
1734    /// Enable freshness stamping of canonical event-derived writes (opt-in).
1735    ///
1736    /// Installs a [`FreshnessRegistry`] the runtime owns; while it is present,
1737    /// applying a canonical handler storage-slot effect for a block `N` stamps the
1738    /// touched `(address, slot)` as
1739    /// [`Validity::ValidThrough`](crate::freshness::Validity::ValidThrough)`(N)`.
1740    /// The slot is therefore not volatile *at* `N` (event-maintained, no need to
1741    /// re-verify) but ages to volatile once the clock passes `N`.
1742    ///
1743    /// Idempotent: if a registry is already installed it is left untouched, so an
1744    /// existing registry (and any stamps it holds) is never clobbered.
1745    pub fn enable_freshness_stamping(&mut self) {
1746        if self.freshness.is_none() {
1747            self.freshness = Some(FreshnessRegistry::new());
1748        }
1749    }
1750
1751    /// Borrow the runtime's freshness registry, if stamping was enabled.
1752    ///
1753    /// Returns `None` unless
1754    /// [`enable_freshness_stamping`](Self::enable_freshness_stamping) was called.
1755    pub fn freshness(&self) -> Option<&FreshnessRegistry> {
1756        self.freshness.as_ref()
1757    }
1758
1759    /// Mutably borrow the runtime's freshness registry, if stamping was enabled.
1760    ///
1761    /// Returns `None` unless
1762    /// [`enable_freshness_stamping`](Self::enable_freshness_stamping) was called.
1763    pub fn freshness_mut(&mut self) -> Option<&mut FreshnessRegistry> {
1764        self.freshness.as_mut()
1765    }
1766
1767    /// Return the current queryable [`CacheHealth`] of the runtime.
1768    pub fn health(&self) -> CacheHealth {
1769        self.health
1770    }
1771
1772    /// Return a point-in-time snapshot of the runtime's observability counters.
1773    pub fn metrics(&self) -> CacheMetricsSnapshot {
1774        self.metrics.snapshot()
1775    }
1776
1777    /// Complete the caller-driven self-heal by returning health to
1778    /// [`CacheHealth::Healthy`].
1779    ///
1780    /// A trust-loss event (a reorg deeper than the journal, or a detected missed
1781    /// block range) escalates health toward [`CacheHealth::Unhealthy`] as a
1782    /// "stop until rebuilt" signal that the caller must act on. Once the caller
1783    /// has resynced or rebuilt the affected state, it invokes this to clear the
1784    /// signal. It does not emit a [`ReactiveReport::Health`] report, since it is
1785    /// called outside an ingest cycle.
1786    pub fn reset_health(&mut self) {
1787        self.health = CacheHealth::Healthy;
1788    }
1789
1790    /// Escalate health one rung up the trust-loss ladder for a trust-loss event
1791    /// observed at `block`, returning a [`ReactiveReport::Health`] report when the
1792    /// state actually changes.
1793    ///
1794    /// The ladder is:
1795    /// - [`Healthy`](CacheHealth::Healthy) -> [`Degraded`](CacheHealth::Degraded)
1796    /// - [`Degraded`](CacheHealth::Degraded) -> [`Unhealthy`](CacheHealth::Unhealthy)
1797    /// - [`Unhealthy`](CacheHealth::Unhealthy) -> no change (`None`)
1798    ///
1799    /// A first event degrades; a second escalates to the terminal
1800    /// [`Unhealthy`](CacheHealth::Unhealthy) stop signal. This is shared by both
1801    /// trust-loss paths (deep reorg beyond the journal and missed-range
1802    /// detection) so mixed event types climb the same ladder.
1803    fn escalate_trust(&mut self, block: u64) -> Option<Arc<ReactiveReport<N>>> {
1804        let to = match self.health {
1805            CacheHealth::Healthy => CacheHealth::Degraded { since_block: block },
1806            CacheHealth::Degraded { .. } => CacheHealth::Unhealthy { since_block: block },
1807            CacheHealth::Unhealthy { .. } => return None,
1808        };
1809        self.transition_health(to, Some(block))
1810    }
1811
1812    /// Transition health to `to`, returning a [`ReactiveReport::Health`] report
1813    /// when the state actually changes.
1814    ///
1815    /// The returned report must be threaded into the ingest cycle's dispatched
1816    /// reports so it reaches hooks and appears in
1817    /// [`ReactiveBatchReport::reports`]. Returns `None` when `to` equals the
1818    /// current state (no transition, no report).
1819    fn transition_health(
1820        &mut self,
1821        to: CacheHealth,
1822        block: Option<u64>,
1823    ) -> Option<Arc<ReactiveReport<N>>> {
1824        if to == self.health {
1825            return None;
1826        }
1827        let from = self.health;
1828        self.health = to;
1829        Some(Arc::new(ReactiveReport::Health(HealthReport {
1830            from,
1831            to,
1832            block,
1833            _network: PhantomData,
1834        })))
1835    }
1836
1837    /// Register a handler.
1838    pub fn register_handler(
1839        &mut self,
1840        handler: Arc<dyn ReactiveHandler<N>>,
1841    ) -> Result<(), RegisterError> {
1842        self.registry.register_handler(handler)
1843    }
1844
1845    /// Remove one handler from the runtime registry without resetting runtime state.
1846    ///
1847    /// This delegates to [`ReactiveRegistry::unregister_handler`] only. It does
1848    /// not clear the reorg journal, health, metrics, hooks, pending resyncs,
1849    /// tracking policy, freshness registry, or root-gate baselines, and it does
1850    /// not purge [`EvmCache`] state. Callers that want cache eviction must issue
1851    /// explicit `StateUpdate::purge` updates or use cache purge APIs separately.
1852    pub fn unregister_handler(&mut self, id: &HandlerId) -> Option<Arc<dyn ReactiveHandler<N>>> {
1853        self.registry.unregister_handler(id)
1854    }
1855
1856    /// Return true when the runtime has a registered handler with `id`.
1857    pub fn contains_handler(&self, id: &HandlerId) -> bool {
1858        self.registry.contains_handler(id)
1859    }
1860
1861    /// Ids of all registered handlers, in registration (= routing) order.
1862    pub fn handler_ids(&self) -> Vec<HandlerId> {
1863        self.registry.handler_ids()
1864    }
1865
1866    /// Borrow the interests owned by one registered handler.
1867    pub fn handler_interests(&self, id: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
1868        self.registry.handler_interests(id)
1869    }
1870
1871    /// The most recently journaled canonical block, if any.
1872    ///
1873    /// This is the runtime's current chain position: the canonical block most
1874    /// recently recorded by ingestion. Reorged blocks are dropped from the
1875    /// journal during recovery, so a rolled-back head does not linger here.
1876    /// [`ReactiveEngine::register_handler`] uses it as the default backfill
1877    /// anchor for handlers registered mid-lifecycle. `None` until the first
1878    /// canonical input is journaled, and always `None` when
1879    /// [`ReactiveConfig::journal_depth`] is 0 (journaling disabled).
1880    pub fn last_canonical_block(&self) -> Option<BlockRef> {
1881        self.journal.back().map(|entry| entry.block.clone())
1882    }
1883
1884    /// Queued resync requests: surfaced by handlers but not yet executed by an
1885    /// [`ingest_batch_with_resync`](Self::ingest_batch_with_resync) pass.
1886    ///
1887    /// Callers driving resync execution themselves (plain
1888    /// [`ingest_batch`](Self::ingest_batch) loops) can read the ledger here;
1889    /// reorg recovery cancels entries whose pinned blocks were dropped, and
1890    /// [`cancel_pending_resyncs`](Self::cancel_pending_resyncs) drops entries
1891    /// for torn-down accounts.
1892    pub fn pending_resyncs(&self) -> &[ResyncRequest] {
1893        &self.pending_resyncs
1894    }
1895
1896    /// Cancel queued resync work that targets `address`, returning the
1897    /// cancelled portions.
1898    ///
1899    /// Every pending [`ResyncRequest`] target referencing `address` is removed;
1900    /// a request reduced to zero targets is dropped entirely, while
1901    /// mixed-target requests keep their other accounts queued. Each returned
1902    /// request mirrors the original id/reason/block/priority and carries only
1903    /// the targets that were cancelled.
1904    ///
1905    /// This is part of the adapter-teardown recipe (see
1906    /// [`ReactiveEngine::unregister_handler`]): it clears the pending ledger so
1907    /// a dropped pool's queued repairs stop occupying memory and stop
1908    /// surfacing as cancellations in reorg reports. It cannot recall requests
1909    /// already returned to the caller in earlier batch reports.
1910    pub fn cancel_pending_resyncs(&mut self, address: Address) -> Vec<ResyncRequest> {
1911        let mut cancelled = Vec::new();
1912        self.pending_resyncs.retain_mut(|request| {
1913            let (matching, remaining): (Vec<_>, Vec<_>) = request
1914                .targets
1915                .drain(..)
1916                .partition(|target| resync_target_address(target) == address);
1917            request.targets = remaining;
1918            if !matching.is_empty() {
1919                cancelled.push(ResyncRequest {
1920                    id: request.id.clone(),
1921                    reason: request.reason.clone(),
1922                    block: request.block.clone(),
1923                    targets: matching,
1924                    priority: request.priority,
1925                });
1926            }
1927            !request.targets.is_empty()
1928        });
1929        cancelled
1930    }
1931
1932    /// Register a hook.
1933    pub fn register_hook(&mut self, hook: Arc<dyn ReactiveHook<N>>) -> Result<(), RegisterError> {
1934        self.hooks.push(hook);
1935        Ok(())
1936    }
1937
1938    /// Return all registered interests in handler registration order.
1939    pub fn interests(&self) -> Vec<ReactiveInterest<N>> {
1940        self.registry.interests()
1941    }
1942
1943    /// Ingest a batch, apply valid direct state effects, and dispatch reports.
1944    pub fn ingest_batch(
1945        &mut self,
1946        cache: &mut EvmCache,
1947        batch: ReactiveInputBatch<N>,
1948    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
1949        let batch_report = self.ingest_batch_direct(cache, batch)?;
1950        self.dispatch_reports(&batch_report.reports);
1951        let _ = &self.config;
1952        Ok(batch_report)
1953    }
1954
1955    /// Ingest a batch, then execute surfaced storage resync requests.
1956    ///
1957    /// This entrypoint preserves [`ingest_batch`](Self::ingest_batch) behavior for
1958    /// direct handler effects, then runs a synchronous resync phase over the
1959    /// collected [`ResyncRequest`]s. Storage targets are fetched through
1960    /// [`EvmCache::storage_batch_fetcher`] grouped by [`ResyncBlock`], successful
1961    /// values are applied as [`StateUpdate::slot`] updates through
1962    /// [`EvmCache::apply_updates`], and unsupported or failed targets are reported
1963    /// in [`ResyncReport::failed`]. It does not start subscribers, background
1964    /// workers, or network transport.
1965    pub fn ingest_batch_with_resync(
1966        &mut self,
1967        cache: &mut EvmCache,
1968        batch: ReactiveInputBatch<N>,
1969    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
1970        let mut batch_report = self.ingest_batch_direct(cache, batch)?;
1971
1972        if !batch_report.resyncs.is_empty() {
1973            let resync_report = execute_resync_requests(cache, &batch_report.resyncs);
1974            // Count unique logical requests: several handlers may emit the same
1975            // ResyncId in one batch, and duplicates fan out per-origin in the
1976            // report but are one unit of resync work for the metric.
1977            let unique_requests = resync_report
1978                .requested
1979                .iter()
1980                .map(|request| &request.id)
1981                .collect::<HashSet<_>>()
1982                .len();
1983            self.metrics
1984                .resync_requests
1985                .fetch_add(unique_requests as u64, Ordering::Relaxed);
1986            self.metrics
1987                .resync_failures
1988                .fetch_add(resync_report.failed.len() as u64, Ordering::Relaxed);
1989            self.remove_pending_resyncs(batch_report.resyncs.iter().map(|request| &request.id));
1990            self.record_journal_resync(&resync_report);
1991            batch_report
1992                .reports
1993                .push(Arc::new(ReactiveReport::Resynced(resync_report)));
1994        }
1995
1996        self.dispatch_reports(&batch_report.reports);
1997        let _ = &self.config;
1998        Ok(batch_report)
1999    }
2000
2001    fn ingest_batch_direct(
2002        &mut self,
2003        cache: &mut EvmCache,
2004        batch: ReactiveInputBatch<N>,
2005    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
2006        let records = sort_records(dedupe_records(batch.into_records()));
2007
2008        let mut batch_report = ReactiveBatchReport::default();
2009        let mut reports_to_dispatch = Vec::new();
2010        // Phase-8 step 4: accumulate the addresses a decoder actually wrote this
2011        // batch (union of applied `StateDiff` addresses) and the batch's canonical
2012        // block number, so the per-block root gate can run once after the record
2013        // loop with the full touched set.
2014        let mut touched_addrs: HashSet<Address> = HashSet::new();
2015        let mut canonical_batch_block: Option<u64> = None;
2016
2017        for record in records {
2018            let input_ref = record.input_ref();
2019            reports_to_dispatch.push(Arc::new(ReactiveReport::Input(InputReport {
2020                input_ref,
2021                context: record.context.clone(),
2022                _network: PhantomData,
2023            })));
2024
2025            if let Some(reorg_report) =
2026                self.recover_for_canonical_input(cache, &record, &mut reports_to_dispatch)
2027            {
2028                self.metrics
2029                    .reorgs_recovered
2030                    .fetch_add(1, Ordering::Relaxed);
2031                remove_canceled_resyncs_from_batch(
2032                    &mut batch_report.resyncs,
2033                    &reorg_report.canceled_resyncs,
2034                );
2035                reports_to_dispatch.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
2036            }
2037
2038            if let Some(reorg_report) =
2039                self.recover_for_reorged_input(cache, &record, &mut reports_to_dispatch)
2040            {
2041                self.metrics
2042                    .reorgs_recovered
2043                    .fetch_add(1, Ordering::Relaxed);
2044                remove_canceled_resyncs_from_batch(
2045                    &mut batch_report.resyncs,
2046                    &reorg_report.canceled_resyncs,
2047                );
2048                reports_to_dispatch.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
2049                continue;
2050            }
2051
2052            if let Some(block) = canonical_record_block(&record) {
2053                // Phase-8 step 4: remember the batch's canonical block (the last
2054                // canonical record wins) so the root gate probes at that height.
2055                canonical_batch_block = Some(block.number);
2056                self.record_journal_input(block, input_ref);
2057            }
2058
2059            // Phase-8 step 2: drive a per-block env refresh from canonical
2060            // headers. Best-effort — a strict validation failure is surfaced as
2061            // a non-fatal error report and does not abort the batch.
2062            if let Some(Err(err)) = advance_block_for_canonical_record(cache, &record) {
2063                reports_to_dispatch.push(Arc::new(ReactiveReport::Error(ReactiveErrorReport {
2064                    input_ref: Some(input_ref),
2065                    message: err.to_string(),
2066                    _network: PhantomData,
2067                })));
2068            }
2069
2070            let executions = self.execute_handlers(cache, &record, input_ref)?;
2071            if executions.is_empty() {
2072                continue;
2073            }
2074
2075            reports_to_dispatch.push(Arc::new(ReactiveReport::Decoded(DecodedReport {
2076                input_ref,
2077                handler_ids: executions
2078                    .iter()
2079                    .map(|execution| execution.handler_id.clone())
2080                    .collect(),
2081                _network: PhantomData,
2082            })));
2083
2084            detect_conflicts(input_ref, &executions)?;
2085
2086            // Phase-8 step 3: canonical block number for freshness stamping.
2087            // Copied out as a plain `u64` (dropping the borrow of `record`) so it
2088            // can be used while `self.freshness_mut()` mutably borrows `self`
2089            // inside the execution loop. `None` for pending/removed/reorged
2090            // records — those never stamp canonical freshness.
2091            let canonical_block_number = canonical_record_block(&record).map(|block| block.number);
2092
2093            for execution in executions {
2094                let diff = if execution.state_updates.is_empty() {
2095                    StateDiff::default()
2096                } else {
2097                    cache.apply_updates(&execution.state_updates)
2098                };
2099
2100                batch_report
2101                    .resyncs
2102                    .extend(execution.resyncs.iter().cloned());
2103                self.pending_resyncs
2104                    .extend(execution.resyncs.iter().cloned());
2105                batch_report
2106                    .speculative
2107                    .extend(execution.speculative.iter().cloned());
2108
2109                let applied = AppliedReport {
2110                    input_ref,
2111                    handler_id: execution.handler_id,
2112                    quality: execution.quality,
2113                    tags: execution.tags,
2114                    diff,
2115                    state_updates: execution.state_updates,
2116                    invalidations: execution.invalidations,
2117                    resyncs: execution.resyncs,
2118                    speculative: execution.speculative,
2119                    hook_signals: execution.hook_signals,
2120                    _network: PhantomData,
2121                };
2122                // Phase-8 step 3 (opt-in): stamp every touched `(address, slot)`
2123                // from this canonical handler write as `ValidThrough(N)`, so an
2124                // event-maintained slot stops being re-verified until the clock
2125                // passes its write block. Read the changed slots straight off
2126                // `applied.diff` (which borrows the local, not `self`) and stamp
2127                // via `self.freshness`, done before `applied` is moved into the
2128                // journal/batch below. Only genuinely-changed slots appear here,
2129                // since a no-op re-write records no `SlotChange`.
2130                if let (Some(number), Some(registry)) =
2131                    (canonical_block_number, self.freshness.as_mut())
2132                {
2133                    for change in &applied.diff.slots {
2134                        registry.valid_through_slot(change.address, change.slot, number);
2135                    }
2136                }
2137
2138                // Phase-8 step 4: record every address this decoder actually wrote
2139                // (or attempted to write) so the root gate can tell a
2140                // decoder-covered root move from an uncovered coverage gap. Fold in
2141                // the full `StateDiff` address footprint — real changes
2142                // (`slots`/`accounts`/`purged`) and cold-skipped attempts alike, so
2143                // a decoder that tried to write a cold slot still counts as
2144                // covering the account.
2145                collect_diff_addresses(&applied.diff, &mut touched_addrs);
2146
2147                let report = Arc::new(ReactiveReport::Applied(applied.clone()));
2148                reports_to_dispatch.push(report);
2149                if let Some(block) = canonical_record_block(&record) {
2150                    self.record_journal_applied(block, applied.clone());
2151                }
2152                batch_report.applied.push(applied);
2153            }
2154        }
2155
2156        // Phase-8 step 4 + §6.2 cadence: accumulate this batch's touched
2157        // addresses (after all handler effects, so the set is complete), then
2158        // fire the root gate only on cadence boundaries. The gate diffs
2159        // against persisted baselines, so skipped blocks lose no detection —
2160        // but the touched set must be the union since the last firing, or a
2161        // decoder-covered write in a skipped block would false-positive as a
2162        // CoverageGap. Fired resyncs surface in `batch_report.resyncs` (so
2163        // callers see them and `ingest_batch_with_resync` executes them) and
2164        // coverage reports go into the dispatched reports.
2165        if self.root_gate_runnable(cache) {
2166            self.touched_since_gate
2167                .extend(touched_addrs.iter().copied());
2168            if self.root_gate_due(canonical_batch_block) {
2169                let accumulated = std::mem::take(&mut self.touched_since_gate);
2170                self.run_root_gate(
2171                    cache,
2172                    canonical_batch_block,
2173                    &accumulated,
2174                    &mut batch_report.resyncs,
2175                    &mut reports_to_dispatch,
2176                );
2177                self.last_gate_block = canonical_batch_block;
2178            }
2179        } else {
2180            // A gate that cannot run (disabled, nothing root-gated, or no
2181            // proof fetcher) must not grow the accumulator unboundedly.
2182            // Dropping it is safe: without a runnable gate no baselines exist
2183            // (a fetcher cannot be uninstalled, and untracking drops the
2184            // baseline), so there is nothing a lost touched set could falsely
2185            // gap against later.
2186            self.touched_since_gate.clear();
2187        }
2188
2189        batch_report.reports = reports_to_dispatch;
2190        Ok(batch_report)
2191    }
2192
2193    /// Whether the root gate could produce any signal at all: some tracked
2194    /// account is root-gated (`Slots` never is) and a proof fetcher exists.
2195    /// When this is false the touched accumulator is dropped rather than
2196    /// grown (see the ingest call site for why that is safe).
2197    fn root_gate_runnable(&self, cache: &EvmCache) -> bool {
2198        if matches!(self.root_gate_cadence, RootGateCadence::Disabled) {
2199            return false;
2200        }
2201        let has_gated_targets = self
2202            .tracking
2203            .values()
2204            .any(|policy| !matches!(policy, TrackingPolicy::Slots { .. }));
2205        has_gated_targets && cache.account_proof_fetcher().is_some()
2206    }
2207
2208    /// Whether the root gate is due at this batch's canonical block (§6.2):
2209    /// the first canonical block ever seen always fires (baseline adoption
2210    /// must not wait a full window), then at most once every `n` blocks.
2211    fn root_gate_due(&self, canonical_block: Option<u64>) -> bool {
2212        let Some(block) = canonical_block else {
2213            return false;
2214        };
2215        match self.root_gate_cadence {
2216            RootGateCadence::Disabled => false,
2217            RootGateCadence::EveryNBlocks(n) => match self.last_gate_block {
2218                None => true,
2219                Some(last) => block >= last.saturating_add(n.get()),
2220            },
2221        }
2222    }
2223
2224    /// The `storageHash` root gate (Phase-8 step 4), fired per
2225    /// [`RootGateCadence`] window (§6.2).
2226    ///
2227    /// Runs at the firing batch's canonical block, with `touched` carrying the
2228    /// union of decoder-touched addresses since the previous firing. For each tracked
2229    /// [`WholeAccount`](TrackingPolicy::WholeAccount) / [`Scalars`](TrackingPolicy::Scalars)
2230    /// account, probe the root (and account fields) via the account-proof seam and
2231    /// apply the spec §4 table:
2232    ///
2233    /// - No baseline yet ⇒ **adopt** (no gap, no resync — adoption is not a gap).
2234    /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root unchanged ⇒ nothing.
2235    /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root moved, `addr ∈ touched`
2236    ///   ⇒ a decoder covered it; re-adopt, no gap.
2237    /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root moved, `addr ∉ touched`
2238    ///   ⇒ emit [`ReactiveReport::CoverageGap`], count it, schedule a
2239    ///   [`ResyncReason::RootMoved`] account resync, re-adopt.
2240    /// - [`Scalars`](TrackingPolicy::Scalars) ⇒ compare balance/nonce/code-hash to
2241    ///   the baseline (native field changes never move the storage root); on a move
2242    ///   with `addr ∉ touched`, schedule a [`ResyncReason::RootMoved`] account
2243    ///   resync for the changed fields and re-adopt.
2244    ///
2245    /// No-op when the tracking registry is empty, when the batch has no canonical
2246    /// block, or when the cache has no account-proof fetcher installed.
2247    /// [`Slots`](TrackingPolicy::Slots) accounts are never root-gated (spec
2248    /// Decision 3).
2249    fn run_root_gate(
2250        &mut self,
2251        cache: &EvmCache,
2252        canonical_block: Option<u64>,
2253        touched: &HashSet<Address>,
2254        resyncs: &mut Vec<ResyncRequest>,
2255        reports: &mut Vec<Arc<ReactiveReport<N>>>,
2256    ) {
2257        if self.tracking.is_empty() {
2258            return;
2259        }
2260        let Some(block) = canonical_block else {
2261            return;
2262        };
2263        let Some(fetcher) = cache.account_proof_fetcher().cloned() else {
2264            return;
2265        };
2266
2267        // Collect the root-gated targets (Slots opts out) in a stable order so a
2268        // single-block sequence of resyncs/reports is deterministic.
2269        let mut targets: Vec<(Address, bool)> = self
2270            .tracking
2271            .iter()
2272            .filter_map(|(address, policy)| match policy {
2273                TrackingPolicy::Slots { .. } => None,
2274                TrackingPolicy::WholeAccount => Some((*address, true)),
2275                TrackingPolicy::Scalars => Some((*address, false)),
2276            })
2277            .collect();
2278        if targets.is_empty() {
2279            return;
2280        }
2281        targets.sort_by_key(|(address, _)| *address);
2282
2283        let block_id = BlockId::number(block);
2284        // ONE seam invocation carries every root-gated target (root-only
2285        // probes: no storage keys needed). eth_getProof is single-address at
2286        // the RPC level, so batching here lets the fetcher fan the requests
2287        // out concurrently instead of paying N sequential round trips.
2288        let mut probes: HashMap<Address, StorageFetchResult<AccountProof>> = (fetcher)(
2289            targets
2290                .iter()
2291                .map(|&(address, _)| (address, vec![]))
2292                .collect(),
2293            block_id,
2294        )
2295        .into_iter()
2296        .collect();
2297        for (address, whole_account) in targets {
2298            let Some(Ok(proof)) = probes.remove(&address) else {
2299                // A failed/omitted probe carries no signal; leave the baseline
2300                // untouched and try again next block.
2301                continue;
2302            };
2303
2304            let baseline = self.tracked_roots.get(&address).cloned();
2305            let Some(baseline) = baseline else {
2306                // First observation: adopt the baseline. Not a coverage gap.
2307                self.adopt_root(address, block, &proof);
2308                continue;
2309            };
2310
2311            // A stale probe (a batch whose canonical block is not newer than the
2312            // last one we baselined this account against) carries no forward
2313            // signal: skip it rather than diff against — or clobber — a newer
2314            // baseline.
2315            if block <= baseline.last_block {
2316                continue;
2317            }
2318
2319            if whole_account {
2320                if proof.storage_hash == baseline.last_root {
2321                    // Tight steady-state path: unchanged root ⇒ nothing.
2322                    continue;
2323                }
2324                // Root moved.
2325                if !touched.contains(&address) {
2326                    // Moved with no covering decoder — the coverage gap.
2327                    reports.push(Arc::new(ReactiveReport::CoverageGap(CoverageGapReport {
2328                        address,
2329                        block,
2330                        _network: PhantomData,
2331                    })));
2332                    self.metrics.coverage_gaps.fetch_add(1, Ordering::Relaxed);
2333                    resyncs.push(root_moved_account_resync(
2334                        address,
2335                        block,
2336                        AccountFieldMask {
2337                            balance: true,
2338                            nonce: true,
2339                            code: true,
2340                        },
2341                    ));
2342                }
2343                // Adopt the new root whether or not a decoder covered it.
2344                self.adopt_root(address, block, &proof);
2345            } else {
2346                // Scalars: compare the account fields directly (native changes do
2347                // not move the storage root).
2348                let balance_moved = proof.balance != baseline.balance;
2349                let nonce_moved = proof.nonce != baseline.nonce;
2350                let code_moved = proof.code_hash != baseline.code_hash;
2351                if (balance_moved || nonce_moved || code_moved) && !touched.contains(&address) {
2352                    resyncs.push(root_moved_account_resync(
2353                        address,
2354                        block,
2355                        AccountFieldMask {
2356                            balance: balance_moved,
2357                            nonce: nonce_moved,
2358                            code: code_moved,
2359                        },
2360                    ));
2361                }
2362                self.adopt_root(address, block, &proof);
2363            }
2364        }
2365    }
2366
2367    /// Adopt (or re-adopt) `proof` as the baseline for `address` at `block`.
2368    fn adopt_root(&mut self, address: Address, block: u64, proof: &AccountProof) {
2369        self.tracked_roots.insert(
2370            address,
2371            TrackedRoot {
2372                last_root: proof.storage_hash,
2373                last_block: block,
2374                balance: proof.balance,
2375                nonce: proof.nonce,
2376                code_hash: proof.code_hash,
2377            },
2378        );
2379    }
2380
2381    fn execute_handlers(
2382        &self,
2383        cache: &EvmCache,
2384        record: &ReactiveInputRecord<N>,
2385        input_ref: InputRef,
2386    ) -> Result<Vec<HandlerExecution>, ReactiveError> {
2387        let mut executions = Vec::new();
2388        for registered in self.registry.handlers() {
2389            if !registered.matches(&record.input) {
2390                continue;
2391            }
2392
2393            let outcome = registered
2394                .handler
2395                .handle(&record.context, &record.input, cache)
2396                .map_err(|source| ReactiveError::HandlerFailed {
2397                    handler_id: registered.id.clone(),
2398                    source,
2399                })?;
2400
2401            if let Err(error) =
2402                validate_effects(input_ref, &record.context, &registered.id, &outcome.effects)
2403            {
2404                if matches!(error, ReactiveError::InvalidPendingEffect { .. }) {
2405                    self.metrics
2406                        .pending_contamination
2407                        .fetch_add(1, Ordering::Relaxed);
2408                }
2409                return Err(error);
2410            }
2411            executions.push(HandlerExecution::from_outcome(
2412                registered.id.clone(),
2413                input_ref,
2414                outcome,
2415            ));
2416        }
2417        Ok(executions)
2418    }
2419
2420    fn dispatch_reports(&self, reports: &[Arc<ReactiveReport<N>>]) {
2421        for report in reports {
2422            for hook in &self.hooks {
2423                hook.on_report(report.clone());
2424            }
2425        }
2426    }
2427
2428    fn recover_for_canonical_input(
2429        &mut self,
2430        cache: &mut EvmCache,
2431        record: &ReactiveInputRecord<N>,
2432        health_reports: &mut Vec<Arc<ReactiveReport<N>>>,
2433    ) -> Option<ReorgReport<N>> {
2434        let block = canonical_record_block(record)?;
2435        let latest = self.journal.back()?.block.clone();
2436
2437        if self
2438            .journal
2439            .iter()
2440            .any(|entry| entry.block.hash == block.hash && entry.block.number == block.number)
2441        {
2442            return None;
2443        }
2444
2445        if block.number == latest.number.saturating_add(1) && block.parent_hash == Some(latest.hash)
2446        {
2447            return None;
2448        }
2449
2450        if block.number > latest.number.saturating_add(1) {
2451            // A forward gap: blocks between the journaled head and the arriving
2452            // block were never observed (e.g. a disconnect). Make it observable
2453            // and escalate health, but still accept the arriving block so it
2454            // journals/applies normally (the chain extends).
2455            self.metrics.missed_ranges.fetch_add(1, Ordering::Relaxed);
2456            health_reports.extend(self.escalate_trust(block.number));
2457            health_reports.push(Arc::new(ReactiveReport::MissedBlockRange(
2458                MissedRangeReport {
2459                    from: latest.number + 1,
2460                    to: block.number - 1,
2461                    block: block.number,
2462                    _network: PhantomData,
2463                },
2464            )));
2465            return None;
2466        }
2467
2468        let dropped = if let Some(parent_hash) = block.parent_hash {
2469            if let Some(parent_index) = self
2470                .journal
2471                .iter()
2472                .rposition(|entry| entry.block.hash == parent_hash)
2473            {
2474                self.drain_journal_after(parent_index)
2475            } else {
2476                health_reports.extend(self.warn_under_recovery(block.number));
2477                self.drain_journal_from_number(block.number)
2478            }
2479        } else {
2480            health_reports.extend(self.warn_under_recovery(block.number));
2481            self.drain_journal_from_number(block.number)
2482        };
2483
2484        self.recover_dropped_journals(cache, dropped, ReorgReason::ParentMismatch)
2485    }
2486
2487    fn recover_for_reorged_input(
2488        &mut self,
2489        cache: &mut EvmCache,
2490        record: &ReactiveInputRecord<N>,
2491        health_reports: &mut Vec<Arc<ReactiveReport<N>>>,
2492    ) -> Option<ReorgReport<N>> {
2493        let (dropped_block, reason) = reorg_signal_block(record)?;
2494        let dropped = if let Some(index) = self
2495            .journal
2496            .iter()
2497            .position(|entry| entry.block.hash == dropped_block.hash)
2498        {
2499            self.drain_journal_from(index)
2500        } else {
2501            health_reports.extend(self.warn_under_recovery(dropped_block.number));
2502            self.drain_journal_from_number(dropped_block.number)
2503        };
2504
2505        if dropped.is_empty() {
2506            let canceled_resyncs =
2507                self.cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&dropped_block));
2508            if canceled_resyncs.is_empty() {
2509                return None;
2510            }
2511            return Some(ReorgReport {
2512                dropped: Some(dropped_block.clone()),
2513                dropped_blocks: vec![dropped_block],
2514                dropped_inputs: Vec::new(),
2515                rollback_updates: Vec::new(),
2516                rollback_diff: StateDiff::default(),
2517                purge_updates: Vec::new(),
2518                purge_diff: StateDiff::default(),
2519                canceled_resyncs,
2520                reason,
2521                _network: PhantomData,
2522            });
2523        }
2524
2525        self.recover_dropped_journals(cache, dropped, reason)
2526    }
2527
2528    /// Warn that a reorg references a block no longer resident in the journal, so
2529    /// recovery is limited to the blocks still journaled — effects from aged-out
2530    /// blocks are neither rolled back nor purged (the freshness/validation loop is
2531    /// the backstop). Makes the under-recovery observable instead of silent.
2532    ///
2533    /// This is a deep reorg: it increments the `deep_reorgs` counter and escalates
2534    /// health along the trust-loss ladder via [`escalate_trust`](Self::escalate_trust)
2535    /// (a first event degrades to [`CacheHealth::Degraded`], a second escalates to
2536    /// [`CacheHealth::Unhealthy`]). Any resulting [`ReactiveReport::Health`]
2537    /// transition is returned so the caller can thread it into the ingest cycle's
2538    /// dispatched reports.
2539    fn warn_under_recovery(&mut self, reorg_number: u64) -> Option<Arc<ReactiveReport<N>>> {
2540        let oldest_journaled = self.journal.front().map(|entry| entry.block.number);
2541        tracing::warn!(
2542            reorg_block = reorg_number,
2543            oldest_journaled = ?oldest_journaled,
2544            journal_depth = self.config.journal_depth,
2545            "reactive reorg recovery is incomplete: the reorged block is no longer \
2546             in the journal, so effects from blocks aged out of the journal are \
2547             neither rolled back nor purged (the freshness/validation loop is the \
2548             backstop). Increase ReactiveConfig::journal_depth to recover deeper \
2549             reorgs precisely."
2550        );
2551
2552        self.metrics.deep_reorgs.fetch_add(1, Ordering::Relaxed);
2553
2554        self.escalate_trust(reorg_number)
2555    }
2556
2557    fn record_journal_input(&mut self, block: &BlockRef, input_ref: InputRef) {
2558        let entry = self.journal_entry_mut(block);
2559        if !entry.inputs.contains(&input_ref) {
2560            entry.inputs.push(input_ref);
2561        }
2562        self.trim_journal();
2563    }
2564
2565    fn record_journal_applied(&mut self, block: &BlockRef, applied: AppliedReport<N>) {
2566        self.journal_entry_mut(block).applied.push(applied);
2567        self.trim_journal();
2568    }
2569
2570    fn record_journal_resync(&mut self, report: &ResyncReport) {
2571        if report.diff.is_empty() {
2572            return;
2573        }
2574        let Some(block) = single_hash_pinned_resync_block(report) else {
2575            return;
2576        };
2577        self.journal_entry_mut(&block).resynced.push(report.clone());
2578        self.trim_journal();
2579    }
2580
2581    fn journal_entry_mut(&mut self, block: &BlockRef) -> &mut BlockJournal<N> {
2582        if let Some(index) = self
2583            .journal
2584            .iter()
2585            .position(|entry| entry.block.hash == block.hash && entry.block.number == block.number)
2586        {
2587            return &mut self.journal[index];
2588        }
2589
2590        self.journal.push_back(BlockJournal {
2591            block: block.clone(),
2592            inputs: Vec::new(),
2593            applied: Vec::new(),
2594            resynced: Vec::new(),
2595        });
2596        let index = self.journal.len() - 1;
2597        &mut self.journal[index]
2598    }
2599
2600    fn trim_journal(&mut self) {
2601        if self.config.journal_depth == 0 {
2602            self.journal.clear();
2603            return;
2604        }
2605        while self.journal.len() > self.config.journal_depth {
2606            self.journal.pop_front();
2607        }
2608    }
2609
2610    fn drain_journal_after(&mut self, index: usize) -> Vec<BlockJournal<N>> {
2611        self.journal.drain((index + 1)..).collect()
2612    }
2613
2614    fn drain_journal_from(&mut self, index: usize) -> Vec<BlockJournal<N>> {
2615        self.journal.drain(index..).collect()
2616    }
2617
2618    fn drain_journal_from_number(&mut self, number: u64) -> Vec<BlockJournal<N>> {
2619        let Some(index) = self
2620            .journal
2621            .iter()
2622            .position(|entry| entry.block.number >= number)
2623        else {
2624            return Vec::new();
2625        };
2626        self.drain_journal_from(index)
2627    }
2628
2629    fn recover_dropped_journals(
2630        &mut self,
2631        cache: &mut EvmCache,
2632        dropped: Vec<BlockJournal<N>>,
2633        reason: ReorgReason,
2634    ) -> Option<ReorgReport<N>> {
2635        if dropped.is_empty() {
2636            return None;
2637        }
2638
2639        let dropped_blocks: Vec<_> = dropped.iter().map(|entry| entry.block.clone()).collect();
2640        let dropped_inputs: Vec<_> = dropped
2641            .iter()
2642            .flat_map(|entry| entry.inputs.iter().copied())
2643            .collect();
2644        let canceled_resyncs = self.cancel_resyncs_for_dropped_blocks(&dropped_blocks);
2645        let purge_scopes = purge_scopes_for_dropped_journals(&dropped);
2646        let rollback_updates = rollback_updates_for_dropped_journals(&dropped, &purge_scopes);
2647        let purge_updates: Vec<_> = purge_scopes
2648            .iter()
2649            .map(|(address, scope)| StateUpdate::purge(*address, scope.clone()))
2650            .collect();
2651
2652        let rollback_diff = if rollback_updates.is_empty() {
2653            StateDiff::default()
2654        } else {
2655            cache.apply_updates(&rollback_updates)
2656        };
2657        let purge_diff = if purge_updates.is_empty() {
2658            StateDiff::default()
2659        } else {
2660            cache.apply_updates(&purge_updates)
2661        };
2662
2663        Some(ReorgReport {
2664            dropped: dropped_blocks.first().cloned(),
2665            dropped_blocks,
2666            dropped_inputs,
2667            rollback_updates,
2668            rollback_diff,
2669            purge_updates,
2670            purge_diff,
2671            canceled_resyncs,
2672            reason,
2673            _network: PhantomData,
2674        })
2675    }
2676
2677    fn cancel_resyncs_for_dropped_blocks(
2678        &mut self,
2679        dropped_blocks: &[BlockRef],
2680    ) -> Vec<ResyncRequest> {
2681        let mut canceled = Vec::new();
2682        self.pending_resyncs.retain(|request| {
2683            let should_cancel = resync_request_targets_dropped_block(request, dropped_blocks);
2684            if should_cancel {
2685                canceled.push(request.clone());
2686            }
2687            !should_cancel
2688        });
2689        canceled
2690    }
2691
2692    fn remove_pending_resyncs<'a>(&mut self, ids: impl IntoIterator<Item = &'a ResyncId>) {
2693        let ids: HashSet<_> = ids.into_iter().cloned().collect();
2694        self.pending_resyncs
2695            .retain(|request| !ids.contains(&request.id));
2696    }
2697}
2698
2699/// Fold every address a [`StateDiff`] references — genuine changes
2700/// (`slots`/`accounts`/`purged`) and cold-skipped attempts (`skipped*`) alike —
2701/// into `into`. Used by the per-block root gate to accumulate the batch's
2702/// decoder-touched address set: an account a decoder wrote (or tried to write) is
2703/// "covered," so a subsequent root move for it is not a coverage gap.
2704fn collect_diff_addresses(diff: &StateDiff, into: &mut HashSet<Address>) {
2705    into.extend(diff.slots.iter().map(|change| change.address));
2706    into.extend(diff.accounts.iter().map(|change| change.address));
2707    into.extend(diff.purged.iter().map(|purge| purge.address));
2708    into.extend(diff.skipped.iter().map(|skipped| skipped.address));
2709    into.extend(diff.skipped_balances.iter().map(|skipped| skipped.address));
2710    into.extend(diff.skipped_masks.iter().map(|skipped| skipped.address));
2711    into.extend(diff.skipped_accounts.iter().map(|skipped| skipped.address));
2712}
2713
2714/// Build the [`ResyncReason::RootMoved`] account resync the root gate schedules
2715/// for an uncovered move. Re-reads `address`'s `fields` at `block` through the
2716/// existing account-resync path (Wave 2). The id is derived from the address and
2717/// block so a repeated move on the same account/block coalesces deterministically.
2718fn root_moved_account_resync(
2719    address: Address,
2720    block: u64,
2721    fields: AccountFieldMask,
2722) -> ResyncRequest {
2723    ResyncRequest {
2724        id: ResyncId::new(format!("root-moved:{address:#x}:{block}")),
2725        reason: ResyncReason::RootMoved,
2726        block: ResyncBlock::Number(block),
2727        targets: vec![ResyncTarget::Account { address, fields }],
2728        priority: ResyncPriority::Normal,
2729    }
2730}
2731
2732fn canonical_record_block<N: Network>(record: &ReactiveInputRecord<N>) -> Option<&BlockRef> {
2733    if matches!(&record.input, ReactiveInput::Log(log) if log.removed) {
2734        return None;
2735    }
2736    if is_canonical_status(&record.context.chain_status) {
2737        return context_block_ref(&record.context);
2738    }
2739    None
2740}
2741
2742/// Best-effort per-block env refresh (Phase-8 step 2).
2743///
2744/// For a canonical record carrying a full header — a
2745/// [`ReactiveInput::BlockHeader`] or [`ReactiveInput::FullBlock`] — refresh the
2746/// cache's block env from that header via [`EvmCache::advance_block`]. Returns
2747/// `Some(result)` when a header was present (so the caller can surface a strict
2748/// validation error), and `None` for pending/reorged records or non-header
2749/// inputs, which must never drive a canonical env refresh.
2750fn advance_block_for_canonical_record<N: Network>(
2751    cache: &mut EvmCache,
2752    record: &ReactiveInputRecord<N>,
2753) -> Option<Result<(), BlockContextError>> {
2754    if !is_canonical_status(&record.context.chain_status) {
2755        return None;
2756    }
2757    match &record.input {
2758        ReactiveInput::BlockHeader(header) => Some(cache.advance_block(header)),
2759        ReactiveInput::FullBlock(block) => Some(cache.advance_block(block.header())),
2760        _ => None,
2761    }
2762}
2763
2764fn context_block_ref(ctx: &ReactiveContext) -> Option<&BlockRef> {
2765    match &ctx.chain_status {
2766        ChainStatus::Included { block, .. }
2767        | ChainStatus::Safe { block }
2768        | ChainStatus::Finalized { block } => Some(block),
2769        ChainStatus::Reorged { dropped_from } => Some(dropped_from),
2770        ChainStatus::Pending => ctx.block.as_ref(),
2771    }
2772}
2773
2774fn reorg_signal_block<N: Network>(
2775    record: &ReactiveInputRecord<N>,
2776) -> Option<(BlockRef, ReorgReason)> {
2777    if matches!(&record.input, ReactiveInput::Log(log) if log.removed) {
2778        return block_ref_from_record(record).map(|block| (block, ReorgReason::RemovedLog));
2779    }
2780
2781    if let ChainStatus::Reorged { dropped_from } = &record.context.chain_status {
2782        return Some((dropped_from.clone(), ReorgReason::ReorgedInput));
2783    }
2784
2785    None
2786}
2787
2788fn block_ref_from_record<N: Network>(record: &ReactiveInputRecord<N>) -> Option<BlockRef> {
2789    context_block_ref(&record.context)
2790        .cloned()
2791        .or_else(|| match &record.input {
2792            ReactiveInput::Log(log) => Some(BlockRef {
2793                number: log.block_number?,
2794                hash: log.block_hash?,
2795                parent_hash: None,
2796                timestamp: log.block_timestamp,
2797            }),
2798            ReactiveInput::BlockHeader(header) => Some(BlockRef {
2799                number: header.number(),
2800                hash: header.hash(),
2801                parent_hash: Some(header.parent_hash()),
2802                timestamp: Some(header.timestamp()),
2803            }),
2804            ReactiveInput::FullBlock(block) => {
2805                let header = block.header();
2806                Some(BlockRef {
2807                    number: header.number(),
2808                    hash: header.hash(),
2809                    parent_hash: Some(header.parent_hash()),
2810                    timestamp: Some(header.timestamp()),
2811                })
2812            }
2813            ReactiveInput::PendingTxHash(_) | ReactiveInput::PendingTx(_) => None,
2814        })
2815}
2816
2817fn remove_canceled_resyncs_from_batch(
2818    resyncs: &mut Vec<ResyncRequest>,
2819    canceled: &[ResyncRequest],
2820) {
2821    if canceled.is_empty() {
2822        return;
2823    }
2824    let canceled_ids: HashSet<_> = canceled.iter().map(|request| request.id.clone()).collect();
2825    resyncs.retain(|request| !canceled_ids.contains(&request.id));
2826}
2827
2828fn resync_target_address(target: &ResyncTarget) -> Address {
2829    match target {
2830        ResyncTarget::StorageSlot { address, .. }
2831        | ResyncTarget::StorageSlots { address, .. }
2832        | ResyncTarget::Account { address, .. } => *address,
2833    }
2834}
2835
2836fn resync_request_targets_dropped_block(
2837    request: &ResyncRequest,
2838    dropped_blocks: &[BlockRef],
2839) -> bool {
2840    let ResyncBlock::Hash { number, hash, .. } = &request.block else {
2841        return false;
2842    };
2843    dropped_blocks
2844        .iter()
2845        .any(|block| block.hash == *hash && block.number == *number)
2846}
2847
2848fn single_hash_pinned_resync_block(report: &ResyncReport) -> Option<BlockRef> {
2849    let first = report.requested.first()?.block.clone();
2850    if !report
2851        .requested
2852        .iter()
2853        .all(|request| request.block == first)
2854    {
2855        return None;
2856    }
2857
2858    let ResyncBlock::Hash { number, hash, .. } = first else {
2859        return None;
2860    };
2861
2862    Some(BlockRef {
2863        number,
2864        hash,
2865        parent_hash: None,
2866        timestamp: None,
2867    })
2868}
2869
2870fn purge_scopes_for_dropped_journals<N: Network>(
2871    dropped: &[BlockJournal<N>],
2872) -> Vec<(Address, PurgeScope)> {
2873    let mut scopes: Vec<(Address, PurgeScope)> = Vec::new();
2874    for entry in dropped.iter().rev() {
2875        for resynced in entry.resynced.iter().rev() {
2876            merge_purge_scopes_for_diff(&mut scopes, &resynced.diff);
2877        }
2878        for applied in entry.applied.iter().rev() {
2879            merge_purge_scopes_for_diff(&mut scopes, &applied.diff);
2880        }
2881    }
2882    scopes
2883}
2884
2885fn rollback_updates_for_dropped_journals<N: Network>(
2886    dropped: &[BlockJournal<N>],
2887    purge_scopes: &[(Address, PurgeScope)],
2888) -> Vec<StateUpdate> {
2889    let purge_addresses: HashSet<_> = purge_scopes
2890        .iter()
2891        .map(|(address, _scope)| *address)
2892        .collect();
2893    let mut updates = Vec::new();
2894    for entry in dropped.iter().rev() {
2895        for resynced in entry.resynced.iter().rev() {
2896            push_rollback_updates_for_diff(&mut updates, &resynced.diff, &purge_addresses);
2897        }
2898        for applied in entry.applied.iter().rev() {
2899            push_rollback_updates_for_diff(&mut updates, &applied.diff, &purge_addresses);
2900        }
2901    }
2902    updates
2903}
2904
2905fn merge_purge_scopes_for_diff(scopes: &mut Vec<(Address, PurgeScope)>, diff: &StateDiff) {
2906    for change in &diff.accounts {
2907        merge_purge_scope(scopes, change.address, PurgeScope::Account);
2908    }
2909    for record in &diff.purged {
2910        merge_purge_scope(scopes, record.address, record.scope.clone());
2911    }
2912}
2913
2914fn push_rollback_updates_for_diff(
2915    updates: &mut Vec<StateUpdate>,
2916    diff: &StateDiff,
2917    purge_addresses: &HashSet<Address>,
2918) {
2919    for change in diff.slots.iter().rev() {
2920        if purge_addresses.contains(&change.address) {
2921            continue;
2922        }
2923        updates.push(StateUpdate::slot(change.address, change.slot, change.old));
2924    }
2925}
2926
2927fn merge_purge_scope(scopes: &mut Vec<(Address, PurgeScope)>, address: Address, scope: PurgeScope) {
2928    if let Some((_existing_address, existing_scope)) = scopes
2929        .iter_mut()
2930        .find(|(existing_address, _scope)| *existing_address == address)
2931    {
2932        *existing_scope = merged_purge_scope(existing_scope.clone(), scope);
2933    } else {
2934        scopes.push((address, scope));
2935    }
2936}
2937
2938fn merged_purge_scope(left: PurgeScope, right: PurgeScope) -> PurgeScope {
2939    match (left, right) {
2940        (PurgeScope::Account, _) | (_, PurgeScope::Account) => PurgeScope::Account,
2941        (PurgeScope::AllStorage, _) | (_, PurgeScope::AllStorage) => PurgeScope::AllStorage,
2942        (PurgeScope::Slots(mut left), PurgeScope::Slots(right)) => {
2943            for slot in right {
2944                if !left.contains(&slot) {
2945                    left.push(slot);
2946                }
2947            }
2948            PurgeScope::Slots(left)
2949        }
2950    }
2951}
2952
2953#[derive(Clone, Debug)]
2954struct StorageFetchSlot {
2955    address: Address,
2956    slot: U256,
2957    origins: Vec<StorageFetchOrigin>,
2958}
2959
2960#[derive(Clone, Debug)]
2961struct StorageFetchOrigin {
2962    request_id: ResyncId,
2963    target: ResyncTarget,
2964}
2965
2966#[derive(Clone, Debug)]
2967struct StorageFetchGroup {
2968    block: ResyncBlock,
2969    slots: Vec<StorageFetchSlot>,
2970    seen: HashSet<(Address, U256)>,
2971}
2972
2973/// One account-target resync collected during request scanning, resolved through
2974/// the account proof fetcher after storage groups are processed.
2975#[derive(Clone, Debug)]
2976struct AccountResyncTarget {
2977    request_id: ResyncId,
2978    block: ResyncBlock,
2979    address: Address,
2980    fields: AccountFieldMask,
2981}
2982
2983fn resolve_trace_resyncs(
2984    cache: &EvmCache,
2985    storage_groups: &mut Vec<StorageFetchGroup>,
2986    account_targets: &mut Vec<AccountResyncTarget>,
2987    state_updates: &mut Vec<StateUpdate>,
2988) {
2989    let Some(fetcher) = cache.block_state_diff_fetcher().cloned() else {
2990        return;
2991    };
2992
2993    let mut blocks = Vec::new();
2994    let mut seen = HashSet::new();
2995    for block in storage_groups
2996        .iter()
2997        .map(|group| group.block.clone())
2998        .chain(account_targets.iter().map(|target| target.block.clone()))
2999    {
3000        if seen.insert(block.clone()) {
3001            blocks.push(block);
3002        }
3003    }
3004
3005    let mut traces = HashMap::new();
3006    for block in blocks {
3007        match (fetcher)(resync_block_to_block_id(&block)) {
3008            Ok(diff) => {
3009                traces.insert(block, diff);
3010            }
3011            Err(error) => {
3012                tracing::debug!(
3013                    block = ?block,
3014                    error = %error,
3015                    "block trace resync source failed; falling back to point resync"
3016                );
3017            }
3018        }
3019    }
3020
3021    for group in storage_groups.iter_mut() {
3022        let Some(trace) = traces.get(&group.block) else {
3023            continue;
3024        };
3025        group.slots.retain(|slot| {
3026            if let Some(value) = trace_storage_value(trace, slot.address, slot.slot) {
3027                state_updates.push(StateUpdate::slot(slot.address, slot.slot, value));
3028                return false;
3029            }
3030            cache
3031                .cached_storage_value(slot.address, slot.slot)
3032                .is_none()
3033        });
3034        group.seen = group
3035            .slots
3036            .iter()
3037            .map(|slot| (slot.address, slot.slot))
3038            .collect();
3039    }
3040    storage_groups.retain(|group| !group.slots.is_empty());
3041
3042    let mut unresolved_accounts = Vec::new();
3043    for mut account in account_targets.drain(..) {
3044        let Some(trace) = traces.get(&account.block) else {
3045            unresolved_accounts.push(account);
3046            continue;
3047        };
3048        let Some(trace_account) = trace
3049            .accounts
3050            .iter()
3051            .find(|diff| diff.address == account.address)
3052        else {
3053            unresolved_accounts.push(account);
3054            continue;
3055        };
3056
3057        let mut patch = AccountPatch::default();
3058        let mut unresolved = AccountFieldMask::default();
3059        if account.fields.balance {
3060            if let Some(balance) = trace_account.balance {
3061                patch = patch.balance(balance);
3062            } else {
3063                unresolved.balance = true;
3064            }
3065        }
3066        if account.fields.nonce {
3067            if let Some(nonce) = trace_account.nonce {
3068                patch = patch.nonce(nonce);
3069            } else {
3070                unresolved.nonce = true;
3071            }
3072        }
3073        if account.fields.code {
3074            if let Some(code) = &trace_account.code {
3075                patch = patch.code(code.clone());
3076            } else {
3077                unresolved.code = true;
3078            }
3079        }
3080
3081        if patch.balance.is_some() || patch.nonce.is_some() || patch.code.is_some() {
3082            state_updates.push(StateUpdate::account_upsert(account.address, patch));
3083        }
3084        if !account_field_mask_empty(unresolved) {
3085            account.fields = unresolved;
3086            unresolved_accounts.push(account);
3087        }
3088    }
3089    *account_targets = unresolved_accounts;
3090}
3091
3092fn trace_storage_value(trace: &BlockStateDiff, address: Address, slot: U256) -> Option<U256> {
3093    trace
3094        .accounts
3095        .iter()
3096        .find(|account| account.address == address)
3097        .and_then(|account| {
3098            account
3099                .storage
3100                .iter()
3101                .find(|entry| entry.slot == slot)
3102                .map(|entry| entry.value)
3103        })
3104}
3105
3106fn account_field_mask_empty(mask: AccountFieldMask) -> bool {
3107    !mask.balance && !mask.nonce && !mask.code
3108}
3109
3110fn execute_resync_requests(cache: &mut EvmCache, requests: &[ResyncRequest]) -> ResyncReport {
3111    let mut failed = Vec::new();
3112    let mut storage_groups: Vec<StorageFetchGroup> = Vec::new();
3113    let mut account_targets: Vec<AccountResyncTarget> = Vec::new();
3114
3115    for request in requests {
3116        for target in &request.targets {
3117            match target {
3118                ResyncTarget::StorageSlot { address, slot } => {
3119                    push_storage_resync_slot(
3120                        &mut storage_groups,
3121                        &request.id,
3122                        &request.block,
3123                        *address,
3124                        *slot,
3125                    );
3126                }
3127                ResyncTarget::StorageSlots { address, slots } => {
3128                    for slot in slots {
3129                        push_storage_resync_slot(
3130                            &mut storage_groups,
3131                            &request.id,
3132                            &request.block,
3133                            *address,
3134                            *slot,
3135                        );
3136                    }
3137                }
3138                ResyncTarget::Account { address, fields } => {
3139                    account_targets.push(AccountResyncTarget {
3140                        request_id: request.id.clone(),
3141                        block: request.block.clone(),
3142                        address: *address,
3143                        fields: *fields,
3144                    });
3145                }
3146            }
3147        }
3148    }
3149
3150    let mut state_updates = Vec::new();
3151    resolve_trace_resyncs(
3152        cache,
3153        &mut storage_groups,
3154        &mut account_targets,
3155        &mut state_updates,
3156    );
3157
3158    if !storage_groups.is_empty() {
3159        if let Some(fetcher) = cache.storage_batch_fetcher().cloned() {
3160            for group in storage_groups {
3161                let block = group.block.clone();
3162                let fetches: Vec<(Address, U256)> = group
3163                    .slots
3164                    .iter()
3165                    .map(|slot| (slot.address, slot.slot))
3166                    .collect();
3167                let results = (fetcher)(fetches, resync_block_to_block_id(&block));
3168                let mut pending: HashMap<(Address, U256), StorageFetchSlot> = group
3169                    .slots
3170                    .iter()
3171                    .cloned()
3172                    .map(|slot| ((slot.address, slot.slot), slot))
3173                    .collect();
3174
3175                for (address, slot, fetched) in results {
3176                    let Some(requested_slot) = pending.remove(&(address, slot)) else {
3177                        continue;
3178                    };
3179                    match fetched {
3180                        Ok(value) => state_updates.push(StateUpdate::slot(address, slot, value)),
3181                        Err(error) => {
3182                            let message = error.to_string();
3183                            push_resync_failures(
3184                                &mut failed,
3185                                &block,
3186                                requested_slot.origins,
3187                                ResyncFailureKind::StorageFetchFailed,
3188                                message,
3189                            );
3190                        }
3191                    }
3192                }
3193
3194                for requested_slot in group.slots {
3195                    if pending
3196                        .remove(&(requested_slot.address, requested_slot.slot))
3197                        .is_some()
3198                    {
3199                        push_resync_failures(
3200                            &mut failed,
3201                            &block,
3202                            requested_slot.origins,
3203                            ResyncFailureKind::StorageFetchOmitted,
3204                            "storage batch fetcher did not return a value for slot".to_string(),
3205                        );
3206                    }
3207                }
3208            }
3209        } else {
3210            for group in storage_groups {
3211                let block = group.block.clone();
3212                for slot in group.slots {
3213                    push_resync_failures(
3214                        &mut failed,
3215                        &block,
3216                        slot.origins,
3217                        ResyncFailureKind::MissingStorageFetcher,
3218                        "storage resync requires a storage batch fetcher".to_string(),
3219                    );
3220                }
3221            }
3222        }
3223    }
3224
3225    if !account_targets.is_empty() {
3226        if let Some(fetcher) = cache.account_proof_fetcher().cloned() {
3227            // ONE seam invocation per distinct resync block (targets may pin
3228            // different blocks): eth_getProof is single-address at the RPC
3229            // level, so batching the addresses lets the fetcher fan the
3230            // requests out concurrently instead of paying one round trip per
3231            // account. Root-only probes: account fields need no storage keys.
3232            let mut groups: Vec<(BlockId, Vec<_>)> = Vec::new();
3233            for account in account_targets {
3234                let block_id = resync_block_to_block_id(&account.block);
3235                match groups
3236                    .iter_mut()
3237                    .find(|(group_block, _)| *group_block == block_id)
3238                {
3239                    Some((_, group)) => group.push(account),
3240                    None => groups.push((block_id, vec![account])),
3241                }
3242            }
3243            for (block_id, group) in groups {
3244                let probes: HashMap<Address, StorageFetchResult<AccountProof>> = (fetcher)(
3245                    group
3246                        .iter()
3247                        .map(|account| (account.address, vec![]))
3248                        .collect(),
3249                    block_id,
3250                )
3251                .into_iter()
3252                .collect();
3253                for account in group {
3254                    // `get` + clone rather than `remove`: two targets for the
3255                    // same address in one group must both resolve from the
3256                    // single probe.
3257                    match probes.get(&account.address).cloned() {
3258                        Some(Ok(proof)) => {
3259                            // Build an authoritative account update from the requested
3260                            // field mask. Use the MATERIALIZING `account_upsert` so a
3261                            // resync applies even to a cold account (a partial `Account`
3262                            // patch on a cold address is silently skipped).
3263                            let mut patch = AccountPatch::default();
3264                            if account.fields.balance {
3265                                patch = patch.balance(proof.balance);
3266                            }
3267                            if account.fields.nonce {
3268                                patch = patch.nonce(proof.nonce);
3269                            }
3270                            // Note: `AccountProof` carries `code_hash`, not code bytes;
3271                            // the `eth_getProof` seam cannot supply runtime code, so a
3272                            // code-field resync is a no-op here (code freshness is
3273                            // handled by a later wave). We still materialize the account
3274                            // so requested balance/nonce fields take effect.
3275                            state_updates.push(StateUpdate::account_upsert(account.address, patch));
3276                        }
3277                        Some(Err(error)) => {
3278                            failed.push(ResyncFailure {
3279                                request_id: account.request_id,
3280                                block: account.block,
3281                                target: ResyncTarget::Account {
3282                                    address: account.address,
3283                                    fields: account.fields,
3284                                },
3285                                kind: ResyncFailureKind::AccountFetchFailed,
3286                                message: error.to_string(),
3287                            });
3288                        }
3289                        None => {
3290                            failed.push(ResyncFailure {
3291                                request_id: account.request_id,
3292                                block: account.block,
3293                                target: ResyncTarget::Account {
3294                                    address: account.address,
3295                                    fields: account.fields,
3296                                },
3297                                kind: ResyncFailureKind::AccountFetchOmitted,
3298                                message:
3299                                    "account proof fetcher did not return a result for address"
3300                                        .to_string(),
3301                            });
3302                        }
3303                    }
3304                }
3305            }
3306        } else {
3307            for account in account_targets {
3308                failed.push(ResyncFailure {
3309                    request_id: account.request_id,
3310                    block: account.block,
3311                    target: ResyncTarget::Account {
3312                        address: account.address,
3313                        fields: account.fields,
3314                    },
3315                    kind: ResyncFailureKind::MissingAccountFetcher,
3316                    message: "account resync requires an account proof fetcher".to_string(),
3317                });
3318            }
3319        }
3320    }
3321
3322    let diff = if state_updates.is_empty() {
3323        StateDiff::default()
3324    } else {
3325        cache.apply_updates(&state_updates)
3326    };
3327
3328    ResyncReport {
3329        requested: requests.to_vec(),
3330        state_updates,
3331        diff,
3332        failed,
3333    }
3334}
3335
3336fn push_resync_failures(
3337    failed: &mut Vec<ResyncFailure>,
3338    block: &ResyncBlock,
3339    origins: Vec<StorageFetchOrigin>,
3340    kind: ResyncFailureKind,
3341    message: String,
3342) {
3343    for origin in origins {
3344        failed.push(ResyncFailure {
3345            request_id: origin.request_id,
3346            block: block.clone(),
3347            target: origin.target,
3348            kind,
3349            message: message.clone(),
3350        });
3351    }
3352}
3353
3354fn push_storage_resync_slot(
3355    groups: &mut Vec<StorageFetchGroup>,
3356    request_id: &ResyncId,
3357    block: &ResyncBlock,
3358    address: Address,
3359    slot: U256,
3360) {
3361    let group_index = if let Some(index) = groups.iter().position(|group| group.block == *block) {
3362        index
3363    } else {
3364        groups.push(StorageFetchGroup {
3365            block: block.clone(),
3366            slots: Vec::new(),
3367            seen: HashSet::new(),
3368        });
3369        groups.len() - 1
3370    };
3371
3372    let group = &mut groups[group_index];
3373    let origin = StorageFetchOrigin {
3374        request_id: request_id.clone(),
3375        target: ResyncTarget::StorageSlot { address, slot },
3376    };
3377    if group.seen.insert((address, slot)) {
3378        group.slots.push(StorageFetchSlot {
3379            address,
3380            slot,
3381            origins: vec![origin],
3382        });
3383    } else if let Some(existing) = group
3384        .slots
3385        .iter_mut()
3386        .find(|existing| existing.address == address && existing.slot == slot)
3387    {
3388        existing.origins.push(origin);
3389    }
3390}
3391
3392fn resync_block_to_block_id(block: &ResyncBlock) -> BlockId {
3393    match block {
3394        ResyncBlock::Latest => BlockId::latest(),
3395        ResyncBlock::Safe => BlockId::safe(),
3396        ResyncBlock::Finalized => BlockId::finalized(),
3397        ResyncBlock::Number(number) => BlockId::number(*number),
3398        ResyncBlock::Hash {
3399            number: _,
3400            hash,
3401            require_canonical,
3402        } => BlockId::from((*hash, Some(*require_canonical))),
3403    }
3404}
3405
3406impl<N: Network> RegisteredHandler<N> {
3407    fn matches(&self, input: &ReactiveInput<N>) -> bool {
3408        self.interests
3409            .iter()
3410            .any(|interest| interest_matches(interest, input))
3411    }
3412
3413    fn route_log(&self, log: &Log) -> Option<ReactiveLogRoute> {
3414        self.interests.iter().find_map(|interest| match interest {
3415            ReactiveInterest::Logs(interest) if interest.matches(log) => Some(ReactiveLogRoute {
3416                handler_id: self.id.clone(),
3417                route_key: interest.route_key(log),
3418            }),
3419            ReactiveInterest::Logs(_)
3420            | ReactiveInterest::Blocks(_)
3421            | ReactiveInterest::PendingTransactions(_) => None,
3422        })
3423    }
3424}
3425
3426fn merge_log_subscription_filter(filters: &mut Vec<Filter>, next: &Filter) {
3427    if let Some(existing) = filters
3428        .iter_mut()
3429        .find(|existing| existing.block_option == next.block_option)
3430    {
3431        merge_filter_set(&mut existing.address, &next.address);
3432        for (existing_topic, next_topic) in existing.topics.iter_mut().zip(next.topics.iter()) {
3433            merge_filter_set(existing_topic, next_topic);
3434        }
3435    } else {
3436        filters.push(next.clone());
3437    }
3438}
3439
3440fn merge_filter_set<T: Clone + Eq + Hash>(target: &mut FilterSet<T>, source: &FilterSet<T>) {
3441    if target.is_empty() {
3442        return;
3443    }
3444    if source.is_empty() {
3445        *target = FilterSet::default();
3446        return;
3447    }
3448    for value in source.iter() {
3449        target.insert(value.clone());
3450    }
3451}
3452
3453#[derive(Clone, Debug)]
3454struct HandlerExecution {
3455    handler_id: HandlerId,
3456    quality: StateEffectQuality,
3457    tags: Vec<ReportTag>,
3458    state_updates: Vec<StateUpdate>,
3459    invalidations: Vec<InvalidationRequest>,
3460    resyncs: Vec<ResyncRequest>,
3461    speculative: Vec<SpeculativeRequest>,
3462    hook_signals: Vec<HookSignal>,
3463}
3464
3465impl HandlerExecution {
3466    fn from_outcome(handler_id: HandlerId, input_ref: InputRef, outcome: HandlerOutcome) -> Self {
3467        let mut state_updates = Vec::new();
3468        let mut invalidations = Vec::new();
3469        let mut resyncs = Vec::new();
3470        let mut speculative = Vec::new();
3471        let mut hook_signals = Vec::new();
3472
3473        for effect in outcome.effects {
3474            match effect {
3475                ReactiveEffect::StateUpdate(update) => state_updates.push(update),
3476                ReactiveEffect::Invalidate(invalidation) => {
3477                    state_updates.push(StateUpdate::purge(
3478                        invalidation.address,
3479                        invalidation.scope.clone(),
3480                    ));
3481                    invalidations.push(invalidation);
3482                }
3483                ReactiveEffect::Resync(request) => resyncs.push(request),
3484                ReactiveEffect::Hook(signal) => hook_signals.push(signal),
3485                ReactiveEffect::Speculative(mut request) => {
3486                    request.input_ref = input_ref;
3487                    speculative.push(request);
3488                }
3489            }
3490        }
3491
3492        Self {
3493            handler_id,
3494            quality: outcome.quality,
3495            tags: outcome.tags,
3496            state_updates,
3497            invalidations,
3498            resyncs,
3499            speculative,
3500            hook_signals,
3501        }
3502    }
3503}
3504
3505fn dedupe_records<N: Network>(records: Vec<ReactiveInputRecord<N>>) -> Vec<ReactiveInputRecord<N>> {
3506    let mut seen = HashSet::new();
3507    let mut deduped = Vec::with_capacity(records.len());
3508    for record in records {
3509        if seen.insert(record.input_ref()) {
3510            deduped.push(record);
3511        }
3512    }
3513    deduped
3514}
3515
3516fn sort_records<N: Network>(records: Vec<ReactiveInputRecord<N>>) -> Vec<ReactiveInputRecord<N>> {
3517    let mut indexed: Vec<(usize, ReactiveInputRecord<N>)> =
3518        records.into_iter().enumerate().collect();
3519    indexed.sort_by_key(|(index, record)| record_sort_key(*index, record));
3520    indexed.into_iter().map(|(_, record)| record).collect()
3521}
3522
3523fn record_sort_key<N: Network>(index: usize, record: &ReactiveInputRecord<N>) -> RecordSortKey {
3524    if let ReactiveInput::Log(log) = &record.input
3525        && is_canonical_status(&record.context.chain_status)
3526        && !log.removed
3527    {
3528        return RecordSortKey {
3529            class: 0,
3530            block_number: log
3531                .block_number
3532                .or(record.context.block.as_ref().map(|block| block.number))
3533                .unwrap_or(u64::MAX),
3534            transaction_index: log
3535                .transaction_index
3536                .or(record.context.transaction_index)
3537                .unwrap_or(u64::MAX),
3538            log_index: log
3539                .log_index
3540                .or(record.context.log_index)
3541                .unwrap_or(u64::MAX),
3542            original_index: index,
3543        };
3544    }
3545
3546    RecordSortKey {
3547        class: 1,
3548        block_number: 0,
3549        transaction_index: 0,
3550        log_index: 0,
3551        original_index: index,
3552    }
3553}
3554
3555#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
3556struct RecordSortKey {
3557    class: u8,
3558    block_number: u64,
3559    transaction_index: u64,
3560    log_index: u64,
3561    original_index: usize,
3562}
3563
3564fn interest_matches<N: Network>(interest: &ReactiveInterest<N>, input: &ReactiveInput<N>) -> bool {
3565    match (interest, input) {
3566        (ReactiveInterest::Logs(interest), ReactiveInput::Log(log)) => interest.matches(log),
3567        (
3568            ReactiveInterest::Blocks(BlockInterest {
3569                mode: BlockInterestMode::Header,
3570            }),
3571            ReactiveInput::BlockHeader(_),
3572        ) => true,
3573        (
3574            ReactiveInterest::Blocks(BlockInterest {
3575                mode: BlockInterestMode::FullBlock,
3576            }),
3577            ReactiveInput::FullBlock(_),
3578        ) => true,
3579        (ReactiveInterest::PendingTransactions(interest), ReactiveInput::PendingTxHash(_)) => {
3580            interest.matches_hash_only()
3581        }
3582        (ReactiveInterest::PendingTransactions(interest), ReactiveInput::PendingTx(tx)) => {
3583            interest.matches_tx(tx)
3584        }
3585        _ => false,
3586    }
3587}
3588
3589fn validate_effects(
3590    input_ref: InputRef,
3591    ctx: &ReactiveContext,
3592    handler_id: &HandlerId,
3593    effects: &[ReactiveEffect],
3594) -> Result<(), ReactiveError> {
3595    let pending = matches!(ctx.chain_status, ChainStatus::Pending)
3596        || matches!(input_ref, InputRef::PendingTx { .. });
3597    if !pending {
3598        return Ok(());
3599    }
3600
3601    for effect in effects {
3602        let effect_kind = match effect {
3603            ReactiveEffect::StateUpdate(_) => Some("state_update"),
3604            ReactiveEffect::Invalidate(_) => Some("invalidate"),
3605            ReactiveEffect::Resync(_) => Some("resync"),
3606            ReactiveEffect::Hook(_) | ReactiveEffect::Speculative(_) => None,
3607        };
3608        if let Some(effect_kind) = effect_kind {
3609            return Err(ReactiveError::InvalidPendingEffect {
3610                input_ref: Box::new(input_ref),
3611                handler_id: handler_id.clone(),
3612                effect_kind,
3613            });
3614        }
3615    }
3616    Ok(())
3617}
3618
3619fn detect_conflicts(
3620    input_ref: InputRef,
3621    executions: &[HandlerExecution],
3622) -> Result<(), ReactiveError> {
3623    let mut writes: HashMap<EffectTarget, (AbsoluteValue, HandlerId)> = HashMap::new();
3624    for execution in executions {
3625        for update in &execution.state_updates {
3626            for (target, value) in absolute_writes(update) {
3627                if let Some((previous_value, previous_handler)) = writes.get(&target) {
3628                    if previous_value != &value {
3629                        return Err(ReactiveError::ConflictingEffects {
3630                            input_ref: Box::new(input_ref),
3631                            target: Box::new(target),
3632                            first: previous_handler.clone(),
3633                            second: execution.handler_id.clone(),
3634                        });
3635                    }
3636                } else {
3637                    writes.insert(target, (value, execution.handler_id.clone()));
3638                }
3639            }
3640        }
3641    }
3642    Ok(())
3643}
3644
3645fn absolute_writes(update: &StateUpdate) -> Vec<(EffectTarget, AbsoluteValue)> {
3646    match update {
3647        StateUpdate::Slot {
3648            address,
3649            slot,
3650            value,
3651        } => vec![(
3652            EffectTarget::StorageSlot {
3653                address: *address,
3654                slot: *slot,
3655            },
3656            AbsoluteValue::U256(*value),
3657        )],
3658        StateUpdate::SlotMasked {
3659            address,
3660            slot,
3661            mask,
3662            value,
3663        } => vec![(
3664            EffectTarget::MaskedStorageSlot {
3665                address: *address,
3666                slot: *slot,
3667                mask: *mask,
3668            },
3669            AbsoluteValue::U256(*value),
3670        )],
3671        StateUpdate::Account { address, patch } | StateUpdate::AccountUpsert { address, patch } => {
3672            account_patch_writes(*address, patch)
3673        }
3674        StateUpdate::SlotDelta { .. }
3675        | StateUpdate::BalanceDelta { .. }
3676        | StateUpdate::Purge { .. } => Vec::new(),
3677    }
3678}
3679
3680fn account_patch_writes(
3681    address: Address,
3682    patch: &AccountPatch,
3683) -> Vec<(EffectTarget, AbsoluteValue)> {
3684    let mut writes = Vec::new();
3685    if let Some(balance) = patch.balance {
3686        writes.push((
3687            EffectTarget::AccountBalance { address },
3688            AbsoluteValue::U256(balance),
3689        ));
3690    }
3691    if let Some(nonce) = patch.nonce {
3692        writes.push((
3693            EffectTarget::AccountNonce { address },
3694            AbsoluteValue::U64(nonce),
3695        ));
3696    }
3697    if let Some(code) = &patch.code {
3698        writes.push((
3699            EffectTarget::AccountCode { address },
3700            AbsoluteValue::Bytes(code.clone()),
3701        ));
3702    }
3703    writes
3704}
3705
3706fn input_ref<N: Network>(input: &ReactiveInput<N>, ctx: &ReactiveContext) -> InputRef {
3707    match input {
3708        ReactiveInput::Log(log) => InputRef::Log {
3709            chain_id: ctx.chain_id,
3710            block_hash: log
3711                .block_hash
3712                .or(ctx.block.as_ref().map(|block| block.hash))
3713                .unwrap_or_default(),
3714            transaction_hash: log.transaction_hash.unwrap_or_default(),
3715            log_index: log.log_index.or(ctx.log_index).unwrap_or_default(),
3716        },
3717        ReactiveInput::PendingTxHash(hash) => InputRef::PendingTx {
3718            chain_id: ctx.chain_id,
3719            hash: *hash,
3720        },
3721        ReactiveInput::PendingTx(tx) => InputRef::PendingTx {
3722            chain_id: ctx.chain_id,
3723            hash: tx.tx_hash(),
3724        },
3725        ReactiveInput::BlockHeader(header) => InputRef::Block {
3726            chain_id: ctx.chain_id,
3727            hash: header.hash(),
3728            number: header.number(),
3729        },
3730        ReactiveInput::FullBlock(block) => {
3731            let header = block.header();
3732            InputRef::Block {
3733                chain_id: ctx.chain_id,
3734                hash: header.hash(),
3735                number: header.number(),
3736            }
3737        }
3738    }
3739}
3740
3741fn is_canonical_status(status: &ChainStatus) -> bool {
3742    matches!(
3743        status,
3744        ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. }
3745    )
3746}
3747
3748/// Adapter that wraps a legacy [`EventDecoder`] as a log-only reactive handler.
3749pub struct EventDecoderHandler {
3750    id: HandlerId,
3751    decoder: Arc<dyn EventDecoder>,
3752    interest: LogInterest,
3753}
3754
3755impl EventDecoderHandler {
3756    /// Create an adapter from a decoder and log interest.
3757    pub fn new(id: HandlerId, decoder: Arc<dyn EventDecoder>, interest: LogInterest) -> Self {
3758        Self {
3759            id,
3760            decoder,
3761            interest,
3762        }
3763    }
3764}
3765
3766impl<N: Network> ReactiveHandler<N> for EventDecoderHandler {
3767    fn id(&self) -> HandlerId {
3768        self.id.clone()
3769    }
3770
3771    fn interests(&self) -> Vec<ReactiveInterest<N>> {
3772        vec![ReactiveInterest::Logs(self.interest.clone())]
3773    }
3774
3775    fn handle(
3776        &self,
3777        _ctx: &ReactiveContext,
3778        input: &ReactiveInput<N>,
3779        state: &dyn StateView,
3780    ) -> Result<HandlerOutcome, HandlerError> {
3781        let ReactiveInput::Log(log) = input else {
3782            return Ok(HandlerOutcome::empty(StateEffectQuality::NoStateEffect));
3783        };
3784
3785        Ok(HandlerOutcome {
3786            effects: self
3787                .decoder
3788                .decode(&log.inner, state)
3789                .into_iter()
3790                .map(ReactiveEffect::StateUpdate)
3791                .collect(),
3792            quality: StateEffectQuality::ExactFromInput,
3793            tags: Vec::new(),
3794        })
3795    }
3796}
3797
3798/// Provider-agnostic subscriber interface.
3799pub trait EventSubscriber<N: Network = Ethereum>: Send {
3800    /// Replace all interests registered with the subscriber.
3801    ///
3802    /// Implementations may use this as a full setup/reset operation. The
3803    /// in-crate [`AlloySubscriber`] clears owner-scoped interest state and
3804    /// delivery/dedupe bookkeeping when this method is called.
3805    fn register_interests(
3806        &mut self,
3807        interests: &[ReactiveInterest<N>],
3808    ) -> Result<(), SubscriberError>;
3809
3810    /// Return the next input batch, or `Ok(None)` when the stream is exhausted.
3811    fn next_batch(&mut self) -> SubscriberNextBatch<'_, N>;
3812}
3813
3814/// Boxed future returned by [`EventSubscriber::next_batch`].
3815pub type SubscriberNextBatch<'a, N> = Pin<
3816    Box<dyn Future<Output = Result<Option<ReactiveInputBatch<N>>, SubscriberError>> + Send + 'a>,
3817>;
3818
3819/// Subscriber mode requested for the Alloy subscriber.
3820#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
3821pub enum SubscriberMode {
3822    /// Prefer the default compiled transport.
3823    ///
3824    /// With the default `reactive-ws` feature this resolves to pubsub/WebSocket
3825    /// subscriptions. Without `reactive-ws`, it resolves to polling only when
3826    /// the opt-in `reactive-polling` feature is enabled.
3827    #[default]
3828    Auto,
3829    /// Use provider pubsub streams.
3830    PubSub,
3831    /// Use polling/watch APIs. Requires the `reactive-polling` feature.
3832    Polling,
3833}
3834
3835/// Subscriber configuration.
3836#[derive(Clone, Debug, PartialEq, Eq)]
3837pub struct SubscriberConfig {
3838    /// Hydrate pending transaction hashes into full bodies when possible.
3839    pub hydrate_pending_transactions: bool,
3840    /// Maximum records to emit per batch.
3841    pub max_batch_size: usize,
3842    /// Reconnect policy for WebSocket/pubsub streams.
3843    pub reconnect: SubscriberReconnectConfig,
3844}
3845
3846impl Default for SubscriberConfig {
3847    fn default() -> Self {
3848        Self {
3849            hydrate_pending_transactions: false,
3850            max_batch_size: 1024,
3851            reconnect: SubscriberReconnectConfig::default(),
3852        }
3853    }
3854}
3855
3856/// WebSocket/pubsub reconnect policy.
3857///
3858/// Reconnects are applied after an established subscription stream terminates.
3859/// Initial subscription failures are still returned immediately so deployment
3860/// mistakes, unsupported transports, and bad endpoints fail fast.
3861#[derive(Clone, Debug, PartialEq, Eq)]
3862pub struct SubscriberReconnectConfig {
3863    /// Whether pubsub streams should be recreated after termination.
3864    pub enabled: bool,
3865    /// Delay before the first reconnect attempt.
3866    pub initial_delay: Duration,
3867    /// Delay before the second reconnect attempt. Later retries double this
3868    /// delay up to [`Self::max_delay`].
3869    pub retry_delay: Duration,
3870    /// Maximum delay between reconnect attempts.
3871    pub max_delay: Duration,
3872    /// Maximum reconnect attempts per terminated stream. `None` retries forever.
3873    pub max_attempts: Option<usize>,
3874    /// Number of recently emitted canonical input refs remembered to suppress
3875    /// duplicates across reconnect backfill and subscription replay.
3876    pub dedupe_window: usize,
3877}
3878
3879impl Default for SubscriberReconnectConfig {
3880    fn default() -> Self {
3881        Self {
3882            enabled: true,
3883            initial_delay: Duration::ZERO,
3884            retry_delay: Duration::from_millis(250),
3885            max_delay: Duration::from_secs(30),
3886            max_attempts: Some(3),
3887            dedupe_window: 4096,
3888        }
3889    }
3890}
3891
3892/// Historical log backfill requested when adding subscriber interests.
3893///
3894/// Backfill applies only to [`ReactiveInterest::Logs`] entries. Block and
3895/// pending-transaction interests are live-only. `AlloySubscriber` emits records
3896/// fetched through this policy as [`InputSource::Backfill`] before attempting
3897/// live stream initialization, and a drained backfill seeds the filter's
3898/// delivery anchor at its resolved upper bound (even when the window held no
3899/// logs), so the newly added filter gets the same reconnect/catch-up protection
3900/// an established one has.
3901#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3902pub struct SubscriberBackfill {
3903    from_block: u64,
3904    to_block: Option<u64>,
3905}
3906
3907impl SubscriberBackfill {
3908    /// Backfill an inclusive block range.
3909    pub fn range(from_block: u64, to_block: u64) -> Self {
3910        Self {
3911            from_block,
3912            to_block: Some(to_block),
3913        }
3914    }
3915
3916    /// Backfill from `from_block` through the provider's latest block.
3917    pub fn from_block(from_block: u64) -> Self {
3918        Self {
3919            from_block,
3920            to_block: None,
3921        }
3922    }
3923
3924    /// First block included in the backfill.
3925    pub fn start_block(&self) -> u64 {
3926        self.from_block
3927    }
3928
3929    /// Last block included in the backfill, or `None` for provider latest.
3930    pub fn end_block(&self) -> Option<u64> {
3931        self.to_block
3932    }
3933}
3934
3935/// Extension trait for subscribers that can add and remove handler-owned
3936/// interests incrementally.
3937///
3938/// [`EventSubscriber::register_interests`] remains the full-replacement setup
3939/// API. Implement this trait when a subscriber can preserve unrelated live
3940/// sources and delivery state while one handler's interests are added or
3941/// removed. Implementations should make owner *replacement* continuity-safe:
3942/// updating an owner's interests must not silently discard delivery progress
3943/// the previous interests had already established (the in-crate
3944/// [`AlloySubscriber`] carries the owner's prior delivery anchor over to
3945/// changed filter shapes and automatically backfills the gap).
3946pub trait InterestOwnerSubscriber<N: Network = Ethereum>: EventSubscriber<N> {
3947    /// Add or replace the interests owned by `owner`.
3948    fn add_interest_owner(
3949        &mut self,
3950        owner: HandlerId,
3951        interests: &[ReactiveInterest<N>],
3952    ) -> Result<(), SubscriberError>;
3953
3954    /// Add or replace owner interests and schedule log backfill for that owner.
3955    fn add_interest_owner_with_backfill(
3956        &mut self,
3957        owner: HandlerId,
3958        interests: &[ReactiveInterest<N>],
3959        backfill: SubscriberBackfill,
3960    ) -> Result<(), SubscriberError>;
3961
3962    /// Remove one owner's interests, preserving unrelated interests.
3963    fn remove_interest_owner(&mut self, owner: &HandlerId) -> Option<Vec<ReactiveInterest<N>>>;
3964
3965    /// Borrow the interests currently owned by `owner`.
3966    fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]>;
3967}
3968
3969/// Binds a [`ReactiveRuntime`] to an [`EventSubscriber`] for the common
3970/// subscribe-ingest lifecycle.
3971///
3972/// The engine treats the runtime registry as the single source of truth for
3973/// handler lifecycle: [`register_handler`](Self::register_handler) and
3974/// [`unregister_handler`](Self::unregister_handler) update runtime routing and
3975/// subscriber interests as one operation, keyed by the handler's stable
3976/// [`HandlerId`]. Registration is continuity-safe by default — once the runtime
3977/// has journaled a canonical block, a newly registered handler is backfilled
3978/// from that block, so a pool discovered in block *N* (say via a factory
3979/// `PoolCreated` event) misses none of its own logs from *N* onward even though
3980/// its live subscription starts later. Overlap between backfill and live
3981/// delivery is absorbed by subscriber and runtime dedup.
3982///
3983/// Registration methods by intent:
3984///
3985/// | Method | Backfill |
3986/// |---|---|
3987/// | [`register_handler`](Self::register_handler) | from the runtime's last canonical block (live-only on a fresh runtime) |
3988/// | [`register_handler_with_backfill`](Self::register_handler_with_backfill) | explicit range or anchor (deep history) |
3989/// | [`register_handler_live_only`](Self::register_handler_live_only) | none — future logs only |
3990///
3991/// Unregistering a handler stops future subscription routing and runtime
3992/// decode for that handler; it deliberately does not evict [`EvmCache`] state
3993/// or undo runtime side effects. See
3994/// [`unregister_handler`](Self::unregister_handler) for the complete teardown
3995/// recipe.
3996///
3997/// The runtime and subscriber stay independently accessible through
3998/// [`runtime_mut`](Self::runtime_mut) / [`subscriber_mut`](Self::subscriber_mut)
3999/// for advanced use. One caution: avoid calling
4000/// [`EventSubscriber::register_interests`] (the full-replacement setup API) on
4001/// an engine-managed subscriber — implementations may clear owner-scoped
4002/// bookkeeping, after which per-handler unregistration no longer releases the
4003/// handler's transport subscriptions. To bootstrap the subscriber from a
4004/// runtime that already has handlers, use
4005/// [`sync_handler_interests`](Self::sync_handler_interests), which registers
4006/// one owner per handler instead of one unowned blob.
4007pub struct ReactiveEngine<S, N: Network = Ethereum> {
4008    runtime: ReactiveRuntime<N>,
4009    subscriber: S,
4010}
4011
4012impl<S, N> ReactiveEngine<S, N>
4013where
4014    N: Network,
4015    S: EventSubscriber<N>,
4016{
4017    /// Bind a runtime and subscriber.
4018    pub fn new(runtime: ReactiveRuntime<N>, subscriber: S) -> Self {
4019        Self {
4020            runtime,
4021            subscriber,
4022        }
4023    }
4024
4025    /// Split the engine into its runtime and subscriber parts.
4026    pub fn into_parts(self) -> (ReactiveRuntime<N>, S) {
4027        (self.runtime, self.subscriber)
4028    }
4029
4030    /// Borrow the runtime.
4031    pub fn runtime(&self) -> &ReactiveRuntime<N> {
4032        &self.runtime
4033    }
4034
4035    /// Mutably borrow the runtime.
4036    pub fn runtime_mut(&mut self) -> &mut ReactiveRuntime<N> {
4037        &mut self.runtime
4038    }
4039
4040    /// Borrow the subscriber.
4041    pub fn subscriber(&self) -> &S {
4042        &self.subscriber
4043    }
4044
4045    /// Mutably borrow the subscriber.
4046    pub fn subscriber_mut(&mut self) -> &mut S {
4047        &mut self.subscriber
4048    }
4049
4050    /// Poll the subscriber for the next batch.
4051    pub fn next_batch(&mut self) -> SubscriberNextBatch<'_, N> {
4052        self.subscriber.next_batch()
4053    }
4054
4055    /// Ingest one already-polled batch through the runtime (direct effects
4056    /// only; surfaced resync requests are reported, not executed).
4057    pub fn ingest_batch(
4058        &mut self,
4059        cache: &mut EvmCache,
4060        batch: ReactiveInputBatch<N>,
4061    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
4062        self.runtime.ingest_batch(cache, batch)
4063    }
4064
4065    /// Ingest one already-polled batch and execute the storage/account resyncs
4066    /// it surfaces, exactly like
4067    /// [`ReactiveRuntime::ingest_batch_with_resync`].
4068    pub fn ingest_batch_with_resync(
4069        &mut self,
4070        cache: &mut EvmCache,
4071        batch: ReactiveInputBatch<N>,
4072    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
4073        self.runtime.ingest_batch_with_resync(cache, batch)
4074    }
4075
4076    /// Poll the subscriber once and ingest the returned batch when present
4077    /// (direct effects only).
4078    pub async fn next_ingest(
4079        &mut self,
4080        cache: &mut EvmCache,
4081    ) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
4082        let Some(batch) = self.subscriber.next_batch().await? else {
4083            return Ok(None);
4084        };
4085        Ok(Some(self.runtime.ingest_batch(cache, batch)?))
4086    }
4087
4088    /// Poll the subscriber once and ingest the returned batch with resync
4089    /// execution — the loop shape for consumers that rely on coverage-gap
4090    /// repair (root-gate resyncs, handler-requested re-reads).
4091    pub async fn next_ingest_with_resync(
4092        &mut self,
4093        cache: &mut EvmCache,
4094    ) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
4095        let Some(batch) = self.subscriber.next_batch().await? else {
4096            return Ok(None);
4097        };
4098        Ok(Some(self.runtime.ingest_batch_with_resync(cache, batch)?))
4099    }
4100}
4101
4102impl<S, N> ReactiveEngine<S, N>
4103where
4104    N: Network,
4105    S: InterestOwnerSubscriber<N>,
4106{
4107    /// Register a handler with both the runtime and subscriber, backfilling its
4108    /// log interests from the runtime's last canonical block.
4109    ///
4110    /// This is the continuity-safe default for mid-lifecycle registration: the
4111    /// runtime already knows how far it has processed the chain, so the new
4112    /// handler's logs are fetched from that block forward and no discovery gap
4113    /// opens between "we decided to track this pool" and "its live subscription
4114    /// started". On a runtime that has not journaled any canonical block yet
4115    /// (fresh start, or `journal_depth` 0) registration is live-only, matching
4116    /// pre-ingestion bootstrap. Use
4117    /// [`register_handler_with_backfill`](Self::register_handler_with_backfill)
4118    /// for deeper history or
4119    /// [`register_handler_live_only`](Self::register_handler_live_only) to opt
4120    /// out of backfill entirely.
4121    ///
4122    /// If subscriber registration fails, the runtime registration is rolled back
4123    /// before the error is returned.
4124    pub fn register_handler(
4125        &mut self,
4126        handler: Arc<dyn ReactiveHandler<N>>,
4127    ) -> Result<(), ReactiveEngineRegisterError> {
4128        let backfill = self
4129            .runtime
4130            .last_canonical_block()
4131            .map(|block| SubscriberBackfill::from_block(block.number));
4132        self.register_handler_inner(handler, backfill)
4133    }
4134
4135    /// Register a handler and request an explicit owner-scoped log backfill for
4136    /// its interests (deep history / custom anchors).
4137    ///
4138    /// If subscriber registration fails, the runtime registration is rolled back
4139    /// before the error is returned.
4140    pub fn register_handler_with_backfill(
4141        &mut self,
4142        handler: Arc<dyn ReactiveHandler<N>>,
4143        backfill: SubscriberBackfill,
4144    ) -> Result<(), ReactiveEngineRegisterError> {
4145        self.register_handler_inner(handler, Some(backfill))
4146    }
4147
4148    /// Register a handler without any log backfill — only logs delivered after
4149    /// its live subscription starts are routed to it.
4150    ///
4151    /// If subscriber registration fails, the runtime registration is rolled back
4152    /// before the error is returned.
4153    pub fn register_handler_live_only(
4154        &mut self,
4155        handler: Arc<dyn ReactiveHandler<N>>,
4156    ) -> Result<(), ReactiveEngineRegisterError> {
4157        self.register_handler_inner(handler, None)
4158    }
4159
4160    fn register_handler_inner(
4161        &mut self,
4162        handler: Arc<dyn ReactiveHandler<N>>,
4163        backfill: Option<SubscriberBackfill>,
4164    ) -> Result<(), ReactiveEngineRegisterError> {
4165        let id = handler.id();
4166        self.runtime.register_handler(handler)?;
4167        let interests = self
4168            .runtime
4169            .handler_interests(&id)
4170            .expect("handler was just registered")
4171            .to_vec();
4172
4173        let subscribed = match backfill {
4174            Some(backfill) => {
4175                self.subscriber
4176                    .add_interest_owner_with_backfill(id.clone(), &interests, backfill)
4177            }
4178            None => self.subscriber.add_interest_owner(id.clone(), &interests),
4179        };
4180        if let Err(error) = subscribed {
4181            self.runtime.unregister_handler(&id);
4182            return Err(error.into());
4183        }
4184
4185        Ok(())
4186    }
4187
4188    /// Register every handler currently in the runtime registry as a subscriber
4189    /// interest owner.
4190    ///
4191    /// This is the bootstrap path for an engine built around a pre-populated
4192    /// runtime: each handler becomes its own owner (upsert semantics, so
4193    /// rerunning is safe and already-registered owners are refreshed in place).
4194    /// No backfill is requested — bootstrap happens before ingestion starts, so
4195    /// there is no processed position to be continuous with; use
4196    /// [`register_handler_with_backfill`](Self::register_handler_with_backfill)
4197    /// for handlers that need history. Owners are not removed by this call: use
4198    /// [`unregister_handler`](Self::unregister_handler) for lifecycle removal
4199    /// rather than mutating the runtime registry directly.
4200    ///
4201    /// On error, owners already synced stay registered (upserts are
4202    /// independent); the call can simply be retried.
4203    pub fn sync_handler_interests(&mut self) -> Result<(), SubscriberError> {
4204        for id in self.runtime.handler_ids() {
4205            let interests = self
4206                .runtime
4207                .handler_interests(&id)
4208                .map(<[ReactiveInterest<N>]>::to_vec)
4209                .unwrap_or_default();
4210            self.subscriber.add_interest_owner(id, &interests)?;
4211        }
4212        Ok(())
4213    }
4214
4215    /// Unregister a handler from both the subscriber and runtime.
4216    ///
4217    /// Subscriber interests are removed first so no new live records are routed
4218    /// to a handler after it has left the runtime registry. Returns the removed
4219    /// handler when the id was registered.
4220    ///
4221    /// This is the routing/transport half of dropping an adapter. State the
4222    /// handler accumulated is deliberately left in place; the complete teardown
4223    /// for a pool or adapter that will not return is:
4224    ///
4225    /// ```text
4226    /// engine.unregister_handler(&id);
4227    /// for address in handler_addresses {
4228    ///     // stop root-gate eth_getProof probes for the account
4229    ///     engine.runtime_mut().untrack_account(address);
4230    ///     // drop its queued (unexecuted) repair work from the pending ledger
4231    ///     engine.runtime_mut().cancel_pending_resyncs(address);
4232    /// }
4233    /// // optional: evict cached state via StateUpdate::purge / cache purge APIs
4234    /// ```
4235    ///
4236    /// Health, metrics, the reorg journal, hooks, and freshness stamps are
4237    /// runtime-global and are never touched by handler removal.
4238    pub fn unregister_handler(&mut self, id: &HandlerId) -> Option<Arc<dyn ReactiveHandler<N>>> {
4239        self.subscriber.remove_interest_owner(id);
4240        self.runtime.unregister_handler(id)
4241    }
4242}
4243
4244/// Alloy-backed event subscriber.
4245///
4246/// The default transport slice drives Alloy pubsub subscriptions for logs,
4247/// block headers, and pending transaction hashes. The HTTP polling `watch_*`
4248/// transport remains available behind the opt-in `reactive-polling` feature.
4249/// Pubsub streams reconnect automatically after termination, and log
4250/// subscriptions are backfilled from the last seen block. Owner-scoped log
4251/// additions can request backfill from an explicit block anchor. Full pending
4252/// transaction hydration and full block bodies remain explicit follow-up work.
4253/// With no registered interests, [`EventSubscriber::next_batch`] returns
4254/// `Ok(None)`.
4255pub struct AlloySubscriber<P, N: Network = Ethereum> {
4256    provider: P,
4257    mode: SubscriberMode,
4258    config: SubscriberConfig,
4259    base_interests: Vec<ReactiveInterest<N>>,
4260    owned_interests: Vec<OwnedSubscriberInterests<N>>,
4261    interests: Vec<ReactiveInterest<N>>,
4262    /// Stable source id per distinct live log filter. Ids key delivery anchors
4263    /// and live `SubscriberEvent`s; entries are retired (and their anchors
4264    /// pruned) when no base or owner interest references the filter anymore, so
4265    /// long-lived owner churn cannot grow this map unboundedly.
4266    log_source_ids: HashMap<Filter, usize>,
4267    next_log_source_id: usize,
4268    pending_backfills: VecDeque<QueuedSubscriberBackfill>,
4269    /// Set when interest bookkeeping changed since the last successful stream
4270    /// reconcile, so steady-state polling skips the desired-vs-live diff.
4271    sources_dirty: bool,
4272    state: AlloySubscriberState<N>,
4273    pending_records: VecDeque<ReactiveInputRecord<N>>,
4274    last_seen_log_blocks: HashMap<usize, u64>,
4275    recent_input_refs: VecDeque<InputRef>,
4276    recent_input_ref_set: HashSet<InputRef>,
4277    _network: PhantomData<N>,
4278}
4279
4280struct OwnedSubscriberInterests<N: Network = Ethereum> {
4281    owner: HandlerId,
4282    interests: Vec<ReactiveInterest<N>>,
4283}
4284
4285struct QueuedSubscriberBackfill {
4286    owner: HandlerId,
4287    filter: Filter,
4288    backfill: SubscriberBackfill,
4289}
4290
4291/// Best-effort installation of rustls' `ring` crypto provider as the process
4292/// default, so an `wss://` TLS handshake under `reactive-ws` does not panic with
4293/// "no process-level CryptoProvider available". Runs at most once and ignores the
4294/// error if a default provider is already installed (the host app may have set
4295/// its own).
4296#[cfg(feature = "reactive-ws")]
4297fn ensure_ring_crypto_provider() {
4298    use std::sync::Once;
4299    static INSTALL: Once = Once::new();
4300    INSTALL.call_once(|| {
4301        let _ = rustls::crypto::ring::default_provider().install_default();
4302    });
4303}
4304
4305impl<P, N: Network> AlloySubscriber<P, N> {
4306    /// Create a new Alloy subscriber.
4307    pub fn new(provider: P, mode: SubscriberMode, config: SubscriberConfig) -> Self {
4308        #[cfg(feature = "reactive-ws")]
4309        ensure_ring_crypto_provider();
4310        Self {
4311            provider,
4312            mode,
4313            config,
4314            base_interests: Vec::new(),
4315            owned_interests: Vec::new(),
4316            interests: Vec::new(),
4317            log_source_ids: HashMap::new(),
4318            next_log_source_id: 0,
4319            pending_backfills: VecDeque::new(),
4320            sources_dirty: true,
4321            state: AlloySubscriberState::Uninitialized,
4322            pending_records: VecDeque::new(),
4323            last_seen_log_blocks: HashMap::new(),
4324            recent_input_refs: VecDeque::new(),
4325            recent_input_ref_set: HashSet::new(),
4326            _network: PhantomData,
4327        }
4328    }
4329
4330    /// Borrow the provider.
4331    pub fn provider(&self) -> &P {
4332        &self.provider
4333    }
4334
4335    /// Subscriber mode.
4336    pub fn mode(&self) -> SubscriberMode {
4337        self.mode
4338    }
4339
4340    /// Subscriber config.
4341    pub fn config(&self) -> &SubscriberConfig {
4342        &self.config
4343    }
4344
4345    /// Registered interests across base and owner-scoped registrations.
4346    pub fn registered_interests(&self) -> &[ReactiveInterest<N>] {
4347        &self.interests
4348    }
4349
4350    /// Add or replace the interests owned by `owner`.
4351    ///
4352    /// This preserves unrelated owners, queued/pending records, recent dedupe
4353    /// state, and last-seen log anchors. The live transport is reconciled on the
4354    /// next [`EventSubscriber::next_batch`] call so newly added log filters can
4355    /// be subscribed without rebuilding the whole subscriber object.
4356    ///
4357    /// Replacing an existing owner is continuity-safe: filters the owner
4358    /// already had keep their delivery anchors, and any changed or new filter
4359    /// shape is automatically backfilled from the owner's oldest prior anchor —
4360    /// growing a pool set on an established owner does not open a delivery gap
4361    /// for what the old subscription had already covered. A brand-new owner has
4362    /// no anchor to inherit; pass an explicit
4363    /// [`add_interest_owner_with_backfill`](Self::add_interest_owner_with_backfill)
4364    /// anchor (or register through [`ReactiveEngine::register_handler`], which
4365    /// anchors to the runtime's last canonical block).
4366    pub fn add_interest_owner(
4367        &mut self,
4368        owner: HandlerId,
4369        interests: &[ReactiveInterest<N>],
4370    ) -> Result<(), SubscriberError> {
4371        self.set_interest_owner(owner, interests, None)
4372    }
4373
4374    /// Add or replace owner interests and schedule log backfill for that owner.
4375    ///
4376    /// Backfill is queued only for log interests; block and pending transaction
4377    /// interests are live-only. Queued backfill is drained before live stream
4378    /// initialization, its resolved upper bound seeds the filter's delivery
4379    /// anchor, and the anchored filter is caught up again right after its live
4380    /// stream connects — so the discovery boundary is closed end to end as long
4381    /// as `backfill` starts at (or before) the block the interest was
4382    /// discovered in. Continuity backfill for a replaced owner (see
4383    /// [`add_interest_owner`](Self::add_interest_owner)) is queued in addition,
4384    /// unless this explicit backfill is open-ended and already starts at or
4385    /// below the owner's prior anchor.
4386    pub fn add_interest_owner_with_backfill(
4387        &mut self,
4388        owner: HandlerId,
4389        interests: &[ReactiveInterest<N>],
4390        backfill: SubscriberBackfill,
4391    ) -> Result<(), SubscriberError> {
4392        self.set_interest_owner(owner, interests, Some(backfill))
4393    }
4394
4395    /// Remove one owner's interests, preserving unrelated owner/base interests.
4396    ///
4397    /// The owner's queued backfills are dropped, and source-id/anchor
4398    /// bookkeeping for filters no other owner references is retired. Live
4399    /// streams for retired filters are torn down on the next
4400    /// [`EventSubscriber::next_batch`] call (dropping an Alloy subscription
4401    /// unsubscribes provider-side); events already in flight from them stop
4402    /// matching the merged interest set and are discarded.
4403    pub fn remove_interest_owner(&mut self, owner: &HandlerId) -> Option<Vec<ReactiveInterest<N>>> {
4404        let index = self
4405            .owned_interests
4406            .iter()
4407            .position(|entry| &entry.owner == owner)?;
4408        let removed = self.owned_interests.remove(index).interests;
4409        self.pending_backfills
4410            .retain(|backfill| &backfill.owner != owner);
4411        self.rebuild_registered_interests();
4412        self.retire_unreferenced_filters();
4413        self.sources_dirty = true;
4414        Some(removed)
4415    }
4416
4417    /// Borrow the interests currently owned by `owner`.
4418    pub fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
4419        self.owned_interests
4420            .iter()
4421            .find(|entry| &entry.owner == owner)
4422            .map(|entry| entry.interests.as_slice())
4423    }
4424
4425    fn set_interest_owner(
4426        &mut self,
4427        owner: HandlerId,
4428        interests: &[ReactiveInterest<N>],
4429        backfill: Option<SubscriberBackfill>,
4430    ) -> Result<(), SubscriberError> {
4431        validate_subscriber_config(&self.config)?;
4432
4433        let mut next_owned = self.clone_owned_interests();
4434        match next_owned.iter_mut().find(|entry| entry.owner == owner) {
4435            Some(entry) => entry.interests = interests.to_vec(),
4436            None => next_owned.push(OwnedSubscriberInterests {
4437                owner: owner.clone(),
4438                interests: interests.to_vec(),
4439            }),
4440        }
4441        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
4442        validate_supported_interests(self.mode, &self.config, &next_registered)?;
4443
4444        // Continuity capture, before the mutation lands: the owner's previous
4445        // filter shapes and the oldest delivery anchor among them. A changed
4446        // filter gets a fresh source id with no anchor, so without this
4447        // hand-off, replacing an owner's interests (the normal way to grow a
4448        // pool set) would silently discard the delivery watermark and open a
4449        // gap until some later explicit backfill.
4450        let previous_filters: Vec<Filter> = self
4451            .owner_interests(&owner)
4452            .map(log_filters)
4453            .unwrap_or_default();
4454        let continuity_anchor: Option<u64> = previous_filters
4455            .iter()
4456            .filter_map(|filter| self.log_anchor(filter))
4457            .min();
4458
4459        self.owned_interests = next_owned;
4460        self.interests = next_registered;
4461        self.retire_unreferenced_filters();
4462        self.sources_dirty = true;
4463
4464        // Re-queue this owner's backfills from scratch: previously queued
4465        // entries may reference filter shapes that no longer exist.
4466        self.pending_backfills
4467            .retain(|queued| queued.owner != owner);
4468        for filter in log_filters(interests) {
4469            if let Some(backfill) = backfill {
4470                self.pending_backfills.push_back(QueuedSubscriberBackfill {
4471                    owner: owner.clone(),
4472                    filter: filter.clone(),
4473                    backfill,
4474                });
4475            }
4476
4477            // Continuity backfill for changed/new shapes only: an unchanged
4478            // filter kept its anchor and its live stream, and an open-ended
4479            // explicit backfill starting at or below the anchor already covers
4480            // the window.
4481            let unchanged = previous_filters.contains(&filter);
4482            let explicit_covers = backfill.is_some_and(|explicit| {
4483                explicit.end_block().is_none()
4484                    && continuity_anchor.is_some_and(|anchor| explicit.start_block() <= anchor)
4485            });
4486            if let Some(anchor) = continuity_anchor
4487                && !unchanged
4488                && !explicit_covers
4489            {
4490                self.pending_backfills.push_back(QueuedSubscriberBackfill {
4491                    owner: owner.clone(),
4492                    filter,
4493                    backfill: SubscriberBackfill::from_block(anchor),
4494                });
4495            }
4496        }
4497        Ok(())
4498    }
4499
4500    fn clone_owned_interests(&self) -> Vec<OwnedSubscriberInterests<N>> {
4501        self.owned_interests
4502            .iter()
4503            .map(|entry| OwnedSubscriberInterests {
4504                owner: entry.owner.clone(),
4505                interests: entry.interests.clone(),
4506            })
4507            .collect()
4508    }
4509
4510    fn rebuild_registered_interests(&mut self) {
4511        self.interests = aggregate_interests(&self.base_interests, &self.owned_interests);
4512    }
4513
4514    /// Delivery anchor (last block known fully delivered) for `filter`, if the
4515    /// filter has a source id and has seen delivery.
4516    fn log_anchor(&self, filter: &Filter) -> Option<u64> {
4517        let id = self.log_source_ids.get(filter)?;
4518        self.last_seen_log_blocks.get(id).copied()
4519    }
4520
4521    /// Every live log filter across base and owner interests — merged within
4522    /// each origin (owner boundaries are preserved so one owner's churn cannot
4523    /// rewrite another's subscription), then deduplicated across origins so an
4524    /// identical shape shared by several owners maps to exactly one stream and
4525    /// one delivery anchor.
4526    // `Filter` derives `Hash`/`Eq` and has no interior mutability; the
4527    // `mutable_key_type` lint is a known false positive for it.
4528    #[allow(clippy::mutable_key_type)]
4529    fn log_stream_filters(&self) -> Vec<Filter> {
4530        let mut filters = log_filters(&self.base_interests);
4531        for entry in &self.owned_interests {
4532            filters.extend(log_filters(&entry.interests));
4533        }
4534        let mut seen = HashSet::new();
4535        filters.retain(|filter| seen.insert(filter.clone()));
4536        filters
4537    }
4538
4539    /// Drop source-id and anchor bookkeeping for filters no longer referenced
4540    /// by any base or owner interest, so long-lived owner churn cannot grow the
4541    /// maps unboundedly. Live streams for retired filters are pruned by the
4542    /// next reconcile.
4543    // `Filter` derives `Hash`/`Eq` and has no interior mutability; the
4544    // `mutable_key_type` lint is a known false positive for it.
4545    #[allow(clippy::mutable_key_type)]
4546    fn retire_unreferenced_filters(&mut self) {
4547        let live: HashSet<Filter> = self.log_stream_filters().into_iter().collect();
4548        self.log_source_ids
4549            .retain(|filter, _| live.contains(filter));
4550        let live_ids: HashSet<usize> = self.log_source_ids.values().copied().collect();
4551        self.last_seen_log_blocks
4552            .retain(|id, _| live_ids.contains(id));
4553    }
4554
4555    fn drain_next_batch(&mut self) -> Option<ReactiveInputBatch<N>> {
4556        if self.pending_records.is_empty() {
4557            return None;
4558        }
4559
4560        let len = self.config.max_batch_size.min(self.pending_records.len());
4561        let records = self.pending_records.drain(..len).collect();
4562        Some(ReactiveInputBatch::new(records))
4563    }
4564
4565    fn reset_delivery_state(&mut self) {
4566        self.pending_records.clear();
4567        self.last_seen_log_blocks.clear();
4568        self.recent_input_refs.clear();
4569        self.recent_input_ref_set.clear();
4570        self.pending_backfills.clear();
4571        self.log_source_ids.clear();
4572        self.next_log_source_id = 0;
4573        self.sources_dirty = true;
4574    }
4575}
4576
4577impl<P, N> InterestOwnerSubscriber<N> for AlloySubscriber<P, N>
4578where
4579    P: Provider<N> + Send + Sync,
4580    N: Network + 'static,
4581    N::HeaderResponse: Send + 'static,
4582{
4583    fn add_interest_owner(
4584        &mut self,
4585        owner: HandlerId,
4586        interests: &[ReactiveInterest<N>],
4587    ) -> Result<(), SubscriberError> {
4588        AlloySubscriber::add_interest_owner(self, owner, interests)
4589    }
4590
4591    fn add_interest_owner_with_backfill(
4592        &mut self,
4593        owner: HandlerId,
4594        interests: &[ReactiveInterest<N>],
4595        backfill: SubscriberBackfill,
4596    ) -> Result<(), SubscriberError> {
4597        AlloySubscriber::add_interest_owner_with_backfill(self, owner, interests, backfill)
4598    }
4599
4600    fn remove_interest_owner(&mut self, owner: &HandlerId) -> Option<Vec<ReactiveInterest<N>>> {
4601        AlloySubscriber::remove_interest_owner(self, owner)
4602    }
4603
4604    fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
4605        AlloySubscriber::owner_interests(self, owner)
4606    }
4607}
4608
4609enum AlloySubscriberState<N: Network> {
4610    Uninitialized,
4611    Active(SubscriberStreams<N>),
4612    Empty,
4613}
4614
4615struct SubscriberStreams<N: Network> {
4616    entries: Vec<SubscriberStreamEntry<N>>,
4617    next_index: usize,
4618}
4619
4620struct SubscriberStreamEntry<N: Network> {
4621    source: SubscriberStreamSource,
4622    stream: BoxStream<'static, SubscriberEvent<N>>,
4623}
4624
4625impl<N: Network> SubscriberStreams<N> {
4626    fn new() -> Self {
4627        Self {
4628            entries: Vec::new(),
4629            next_index: 0,
4630        }
4631    }
4632
4633    fn is_empty(&self) -> bool {
4634        self.entries.is_empty()
4635    }
4636
4637    fn push(
4638        &mut self,
4639        source: SubscriberStreamSource,
4640        stream: BoxStream<'static, SubscriberEvent<N>>,
4641    ) {
4642        self.entries.push(SubscriberStreamEntry { source, stream });
4643    }
4644
4645    #[cfg(all(test, feature = "reactive-ws"))]
4646    fn len(&self) -> usize {
4647        self.entries.len()
4648    }
4649
4650    fn contains_source(&self, source: &SubscriberStreamSource) -> bool {
4651        self.entries
4652            .iter()
4653            .any(|entry| entry.source.same_key(source))
4654    }
4655
4656    fn retain_sources(&mut self, sources: &[SubscriberStreamSource]) {
4657        self.entries
4658            .retain(|entry| sources.iter().any(|source| entry.source.same_key(source)));
4659        self.normalize_next_index();
4660    }
4661
4662    fn normalize_next_index(&mut self) {
4663        if self.entries.is_empty() {
4664            self.next_index = 0;
4665        } else if self.next_index >= self.entries.len() {
4666            self.next_index %= self.entries.len();
4667        }
4668    }
4669
4670    async fn next(&mut self) -> Option<SubscriberEvent<N>> {
4671        poll_fn(|cx| {
4672            self.normalize_next_index();
4673            if self.entries.is_empty() {
4674                return std::task::Poll::Ready(None);
4675            }
4676
4677            let mut index = self.next_index;
4678            let mut checked = 0usize;
4679            while checked < self.entries.len() {
4680                if index >= self.entries.len() {
4681                    index = 0;
4682                }
4683                match self.entries[index].stream.as_mut().poll_next(cx) {
4684                    std::task::Poll::Ready(Some(event)) => {
4685                        if matches!(event, SubscriberEvent::StreamTerminated(_)) {
4686                            self.entries.remove(index);
4687                            self.next_index = if self.entries.is_empty() {
4688                                0
4689                            } else {
4690                                index % self.entries.len()
4691                            };
4692                        } else {
4693                            self.next_index = (index + 1) % self.entries.len();
4694                        }
4695                        return std::task::Poll::Ready(Some(event));
4696                    }
4697                    std::task::Poll::Ready(None) => {
4698                        self.entries.remove(index);
4699                        if self.entries.is_empty() {
4700                            self.next_index = 0;
4701                            return std::task::Poll::Ready(None);
4702                        }
4703                    }
4704                    std::task::Poll::Pending => {
4705                        checked += 1;
4706                        index += 1;
4707                    }
4708                }
4709            }
4710
4711            if self.entries.is_empty() {
4712                std::task::Poll::Ready(None)
4713            } else {
4714                self.next_index = index % self.entries.len();
4715                std::task::Poll::Pending
4716            }
4717        })
4718        .await
4719    }
4720}
4721
4722#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4723#[allow(dead_code)]
4724enum SubscriberTransport {
4725    PubSub,
4726    Polling,
4727}
4728
4729#[derive(Clone, Debug)]
4730enum SubscriberStreamSource {
4731    PubSubLog { id: usize, filter: Filter },
4732    PubSubPendingHashes,
4733    PubSubBlockHeaders,
4734    PollingLog { filter: Filter },
4735    PollingPendingHashes,
4736}
4737
4738impl SubscriberStreamSource {
4739    fn label(&self) -> &'static str {
4740        match self {
4741            Self::PubSubLog { .. } => "pubsub log",
4742            Self::PubSubPendingHashes => "pubsub pending transaction hash",
4743            Self::PubSubBlockHeaders => "pubsub block header",
4744            Self::PollingLog { .. } => "polling log",
4745            Self::PollingPendingHashes => "polling pending transaction hash",
4746        }
4747    }
4748
4749    fn is_pubsub(&self) -> bool {
4750        matches!(
4751            self,
4752            Self::PubSubLog { .. } | Self::PubSubPendingHashes | Self::PubSubBlockHeaders
4753        )
4754    }
4755
4756    fn same_key(&self, other: &Self) -> bool {
4757        match (self, other) {
4758            (Self::PubSubLog { filter: left, .. }, Self::PubSubLog { filter: right, .. })
4759            | (Self::PollingLog { filter: left }, Self::PollingLog { filter: right }) => {
4760                left == right
4761            }
4762            (Self::PubSubPendingHashes, Self::PubSubPendingHashes)
4763            | (Self::PubSubBlockHeaders, Self::PubSubBlockHeaders)
4764            | (Self::PollingPendingHashes, Self::PollingPendingHashes) => true,
4765            _ => false,
4766        }
4767    }
4768}
4769
4770#[allow(dead_code)]
4771enum SubscriberEvent<N: Network> {
4772    Log { source_id: usize, log: Log },
4773    BackfilledLogs { source_id: usize, logs: Vec<Log> },
4774    Logs(Vec<Log>),
4775    BlockHeader(N::HeaderResponse),
4776    PendingHash(B256),
4777    PendingHashes(Vec<B256>),
4778    StreamTerminated(SubscriberStreamSource),
4779}
4780
4781impl<P, N> EventSubscriber<N> for AlloySubscriber<P, N>
4782where
4783    P: Provider<N> + Send + Sync,
4784    N: Network + 'static,
4785    N::HeaderResponse: Send + 'static,
4786{
4787    fn register_interests(
4788        &mut self,
4789        interests: &[ReactiveInterest<N>],
4790    ) -> Result<(), SubscriberError> {
4791        validate_subscriber_config(&self.config)?;
4792        validate_supported_interests(self.mode, &self.config, interests)?;
4793
4794        self.base_interests = interests.to_vec();
4795        self.owned_interests.clear();
4796        self.rebuild_registered_interests();
4797        self.reset_delivery_state();
4798        self.state = AlloySubscriberState::Uninitialized;
4799        Ok(())
4800    }
4801
4802    fn next_batch(&mut self) -> SubscriberNextBatch<'_, N> {
4803        Box::pin(async {
4804            if let Some(batch) = self.drain_next_batch() {
4805                return Ok(Some(batch));
4806            }
4807
4808            self.drain_pending_backfills().await?;
4809            if let Some(batch) = self.drain_next_batch() {
4810                return Ok(Some(batch));
4811            }
4812
4813            self.ensure_streams().await?;
4814            if let Some(batch) = self.drain_next_batch() {
4815                return Ok(Some(batch));
4816            }
4817
4818            if self.interests.is_empty() {
4819                return Ok(None);
4820            }
4821
4822            loop {
4823                let Some(event) = self.next_event().await? else {
4824                    return Ok(None);
4825                };
4826
4827                self.enqueue_event(event);
4828                if let Some(batch) = self.drain_next_batch() {
4829                    return Ok(Some(batch));
4830                }
4831            }
4832        })
4833    }
4834}
4835
4836impl<P, N> AlloySubscriber<P, N>
4837where
4838    P: Provider<N> + Send + Sync,
4839    N: Network + 'static,
4840    N::HeaderResponse: Send + 'static,
4841{
4842    /// Bring live streams in line with the current interest set.
4843    ///
4844    /// Runs incrementally: the desired-vs-live diff only happens when interest
4845    /// bookkeeping changed since the last successful pass (`sources_dirty`), so
4846    /// steady-state polling costs nothing here. Missing sources are connected,
4847    /// sources for retired filters are dropped (dropping an Alloy subscription
4848    /// unsubscribes provider-side), and unrelated live streams — with their
4849    /// delivery and anchor state — are left untouched.
4850    ///
4851    /// A newly connected log source whose filter already has a delivery anchor
4852    /// is caught up from that anchor immediately after subscribing (the same
4853    /// subscribe-then-backfill order the reconnect path uses). Together with
4854    /// anchor seeding in [`Self::drain_pending_backfills`], that closes the
4855    /// window between an adoption backfill and live stream start.
4856    async fn ensure_streams(&mut self) -> Result<(), SubscriberError> {
4857        if !self.sources_dirty {
4858            return Ok(());
4859        }
4860        // An interest-less subscriber stays Uninitialized and never touches the
4861        // provider ([`EventSubscriber::next_batch`] returns `Ok(None)`),
4862        // matching setup-before-interests behavior.
4863        if matches!(self.state, AlloySubscriberState::Uninitialized) && self.interests.is_empty() {
4864            return Ok(());
4865        }
4866
4867        let desired = self.stream_sources()?;
4868        let missing: Vec<SubscriberStreamSource> = match &self.state {
4869            AlloySubscriberState::Active(streams) => desired
4870                .iter()
4871                .filter(|source| !streams.contains_source(source))
4872                .cloned()
4873                .collect(),
4874            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => desired.clone(),
4875        };
4876
4877        let mut connected = Vec::new();
4878        for source in missing {
4879            let stream = self.connect_source_stream(source.clone()).await?;
4880            // Anchored catch-up for a source with a known delivery watermark
4881            // (seeded by a drained adoption backfill, or inherited from a
4882            // filter shape that was live before): subscribe first, then fetch
4883            // the gap, so nothing lands between the two.
4884            if let Some(event) = self.backfill_reconnected_source(&source).await? {
4885                self.enqueue_event(event);
4886            }
4887            connected.push((source, stream));
4888        }
4889
4890        match &mut self.state {
4891            AlloySubscriberState::Active(streams) => {
4892                streams.retain_sources(&desired);
4893                for (source, stream) in connected {
4894                    streams.push(source, stream);
4895                }
4896                if streams.is_empty() {
4897                    self.state = AlloySubscriberState::Empty;
4898                }
4899            }
4900            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
4901                let mut streams = SubscriberStreams::new();
4902                for (source, stream) in connected {
4903                    streams.push(source, stream);
4904                }
4905                self.state = if streams.is_empty() {
4906                    AlloySubscriberState::Empty
4907                } else {
4908                    AlloySubscriberState::Active(streams)
4909                };
4910            }
4911        }
4912
4913        self.sources_dirty = false;
4914        Ok(())
4915    }
4916
4917    /// Fetch queued adoption/continuity backfills, oldest first.
4918    ///
4919    /// An entry is consumed only after its `get_logs` fetch succeeds — a
4920    /// transient RPC failure surfaces the error and leaves the entry queued for
4921    /// the next poll, so a flaky request cannot silently discard the missed
4922    /// window the backfill exists to close. Open-ended backfills resolve their
4923    /// upper bound to the provider's current head before fetching, and every
4924    /// drained backfill advances the filter's delivery anchor to that bound —
4925    /// even a zero-log window — so the filter is reconnect-protected from then
4926    /// on. Draining pauses as soon as records are ready for delivery; remaining
4927    /// entries stay queued.
4928    async fn drain_pending_backfills(&mut self) -> Result<(), SubscriberError> {
4929        while let Some(queued) = self.pending_backfills.front() {
4930            // Owner was removed while its backfill was queued.
4931            if self.owner_interests(&queued.owner).is_none() {
4932                self.pending_backfills.pop_front();
4933                continue;
4934            }
4935            let filter = queued.filter.clone();
4936            let backfill = queued.backfill;
4937
4938            let to_block = match backfill.end_block() {
4939                Some(to_block) => to_block,
4940                None => self
4941                    .provider
4942                    .get_block_number()
4943                    .await
4944                    .map_err(provider_error)?,
4945            };
4946            if to_block < backfill.start_block() {
4947                // Anchor already at (or past) the provider head: nothing to
4948                // fetch, and the anchor keeps its current value.
4949                self.pending_backfills.pop_front();
4950                continue;
4951            }
4952
4953            let range = filter
4954                .clone()
4955                .from_block(backfill.start_block())
4956                .to_block(to_block);
4957            let logs = self
4958                .provider
4959                .get_logs(&range)
4960                .await
4961                .map_err(provider_error)?;
4962
4963            // Fetch succeeded: consume the entry, deliver, and advance the
4964            // anchor through the fetched bound.
4965            self.pending_backfills.pop_front();
4966            let source_id = self.log_source_id(&filter);
4967            self.enqueue_backfilled_logs(logs, Some(source_id));
4968            let anchor = self
4969                .last_seen_log_blocks
4970                .entry(source_id)
4971                .or_insert(to_block);
4972            *anchor = (*anchor).max(to_block);
4973
4974            if !self.pending_records.is_empty() {
4975                break;
4976            }
4977        }
4978        Ok(())
4979    }
4980
4981    fn stream_sources(&mut self) -> Result<Vec<SubscriberStreamSource>, SubscriberError> {
4982        match resolve_subscriber_transport(self.mode)? {
4983            SubscriberTransport::PubSub => Ok(self.pubsub_stream_sources()),
4984            SubscriberTransport::Polling => Ok(self.polling_stream_sources()),
4985        }
4986    }
4987
4988    fn pubsub_stream_sources(&mut self) -> Vec<SubscriberStreamSource> {
4989        let mut sources = Vec::new();
4990
4991        for filter in self.log_stream_filters() {
4992            let id = self.log_source_id(&filter);
4993            sources.push(SubscriberStreamSource::PubSubLog { id, filter });
4994        }
4995
4996        if needs_pending_hash_stream(&self.interests) {
4997            sources.push(SubscriberStreamSource::PubSubPendingHashes);
4998        }
4999
5000        if needs_header_block_stream(&self.interests) {
5001            sources.push(SubscriberStreamSource::PubSubBlockHeaders);
5002        }
5003
5004        sources
5005    }
5006
5007    fn polling_stream_sources(&self) -> Vec<SubscriberStreamSource> {
5008        let mut sources = Vec::new();
5009
5010        for filter in self.log_stream_filters() {
5011            sources.push(SubscriberStreamSource::PollingLog { filter });
5012        }
5013
5014        if needs_pending_hash_stream(&self.interests) {
5015            sources.push(SubscriberStreamSource::PollingPendingHashes);
5016        }
5017
5018        sources
5019    }
5020
5021    fn log_source_id(&mut self, filter: &Filter) -> usize {
5022        if let Some(id) = self.log_source_ids.get(filter) {
5023            return *id;
5024        }
5025
5026        let id = self.next_log_source_id;
5027        self.next_log_source_id = self.next_log_source_id.saturating_add(1);
5028        self.log_source_ids.insert(filter.clone(), id);
5029        id
5030    }
5031
5032    async fn connect_source_stream(
5033        &mut self,
5034        source: SubscriberStreamSource,
5035    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
5036        match source {
5037            SubscriberStreamSource::PubSubLog { id, filter } => {
5038                self.connect_pubsub_log_stream(id, filter).await
5039            }
5040            SubscriberStreamSource::PubSubPendingHashes => {
5041                self.connect_pubsub_pending_hash_stream().await
5042            }
5043            SubscriberStreamSource::PubSubBlockHeaders => {
5044                self.connect_pubsub_block_header_stream().await
5045            }
5046            SubscriberStreamSource::PollingLog { filter } => {
5047                self.connect_polling_log_stream(filter).await
5048            }
5049            SubscriberStreamSource::PollingPendingHashes => {
5050                self.connect_polling_pending_hash_stream().await
5051            }
5052        }
5053    }
5054
5055    async fn connect_pubsub_log_stream(
5056        &mut self,
5057        id: usize,
5058        filter: Filter,
5059    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
5060        #[cfg(feature = "reactive-ws")]
5061        {
5062            let source = SubscriberStreamSource::PubSubLog {
5063                id,
5064                filter: filter.clone(),
5065            };
5066            let stream = self
5067                .provider
5068                .subscribe_logs(&filter)
5069                .channel_size(self.config.max_batch_size.max(1))
5070                .await
5071                .map_err(provider_error)?
5072                .into_stream()
5073                .map(move |log| SubscriberEvent::Log { source_id: id, log });
5074            Ok(stream_with_termination(stream, source))
5075        }
5076
5077        #[cfg(not(feature = "reactive-ws"))]
5078        {
5079            let _ = (id, filter);
5080            Err(SubscriberError::Unsupported(
5081                "AlloySubscriber pubsub mode requires the reactive-ws feature",
5082            ))
5083        }
5084    }
5085
5086    async fn connect_pubsub_pending_hash_stream(
5087        &mut self,
5088    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
5089        #[cfg(feature = "reactive-ws")]
5090        {
5091            let stream = self
5092                .provider
5093                .subscribe_pending_transactions()
5094                .channel_size(self.config.max_batch_size.max(1))
5095                .await
5096                .map_err(provider_error)?
5097                .into_stream()
5098                .map(SubscriberEvent::PendingHash);
5099            Ok(stream_with_termination(
5100                stream,
5101                SubscriberStreamSource::PubSubPendingHashes,
5102            ))
5103        }
5104
5105        #[cfg(not(feature = "reactive-ws"))]
5106        {
5107            Err(SubscriberError::Unsupported(
5108                "AlloySubscriber pubsub mode requires the reactive-ws feature",
5109            ))
5110        }
5111    }
5112
5113    async fn connect_pubsub_block_header_stream(
5114        &mut self,
5115    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
5116        #[cfg(feature = "reactive-ws")]
5117        {
5118            let stream = self
5119                .provider
5120                .subscribe_blocks()
5121                .channel_size(self.config.max_batch_size.max(1))
5122                .await
5123                .map_err(provider_error)?
5124                .into_stream()
5125                .map(SubscriberEvent::BlockHeader);
5126            Ok(stream_with_termination(
5127                stream,
5128                SubscriberStreamSource::PubSubBlockHeaders,
5129            ))
5130        }
5131
5132        #[cfg(not(feature = "reactive-ws"))]
5133        {
5134            Err(SubscriberError::Unsupported(
5135                "AlloySubscriber pubsub mode requires the reactive-ws feature",
5136            ))
5137        }
5138    }
5139
5140    async fn connect_polling_log_stream(
5141        &mut self,
5142        filter: Filter,
5143    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
5144        #[cfg(feature = "reactive-polling")]
5145        {
5146            let source = SubscriberStreamSource::PollingLog {
5147                filter: filter.clone(),
5148            };
5149            let stream = self
5150                .provider
5151                .watch_logs(&filter)
5152                .await
5153                .map_err(provider_error)?
5154                .with_channel_size(self.config.max_batch_size.max(1))
5155                .into_stream()
5156                .map(SubscriberEvent::Logs);
5157            Ok(stream_with_termination(stream, source))
5158        }
5159
5160        #[cfg(not(feature = "reactive-polling"))]
5161        {
5162            let _ = filter;
5163            Err(SubscriberError::Unsupported(
5164                "AlloySubscriber polling mode requires the reactive-polling feature",
5165            ))
5166        }
5167    }
5168
5169    async fn connect_polling_pending_hash_stream(
5170        &mut self,
5171    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
5172        #[cfg(feature = "reactive-polling")]
5173        {
5174            let stream = self
5175                .provider
5176                .watch_pending_transactions()
5177                .await
5178                .map_err(provider_error)?
5179                .with_channel_size(self.config.max_batch_size.max(1))
5180                .into_stream()
5181                .map(SubscriberEvent::PendingHashes);
5182            Ok(stream_with_termination(
5183                stream,
5184                SubscriberStreamSource::PollingPendingHashes,
5185            ))
5186        }
5187
5188        #[cfg(not(feature = "reactive-polling"))]
5189        {
5190            Err(SubscriberError::Unsupported(
5191                "AlloySubscriber polling mode requires the reactive-polling feature",
5192            ))
5193        }
5194    }
5195
5196    async fn next_event(&mut self) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
5197        loop {
5198            let event = match &mut self.state {
5199                AlloySubscriberState::Active(streams) => streams.next().await,
5200                AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
5201                    return Ok(None);
5202                }
5203            };
5204
5205            let Some(event) = event else {
5206                return Err(SubscriberError::Provider(
5207                    "Alloy subscriber streams terminated before the subscriber was stopped"
5208                        .to_owned(),
5209                ));
5210            };
5211
5212            match event {
5213                SubscriberEvent::StreamTerminated(source) => {
5214                    if let Some(backfill_event) = self.reconnect_source_stream(source).await? {
5215                        return Ok(Some(backfill_event));
5216                    }
5217                }
5218                event => return Ok(Some(event)),
5219            }
5220        }
5221    }
5222
5223    fn enqueue_event(&mut self, event: SubscriberEvent<N>) {
5224        match event {
5225            SubscriberEvent::Log { source_id, log } => {
5226                if log_matches_any_interest(&log, &self.interests) {
5227                    let record = log_input_record(log, InputSource::Subscription);
5228                    self.note_log_block(source_id, &record);
5229                    self.enqueue_record(record);
5230                }
5231            }
5232            SubscriberEvent::BackfilledLogs { source_id, logs } => {
5233                self.enqueue_backfilled_logs(logs, Some(source_id));
5234            }
5235            SubscriberEvent::Logs(logs) => self.pending_records.extend(
5236                logs.into_iter()
5237                    .filter(|log| log_matches_any_interest(log, &self.interests))
5238                    .map(|log| log_input_record(log, InputSource::Poll)),
5239            ),
5240            SubscriberEvent::BlockHeader(header) => {
5241                if needs_header_block_stream(&self.interests) {
5242                    let record = block_header_input_record::<N>(header);
5243                    self.enqueue_record(record);
5244                }
5245            }
5246            SubscriberEvent::PendingHash(hash) => {
5247                let record = pending_hash_input_record::<N>(hash, InputSource::Subscription);
5248                self.enqueue_record(record);
5249            }
5250            SubscriberEvent::PendingHashes(hashes) => self.pending_records.extend(
5251                hashes
5252                    .into_iter()
5253                    .map(|hash| pending_hash_input_record::<N>(hash, InputSource::Poll)),
5254            ),
5255            SubscriberEvent::StreamTerminated(_) => {}
5256        }
5257    }
5258
5259    fn enqueue_backfilled_logs(&mut self, logs: Vec<Log>, source_id: Option<usize>) {
5260        for log in logs {
5261            if log_matches_any_interest(&log, &self.interests) {
5262                let record = log_input_record(log, InputSource::Backfill);
5263                if let Some(source_id) = source_id {
5264                    self.note_log_block(source_id, &record);
5265                }
5266                self.enqueue_record(record);
5267            }
5268        }
5269    }
5270
5271    async fn reconnect_source_stream(
5272        &mut self,
5273        source: SubscriberStreamSource,
5274    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
5275        if !source.is_pubsub() {
5276            return Err(stream_terminated_error(&source));
5277        }
5278
5279        if !self.config.reconnect.enabled {
5280            return Err(SubscriberError::Provider(format!(
5281                "Alloy subscriber {} stream terminated and reconnect is disabled",
5282                source.label()
5283            )));
5284        }
5285
5286        let mut attempts = 0usize;
5287        let mut delay = self.config.reconnect.initial_delay;
5288        let mut retry_delay = self.config.reconnect.retry_delay;
5289
5290        loop {
5291            attempts = attempts.saturating_add(1);
5292            if !delay.is_zero() {
5293                tokio::time::sleep(delay).await;
5294            }
5295
5296            match self.reconnect_source_once(source.clone()).await {
5297                Ok(backfill_event) => return Ok(backfill_event),
5298                Err(error) if reconnect_attempts_exhausted(attempts, &self.config.reconnect) => {
5299                    return Err(SubscriberError::Provider(format!(
5300                        "Alloy subscriber {} stream terminated and reconnect failed after {attempts} attempt(s): {error}",
5301                        source.label()
5302                    )));
5303                }
5304                Err(error) => {
5305                    tracing::warn!(
5306                        stream = source.label(),
5307                        attempts,
5308                        error = %error,
5309                        "Alloy subscriber reconnect attempt failed"
5310                    );
5311                    delay = retry_delay;
5312                    retry_delay =
5313                        next_reconnect_delay(retry_delay, self.config.reconnect.max_delay);
5314                }
5315            }
5316        }
5317    }
5318
5319    async fn reconnect_source_once(
5320        &mut self,
5321        source: SubscriberStreamSource,
5322    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
5323        let stream = self.connect_source_stream(source.clone()).await?;
5324        let backfill_event = self.backfill_reconnected_source(&source).await?;
5325
5326        match &mut self.state {
5327            AlloySubscriberState::Active(streams) => streams.push(source, stream),
5328            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
5329                return Err(SubscriberError::Provider(
5330                    "Alloy subscriber state changed before reconnect completed".to_owned(),
5331                ));
5332            }
5333        }
5334
5335        Ok(backfill_event)
5336    }
5337
5338    async fn backfill_reconnected_source(
5339        &mut self,
5340        source: &SubscriberStreamSource,
5341    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
5342        let SubscriberStreamSource::PubSubLog { id, filter } = source else {
5343            return Ok(None);
5344        };
5345        let Some(from_block) = self.last_seen_log_blocks.get(id).copied() else {
5346            return Ok(None);
5347        };
5348
5349        let latest = self
5350            .provider
5351            .get_block_number()
5352            .await
5353            .map_err(provider_error)?;
5354        if latest < from_block {
5355            return Ok(None);
5356        }
5357
5358        let logs = self
5359            .provider
5360            .get_logs(&filter.clone().from_block(from_block).to_block(latest))
5361            .await
5362            .map_err(provider_error)?;
5363        Ok(Some(SubscriberEvent::BackfilledLogs {
5364            source_id: *id,
5365            logs,
5366        }))
5367    }
5368
5369    fn note_log_block(&mut self, source_id: usize, record: &ReactiveInputRecord<N>) {
5370        if let Some(block) = record.context.block.as_ref() {
5371            self.last_seen_log_blocks.insert(source_id, block.number);
5372        }
5373    }
5374
5375    fn enqueue_record(&mut self, record: ReactiveInputRecord<N>) {
5376        if self.should_skip_recent_duplicate(&record) {
5377            return;
5378        }
5379        self.remember_record(&record);
5380        self.pending_records.push_back(record);
5381    }
5382
5383    fn should_skip_recent_duplicate(&self, record: &ReactiveInputRecord<N>) -> bool {
5384        if !should_dedupe_record(record) {
5385            return false;
5386        }
5387        self.recent_input_ref_set.contains(&record.input_ref())
5388    }
5389
5390    fn remember_record(&mut self, record: &ReactiveInputRecord<N>) {
5391        if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
5392            return;
5393        }
5394
5395        let input_ref = record.input_ref();
5396        if !self.recent_input_ref_set.insert(input_ref) {
5397            return;
5398        }
5399        self.recent_input_refs.push_back(input_ref);
5400
5401        while self.recent_input_refs.len() > self.config.reconnect.dedupe_window {
5402            if let Some(evicted) = self.recent_input_refs.pop_front() {
5403                self.recent_input_ref_set.remove(&evicted);
5404            }
5405        }
5406    }
5407}
5408
5409#[cfg(any(feature = "reactive-ws", feature = "reactive-polling", test))]
5410fn stream_with_termination<N, S>(
5411    stream: S,
5412    source: SubscriberStreamSource,
5413) -> BoxStream<'static, SubscriberEvent<N>>
5414where
5415    N: Network + 'static,
5416    S: futures::Stream<Item = SubscriberEvent<N>> + Send + 'static,
5417{
5418    stream
5419        .chain(stream::once(async move {
5420            SubscriberEvent::StreamTerminated(source)
5421        }))
5422        .boxed()
5423}
5424
5425fn aggregate_interests<N: Network>(
5426    base: &[ReactiveInterest<N>],
5427    owned: &[OwnedSubscriberInterests<N>],
5428) -> Vec<ReactiveInterest<N>> {
5429    base.iter()
5430        .cloned()
5431        .chain(
5432            owned
5433                .iter()
5434                .flat_map(|entry| entry.interests.iter().cloned()),
5435        )
5436        .collect()
5437}
5438
5439fn stream_terminated_error(source: &SubscriberStreamSource) -> SubscriberError {
5440    SubscriberError::Provider(format!(
5441        "Alloy subscriber {} stream terminated before the subscriber was stopped",
5442        source.label()
5443    ))
5444}
5445
5446fn reconnect_attempts_exhausted(attempts: usize, config: &SubscriberReconnectConfig) -> bool {
5447    config
5448        .max_attempts
5449        .is_some_and(|max_attempts| attempts >= max_attempts)
5450}
5451
5452fn next_reconnect_delay(current: Duration, max: Duration) -> Duration {
5453    if current.is_zero() {
5454        return current;
5455    }
5456    current.checked_mul(2).unwrap_or(max).min(max)
5457}
5458
5459fn should_dedupe_record<N: Network>(record: &ReactiveInputRecord<N>) -> bool {
5460    match &record.input {
5461        ReactiveInput::Log(log) => {
5462            is_canonical_status(&record.context.chain_status) && !log.removed
5463        }
5464        ReactiveInput::BlockHeader(_) | ReactiveInput::PendingTxHash(_) => true,
5465        ReactiveInput::FullBlock(_) | ReactiveInput::PendingTx(_) => false,
5466    }
5467}
5468
5469#[cfg(test)]
5470mod subscriber_helper_tests {
5471    use super::*;
5472    use alloy_provider::ProviderBuilder;
5473    use alloy_transport::mock::Asserter;
5474
5475    fn rpc_log(removed: bool) -> Log {
5476        Log {
5477            inner: alloy_primitives::Log::new_unchecked(
5478                Address::repeat_byte(0x42),
5479                vec![B256::repeat_byte(0x01)],
5480                Bytes::new(),
5481            ),
5482            block_hash: Some(B256::repeat_byte(0x02)),
5483            block_number: Some(7),
5484            block_timestamp: Some(1_700_000_000),
5485            transaction_hash: Some(B256::repeat_byte(0x03)),
5486            transaction_index: Some(4),
5487            log_index: Some(5),
5488            removed,
5489        }
5490    }
5491
5492    #[tokio::test(flavor = "multi_thread")]
5493    async fn stream_with_termination_yields_terminal_source_marker() {
5494        let mut stream = stream_with_termination::<Ethereum, _>(
5495            stream::iter([SubscriberEvent::<Ethereum>::PendingHash(B256::repeat_byte(
5496                0xaa,
5497            ))]),
5498            SubscriberStreamSource::PubSubPendingHashes,
5499        );
5500
5501        assert!(matches!(
5502            stream.next().await,
5503            Some(SubscriberEvent::PendingHash(hash)) if hash == B256::repeat_byte(0xaa)
5504        ));
5505        assert!(matches!(
5506            stream.next().await,
5507            Some(SubscriberEvent::StreamTerminated(source)) if source.is_pubsub()
5508        ));
5509        assert!(stream.next().await.is_none());
5510    }
5511
5512    #[test]
5513    fn reconnect_delay_doubles_until_capped() {
5514        assert_eq!(
5515            next_reconnect_delay(Duration::from_millis(250), Duration::from_secs(1)),
5516            Duration::from_millis(500)
5517        );
5518        assert_eq!(
5519            next_reconnect_delay(Duration::from_millis(750), Duration::from_secs(1)),
5520            Duration::from_secs(1)
5521        );
5522        assert_eq!(
5523            next_reconnect_delay(Duration::ZERO, Duration::from_secs(1)),
5524            Duration::ZERO
5525        );
5526    }
5527
5528    #[test]
5529    fn canonical_logs_are_deduped_but_removed_logs_are_not() {
5530        let included = log_input_record::<Ethereum>(rpc_log(false), InputSource::Subscription);
5531        let removed = log_input_record::<Ethereum>(rpc_log(true), InputSource::Subscription);
5532
5533        assert!(should_dedupe_record(&included));
5534        assert!(!should_dedupe_record(&removed));
5535    }
5536
5537    #[test]
5538    #[cfg(feature = "reactive-ws")]
5539    fn pubsub_sources_assign_stable_log_ids_before_shared_streams() {
5540        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
5541        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
5542            provider,
5543            SubscriberMode::PubSub,
5544            SubscriberConfig::default(),
5545        );
5546        subscriber
5547            .register_interests(&[
5548                ReactiveInterest::Logs(LogInterest {
5549                    provider_filter: Filter::new().address(Address::repeat_byte(0x01)),
5550                    local_matcher: None,
5551                    route_key: None,
5552                }),
5553                ReactiveInterest::Logs(LogInterest {
5554                    provider_filter: Filter::new().address(Address::repeat_byte(0x02)),
5555                    local_matcher: None,
5556                    route_key: None,
5557                }),
5558                ReactiveInterest::PendingTransactions(PendingTxInterest::default()),
5559            ])
5560            .expect("register base interests");
5561
5562        // The two default-block-option log filters merge into one address
5563        // superset (existing consolidation behavior), so there is one log source
5564        // — assigned id 0, before the pending-hash source.
5565        let sources = subscriber.stream_sources().expect("stream sources");
5566        assert_eq!(sources.len(), 2);
5567        assert!(matches!(
5568            &sources[0],
5569            SubscriberStreamSource::PubSubLog { id: 0, .. }
5570        ));
5571        assert!(matches!(
5572            sources[1],
5573            SubscriberStreamSource::PubSubPendingHashes
5574        ));
5575
5576        // Ids are stable across repeated source construction.
5577        let again = subscriber.stream_sources().expect("stream sources again");
5578        assert!(again[0].same_key(&sources[0]));
5579    }
5580
5581    #[tokio::test(flavor = "multi_thread")]
5582    #[cfg(feature = "reactive-ws")]
5583    async fn pubsub_stream_termination_attempts_reconnect_before_error() {
5584        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
5585        let mut subscriber = AlloySubscriber::new(
5586            provider,
5587            SubscriberMode::PubSub,
5588            SubscriberConfig {
5589                reconnect: SubscriberReconnectConfig {
5590                    initial_delay: Duration::ZERO,
5591                    retry_delay: Duration::ZERO,
5592                    max_delay: Duration::ZERO,
5593                    max_attempts: Some(1),
5594                    ..SubscriberReconnectConfig::default()
5595                },
5596                ..SubscriberConfig::default()
5597            },
5598        );
5599        subscriber.interests = vec![ReactiveInterest::PendingTransactions(
5600            PendingTxInterest::default(),
5601        )];
5602
5603        let mut streams = SubscriberStreams::new();
5604        let source = SubscriberStreamSource::PubSubPendingHashes;
5605        streams.push(
5606            source,
5607            stream::once(async {
5608                SubscriberEvent::<Ethereum>::StreamTerminated(
5609                    SubscriberStreamSource::PubSubPendingHashes,
5610                )
5611            })
5612            .boxed(),
5613        );
5614        subscriber.state = AlloySubscriberState::Active(streams);
5615
5616        let result = subscriber.next_batch().await;
5617        assert!(
5618            matches!(result, Err(SubscriberError::Provider(ref message)) if message.contains("reconnect failed after 1 attempt")),
5619            "terminated pubsub streams should attempt reconnect before surfacing failure: {result:?}"
5620        );
5621    }
5622
5623    #[test]
5624    fn backfilled_logs_skip_recent_subscription_duplicates() {
5625        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
5626        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
5627            provider,
5628            SubscriberMode::PubSub,
5629            SubscriberConfig::default(),
5630        );
5631        subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
5632            provider_filter: Filter::new()
5633                .address(Address::repeat_byte(0x42))
5634                .event_signature(B256::repeat_byte(0x01)),
5635            local_matcher: None,
5636            route_key: None,
5637        })];
5638
5639        let log = rpc_log(false);
5640        subscriber.enqueue_event(SubscriberEvent::Log {
5641            source_id: 0,
5642            log: log.clone(),
5643        });
5644        subscriber.enqueue_event(SubscriberEvent::BackfilledLogs {
5645            source_id: 0,
5646            logs: vec![log],
5647        });
5648
5649        assert_eq!(subscriber.pending_records.len(), 1);
5650        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
5651        assert_eq!(
5652            subscriber.pending_records[0].context.source,
5653            InputSource::Subscription
5654        );
5655    }
5656
5657    #[test]
5658    fn backfilled_logs_surface_with_backfill_source() {
5659        // A backfilled log with no prior subscription duplicate is delivered as
5660        // an `InputSource::Backfill` record (the positive side of the dedup test,
5661        // pinning the README's "marking recovered records as InputSource::Backfill"
5662        // claim — the only place that source is produced).
5663        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
5664        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
5665            provider,
5666            SubscriberMode::PubSub,
5667            SubscriberConfig::default(),
5668        );
5669        subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
5670            provider_filter: Filter::new()
5671                .address(Address::repeat_byte(0x42))
5672                .event_signature(B256::repeat_byte(0x01)),
5673            local_matcher: None,
5674            route_key: None,
5675        })];
5676
5677        subscriber.enqueue_event(SubscriberEvent::BackfilledLogs {
5678            source_id: 0,
5679            logs: vec![rpc_log(false)],
5680        });
5681
5682        assert_eq!(subscriber.pending_records.len(), 1);
5683        assert_eq!(
5684            subscriber.pending_records[0].context.source,
5685            InputSource::Backfill
5686        );
5687        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
5688    }
5689
5690    #[test]
5691    #[cfg(feature = "reactive-ws")]
5692    fn owner_removal_preserves_delivery_and_dedupe_state() {
5693        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
5694        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
5695            provider,
5696            SubscriberMode::PubSub,
5697            SubscriberConfig::default(),
5698        );
5699        subscriber
5700            .add_interest_owner(
5701                HandlerId::new("pool-a"),
5702                &[ReactiveInterest::Logs(LogInterest {
5703                    provider_filter: Filter::new()
5704                        .address(Address::repeat_byte(0x42))
5705                        .event_signature(B256::repeat_byte(0x01)),
5706                    local_matcher: None,
5707                    route_key: None,
5708                })],
5709            )
5710            .expect("register pool-a owner");
5711        subscriber
5712            .add_interest_owner(
5713                HandlerId::new("pool-b"),
5714                &[ReactiveInterest::Logs(LogInterest {
5715                    provider_filter: Filter::new()
5716                        .address(Address::repeat_byte(0x24))
5717                        .event_signature(B256::repeat_byte(0x02)),
5718                    local_matcher: None,
5719                    route_key: None,
5720                })],
5721            )
5722            .expect("register pool-b owner");
5723
5724        // Allocate source ids the way live stream setup would (pool-a -> id 0),
5725        // so the injected delivery anchor hangs off a referenced filter.
5726        let _ = subscriber.stream_sources().expect("stream sources");
5727        subscriber.enqueue_event(SubscriberEvent::Log {
5728            source_id: 0,
5729            log: rpc_log(false),
5730        });
5731        assert_eq!(subscriber.pending_records.len(), 1);
5732        assert_eq!(subscriber.recent_input_refs.len(), 1);
5733        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
5734
5735        let removed = subscriber
5736            .remove_interest_owner(&HandlerId::new("pool-b"))
5737            .expect("pool-b should be removed");
5738
5739        assert_eq!(removed.len(), 1);
5740        assert_eq!(subscriber.pending_records.len(), 1);
5741        assert_eq!(subscriber.recent_input_refs.len(), 1);
5742        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
5743        assert!(
5744            subscriber
5745                .owner_interests(&HandlerId::new("pool-a"))
5746                .is_some()
5747        );
5748        assert!(
5749            subscriber
5750                .owner_interests(&HandlerId::new("pool-b"))
5751                .is_none()
5752        );
5753        assert_eq!(subscriber.registered_interests().len(), 1);
5754    }
5755
5756    #[test]
5757    #[cfg(feature = "reactive-ws")]
5758    fn owner_log_sources_do_not_merge_across_owners() {
5759        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
5760        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
5761            provider,
5762            SubscriberMode::PubSub,
5763            SubscriberConfig::default(),
5764        );
5765        subscriber
5766            .add_interest_owner(
5767                HandlerId::new("pool-a"),
5768                &[ReactiveInterest::Logs(LogInterest {
5769                    provider_filter: Filter::new().address(Address::repeat_byte(0xa1)),
5770                    local_matcher: None,
5771                    route_key: None,
5772                })],
5773            )
5774            .expect("register pool-a owner");
5775
5776        let initial_sources = subscriber.stream_sources().expect("initial sources");
5777        assert_eq!(initial_sources.len(), 1);
5778        let pool_a_source = initial_sources[0].clone();
5779        assert!(matches!(
5780            &pool_a_source,
5781            SubscriberStreamSource::PubSubLog { id: 0, .. }
5782        ));
5783
5784        subscriber
5785            .add_interest_owner(
5786                HandlerId::new("pool-b"),
5787                &[ReactiveInterest::Logs(LogInterest {
5788                    provider_filter: Filter::new().address(Address::repeat_byte(0xb2)),
5789                    local_matcher: None,
5790                    route_key: None,
5791                })],
5792            )
5793            .expect("register pool-b owner");
5794
5795        let expanded_sources = subscriber.stream_sources().expect("expanded sources");
5796        assert_eq!(expanded_sources.len(), 2);
5797        assert!(
5798            expanded_sources
5799                .iter()
5800                .any(|source| source.same_key(&pool_a_source)),
5801            "adding pool-b should not rewrite pool-a's stream source"
5802        );
5803
5804        subscriber
5805            .remove_interest_owner(&HandlerId::new("pool-b"))
5806            .expect("pool-b should be removed");
5807        let trimmed_sources = subscriber.stream_sources().expect("trimmed sources");
5808        assert_eq!(trimmed_sources.len(), 1);
5809        assert!(trimmed_sources[0].same_key(&pool_a_source));
5810    }
5811
5812    #[tokio::test(flavor = "multi_thread")]
5813    #[cfg(feature = "reactive-ws")]
5814    async fn owner_backfill_seeds_reconnect_anchor_before_live_log() {
5815        let asserter = Asserter::new();
5816        asserter.push_success(&vec![rpc_log(false)]);
5817        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
5818        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
5819            provider,
5820            SubscriberMode::PubSub,
5821            SubscriberConfig::default(),
5822        );
5823        subscriber
5824            .add_interest_owner_with_backfill(
5825                HandlerId::new("pool-a"),
5826                &[ReactiveInterest::Logs(LogInterest {
5827                    provider_filter: Filter::new()
5828                        .address(Address::repeat_byte(0x42))
5829                        .event_signature(B256::repeat_byte(0x01)),
5830                    local_matcher: None,
5831                    route_key: None,
5832                })],
5833                SubscriberBackfill::range(1, 7),
5834            )
5835            .expect("register pool-a with backfill");
5836
5837        subscriber
5838            .drain_pending_backfills()
5839            .await
5840            .expect("owner backfill should drain");
5841
5842        assert_eq!(subscriber.pending_records.len(), 1);
5843        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
5844    }
5845
5846    #[tokio::test(flavor = "multi_thread")]
5847    async fn subscriber_streams_poll_ready_sources_round_robin() {
5848        let first_hash = B256::repeat_byte(0x01);
5849        let second_hash = B256::repeat_byte(0x02);
5850        let mut streams = SubscriberStreams::new();
5851        streams.push(
5852            SubscriberStreamSource::PubSubPendingHashes,
5853            stream::iter([
5854                SubscriberEvent::<Ethereum>::PendingHash(first_hash),
5855                SubscriberEvent::<Ethereum>::PendingHash(first_hash),
5856            ])
5857            .boxed(),
5858        );
5859        streams.push(
5860            SubscriberStreamSource::PubSubBlockHeaders,
5861            stream::once(async move { SubscriberEvent::<Ethereum>::PendingHash(second_hash) })
5862                .boxed(),
5863        );
5864
5865        assert!(matches!(
5866            streams.next().await,
5867            Some(SubscriberEvent::PendingHash(hash)) if hash == first_hash
5868        ));
5869        assert!(matches!(
5870            streams.next().await,
5871            Some(SubscriberEvent::PendingHash(hash)) if hash == second_hash
5872        ));
5873    }
5874
5875    #[tokio::test(flavor = "multi_thread")]
5876    #[cfg(feature = "reactive-ws")]
5877    async fn owner_updates_ensure_streams_without_full_reset() {
5878        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
5879        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
5880            provider,
5881            SubscriberMode::PubSub,
5882            SubscriberConfig::default(),
5883        );
5884        subscriber
5885            .register_interests(&[ReactiveInterest::PendingTransactions(
5886                PendingTxInterest::default(),
5887            )])
5888            .expect("register base pending interest");
5889        subscriber
5890            .add_interest_owner(
5891                HandlerId::new("headers"),
5892                &[ReactiveInterest::Blocks(BlockInterest::default())],
5893            )
5894            .expect("register header owner");
5895
5896        let mut streams = SubscriberStreams::new();
5897        streams.push(
5898            SubscriberStreamSource::PubSubPendingHashes,
5899            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
5900        );
5901        streams.push(
5902            SubscriberStreamSource::PubSubBlockHeaders,
5903            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
5904        );
5905        subscriber.state = AlloySubscriberState::Active(streams);
5906
5907        subscriber
5908            .remove_interest_owner(&HandlerId::new("headers"))
5909            .expect("header owner should be removed");
5910        assert!(matches!(
5911            &subscriber.state,
5912            AlloySubscriberState::Active(streams) if streams.len() == 2
5913        ));
5914
5915        subscriber
5916            .ensure_streams()
5917            .await
5918            .expect("pure removal reconciliation should not touch provider");
5919
5920        assert!(matches!(
5921            &subscriber.state,
5922            AlloySubscriberState::Active(streams)
5923                if streams.len() == 1
5924                    && streams.contains_source(&SubscriberStreamSource::PubSubPendingHashes)
5925                    && !streams.contains_source(&SubscriberStreamSource::PubSubBlockHeaders)
5926        ));
5927
5928        subscriber
5929            .add_interest_owner(
5930                HandlerId::new("headers"),
5931                &[ReactiveInterest::Blocks(BlockInterest::default())],
5932            )
5933            .expect("re-add header owner");
5934        assert!(matches!(
5935            &subscriber.state,
5936            AlloySubscriberState::Active(streams) if streams.len() == 1
5937        ));
5938    }
5939
5940    // A log interest matching `rpc_log` (address 0x42, topic0 0x01).
5941    #[cfg(feature = "reactive-ws")]
5942    fn log_interest_matching_rpc_log() -> ReactiveInterest<Ethereum> {
5943        ReactiveInterest::Logs(LogInterest {
5944            provider_filter: Filter::new()
5945                .address(Address::repeat_byte(0x42))
5946                .event_signature(B256::repeat_byte(0x01)),
5947            local_matcher: None,
5948            route_key: None,
5949        })
5950    }
5951
5952    #[cfg(feature = "reactive-ws")]
5953    fn log_interest_for(address: u8) -> ReactiveInterest<Ethereum> {
5954        ReactiveInterest::Logs(LogInterest {
5955            provider_filter: Filter::new().address(Address::repeat_byte(address)),
5956            local_matcher: None,
5957            route_key: None,
5958        })
5959    }
5960
5961    // B1: a transient provider error must not consume the queued backfill — the
5962    // missed window has to survive for the next poll to retry.
5963    #[tokio::test(flavor = "multi_thread")]
5964    #[cfg(feature = "reactive-ws")]
5965    async fn drain_backfill_retains_queue_entry_on_provider_error() {
5966        let asserter = Asserter::new();
5967        asserter.push_failure_msg("rate limited");
5968        asserter.push_success(&vec![rpc_log(false)]);
5969        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
5970        let mut subscriber = AlloySubscriber::new(
5971            provider,
5972            SubscriberMode::PubSub,
5973            SubscriberConfig::default(),
5974        );
5975        subscriber
5976            .add_interest_owner_with_backfill(
5977                HandlerId::new("pool"),
5978                &[log_interest_matching_rpc_log()],
5979                SubscriberBackfill::range(1, 7),
5980            )
5981            .expect("register owner with backfill");
5982        assert_eq!(subscriber.pending_backfills.len(), 1);
5983
5984        let first = subscriber.drain_pending_backfills().await;
5985        assert!(first.is_err(), "provider failure should surface");
5986        assert_eq!(
5987            subscriber.pending_backfills.len(),
5988            1,
5989            "failed fetch must leave the backfill queued for retry"
5990        );
5991        assert!(subscriber.pending_records.is_empty());
5992
5993        subscriber
5994            .drain_pending_backfills()
5995            .await
5996            .expect("retry should succeed");
5997        assert!(subscriber.pending_backfills.is_empty());
5998        assert_eq!(subscriber.pending_records.len(), 1);
5999    }
6000
6001    // B3: a zero-log backfill window still advances the delivery anchor to its
6002    // upper bound, so a later reconnect catches up from the right block.
6003    #[tokio::test(flavor = "multi_thread")]
6004    #[cfg(feature = "reactive-ws")]
6005    async fn drain_backfill_seeds_anchor_on_empty_window() {
6006        let asserter = Asserter::new();
6007        asserter.push_success(&Vec::<Log>::new());
6008        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
6009        let mut subscriber = AlloySubscriber::new(
6010            provider,
6011            SubscriberMode::PubSub,
6012            SubscriberConfig::default(),
6013        );
6014        subscriber
6015            .add_interest_owner_with_backfill(
6016                HandlerId::new("pool"),
6017                &[log_interest_matching_rpc_log()],
6018                SubscriberBackfill::range(1, 42),
6019            )
6020            .expect("register owner with backfill");
6021
6022        subscriber
6023            .drain_pending_backfills()
6024            .await
6025            .expect("empty backfill should drain");
6026
6027        assert!(subscriber.pending_records.is_empty());
6028        let filter = log_filters(subscriber.owner_interests(&HandlerId::new("pool")).unwrap())
6029            .pop()
6030            .unwrap();
6031        assert_eq!(
6032            subscriber.log_anchor(&filter),
6033            Some(42),
6034            "empty window must still seed the anchor at its upper bound"
6035        );
6036    }
6037
6038    // B3 (open-ended): a `from_block`-only backfill resolves its upper bound to
6039    // the provider head and seeds the anchor there.
6040    #[tokio::test(flavor = "multi_thread")]
6041    #[cfg(feature = "reactive-ws")]
6042    async fn drain_backfill_open_ended_resolves_head_and_seeds_anchor() {
6043        let asserter = Asserter::new();
6044        asserter.push_success(&100u64); // get_block_number
6045        asserter.push_success(&Vec::<Log>::new()); // get_logs
6046        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
6047        let mut subscriber = AlloySubscriber::new(
6048            provider,
6049            SubscriberMode::PubSub,
6050            SubscriberConfig::default(),
6051        );
6052        subscriber
6053            .add_interest_owner_with_backfill(
6054                HandlerId::new("pool"),
6055                &[log_interest_matching_rpc_log()],
6056                SubscriberBackfill::from_block(10),
6057            )
6058            .expect("register owner with open-ended backfill");
6059
6060        subscriber
6061            .drain_pending_backfills()
6062            .await
6063            .expect("open-ended backfill should drain");
6064
6065        let filter = log_filters(subscriber.owner_interests(&HandlerId::new("pool")).unwrap())
6066            .pop()
6067            .unwrap();
6068        assert_eq!(subscriber.log_anchor(&filter), Some(100));
6069    }
6070
6071    // B2: two owners requesting the same filter shape share exactly one live
6072    // source (and thus one anchor), rather than double-subscribing.
6073    #[test]
6074    #[cfg(feature = "reactive-ws")]
6075    fn duplicate_filters_across_owners_map_to_single_source() {
6076        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
6077        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
6078            provider,
6079            SubscriberMode::PubSub,
6080            SubscriberConfig::default(),
6081        );
6082        subscriber
6083            .add_interest_owner(HandlerId::new("pool-a"), &[log_interest_for(0xaa)])
6084            .expect("register pool-a");
6085        subscriber
6086            .add_interest_owner(HandlerId::new("pool-b"), &[log_interest_for(0xaa)])
6087            .expect("register pool-b with identical filter");
6088
6089        assert_eq!(
6090            subscriber.log_stream_filters().len(),
6091            1,
6092            "identical filters across owners must collapse to one"
6093        );
6094        let sources = subscriber.stream_sources().expect("stream sources");
6095        assert_eq!(sources.len(), 1);
6096    }
6097
6098    // B4: removing an owner retires the source-id and anchor bookkeeping for
6099    // filters no other owner references, so long-lived churn cannot leak.
6100    #[test]
6101    #[cfg(feature = "reactive-ws")]
6102    fn owner_removal_prunes_source_ids_and_anchors() {
6103        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
6104        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
6105            provider,
6106            SubscriberMode::PubSub,
6107            SubscriberConfig::default(),
6108        );
6109        subscriber
6110            .add_interest_owner(HandlerId::new("pool-a"), &[log_interest_for(0xaa)])
6111            .expect("register pool-a");
6112        subscriber
6113            .add_interest_owner(HandlerId::new("pool-b"), &[log_interest_for(0xbb)])
6114            .expect("register pool-b");
6115
6116        // Allocate ids and simulate delivery anchors on both.
6117        let _ = subscriber.stream_sources().expect("stream sources");
6118        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
6119        let filter_b = log_filters(&[log_interest_for(0xbb)]).pop().unwrap();
6120        let id_a = subscriber.log_source_id(&filter_a);
6121        let id_b = subscriber.log_source_id(&filter_b);
6122        subscriber.last_seen_log_blocks.insert(id_a, 10);
6123        subscriber.last_seen_log_blocks.insert(id_b, 20);
6124        assert_eq!(subscriber.log_source_ids.len(), 2);
6125
6126        subscriber
6127            .remove_interest_owner(&HandlerId::new("pool-b"))
6128            .expect("remove pool-b");
6129
6130        assert_eq!(
6131            subscriber.log_source_ids.len(),
6132            1,
6133            "pool-b's filter id should be retired"
6134        );
6135        assert!(subscriber.log_source_ids.contains_key(&filter_a));
6136        assert_eq!(subscriber.last_seen_log_blocks.get(&id_a), Some(&10));
6137        assert_eq!(
6138            subscriber.last_seen_log_blocks.get(&id_b),
6139            None,
6140            "pool-b's anchor should be pruned"
6141        );
6142    }
6143
6144    // D1: growing an owner's filter set (a new pool on an existing adapter)
6145    // changes the merged filter shape; the new shape must inherit the old
6146    // anchor via an automatic continuity backfill, or logs between the last
6147    // delivery and the new subscription are silently lost.
6148    #[test]
6149    #[cfg(feature = "reactive-ws")]
6150    fn owner_filter_growth_queues_continuity_backfill_from_prior_anchor() {
6151        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
6152        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
6153            provider,
6154            SubscriberMode::PubSub,
6155            SubscriberConfig::default(),
6156        );
6157        subscriber
6158            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
6159            .expect("register amm with pool A");
6160
6161        // Simulate the owner's single merged filter having delivered up to
6162        // block 50.
6163        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
6164        let id_a = subscriber.log_source_id(&filter_a);
6165        subscriber.last_seen_log_blocks.insert(id_a, 50);
6166
6167        // Grow the owner to also watch pool B (same block option -> merges into
6168        // one {A,B} filter, a new shape).
6169        subscriber
6170            .add_interest_owner(
6171                HandlerId::new("amm"),
6172                &[log_interest_for(0xaa), log_interest_for(0xbb)],
6173            )
6174            .expect("grow amm to pools A+B");
6175
6176        assert_eq!(
6177            subscriber.pending_backfills.len(),
6178            1,
6179            "the changed merged filter should queue exactly one continuity backfill"
6180        );
6181        let queued = &subscriber.pending_backfills[0];
6182        assert_eq!(queued.owner, HandlerId::new("amm"));
6183        assert_eq!(queued.backfill.start_block(), 50);
6184        assert_eq!(
6185            queued.backfill.end_block(),
6186            None,
6187            "continuity backfill runs open-ended to the current head"
6188        );
6189    }
6190
6191    // D1 negative: replacing an owner's interests with the identical shape must
6192    // NOT re-fetch — the filter kept its anchor and its live stream.
6193    #[test]
6194    #[cfg(feature = "reactive-ws")]
6195    fn unchanged_owner_filter_does_not_queue_continuity_backfill() {
6196        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
6197        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
6198            provider,
6199            SubscriberMode::PubSub,
6200            SubscriberConfig::default(),
6201        );
6202        subscriber
6203            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
6204            .expect("register amm");
6205        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
6206        let id_a = subscriber.log_source_id(&filter_a);
6207        subscriber.last_seen_log_blocks.insert(id_a, 50);
6208
6209        subscriber
6210            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
6211            .expect("re-register identical interests");
6212
6213        assert!(
6214            subscriber.pending_backfills.is_empty(),
6215            "an unchanged filter shape must not queue continuity backfill"
6216        );
6217    }
6218
6219    // D5 interaction: an explicit open-ended backfill starting at or below the
6220    // owner's prior anchor already covers the continuity window, so no extra
6221    // continuity backfill is queued (no redundant double fetch).
6222    #[test]
6223    #[cfg(feature = "reactive-ws")]
6224    fn explicit_open_ended_backfill_below_anchor_suppresses_continuity() {
6225        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
6226        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
6227            provider,
6228            SubscriberMode::PubSub,
6229            SubscriberConfig::default(),
6230        );
6231        subscriber
6232            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
6233            .expect("register amm");
6234        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
6235        let id_a = subscriber.log_source_id(&filter_a);
6236        subscriber.last_seen_log_blocks.insert(id_a, 50);
6237
6238        // Grow with an explicit deep backfill from block 10 (< anchor 50).
6239        subscriber
6240            .add_interest_owner_with_backfill(
6241                HandlerId::new("amm"),
6242                &[log_interest_for(0xaa), log_interest_for(0xbb)],
6243                SubscriberBackfill::from_block(10),
6244            )
6245            .expect("grow amm with explicit deep backfill");
6246
6247        assert_eq!(
6248            subscriber.pending_backfills.len(),
6249            1,
6250            "only the explicit backfill should be queued; continuity is subsumed"
6251        );
6252        assert_eq!(subscriber.pending_backfills[0].backfill.start_block(), 10);
6253    }
6254
6255    // The dirty flag gates reconciliation: when nothing changed since the last
6256    // reconcile, `ensure_streams` must not touch the provider or the state.
6257    #[tokio::test(flavor = "multi_thread")]
6258    #[cfg(feature = "reactive-ws")]
6259    async fn ensure_streams_is_noop_when_not_dirty() {
6260        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
6261        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
6262            provider,
6263            SubscriberMode::PubSub,
6264            SubscriberConfig::default(),
6265        );
6266        // An interest that WOULD require a new block-header source...
6267        subscriber
6268            .add_interest_owner(
6269                HandlerId::new("headers"),
6270                &[ReactiveInterest::Blocks(BlockInterest::default())],
6271            )
6272            .expect("register header owner");
6273        // ...but we mark bookkeeping clean and start from Empty.
6274        subscriber.state = AlloySubscriberState::Empty;
6275        subscriber.sources_dirty = false;
6276
6277        subscriber
6278            .ensure_streams()
6279            .await
6280            .expect("clean reconcile must be a no-op");
6281
6282        assert!(
6283            matches!(subscriber.state, AlloySubscriberState::Empty),
6284            "not-dirty ensure_streams must not connect new sources"
6285        );
6286    }
6287}
6288
6289fn resolve_subscriber_transport(
6290    mode: SubscriberMode,
6291) -> Result<SubscriberTransport, SubscriberError> {
6292    match mode {
6293        SubscriberMode::PubSub => {
6294            #[cfg(feature = "reactive-ws")]
6295            {
6296                Ok(SubscriberTransport::PubSub)
6297            }
6298            #[cfg(not(feature = "reactive-ws"))]
6299            {
6300                Err(SubscriberError::Unsupported(
6301                    "AlloySubscriber pubsub mode requires the reactive-ws feature",
6302                ))
6303            }
6304        }
6305        SubscriberMode::Polling => {
6306            #[cfg(feature = "reactive-polling")]
6307            {
6308                Ok(SubscriberTransport::Polling)
6309            }
6310            #[cfg(not(feature = "reactive-polling"))]
6311            {
6312                Err(SubscriberError::Unsupported(
6313                    "AlloySubscriber polling mode requires the reactive-polling feature",
6314                ))
6315            }
6316        }
6317        SubscriberMode::Auto => resolve_auto_subscriber_transport(),
6318    }
6319}
6320
6321fn resolve_auto_subscriber_transport() -> Result<SubscriberTransport, SubscriberError> {
6322    #[cfg(feature = "reactive-ws")]
6323    {
6324        Ok(SubscriberTransport::PubSub)
6325    }
6326
6327    #[cfg(all(not(feature = "reactive-ws"), feature = "reactive-polling"))]
6328    {
6329        Ok(SubscriberTransport::Polling)
6330    }
6331
6332    #[cfg(not(any(feature = "reactive-ws", feature = "reactive-polling")))]
6333    {
6334        Err(SubscriberError::Unsupported(
6335            "AlloySubscriber requires either reactive-ws or reactive-polling",
6336        ))
6337    }
6338}
6339
6340fn validate_subscriber_config(config: &SubscriberConfig) -> Result<(), SubscriberError> {
6341    if config.max_batch_size == 0 {
6342        return Err(SubscriberError::InvalidConfig(
6343            "SubscriberConfig::max_batch_size must be greater than zero",
6344        ));
6345    }
6346    if config.reconnect.enabled {
6347        if config.reconnect.retry_delay > config.reconnect.max_delay {
6348            return Err(SubscriberError::InvalidConfig(
6349                "SubscriberReconnectConfig::retry_delay must be less than or equal to max_delay",
6350            ));
6351        }
6352        if matches!(config.reconnect.max_attempts, Some(0)) {
6353            return Err(SubscriberError::InvalidConfig(
6354                "SubscriberReconnectConfig::max_attempts must be greater than zero when set",
6355            ));
6356        }
6357    }
6358    Ok(())
6359}
6360
6361fn validate_supported_interests<N: Network>(
6362    mode: SubscriberMode,
6363    config: &SubscriberConfig,
6364    interests: &[ReactiveInterest<N>],
6365) -> Result<(), SubscriberError> {
6366    let transport = resolve_subscriber_transport(mode)?;
6367
6368    for interest in interests {
6369        match interest {
6370            ReactiveInterest::Logs(_) => {}
6371            ReactiveInterest::PendingTransactions(interest)
6372                if !config.hydrate_pending_transactions && interest.matches_hash_only() => {}
6373            ReactiveInterest::PendingTransactions(_) => {
6374                return Err(SubscriberError::Unsupported(
6375                    "AlloySubscriber currently supports pending transaction hash interests only (full pending-tx hydration is unimplemented)",
6376                ));
6377            }
6378            ReactiveInterest::Blocks(interest) => match (transport, interest.mode) {
6379                (SubscriberTransport::PubSub, BlockInterestMode::Header) => {}
6380                (_, BlockInterestMode::FullBlock) => {
6381                    return Err(SubscriberError::Unsupported(
6382                        "AlloySubscriber full block streams are not implemented in this transport slice",
6383                    ));
6384                }
6385                (SubscriberTransport::Polling, BlockInterestMode::Header) => {
6386                    return Err(SubscriberError::Unsupported(
6387                        "AlloySubscriber polling block streams are not implemented in this transport slice",
6388                    ));
6389                }
6390            },
6391        }
6392    }
6393
6394    Ok(())
6395}
6396
6397fn log_filters<N: Network>(interests: &[ReactiveInterest<N>]) -> Vec<Filter> {
6398    let mut filters = Vec::new();
6399    for interest in interests {
6400        if let ReactiveInterest::Logs(interest) = interest {
6401            merge_log_subscription_filter(&mut filters, &interest.provider_filter);
6402        }
6403    }
6404    filters
6405}
6406
6407fn needs_header_block_stream<N: Network>(interests: &[ReactiveInterest<N>]) -> bool {
6408    interests.iter().any(|interest| {
6409        matches!(
6410            interest,
6411            ReactiveInterest::Blocks(BlockInterest {
6412                mode: BlockInterestMode::Header,
6413            })
6414        )
6415    })
6416}
6417
6418fn needs_pending_hash_stream<N: Network>(interests: &[ReactiveInterest<N>]) -> bool {
6419    interests.iter().any(|interest| {
6420        matches!(
6421            interest,
6422            ReactiveInterest::PendingTransactions(interest) if interest.matches_hash_only()
6423        )
6424    })
6425}
6426
6427fn log_matches_any_interest<N: Network>(log: &Log, interests: &[ReactiveInterest<N>]) -> bool {
6428    interests.iter().any(|interest| {
6429        matches!(
6430            interest,
6431            ReactiveInterest::Logs(interest) if interest.matches(log)
6432        )
6433    })
6434}
6435
6436fn log_input_record<N: Network>(log: Log, source: InputSource) -> ReactiveInputRecord<N> {
6437    let context = log_reactive_context(&log);
6438    ReactiveInputRecord::new(
6439        ReactiveInput::Log(log),
6440        ReactiveContext { source, ..context },
6441    )
6442}
6443
6444fn log_reactive_context(log: &Log) -> ReactiveContext {
6445    let block = match (log.block_hash, log.block_number) {
6446        (Some(hash), Some(number)) => Some(BlockRef {
6447            number,
6448            hash,
6449            parent_hash: None,
6450            timestamp: log.block_timestamp,
6451        }),
6452        _ => None,
6453    };
6454
6455    let chain_status = match (&block, log.removed) {
6456        (Some(block), true) => ChainStatus::Reorged {
6457            dropped_from: block.clone(),
6458        },
6459        (Some(block), false) => ChainStatus::Included {
6460            block: block.clone(),
6461            confirmations: 0,
6462        },
6463        (None, _) => ChainStatus::Pending,
6464    };
6465
6466    ReactiveContext {
6467        chain_id: None,
6468        source: InputSource::Poll,
6469        chain_status,
6470        block,
6471        transaction_index: log.transaction_index,
6472        log_index: log.log_index,
6473    }
6474}
6475
6476fn block_header_input_record<N>(header: N::HeaderResponse) -> ReactiveInputRecord<N>
6477where
6478    N: Network,
6479{
6480    let block = BlockRef {
6481        number: header.number(),
6482        hash: HeaderResponseTrait::hash(&header),
6483        parent_hash: Some(header.parent_hash()),
6484        timestamp: Some(header.timestamp()),
6485    };
6486    ReactiveInputRecord::new(
6487        ReactiveInput::BlockHeader(header),
6488        ReactiveContext {
6489            chain_id: None,
6490            source: InputSource::Subscription,
6491            chain_status: ChainStatus::Included {
6492                block: block.clone(),
6493                confirmations: 0,
6494            },
6495            block: Some(block),
6496            transaction_index: None,
6497            log_index: None,
6498        },
6499    )
6500}
6501
6502fn pending_hash_input_record<N: Network>(
6503    hash: B256,
6504    source: InputSource,
6505) -> ReactiveInputRecord<N> {
6506    ReactiveInputRecord::new(
6507        ReactiveInput::PendingTxHash(hash),
6508        ReactiveContext {
6509            chain_id: None,
6510            source,
6511            chain_status: ChainStatus::Pending,
6512            block: None,
6513            transaction_index: None,
6514            log_index: None,
6515        },
6516    )
6517}
6518
6519fn provider_error(error: impl fmt::Display) -> SubscriberError {
6520    SubscriberError::Provider(error.to_string())
6521}
6522
6523/// Subscriber error.
6524#[derive(Debug, thiserror::Error)]
6525pub enum SubscriberError {
6526    /// Invalid subscriber configuration.
6527    #[error("{0}")]
6528    InvalidConfig(&'static str),
6529    /// Requested subscriber behavior is not implemented.
6530    #[error("{0}")]
6531    Unsupported(&'static str),
6532    /// Provider or transport error.
6533    #[error("provider error: {0}")]
6534    Provider(String),
6535}